From a5d25e1a6f42f7e21e78c19170fa80b48ced5f22 Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Thu, 24 Sep 2026 23:34:30 +0000 Subject: [PATCH] Line boxes over atomic inlines as a generated function CSS 2.1's inline formatting context becomes the twelfth generated function, for the one kind of inline content that needs no font: a block container whose in-flow children are all display: inline-block with explicit width and height. The container's content width, its strut line-height and text-align go in, together with each atomic inline's margin-box size, its own baseline and its vertical-align; where every box lands, the line boxes they sit on, the stack's total height and the last line's baseline come out. Before this, boxtree.rs gave every computed display other than none one block-level box, so an inline-block stacked in normal flow like a block and vertical-align, line-height and text-align reached nothing at all. Now a box records whether it is inline-level, a container whose children are all inline-level establishes an inline formatting context, and fragment.rs lays those children out as block formatting context roots at their explicit sizes before handing the measurements to the function. CSS 2.1 10.8.1's inline-block baseline -- its last in-flow line box's, or its bottom margin edge when it has none or its overflow is not visible -- stays hand-written, since only fragment.rs can see inside the box. Its answer is a set of positions rather than one value, which makes it the first function here that can be wrong about one box and right about its neighbour on the same call, and the first that can panic on a well-typed input by indexing its own output. Two candidates did exactly that and were caught and dropped. The corpus is 111 cases over fifteen categories with none dropped for engine disagreement. Every container sets font-size: 0 beside an explicit line-height in px, so the strut's ascent and descent are zero and its baseline sits at the middle of a known height; whether the three engines agree on that was the open question, and they do, AlignMiddle and StrutTallerThanContent included. cases.rs already writes no whitespace between tags, and both the case file and the cache binary assert no text node reaches the markup. The two seeded-wrong fixtures are wrong about disjoint halves of the function -- one ignores vertical-align, the other never breaks a line -- and between them pass all 111 cases, so the regression set covers the corpus and a candidate must pass every case to be admitted. Both are rejected: 61/111 losing 50 and 86/111 losing 25. One batch over four gateway models compiled 18 of 82 candidates and produced four at 111/111, one from each model; model_0 from qwen3.8-flash-next is committed. That complete seed cost one change to the batch loop. Every other *_real_model_run samples its prompt from the cases outside the regression set, which elsewhere is the same set the committed candidate fails; here the seed is complete from the first batch while the committed candidate passes none of it, so the first run sampled an empty difference and drew nothing. It now samples the whole corpus when the difference is empty -- the same dead end height_real_model_run.rs reached from the opposite direction. Gates: cargo test --workspace passes; fragment_check --random stays 227/227 with every category unchanged; the floats, stacking, relative-positioning and block-height floors all hold exactly. A floor for this function in bench/seam-floor.sh is a hand change after landing, since the landing script refuses a diff under bench/. --- crates/dom/src/computed.rs | 239 ++++++- .../aligns_every_bottom_edge.rs | 96 +++ .../inline-atomic-candidates/baseline.rs | 14 + .../never_breaks_a_line.rs | 109 +++ crates/generation/src/absolute_runner.rs | 4 + crates/generation/src/admit.rs | 33 +- .../generation/src/bin/feature_model_run.rs | 3 +- .../src/bin/inline_atomic_generation_loop.rs | 122 ++++ .../bin/inline_atomic_ground_truth_cache.rs | 143 ++++ .../src/bin/inline_atomic_real_model_run.rs | 643 ++++++++++++++++++ crates/generation/src/candidate_crate.rs | 19 +- .../generation/src/containing_block_runner.rs | 4 + crates/generation/src/float_runner.rs | 4 + crates/generation/src/height_runner.rs | 4 + crates/generation/src/inline_atomic_runner.rs | 445 ++++++++++++ crates/generation/src/lib.rs | 1 + crates/generation/src/margin_runner.rs | 4 + crates/generation/src/percentage_runner.rs | 4 + crates/generation/src/prompt.rs | 94 +++ crates/generation/src/relpos_runner.rs | 4 + crates/generation/src/transform_runner.rs | 4 + crates/harness/src/inline_atomic_cases.rs | 577 ++++++++++++++++ crates/harness/src/lib.rs | 1 + crates/layout/src/boxtree.rs | 122 +++- crates/layout/src/fragment.rs | 329 ++++++++- .../generated/inline_atomic.provenance.json | 16 + crates/layout/src/generated/inline_atomic.rs | 458 +++++++++++++ crates/layout/src/generated/mod.rs | 8 + crates/layout/src/inline_atomic.rs | 281 ++++++++ crates/layout/src/lib.rs | 2 + crates/provenance/src/css21_inline_atomic.rs | 302 ++++++++ crates/provenance/src/lib.rs | 1 + docs/generated-functions.md | 56 +- docs/model-first-cost.md | 82 +++ handoff.md | 218 +++--- roadmap.md | 16 +- 36 files changed, 4334 insertions(+), 128 deletions(-) create mode 100644 crates/generation/fixtures/inline-atomic-candidates/aligns_every_bottom_edge.rs create mode 100644 crates/generation/fixtures/inline-atomic-candidates/baseline.rs create mode 100644 crates/generation/fixtures/inline-atomic-candidates/never_breaks_a_line.rs create mode 100644 crates/generation/src/bin/inline_atomic_generation_loop.rs create mode 100644 crates/generation/src/bin/inline_atomic_ground_truth_cache.rs create mode 100644 crates/generation/src/bin/inline_atomic_real_model_run.rs create mode 100644 crates/generation/src/inline_atomic_runner.rs create mode 100644 crates/harness/src/inline_atomic_cases.rs create mode 100644 crates/layout/src/generated/inline_atomic.provenance.json create mode 100644 crates/layout/src/generated/inline_atomic.rs create mode 100644 crates/layout/src/inline_atomic.rs create mode 100644 crates/provenance/src/css21_inline_atomic.rs diff --git a/crates/dom/src/computed.rs b/crates/dom/src/computed.rs index f1353f4b..ef9c1172 100644 --- a/crates/dom/src/computed.rs +++ b/crates/dom/src/computed.rs @@ -15,11 +15,13 @@ use std::collections::BTreeMap; use style::values::computed::{ - Clear as StyloClear, Float as StyloFloat, Inset as StyloInset, Length as StyloLength, - LengthPercentage as StyloLengthPercentage, Margin as StyloMargin, MaxSize as StyloMaxSize, - PositionProperty as StyloPositionProperty, Size as StyloSize, + BaselineShift as StyloBaselineShift, Clear as StyloClear, Float as StyloFloat, + Inset as StyloInset, Length as StyloLength, LengthPercentage as StyloLengthPercentage, + LineHeight as StyloLineHeight, Margin as StyloMargin, MaxSize as StyloMaxSize, + PositionProperty as StyloPositionProperty, Size as StyloSize, TextAlign as StyloTextAlign, TransformOperation as StyloTransformOperation, ZIndex as StyloZIndex, }; +use style::values::generics::box_::BaselineShiftKeyword as StyloBaselineShiftKeyword; use style_traits::{CssWriter, ToCss}; use crate::node::NodeId; @@ -242,6 +244,58 @@ pub fn is_display_flow_root(document: &Document, node: NodeId) -> bool { document.computed_style(node).is_some_and(|style| to_css_string(&style.clone_display()) == "flow-root") } +/// The computed `display`, as far as box construction currently distinguishes it +/// (`crates/layout/src/boxtree.rs`). Not a full CSS Display 3 type: the inside/outside +/// pair is collapsed to the four keywords box construction branches on, and everything +/// else lands in [`Other`](Display::Other), the way [`Position`] collapses `sticky` into +/// `Static` rather than growing a variant nothing reads. `tables-fixed-layout` and +/// `display-contents` extend this enum when their own keywords start to matter. +/// +/// Read by serialization, the same way [`is_display_none`] and [`is_display_flow_root`] +/// already read the property; both keep working unchanged beside this, since neither is +/// reimplemented in terms of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Display { + Block, + /// The one `display` value that establishes a block formatting context on its own + /// (CSS Display 3 §2.1) -- [`is_display_flow_root`]'s answer, as a variant. + FlowRoot, + /// An atomic inline-level box that lays its own contents out as a block container + /// (CSS Display 3 §2.1): the one inline-level box + /// `crates/layout/src/inline_atomic.rs` places on a line. + InlineBlock, + /// Generates no box at all, and neither does anything in its subtree (CSS 2.1 + /// §9.2.5) -- [`is_display_none`]'s answer, as a variant. + None, + /// Every other keyword: `inline`, `table`, `flex`, `grid`, `contents`, `list-item` + /// and the rest. Each generates a box, and box construction treats it as + /// block-level, which is what the engine did for all of them before this enum + /// existed. + Other, +} + +impl Display { + /// Whether a box with this `display` is inline-level -- participates in an inline + /// formatting context rather than stacking in normal flow (CSS 2.1 §9.2.2). Only + /// [`InlineBlock`](Display::InlineBlock) is, in this build: `inline` itself is out of + /// scope until there is text to put in it (`specs/inline-atomic.md`). + pub fn is_inline_level(self) -> bool { + matches!(self, Display::InlineBlock) + } +} + +pub fn display(document: &Document, node: NodeId) -> Display { + document.computed_style(node).map_or(Display::Block, |style| { + match to_css_string(&style.clone_display()).as_str() { + "none" => Display::None, + "block" => Display::Block, + "flow-root" => Display::FlowRoot, + "inline-block" => Display::InlineBlock, + _ => Display::Other, + } + }) +} + /// CSS 2.1 §9.9.1's `z-index`: `auto` or an integer. Only meaningful on a positioned box /// (an unpositioned box's `z-index` is ignored by CSS 2.1 and by every engine), but the /// accessor reports the computed value regardless and leaves that rule to the @@ -662,6 +716,91 @@ pub fn transform_origin(document: &Document, node: NodeId) -> (LengthPercentage, ) } +/// CSS 2.1 §10.8.1's `vertical-align`, narrowed to the values +/// `crates/layout/src/inline_atomic.rs` places on a line. The font-dependent keywords +/// (`sub`, `super`, `text-top`, `text-bottom`) land in [`Other`](VerticalAlign::Other), +/// the same narrowing [`Position`] applies to `sticky`: this build has no font metrics +/// to resolve them against, and the inline-atomic corpus never generates one. +/// +/// [`Length`](VerticalAlign::Length) carries both the `` and the `` +/// form, positive raising the box above the parent's baseline; a percentage is of the +/// box's own `line-height`, which `crates/layout/src/fragment.rs` resolves before the +/// value reaches a candidate. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum VerticalAlign { + Baseline, + Top, + Bottom, + Middle, + Length(LengthPercentage), + Other, +} + +/// The computed `vertical-align`. This build's Stylo models the property as CSS Inline +/// 3's shorthand over `alignment-baseline`/`baseline-shift`/`baseline-source` rather than +/// as one CSS 2.1 value, so the two longhands that carry the CSS 2.1 keywords are read +/// back and recombined here: `middle` is an `alignment-baseline`, `top` and `bottom` are +/// `baseline-shift` keywords, and a `` or `` is a `baseline-shift` +/// length. `baseline-source` carries `first`/`last`, which CSS 2.1 has no counterpart for +/// and this function does not read. +/// +/// A shift of exactly zero beside an `alignment-baseline` of `baseline` is the initial +/// value, which is `vertical-align: baseline`. +pub fn vertical_align(document: &Document, node: NodeId) -> VerticalAlign { + document.computed_style(node).map_or(VerticalAlign::Baseline, |style| { + match style.clone_baseline_shift() { + StyloBaselineShift::Keyword(StyloBaselineShiftKeyword::Top) => return VerticalAlign::Top, + StyloBaselineShift::Keyword(StyloBaselineShiftKeyword::Bottom) => return VerticalAlign::Bottom, + StyloBaselineShift::Keyword(StyloBaselineShiftKeyword::Center) => return VerticalAlign::Middle, + StyloBaselineShift::Keyword(_) => return VerticalAlign::Other, + StyloBaselineShift::Length(lp) => match LengthPercentage::from_stylo(&lp) { + LengthPercentage::Length(px) if px == 0.0 => {} + resolved => return VerticalAlign::Length(resolved), + }, + } + match to_css_string(&style.clone_alignment_baseline()).as_str() { + "baseline" => VerticalAlign::Baseline, + "middle" => VerticalAlign::Middle, + _ => VerticalAlign::Other, + } + }) +} + +/// The computed `line-height` in CSS pixels, or `None` for `normal` -- the one value +/// whose used height comes from the font rather than from the cascade, and the one +/// `crates/layout/src/inline_atomic.rs` puts out of scope. A `` has already been +/// multiplied by the element's own `font-size` by the time the value computes, and so has +/// a ``, so both arrive here as a plain length. +pub fn line_height(document: &Document, node: NodeId) -> Option { + document.computed_style(node).and_then(|style| match style.clone_line_height() { + StyloLineHeight::Normal => None, + StyloLineHeight::Length(px) => Some(f64::from(px.0.px())), + StyloLineHeight::Number(n) => Some(f64::from(n.0) * f64::from(style.clone_font_size().used_size().px())), + }) +} + +/// CSS 2.1 §16.2's `text-align`, narrowed to the three values +/// `crates/layout/src/inline_atomic.rs` distributes a line box's leftover space by. +/// `justify` and everything else land in [`Other`](TextAlign::Other). The initial +/// computed value is `start`, which in the left-to-right corpus this build measures is +/// [`Left`](TextAlign::Left) -- the same narrowing [`Float`] applies to `inline-start`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextAlign { + Left, + Right, + Center, + Other, +} + +pub fn text_align(document: &Document, node: NodeId) -> TextAlign { + document.computed_style(node).map_or(TextAlign::Left, |style| match style.clone_text_align() { + StyloTextAlign::Start | StyloTextAlign::Left | StyloTextAlign::MozLeft => TextAlign::Left, + StyloTextAlign::End | StyloTextAlign::Right | StyloTextAlign::MozRight => TextAlign::Right, + StyloTextAlign::Center | StyloTextAlign::MozCenter => TextAlign::Center, + StyloTextAlign::Justify => TextAlign::Other, + }) +} + fn to_css_string(value: &T) -> String { let mut s = String::new(); value.to_css(&mut CssWriter::new(&mut s)).expect("ToCss writing to a String never fails"); @@ -885,6 +1024,100 @@ mod tests { ); } + #[test] + fn display_reports_the_four_keywords_box_construction_branches_on() { + let html = "\ +
\ +
"; + let document = Document::parse(html); + assert_eq!(display(&document, find(&document, "a")), Display::None); + assert_eq!(display(&document, find(&document, "b")), Display::FlowRoot); + assert_eq!(display(&document, find(&document, "c")), Display::InlineBlock); + assert_eq!(display(&document, find(&document, "d")), Display::Block); + assert_eq!(display(&document, find(&document, "e")), Display::Other, "inline is out of scope"); + assert_eq!(display(&document, find(&document, "f")), Display::Other); + } + + /// The two older accessors keep answering exactly as they did; [`display`] is an + /// addition beside them, not a replacement (its own doc comment). + #[test] + fn display_agrees_with_the_two_boolean_accessors_it_was_added_beside() { + let html = "\ +
"; + let document = Document::parse(html); + for id in ["a", "b", "c"] { + let node = find(&document, id); + assert_eq!(is_display_none(&document, node), display(&document, node) == Display::None); + assert_eq!(is_display_flow_root(&document, node), display(&document, node) == Display::FlowRoot); + } + } + + #[test] + fn only_inline_block_is_inline_level() { + assert!(Display::InlineBlock.is_inline_level()); + assert!(!Display::Block.is_inline_level()); + assert!(!Display::FlowRoot.is_inline_level()); + assert!(!Display::None.is_inline_level()); + assert!(!Display::Other.is_inline_level()); + } + + #[test] + fn vertical_align_reports_the_css21_keywords_and_lengths() { + let html = "\ +
\ +
"; + let document = Document::parse(html); + let va = |id| vertical_align(&document, find(&document, id)); + assert_eq!(va("a"), VerticalAlign::Baseline); + assert_eq!(va("b"), VerticalAlign::Top); + assert_eq!(va("c"), VerticalAlign::Bottom); + assert_eq!(va("d"), VerticalAlign::Middle); + assert_eq!(va("e"), VerticalAlign::Length(LengthPercentage::Length(12.0))); + assert_eq!(va("f"), VerticalAlign::Length(LengthPercentage::Length(-4.0)), "negative lowers the box"); + assert_eq!(va("g"), VerticalAlign::Length(LengthPercentage::Percentage(0.5))); + assert_eq!(va("h"), VerticalAlign::Other, "sub needs font metrics and is out of scope"); + } + + #[test] + fn vertical_align_defaults_to_baseline() { + let document = Document::parse("
"); + assert_eq!(vertical_align(&document, find(&document, "a")), VerticalAlign::Baseline); + } + + #[test] + fn line_height_is_a_length_unless_it_is_normal() { + let html = "\ +
"; + let document = Document::parse(html); + assert_eq!(line_height(&document, find(&document, "a")), Some(20.0)); + assert_eq!(line_height(&document, find(&document, "b")), None, "normal reads the font"); + assert_eq!(line_height(&document, find(&document, "c")), Some(15.0), "a number multiplies font-size"); + assert_eq!( + line_height(&document, find(&document, "d")), + Some(24.0), + "an explicit length is independent of a zero font-size, which is what the corpus relies on" + ); + } + + #[test] + fn text_align_narrows_to_the_three_values_a_line_box_distributes_by() { + let html = "\ +
"; + let document = Document::parse(html); + assert_eq!(text_align(&document, find(&document, "a")), TextAlign::Left); + assert_eq!(text_align(&document, find(&document, "b")), TextAlign::Right); + assert_eq!(text_align(&document, find(&document, "c")), TextAlign::Center); + assert_eq!(text_align(&document, find(&document, "d")), TextAlign::Other, "justify is out of scope"); + assert_eq!(text_align(&document, find(&document, "e")), TextAlign::Left, "start, in a ltr corpus"); + } + #[test] fn overflow_accessors_report_the_computed_keyword_and_default_to_visible() { let html = "\ diff --git a/crates/generation/fixtures/inline-atomic-candidates/aligns_every_bottom_edge.rs b/crates/generation/fixtures/inline-atomic-candidates/aligns_every_bottom_edge.rs new file mode 100644 index 00000000..1c01c7a3 --- /dev/null +++ b/crates/generation/fixtures/inline-atomic-candidates/aligns_every_bottom_edge.rs @@ -0,0 +1,96 @@ +//! Seeded-wrong candidate: line breaking, line stacking, the strut and `text-align` are +//! all right, but every box is aligned by putting its bottom margin edge on the line's +//! baseline, whatever its `vertical-align` says and whatever its own baseline is. +//! +//! That is the right answer for a plain `vertical-align: baseline` box with no line box +//! inside it, because CSS 2.1 10.8.1 puts such a box's baseline at its bottom margin edge +//! -- so this candidate is right on most of the corpus and wrong exactly where the +//! alignment or the inner baseline matters: the `AlignTop`, `AlignBottom`, `AlignMiddle`, +//! `AlignLength`, `AlignPercentage` and `NestedInlineBlocks` categories in +//! `crates/harness/src/inline_atomic_cases.rs`. If the generation loop admits it, the +//! corpus cannot tell 10.8.1's alignment rules from their absence and needs more cases, +//! not a weaker fixture (`specs/inline-atomic.md`). + +use dom::computed::{TextAlign, VerticalAlign}; +use layout::inline_atomic::{InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox}; + +/// The indices of `inlines` that go on each line box, in order. CSS 2.1 9.4.2: fill a +/// line left to right and start a new one when the next box would take it past the +/// content width; a box wider than the whole line goes alone on its own and overflows. +fn break_into_lines(inputs: &InlineAtomicInputs) -> Vec> { + let mut lines: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut used = 0.0; + for (i, inline) in inputs.inlines.iter().enumerate() { + if !current.is_empty() && used + inline.margin_box_width > inputs.content_width { + lines.push(std::mem::take(&mut current)); + used = 0.0; + } + current.push(i); + used += inline.margin_box_width; + } + if !current.is_empty() { + lines.push(current); + } + lines +} + +/// Where a line's leftover horizontal space goes (CSS 2.1 9.4.2's `text-align`): the +/// offset the first box on the line starts at. +fn line_start(inputs: &InlineAtomicInputs, indices: &[usize]) -> f64 { + let used: f64 = indices.iter().map(|&i| inputs.inlines[i].margin_box_width).sum(); + let leftover = (inputs.content_width - used).max(0.0); + match inputs.text_align { + TextAlign::Right => leftover, + TextAlign::Center => leftover / 2.0, + TextAlign::Left | TextAlign::Other => 0.0, + } +} + +pub fn resolve(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let mut placements = vec![InlinePlacement { x: 0.0, y: 0.0 }; inputs.inlines.len()]; + let mut lines: Vec = Vec::new(); + let mut top = 0.0; + + for indices in break_into_lines(inputs) { + // The strut: half the line height above the baseline and half below it, the + // container's font being zero-sized. + let mut above = inputs.line_height / 2.0; + // Nothing ever reaches below the baseline: every box's bottom edge is pinned to + // it, so the strut's own descent is all there is under the line's baseline. + let below = inputs.line_height / 2.0; + for &i in &indices { + // THE MISTAKE: the box's bottom margin edge goes on the baseline, so its top + // sits a full margin-box height above it -- `vertical_align` is never read + // and `inline.baseline` is never used. + above = above.max(inputs.inlines[i].margin_box_height); + } + let height = above + below; + + let mut x = line_start(inputs, &indices); + for &i in &indices { + let inline = &inputs.inlines[i]; + placements[i] = InlinePlacement { x, y: top + above - inline.margin_box_height }; + x += inline.margin_box_width; + } + lines.push(LineBox { top, height, baseline: above }); + top += height; + } + + // Referenced so the enum stays exhaustively handled somewhere in this candidate even + // though the alignment itself is skipped: a variant added to `VerticalAlign` should + // break this fixture's build rather than silently change what it is wrong about. + if let Some(first) = inputs.inlines.first() { + match first.vertical_align { + VerticalAlign::Baseline + | VerticalAlign::Top + | VerticalAlign::Bottom + | VerticalAlign::Middle + | VerticalAlign::Length(_) + | VerticalAlign::Other => {} + } + } + + let last_baseline = lines.last().map(|line| line.top + line.baseline); + InlineAtomicOutputs { placements, lines, total_height: top, last_baseline } +} diff --git a/crates/generation/fixtures/inline-atomic-candidates/baseline.rs b/crates/generation/fixtures/inline-atomic-candidates/baseline.rs new file mode 100644 index 00000000..85305346 --- /dev/null +++ b/crates/generation/fixtures/inline-atomic-candidates/baseline.rs @@ -0,0 +1,14 @@ +//! Baseline candidate: the deliberately wrong rule this function is seeded with +//! (`specs/inline-atomic.md`) -- every atomic inline on a line of its own at the +//! container's left content edge, `vertical-align`, `text-align` and the strut all +//! ignored, which is what the engine did for a `display: inline-block` box before the +//! function existed. Delegates to `layout::inline_atomic::resolve_baseline` rather than +//! reimplementing it, so the generation loop's baseline and the shipped baseline can +//! never drift from each other by accident (mirrors +//! `crates/generation/fixtures/transform-candidates/baseline.rs`). + +pub fn resolve( + inputs: &layout::inline_atomic::InlineAtomicInputs, +) -> layout::inline_atomic::InlineAtomicOutputs { + layout::inline_atomic::resolve_baseline(inputs) +} diff --git a/crates/generation/fixtures/inline-atomic-candidates/never_breaks_a_line.rs b/crates/generation/fixtures/inline-atomic-candidates/never_breaks_a_line.rs new file mode 100644 index 00000000..8743c176 --- /dev/null +++ b/crates/generation/fixtures/inline-atomic-candidates/never_breaks_a_line.rs @@ -0,0 +1,109 @@ +//! Seeded-wrong candidate: the strut, `vertical-align` in all six of its forms and +//! `text-align` are all right, but a line is never broken -- every atomic inline in the +//! container goes on one line box, however far past the content width it runs. +//! +//! A container whose boxes do fit across it comes out identical to the correct answer, so +//! this candidate is right on most of the corpus and wrong exactly where the line breaks: +//! the `LineBreak`, `WiderThanContainer` and `TextAlignCenter` categories in +//! `crates/harness/src/inline_atomic_cases.rs` (the last of those is built two lines' +//! worth deep for this reason -- its leftover space differs per line). If the generation +//! loop admits it, the corpus cannot tell line breaking from its absence and needs more +//! cases, not a weaker fixture (`specs/inline-atomic.md`). + +use dom::computed::{LengthPercentage, TextAlign, VerticalAlign}; +use layout::inline_atomic::{ + AtomicInline, InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox, +}; + +/// Where a line's leftover horizontal space goes (CSS 2.1 9.4.2's `text-align`): the +/// offset the first box on the line starts at. +fn line_start(inputs: &InlineAtomicInputs, indices: &[usize]) -> f64 { + let used: f64 = indices.iter().map(|&i| inputs.inlines[i].margin_box_width).sum(); + let leftover = (inputs.content_width - used).max(0.0); + match inputs.text_align { + TextAlign::Right => leftover, + TextAlign::Center => leftover / 2.0, + TextAlign::Left | TextAlign::Other => 0.0, + } +} + +/// How far the box's margin-box top sits from the line's baseline, positive downward, for +/// the alignments CSS 2.1 10.8.1 measures against the baseline rather than against the +/// line box. `None` for `top` and `bottom`, which cannot be placed until the line's own +/// extent is known. +fn top_relative_to_baseline(inline: &AtomicInline) -> Option { + match inline.vertical_align { + // The box's own baseline meets the line's. + VerticalAlign::Baseline | VerticalAlign::Other => Some(-inline.baseline), + // Raised by the shift, so its top moves up by that much again. A percentage has + // already been resolved into a length before it gets here. + VerticalAlign::Length(LengthPercentage::Length(px)) => Some(-inline.baseline - px), + VerticalAlign::Length(LengthPercentage::Percentage(_)) + | VerticalAlign::Length(LengthPercentage::Unresolved) => Some(-inline.baseline), + // The box's vertical midpoint meets the baseline; the parent's x-height is zero + // over a zero-size font, so there is nothing to add to it. + VerticalAlign::Middle => Some(-inline.margin_box_height / 2.0), + VerticalAlign::Top | VerticalAlign::Bottom => None, + } +} + +pub fn resolve(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + if inputs.inlines.is_empty() { + return InlineAtomicOutputs { + placements: Vec::new(), + lines: Vec::new(), + total_height: 0.0, + last_baseline: None, + }; + } + + // THE MISTAKE: one line box for every box in the container, whatever + // `inputs.content_width` says. The correct rule starts a new line box as soon as the + // next box would take the line past that width. + let indices: Vec = (0..inputs.inlines.len()).collect(); + + // The strut is half the line height above the baseline and half below it. + let mut above = inputs.line_height / 2.0; + let mut below = inputs.line_height / 2.0; + for &i in &indices { + let inline = &inputs.inlines[i]; + if let Some(top_rel) = top_relative_to_baseline(inline) { + above = above.max(-top_rel); + below = below.max(top_rel + inline.margin_box_height); + } + } + // A `top` box hangs from the line's top edge and a `bottom` box from its bottom, so + // either can only make the line taller, never move the baseline within what is + // already there. CSS 2.1 10.8 asks for the smallest line box that fits them: grow + // downward for a `top` box and upward for a `bottom` one. + for &i in &indices { + let inline = &inputs.inlines[i]; + let height = above + below; + match inline.vertical_align { + VerticalAlign::Top if inline.margin_box_height > height => { + below += inline.margin_box_height - height; + } + VerticalAlign::Bottom if inline.margin_box_height > height => { + above += inline.margin_box_height - height; + } + _ => {} + } + } + let height = above + below; + + let mut placements = vec![InlinePlacement { x: 0.0, y: 0.0 }; inputs.inlines.len()]; + let mut x = line_start(inputs, &indices); + for &i in &indices { + let inline = &inputs.inlines[i]; + let y = match inline.vertical_align { + VerticalAlign::Top => 0.0, + VerticalAlign::Bottom => height - inline.margin_box_height, + _ => above + top_relative_to_baseline(inline).unwrap_or(0.0), + }; + placements[i] = InlinePlacement { x, y }; + x += inline.margin_box_width; + } + + let lines = vec![LineBox { top: 0.0, height, baseline: above }]; + InlineAtomicOutputs { placements, lines, total_height: height, last_baseline: Some(above) } +} diff --git a/crates/generation/src/absolute_runner.rs b/crates/generation/src/absolute_runner.rs index a063dbd2..669f7006 100644 --- a/crates/generation/src/absolute_runner.rs +++ b/crates/generation/src/absolute_runner.rs @@ -56,6 +56,10 @@ pub fn candidate_dump(html: &str, resolver: AbsoluteResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/admit.rs b/crates/generation/src/admit.rs index cb08fb0d..a5c787aa 100644 --- a/crates/generation/src/admit.rs +++ b/crates/generation/src/admit.rs @@ -9,8 +9,9 @@ use std::path::Path; use anyhow::Result; use provenance::{ - closure_for, closure_hash, css21_absolute_positioning, css21_containing_block, css21_margin_collapse, - css21_percentage_resolution, css21_relative_positioning, css21_width, css_transforms_transform, + closure_for, closure_hash, css21_absolute_positioning, css21_containing_block, css21_inline_atomic, + css21_margin_collapse, css21_percentage_resolution, css21_relative_positioning, css21_width, + css_transforms_transform, normalize_spec_text, Hash, ProvenanceRecord, VerificationResult, }; @@ -359,6 +360,34 @@ pub fn record_for_transform( } } +/// [`record_for`]'s counterpart for the inline-atomic function (CSS 2.1 §9.4.2/§10.8, +/// `provenance::css21_inline_atomic`). No extra intermediate-fact check, the same as +/// [`record_for_containing_block`]: `report.admitted` is already the whole gate. +pub fn record_for_inline_atomic( + report: &CandidateReport, + model: &str, + prompt_hash: &Hash, + sampling_seed: u64, +) -> ProvenanceRecord { + let closure = closure_for(css21_inline_atomic::SECTION_ID, &css21_inline_atomic::Fixture); + ProvenanceRecord { + spec_url: css21_inline_atomic::SPEC_URL.to_string(), + section_id: css21_inline_atomic::SECTION_ID.to_string(), + spec_text_hash: Hash::of(normalize_spec_text(css21_inline_atomic::SPEC_TEXT)), + definition_closure_hash: closure_hash(&closure, &css21_inline_atomic::Fixture), + spec_level: css21_inline_atomic::SPEC_LEVEL.to_string(), + spec_revision: SPEC_REVISION.to_string(), + model: model.to_string(), + prompt_hash: prompt_hash.clone(), + sampling_seed, + verification: VerificationResult { + cases_run: report.cases_run, + cases_passed: report.cases_passed, + regression_free: report.admitted, + }, + } +} + /// Writes `record` as `.provenance.json` under `dir` /// (`docs/provenance.md` -> "Where the record lives"). pub fn write(dir: &Path, candidate_name: &str, record: &ProvenanceRecord) -> Result<()> { diff --git a/crates/generation/src/bin/feature_model_run.rs b/crates/generation/src/bin/feature_model_run.rs index e795841b..e1a4c737 100644 --- a/crates/generation/src/bin/feature_model_run.rs +++ b/crates/generation/src/bin/feature_model_run.rs @@ -502,7 +502,8 @@ fn main() -> Result<()> { | Seam::Height | Seam::Overflow | Seam::ContainingBlock - | Seam::Transform => { + | Seam::Transform + | Seam::InlineAtomic => { unreachable!("Args::parse only ever produces Seam::Reflect or Seam::Mutation") } } diff --git a/crates/generation/src/bin/inline_atomic_generation_loop.rs b/crates/generation/src/bin/inline_atomic_generation_loop.rs new file mode 100644 index 00000000..9d8caa6a --- /dev/null +++ b/crates/generation/src/bin/inline_atomic_generation_loop.rs @@ -0,0 +1,122 @@ +//! [`bin/transform_generation_loop.rs`](../transform_generation_loop.rs)'s counterpart +//! for the inline-atomic function: compiles the hand-written candidates in +//! `crates/generation/fixtures/inline-atomic-candidates` into one batch crate, runs every +//! candidate over the cached corpus, and gates each against the permanent regression set +//! (`generation::inline_atomic_runner::evaluate`). Requires +//! `inline_atomic_ground_truth_cache` to have already populated +//! `corpus/inline-atomic-ground-truth/`. +//! +//! Like the transform loop, this one never admits anything: `specs/inline-atomic.md` is +//! explicit that a correct implementation is not written by hand before every model batch +//! has failed, so there is no `correct` fixture here, only `baseline` and two +//! deliberately wrong candidates this binary proves the gate rejects. All three seed the +//! regression set (the comment at the seeding site says why the baseline alone is not +//! enough here either). + +use anyhow::{bail, Result}; +use generation::candidate_crate; +use generation::groundtruth::load_all; +use generation::inline_atomic_runner::passing_case_ids; +use generation::model::{CandidateSource, DirectoryCandidateSource}; +use generation::orchestrate::{repo_root, run_candidates, write_regression_set}; +use layout::inline_atomic::{resolve_baseline, InlineAtomicResolver}; + +fn main() -> Result<()> { + let repo_root = repo_root(); + let cache_dir = repo_root.join("corpus/inline-atomic-ground-truth"); + let cases = load_all(&cache_dir)?; + if cases.is_empty() { + bail!( + "no cached cases under {}; run the inline_atomic_ground_truth_cache binary first", + cache_dir.display() + ); + } + + let fixtures_dir = repo_root.join("crates/generation/fixtures/inline-atomic-candidates"); + let candidates = DirectoryCandidateSource { dir: fixtures_dir }.candidates()?; + println!( + "candidates: {}", + candidates.iter().map(|c| c.name.as_str()).collect::>().join(", ") + ); + + let crate_dir = repo_root.join("corpus/candidates/inline-atomic-generated"); + candidate_crate::write_for(candidate_crate::Seam::InlineAtomic, &crate_dir, &repo_root, &candidates)?; + let binary = candidate_crate::compile(&crate_dir, &crate_dir.join("target"))?; + + let regression_set_path = repo_root.join("corpus/inline-atomic-regression-set.json"); + let report_out = repo_root.join("corpus/candidates/inline-atomic-report.json"); + + // Seeded from every case any hand-written fixture passes -- the shipped (deliberately + // wrong) baseline's own and both seeded-wrong candidates' -- not the baseline's + // alone, the way `bin/transform_generation_loop.rs` and + // `bin/float_generation_loop.rs` already seed, and for the same reason. Each + // seeded-wrong fixture is wrong in one respect and right in the others, so the union + // is the set of cases known to be achievable, and a candidate wrong in any one of + // those respects loses some of it. + // + // The baseline alone would be worse here than anywhere: it puts every atomic inline + // on a line of its own at the container's left edge, which is the wrong answer for + // every case in this corpus except a container holding exactly one box wider than + // half its content width -- so a gate seeded from the baseline would have almost + // nothing to catch a wrong candidate on, and `never_breaks_a_line` (which is right + // about everything else) would sail through it. + if !regression_set_path.exists() { + let ungated = run_candidates(&binary, &cache_dir, None, &report_out)?; + let mut seed: Vec = passing_case_ids(resolve_baseline as InlineAtomicResolver, &cases); + for candidate in &ungated.candidates { + for id in &candidate.passed_case_ids { + if !seed.contains(id) { + seed.push(id.clone()); + } + } + } + write_regression_set(®ression_set_path, &seed)?; + println!( + "seeded the permanent regression set with {} case(s) at least one fixture already passes", + seed.len() + ); + } + + let gated = run_candidates(&binary, &cache_dir, Some(®ression_set_path), &report_out)?; + + println!("\n== per-candidate results =="); + for candidate in &gated.candidates { + println!( + "{:<28} {}/{} passed, admitted={}, regressed={}", + candidate.name, + candidate.cases_passed, + candidate.cases_run, + candidate.admitted, + candidate.regressed_cases.len(), + ); + let mut categories: Vec<_> = candidate.per_category.iter().collect(); + categories.sort_by_key(|(name, _)| (*name).clone()); + for (category, tally) in categories { + println!(" {category:<32} {}/{}", tally.passed, tally.total); + } + } + + // Prove the gate discriminates: every seeded-wrong candidate must not be admitted, + // given this fixture set. No `correct` candidate exists yet to also check the + // positive case with (this binary's own module doc). If one is admitted, the loop or + // the corpus is wrong, not the fixture (`specs/inline-atomic.md`). + let get = |name: &str| gated.candidates.iter().find(|c| c.name == name); + let wrong_names = ["aligns_every_bottom_edge", "never_breaks_a_line"]; + let mut discrimination_ok = true; + for name in wrong_names { + match get(name) { + Some(wrong) if wrong.admitted => { + discrimination_ok = false; + eprintln!("gate did not reject seeded-wrong candidate {name}"); + } + Some(_) => {} + None => bail!("no candidate named {name:?} in the batch"), + } + } + if !discrimination_ok { + bail!("the generation loop did not reject every seeded-wrong candidate"); + } + println!("\ngate rejected every seeded-wrong candidate"); + + Ok(()) +} diff --git a/crates/generation/src/bin/inline_atomic_ground_truth_cache.rs b/crates/generation/src/bin/inline_atomic_ground_truth_cache.rs new file mode 100644 index 00000000..692eeead --- /dev/null +++ b/crates/generation/src/bin/inline_atomic_ground_truth_cache.rs @@ -0,0 +1,143 @@ +//! [`bin/transform_ground_truth_cache.rs`](../transform_ground_truth_cache.rs)'s +//! counterpart for the inline-atomic function: runs the oracle once over +//! `harness::inline_atomic_cases::enumerated`/`random`, writing each case's engine dumps +//! to `corpus/inline-atomic-ground-truth/` (gitignored, and deliberately its own +//! directory -- no other corpus sets `display: inline-block`). +//! +//! Each case is voted before it is written and dropped outright when the engines +//! disagree beyond tolerance, the same way every other cache binary does it. This +//! function needs the drop count broken down by category as well, and for a reason of +//! its own: every container here sets `font-size: 0` so the strut's ascent and descent +//! are zero and no case depends on a font (`crates/harness/src/inline_atomic_cases.rs`'s +//! own module doc). If an engine treats a zero-size font's strut differently from the +//! other two, the cases that turn on the strut -- `StrutTallerThanContent`, +//! `AlignMiddle`, `BaselineDifferentHeights` -- drop together and the others do not, +//! which the per-category table below shows and a bare total would hide. +//! `specs/inline-atomic.md` is explicit that more than a fifth of a category dropping is +//! a finding to record, naming the engine and what it disagreed on, never a corpus bug +//! to paper over or a reason to loosen the 1/60 px tolerance +//! (`docs/geometry-verification.md`). + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::Result; +use generation::groundtruth::CachedCase; +use harness::inline_atomic_cases; +use harness::oracle::Oracle; + +/// Mirrors `bin/transform_ground_truth_cache.rs`'s own constants: modest, so the cache +/// stays cheap to rebuild on one machine. +const RANDOM_SAMPLE_SEED: u64 = 0xABC0FFEE; +const RANDOM_SAMPLE_COUNT: usize = 96; + +/// How many cases each category offered and how many survived the vote. +#[derive(Default, Clone, Copy)] +struct CategoryDrops { + offered: usize, + dropped: usize, +} + +fn main() -> Result<()> { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crate sits two levels below the repository root") + .to_path_buf(); + let out_dir = repo_root.join("corpus/inline-atomic-ground-truth"); + + let mut oracle = Oracle::start(&repo_root, None, None)?; + println!("engines: {}", oracle.engines.join(", ")); + + let mut cases = inline_atomic_cases::enumerated(); + cases.extend(inline_atomic_cases::random(RANDOM_SAMPLE_SEED, RANDOM_SAMPLE_COUNT)); + + let mut written = 0usize; + let mut dropped = 0usize; + let mut per_category: BTreeMap = BTreeMap::new(); + for case in &cases { + // The corpus's own text-free claim, checked once more against the markup that is + // actually about to be measured rather than only in the case file's tests: a + // text node between two atomic inlines would generate an inline box needing a + // font, which is exactly what this corpus is built to keep out. + assert!( + inline_atomic_cases::no_text_between_atomic_inlines(&case.html), + "{}: a text node reached the markup, which would make the measurement font-dependent", + case.id + ); + let categories: Vec = case.categories.iter().map(|c| format!("{c:?}")).collect(); + for category in &categories { + per_category.entry(category.clone()).or_default().offered += 1; + } + let drop_case = |per_category: &mut BTreeMap| { + for category in &categories { + per_category.entry(category.clone()).or_default().dropped += 1; + } + }; + + let (dumps, errors) = oracle.dump(&case.html)?; + for (engine, msg) in &errors { + eprintln!("{}: {engine} failed: {msg}", case.id); + } + let cached = CachedCase { + case_id: case.id.clone(), + seed: case.seed, + categories: categories.clone(), + html: case.html.clone(), + dumps: dumps.into_iter().collect(), + errors: errors.into_iter().collect(), + }; + + match cached.vote() { + Ok(vote_report) => { + let disagreements: Vec<_> = vote_report.untrustworthy().collect(); + if !disagreements.is_empty() { + eprintln!( + "{}: dropped, engines split on {} measurement(s) beyond tolerance", + case.id, + disagreements.len() + ); + drop_case(&mut per_category); + dropped += 1; + continue; + } + } + Err(e) => { + eprintln!("{}: dropped, no reliable vote ({e:?})", case.id); + drop_case(&mut per_category); + dropped += 1; + continue; + } + } + + cached.write(&out_dir)?; + written += 1; + } + + println!("wrote {written} case(s) to {}, dropped {dropped} for engine disagreement", out_dir.display()); + println!("\n== per-category drops =="); + let mut heavy: Vec<&String> = Vec::new(); + for (category, drops) in &per_category { + println!( + "{category:<32} {}/{} kept, {} dropped", + drops.offered - drops.dropped, + drops.offered, + drops.dropped + ); + if drops.offered > 0 && drops.dropped * 5 > drops.offered { + heavy.push(category); + } + } + if !heavy.is_empty() { + println!( + "\nmore than a fifth of these categories dropped: {}", + heavy.iter().map(|c| c.as_str()).collect::>().join(", ") + ); + println!( + "specs/inline-atomic.md asks for which engine disagreed and on what to be recorded \ + before the loop goes on; the per-case lines above name the split measurements" + ); + } + oracle.shutdown()?; + Ok(()) +} diff --git a/crates/generation/src/bin/inline_atomic_real_model_run.rs b/crates/generation/src/bin/inline_atomic_real_model_run.rs new file mode 100644 index 00000000..b0882a17 --- /dev/null +++ b/crates/generation/src/bin/inline_atomic_real_model_run.rs @@ -0,0 +1,643 @@ +//! [`bin/transform_real_model_run.rs`](../transform_real_model_run.rs)'s +//! counterpart for the inline-atomic function: draws candidates from the model +//! gateway, probes, compiles and scores each model's share as soon as that model +//! finishes, and admits the best one across every share against the permanent +//! regression set. Requires `inline_atomic_ground_truth_cache` and +//! `inline_atomic_generation_loop` to have run first, for the cached corpus and the +//! regression set respectively. +//! +//! Model list, provider and the stop condition are `specs/inline-atomic.md`'s: +//! `NEV_PROVIDER` unset so the gateway is used, `NEV_MODELS` naming each alias with +//! its own candidate count and concurrency, and the loop stopping on two flat +//! batches or after six, whichever comes first. `NEV_PROVIDER=claude-code` is the +//! fallback only if the gateway does not answer, and never `anthropic`. +//! +//! Per-model counts are printed the way `bin/overflow_real_model_run.rs` prints +//! them (`orchestrate::print_per_model_summary`, `best_per_model`), and the winner's +//! own model -- not the joined model-list label -- is what the provenance record +//! names, through the `model_of` lookup at admission. +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Instant; + +use anyhow::Result; +use generation::groundtruth::{load_all, CachedCase}; +use generation::model::{ + AnthropicClient, Candidate, CandidateSource, ClaudeCandidateSource, ClaudeCliCandidateSource, + ClaudeCliClient, HttpModelClient, RemoteCandidateSource, +}; +use generation::orchestrate::{ + candidate_index, count_per_model, improved_any_category, load_regression_set, print_per_model_summary, repo_root, + run_candidates, share_ident, write_regression_set, +}; +use generation::runner::{CandidateReport, CategoryTally}; +use generation::{admit, candidate_crate, prompt}; +use provenance::css21_inline_atomic; + +/// Same reasoning as `bin/real_model_run.rs`'s own `sample_by_category`: one case per +/// distinct failing category, rather than every failing document, keeps the prompt +/// representative without sending all of them. +fn sample_by_category<'a>(failing: &[&'a CachedCase]) -> Vec<&'a CachedCase> { + let mut seen = HashSet::new(); + failing + .iter() + .filter(|case| case.categories.iter().any(|cat| seen.insert(cat.clone()))) + .copied() + .collect() +} + +/// How many candidates one batch draws, read from `NEV_CANDIDATES` via +/// `generation::orchestrate::candidate_count` (default 8). A model list (`NEV_MODELS`) +/// can give each model its own count instead. +fn batch_candidate_count() -> usize { + generation::orchestrate::candidate_count() +} + +/// `specs/automated-repair-iteration.md`'s own cap: "do not run more than six" batches in +/// one unattended pass, regardless of the stop condition below. +const MAX_BATCHES: u32 = 6; + +/// `specs/automated-repair-iteration.md`'s stop condition: two consecutive batches with no +/// per-category improvement (`orchestrate::improved_any_category`) end the loop before +/// `MAX_BATCHES` is reached. +const STOP_AFTER_CONSECUTIVE_NO_IMPROVEMENT: u32 = 2; + +/// `MAX_BATCHES`, or fewer when `NEV_MAX_BATCHES` asks for fewer. A value above +/// `MAX_BATCHES`, zero, or unparsable falls back to `MAX_BATCHES` with a note. +fn max_batches() -> u32 { + parse_max_batches(generation::env::var("MAX_BATCHES").ok().as_deref()) +} + +fn parse_max_batches(value: Option<&str>) -> u32 { + match value { + None => MAX_BATCHES, + Some(value) => match value.trim().parse::() { + Ok(n) if (1..=MAX_BATCHES).contains(&n) => n, + _ => { + eprintln!("NEV_MAX_BATCHES={value:?} is not between 1 and {MAX_BATCHES}, using {MAX_BATCHES}"); + MAX_BATCHES + } + }, + } +} + +enum Provider { + Local(HttpModelClient), + Claude(AnthropicClient), + ClaudeCli(ClaudeCliClient), +} + +/// The paths a batch works against, fixed for the whole run. +struct RunPaths { + repo_root: PathBuf, + cache_dir: PathBuf, + regression_set_path: PathBuf, + target_dir: PathBuf, + generated_dir: PathBuf, + /// Held around every candidate-crate build (`score_share`): builds share one target + /// directory and one output binary path, and a mixed batch scores shares concurrently. + build_lock: Mutex<()>, +} + +/// One model's share of a batch -- or a whole batch from a single-model provider -- after +/// probing, compiling and scoring (`score_share`), before anything is admitted. +struct ScoredShare { + /// Every candidate drawn into this share, by name, with the model that produced it. + model_of: BTreeMap, + /// The names that compiled on their own. + compiled: Vec, + /// One report per compiled candidate that scored. + reports: Vec, +} + +/// Every share of a batch merged into one: the model of every candidate, the names that +/// compiled, and the reports in candidate order rather than share-completion order. +fn merge_shares(shares: Vec) -> ScoredShare { + let mut merged = ScoredShare { model_of: BTreeMap::new(), compiled: Vec::new(), reports: Vec::new() }; + for share in shares { + merged.model_of.extend(share.model_of); + merged.compiled.extend(share.compiled); + merged.reports.extend(share.reports); + } + merged.reports.sort_by_key(|r| candidate_index(&r.name)); + merged.compiled.sort_by_key(|name| candidate_index(name)); + merged +} + +/// The admitted candidate with the most passed cases (the earliest on a tie), and the +/// model that produced it -- the model the provenance names. `fallback_model` covers a +/// candidate `model_of` does not know, which a single-model provider never leaves. +fn pick_winner<'a>( + reports: &'a [CandidateReport], + model_of: &'a BTreeMap, + fallback_model: &'a str, +) -> Option<(&'a CandidateReport, &'a str)> { + let mut best: Option<&CandidateReport> = None; + for report in reports { + if report.admitted && best.is_none_or(|b| report.cases_passed > b.cases_passed) { + best = Some(report); + } + } + best.map(|winner| (winner, model_of.get(&winner.name).map(String::as_str).unwrap_or(fallback_model))) +} + +/// Probes each candidate on its own, compiles the survivors as one crate and scores them +/// against cached ground truth, admitting nothing -- `conclude_batch` picks the winner +/// across every share. When a batch mixes models this runs on each model's own draw +/// thread as soon as that model's last reply lands +/// (`RemoteCandidateSource::candidates_by_model`), so a fast model's share is scored while +/// a slow model is still drawing. Every candidate crate builds to the same target path, so +/// `paths.build_lock` covers each build and the copy of the scored binary out of it; +/// scoring then runs outside the lock. +fn score_share( + candidates: Vec, + share_label: &str, + batch_dir: &Path, + paths: &RunPaths, + fallback_model: &str, +) -> Result { + let sources_dir = batch_dir.join("sources"); + let replies_dir = batch_dir.join("replies"); + fs::create_dir_all(&sources_dir)?; + fs::create_dir_all(&replies_dir)?; + let model_of: BTreeMap = candidates + .iter() + .map(|c| (c.name.clone(), c.model.clone().unwrap_or_else(|| fallback_model.to_string()))) + .collect(); + for candidate in &candidates { + fs::write(sources_dir.join(format!("{}.rs", candidate.name)), &candidate.source)?; + if let Some(reply) = &candidate.reply { + fs::write(replies_dir.join(format!("{}.txt", candidate.name)), reply)?; + } + if let Some(t) = candidate.telemetry { + println!( + "{:<12} {:<22} {} prompt + {} completion = {} tokens, {} ms, {} source bytes, stop_reason={}", + candidate.name, + model_of[&candidate.name], + t.prompt_tokens, + t.completion_tokens, + t.total_tokens, + t.wall_clock_ms, + candidate.source.len(), + candidate.stop_reason.as_deref().unwrap_or("none"), + ); + } + } + + // Each candidate is probed on its own first, same reasoning as + // `bin/real_model_run.rs`: one candidate that fails to compile should not take the + // whole batch down with it. The probes build together in one cargo invocation + // (`candidate_crate::probe`), each share in its own probe workspace; every probe and + // scoring crate across every batch shares one target directory so cargo reuses the + // dependency build. The compiler's messages per candidate go to `compile-errors/`. + let probes_started = Instant::now(); + let probe_count = candidates.len(); + let ident = share_ident(share_label); + let outcomes = { + let _build = paths.build_lock.lock().expect("no share panics while building"); + candidate_crate::probe_batch( + candidate_crate::Seam::InlineAtomic, + &batch_dir.join("probe").join(&ident), + &paths.repo_root, + &candidates, + &paths.target_dir, + )? + }; + candidate_crate::write_compile_errors(&batch_dir.join("compile-errors"), &outcomes)?; + let mut surviving = Vec::new(); + for (candidate, outcome) in candidates.into_iter().zip(&outcomes) { + if outcome.compiled { + surviving.push(candidate); + } else { + println!("{}: failed to compile, dropped from the batch", candidate.name); + } + } + println!("{share_label}: {}/{} candidate(s) compiled", surviving.len(), model_of.len()); + println!( + "timing: {share_label}: probed {probe_count} candidate(s) in {:.1}s", + probes_started.elapsed().as_secs_f64() + ); + let compiled: Vec = surviving.iter().map(|c| c.name.clone()).collect(); + if surviving.is_empty() { + return Ok(ScoredShare { model_of, compiled, reports: Vec::new() }); + } + + let crate_dir = batch_dir.join(format!("generated-{ident}")); + candidate_crate::write_for(candidate_crate::Seam::InlineAtomic, &crate_dir, &paths.repo_root, &surviving)?; + let binary = crate_dir.join("candidates-scored"); + let scoring_build_started = Instant::now(); + { + let _build = paths.build_lock.lock().expect("no share panics while building"); + let built = candidate_crate::compile(&crate_dir, &paths.target_dir)?; + fs::copy(&built, &binary)?; + } + println!( + "timing: {share_label}: built the scoring crate in {:.1}s", + scoring_build_started.elapsed().as_secs_f64() + ); + let scoring_started = Instant::now(); + let report = run_candidates( + &binary, + &paths.cache_dir, + Some(&paths.regression_set_path), + &batch_dir.join(format!("report-{ident}.json")), + )?; + println!( + "timing: {share_label}: scored {} candidate(s) in {:.1}s", + compiled.len(), + scoring_started.elapsed().as_secs_f64() + ); + Ok(ScoredShare { model_of, compiled, reports: report.candidates }) +} + +/// Everything a batch does once every share is scored: report compile counts and scores +/// per model, pick the best admitted candidate across all shares, and commit it if it +/// beats what is already committed. Returns the admitted candidate's per-category tally, +/// or an empty map when nothing was admitted -- what `improved_any_category` compares +/// batch to batch. +fn conclude_batch( + shares: Vec, + requested: usize, + batch_dir: &Path, + paths: &RunPaths, + built_prompt: &prompt::Prompt, + model_label: &str, + regression_set: &mut Vec, +) -> Result> { + let sources_dir = batch_dir.join("sources"); + let ScoredShare { model_of, compiled, reports } = merge_shares(shares); + + println!("\n{}/{requested} candidate(s) compiled", compiled.len()); + let drawn_per_model = count_per_model(model_of.keys(), &model_of); + let compiled_per_model = count_per_model(compiled.iter(), &model_of); + for (model, drawn) in &drawn_per_model { + println!(" {model}: {}/{drawn} compiled", compiled_per_model.get(model).copied().unwrap_or(0)); + } + if reports.is_empty() { + println!("no candidate compiled and scored; nothing to evaluate this batch"); + return Ok(BTreeMap::new()); + } + fs::write(batch_dir.join("report.json"), serde_json::to_string_pretty(&reports)?)?; + + println!("\n== per-candidate results =="); + for candidate in &reports { + println!( + "{:<12} {:<22} {}/{} passed, admitted={}, regressed={}", + candidate.name, + model_of.get(&candidate.name).map(String::as_str).unwrap_or(model_label), + candidate.cases_passed, + candidate.cases_run, + candidate.admitted, + candidate.regressed_cases.len(), + ); + let mut categories: Vec<_> = candidate.per_category.iter().collect(); + categories.sort_by_key(|(name, _)| (*name).clone()); + for (category, tally) in categories { + println!(" {category:<32} {}/{}", tally.passed, tally.total); + } + } + + print_per_model_summary(&reports, &model_of); + + let Some((winner, winner_model)) = pick_winner(&reports, &model_of, model_label) else { + println!("\nno candidate from {model_label} was admitted this batch"); + return Ok(BTreeMap::new()); + }; + let record = admit::record_for_inline_atomic(winner, winner_model, &built_prompt.hash, prompt::HAND_WRITTEN_SEED); + let provenance_dir = batch_dir.join("provenance"); + admit::write(&provenance_dir, &winner.name, &record)?; + println!( + "\nadmitted {} from {}; wrote provenance record to {}", + winner.name, + winner_model, + provenance_dir.join(format!("{}.provenance.json", winner.name)).display() + ); + + // The engine builds from here, not from `corpus/` -- a worktree's `corpus/` does not + // survive its removal (`roadmap.md` -> "Decisions", committed candidates). Never let a + // later batch's admission overwrite a better already-committed candidate. + fs::create_dir_all(&paths.generated_dir)?; + let committed = admit::read_committed_score(&paths.generated_dir.join("inline_atomic.provenance.json")); + if admit::beats_committed(&committed, winner.cases_passed) { + let winner_source = fs::read_to_string(sources_dir.join(format!("{}.rs", winner.name)))?; + let header = format!( + "// Admitted candidate `{}` ({}, {}/{}); provenance: inline_atomic.provenance.json\n", + winner.name, winner_model, winner.cases_passed, winner.cases_run + ); + let winner_source = admit::rewrite_layout_imports(&winner_source); + fs::write(paths.generated_dir.join("inline_atomic.rs"), format!("{header}{winner_source}"))?; + admit::write(&paths.generated_dir, "inline_atomic", &record)?; + println!("committed the winner to {}", paths.generated_dir.join("inline_atomic.rs").display()); + println!( + "note: `pub mod inline_atomic;` in {} and the `layout*` entry points' default are a hand change beside this one", + paths.generated_dir.join("mod.rs").display() + ); + } else if let admit::CommittedScore::Existing { cases_passed, cases_run } = committed { + println!( + "{} scores {}/{}, not better than the committed {cases_passed}/{cases_run}; leaving {} unchanged", + winner.name, + winner.cases_passed, + winner.cases_run, + paths.generated_dir.join("inline_atomic.rs").display() + ); + } + + for id in &winner.passed_case_ids { + if !regression_set.contains(id) { + regression_set.push(id.clone()); + } + } + write_regression_set(&paths.regression_set_path, regression_set)?; + println!("regression set now covers {} case(s)", regression_set.len()); + + Ok(winner.per_category.clone()) +} + +fn main() -> Result<()> { + let repo_root = repo_root(); + let candidate_count = batch_candidate_count(); + + let (provider, model_label) = if generation::env::var("PROVIDER").as_deref() == Ok("anthropic") { + let client = match AnthropicClient::from_env() { + Ok(c) => c, + Err(e) => { + println!("no Claude credentials configured, skipping the real-model run: {e:#}"); + return Ok(()); + } + }; + println!("model: {} via Anthropic API", client.model); + let model_label = client.model.clone(); + (Provider::Claude(client), model_label) + } else if generation::env::var("PROVIDER").as_deref() == Ok("claude-code") { + let client = match ClaudeCliClient::from_env() { + Ok(c) => c, + Err(e) => { + println!("claude CLI not available, skipping the real-model run: {e:#}"); + return Ok(()); + } + }; + let model_label = format!("claude-code:{}", client.model.as_deref().unwrap_or("default")); + println!("model: {model_label} via claude CLI subprocess (subscription-billed)"); + (Provider::ClaudeCli(client), model_label) + } else { + let client = match HttpModelClient::from_env() { + Ok(c) => c, + Err(e) => { + println!("no model endpoint configured, skipping the real-model run: {e:#}"); + return Ok(()); + } + }; + match client.reachable() { + Ok(true) => {} + Ok(false) => { + println!( + "{} does not list {} among its models, skipping the real-model run", + client.base_url, client.model + ); + return Ok(()); + } + Err(e) => { + println!("{} is unreachable, skipping the real-model run: {e:#}", client.base_url); + return Ok(()); + } + } + println!("model endpoint reachable: {} ({})", client.base_url, client.model); + let model_label = client.model.clone(); + (Provider::Local(client), model_label) + }; + + let cache_dir = repo_root.join("corpus/inline-atomic-ground-truth"); + let cases = load_all(&cache_dir)?; + let regression_set_path = repo_root.join("corpus/inline-atomic-regression-set.json"); + if cases.is_empty() || !regression_set_path.exists() { + println!( + "no ground-truth cache or transform regression set yet; run inline_atomic_ground_truth_cache and inline_atomic_generation_loop first" + ); + return Ok(()); + } + let mut regression_set = load_regression_set(®ression_set_path)?; + + let run_dir = repo_root.join("corpus/candidates/inline-atomic-model-run"); + let seam_path = repo_root.join("crates/layout/src/inline_atomic.rs"); + let paths = RunPaths { + target_dir: run_dir.join("target"), + generated_dir: repo_root.join("crates/layout/src/generated"), + cache_dir, + regression_set_path, + repo_root, + build_lock: Mutex::new(()), + }; + + let loop_started = Instant::now(); + let mut previous_tally: BTreeMap = BTreeMap::new(); + let mut consecutive_no_improvement = 0u32; + let mut batches_run = 0u32; + + let max_batches = max_batches(); + for batch_num in 1..=max_batches { + // Every other generated function seeds its regression set from its own committed + // candidate's passes, so "outside the regression set" and "the engine still gets + // this wrong" mean the same thing and either can drive the prompt. Not here: + // `inline_atomic_generation_loop` seeds from the union of every fixture's passes + // (its own comment says why), and the two fixtures between them cover the whole + // corpus, so the set is complete from the first batch while the committed + // candidate -- the deliberately wrong vertical stacking -- passes none of it. + // Sampling the empty difference would draw no batch and answer nothing, the same + // dead end `bin/height_real_model_run.rs` hit from the opposite direction, so the + // prompt is sampled over the whole corpus instead. The ordinary stop condition + // still applies (unlike there, where one challenge batch is the whole question): + // a batch here can genuinely improve on the one before it. + let outstanding: Vec<_> = cases.iter().filter(|c| !regression_set.contains(&c.case_id)).collect(); + let whole_corpus = outstanding.is_empty(); + let pool: Vec<_> = if whole_corpus { cases.iter().collect() } else { outstanding }; + let sampled = sample_by_category(&pool); + if sampled.is_empty() { + println!("\nno cases in the corpus at all; nothing to draw a prompt from"); + break; + } + + batches_run = batch_num; + println!("\n===== batch {batch_num}/{max_batches} ====="); + println!( + "{} case(s) to show{}, sampled to {} (one per category)", + pool.len(), + if whole_corpus { + " (the regression set already covers the corpus, so the prompt is sampled over all of it)" + } else { + " (outside the regression set)" + }, + sampled.len() + ); + + let seam_source = fs::read_to_string(&seam_path)?; + let built_prompt = prompt::build_inline_atomic(css21_inline_atomic::SPEC_TEXT, &seam_source, &sampled); + println!("prompt: {} bytes, hash {}", built_prompt.text.len(), built_prompt.hash); + + let batch_dir = run_dir.join(format!("batch-{batch_num}")); + fs::create_dir_all(&batch_dir)?; + fs::write(batch_dir.join("prompt.txt"), &built_prompt.text)?; + + println!("\n== candidates =="); + let (shares, requested) = match &provider { + Provider::Local(client) => { + let source = RemoteCandidateSource { + client, + prompt: &built_prompt.text, + count: candidate_count, + name_prefix: "model", + }; + // Each model's share is scored on its own draw thread as soon as that + // model finishes, so a slow model in the list does not also hold back + // every other model's scoring. + let draw_started = Instant::now(); + let shares = source + .candidates_by_model(|model, candidates| { + println!( + "timing: {}: drew {} candidate(s) in {:.1}s", + model.model, + candidates.len(), + draw_started.elapsed().as_secs_f64() + ); + score_share(candidates, &model.model, &batch_dir, &paths, &model.model) + }) + .into_iter() + .collect::>>()?; + // A model list (`NEV_MODELS`) can give each model its own count, so + // the batch's size is their sum rather than `NEV_CANDIDATES`. + let requested = client.models.iter().map(|m| m.count.unwrap_or(candidate_count)).sum(); + (shares, requested) + } + Provider::Claude(client) => { + let source = ClaudeCandidateSource { + client, + prompt: &built_prompt.text, + count: candidate_count, + name_prefix: "model", + }; + let share = score_share(source.candidates()?, &model_label, &batch_dir, &paths, &model_label)?; + (vec![share], candidate_count) + } + Provider::ClaudeCli(client) => { + let source = ClaudeCliCandidateSource { + client, + prompt: &built_prompt.text, + count: candidate_count, + name_prefix: "model", + }; + let share = score_share(source.candidates()?, &model_label, &batch_dir, &paths, &model_label)?; + (vec![share], candidate_count) + } + }; + let this_tally = conclude_batch( + shares, + requested, + &batch_dir, + &paths, + &built_prompt, + &model_label, + &mut regression_set, + )?; + + if improved_any_category(&previous_tally, &this_tally) { + consecutive_no_improvement = 0; + } else { + consecutive_no_improvement += 1; + println!( + "batch {batch_num}: no per-category improvement over the previous batch ({consecutive_no_improvement}/{STOP_AFTER_CONSECUTIVE_NO_IMPROVEMENT} consecutive with none)" + ); + } + previous_tally = this_tally; + + if consecutive_no_improvement >= STOP_AFTER_CONSECUTIVE_NO_IMPROVEMENT { + println!( + "\n{consecutive_no_improvement} consecutive batches with no per-category improvement; stopping" + ); + break; + } + } + + println!("\n{batches_run} batch(es) run in {:.1}s", loop_started.elapsed().as_secs_f64()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(name: &str, cases_passed: u64, admitted: bool) -> CandidateReport { + CandidateReport { + name: name.to_string(), + cases_run: 111, + cases_passed, + admitted, + regressed_cases: Vec::new(), + per_category: BTreeMap::new(), + passed_case_ids: Vec::new(), + failing_case_diffs: Vec::new(), + } + } + + fn share(pairs: &[(&str, &str)], compiled: &[&str], reports: Vec) -> ScoredShare { + ScoredShare { + model_of: pairs.iter().map(|(n, m)| (n.to_string(), m.to_string())).collect(), + compiled: compiled.iter().map(|n| n.to_string()).collect(), + reports, + } + } + + /// The slower model's share lands second, yet the merged listing reads in draw order. + #[test] + fn merged_shares_list_reports_in_candidate_order() { + let flash = share(&[("model_0", "flash"), ("model_2", "flash")], &["model_0", "model_2"], vec![ + report("model_0", 70, true), + report("model_2", 90, true), + ]); + let fast = share(&[("model_1", "fast"), ("model_10", "fast")], &["model_10"], vec![report("model_10", 85, false)]); + let merged = merge_shares(vec![fast, flash]); + let names: Vec<_> = merged.reports.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, ["model_0", "model_2", "model_10"]); + assert_eq!(merged.compiled, ["model_0", "model_2", "model_10"]); + assert_eq!(merged.model_of.len(), 4); + } + + /// Provenance names the winner's own model, never the joined model-list label, and an + /// unadmitted candidate never wins however high it scores. + #[test] + fn the_winner_is_the_best_admitted_candidate_under_its_own_model() { + let reports = vec![report("model_0", 70, true), report("model_1", 99, false), report("model_2", 92, true)]; + let model_of: BTreeMap = [("model_0", "flash"), ("model_1", "flash"), ("model_2", "fast")] + .iter() + .map(|(n, m)| (n.to_string(), m.to_string())) + .collect(); + let (winner, model) = pick_winner(&reports, &model_of, "flash+fast").expect("an admitted candidate"); + assert_eq!(winner.name, "model_2"); + assert_eq!(model, "fast"); + } + + #[test] + fn nothing_admitted_means_no_winner() { + let reports = vec![report("model_0", 85, false)]; + assert!(pick_winner(&reports, &BTreeMap::new(), "flash").is_none()); + } + + #[test] + fn a_single_model_provider_falls_back_to_its_own_label() { + let reports = vec![report("model_0", 80, true)]; + let model_of = BTreeMap::new(); + let (_, model) = pick_winner(&reports, &model_of, "claude-code:default").expect("admitted"); + assert_eq!(model, "claude-code:default"); + } + + #[test] + fn max_batches_accepts_one_to_six_and_falls_back_otherwise() { + assert_eq!(parse_max_batches(None), 6); + assert_eq!(parse_max_batches(Some("1")), 1); + assert_eq!(parse_max_batches(Some(" 6 ")), 6); + assert_eq!(parse_max_batches(Some("0")), 6); + assert_eq!(parse_max_batches(Some("7")), 6); + assert_eq!(parse_max_batches(Some("many")), 6); + } +} diff --git a/crates/generation/src/candidate_crate.rs b/crates/generation/src/candidate_crate.rs index 85e05d41..0dcd6e16 100644 --- a/crates/generation/src/candidate_crate.rs +++ b/crates/generation/src/candidate_crate.rs @@ -62,6 +62,9 @@ pub enum Seam { /// `pub fn resolve` of type `layout::transform::TransformResolver`; /// `crate::transform_runner::main`. Transform, + /// `pub fn resolve` of type `layout::inline_atomic::InlineAtomicResolver`; + /// `crate::inline_atomic_runner::main`. + InlineAtomic, } impl Seam { @@ -77,7 +80,8 @@ impl Seam { | Seam::Height | Seam::Overflow | Seam::ContainingBlock - | Seam::Transform => { + | Seam::Transform + | Seam::InlineAtomic => { let f = if self == Seam::Mutation { "apply" } else { "resolve" }; format!(" (\"{name}\", candidates::{name}::{f}),\n") } @@ -128,6 +132,10 @@ impl Seam { Seam::Transform => { ("(&str, layout::transform::TransformResolver)", "generation::transform_runner::main") } + Seam::InlineAtomic => ( + "(&str, layout::inline_atomic::InlineAtomicResolver)", + "generation::inline_atomic_runner::main", + ), } } } @@ -652,4 +660,13 @@ mod tests { )); assert!(height.contains("generation::height_runner::main(registry)")); } + + #[test] + fn inline_atomic_registers_its_own_function_and_runner() { + let inline_atomic = main_rs_for(Seam::InlineAtomic); + assert!(inline_atomic.contains( + "let registry: &[(&str, layout::inline_atomic::InlineAtomicResolver)] = &[\n (\"c\", candidates::c::resolve),\n" + )); + assert!(inline_atomic.contains("generation::inline_atomic_runner::main(registry)")); + } } diff --git a/crates/generation/src/containing_block_runner.rs b/crates/generation/src/containing_block_runner.rs index 40fab7de..7240303a 100644 --- a/crates/generation/src/containing_block_runner.rs +++ b/crates/generation/src/containing_block_runner.rs @@ -56,6 +56,10 @@ pub fn candidate_dump(html: &str, resolver: ContainingBlockResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/float_runner.rs b/crates/generation/src/float_runner.rs index c4b7e4ec..822aa77e 100644 --- a/crates/generation/src/float_runner.rs +++ b/crates/generation/src/float_runner.rs @@ -53,6 +53,10 @@ pub fn candidate_dump(html: &str, resolver: FloatResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/height_runner.rs b/crates/generation/src/height_runner.rs index 49e5ed46..6e948d00 100644 --- a/crates/generation/src/height_runner.rs +++ b/crates/generation/src/height_runner.rs @@ -70,6 +70,10 @@ pub fn candidate_dump(html: &str, resolver: HeightResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/inline_atomic_runner.rs b/crates/generation/src/inline_atomic_runner.rs new file mode 100644 index 00000000..24807602 --- /dev/null +++ b/crates/generation/src/inline_atomic_runner.rs @@ -0,0 +1,445 @@ +//! [`crate::transform_runner`]'s counterpart for the inline-atomic function +//! (`crates/layout/src/inline_atomic.rs`, `specs/inline-atomic.md`). Structurally +//! identical: same scoring, same regression-set admission gate, no extra +//! intermediate-fact check the way `crate::margin_runner` adds one against +//! `crates/logic`'s rules -- there is no analogous rules engine for line boxes to +//! cross-check against, so geometry-regression-free is this function's whole admission +//! gate too. +//! +//! Every other function runs at its own admitted candidate rather than its baseline, the +//! same choice `crate::transform_runner` makes and for the same reason: this corpus's +//! documents give every box an explicit width and height, but the container's own width, +//! its padding and border resolution and its auto height are still decided by the width, +//! percentage and height functions, and a case must not fail because one of those got +//! the geometry around the line boxes wrong. Only where the atomic inlines land is under +//! test. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{bail, Context, Result}; +use dom::Document; +use harness::compare::{ + compare, relative_offset, CaseComparison, CaseReport, Consensus, Metric, VoteMeasurement, +}; +use harness::dump::{Dump, Node as DumpNode, Rect, Viewport, TOLERANCE_PX}; +use layout::boxtree::BoxTree; +use layout::fragment; +use layout::inline_atomic::InlineAtomicResolver; + +use crate::groundtruth::CachedCase; +use crate::runner::{CandidateReport, CaseGeometryDiff, CategoryTally, Report}; + +/// The harness's fixed viewport, matching `crate::runner::VIEWPORT_WIDTH`. +const VIEWPORT_WIDTH: f64 = 800.0; +const VIEWPORT_HEIGHT: f64 = 600.0; + +/// Renders `html` through the real layout path with every other function held at its own +/// admitted candidate (this module's own doc comment) and `resolver` standing in for the +/// inline-atomic function. Mirrors `crate::transform_runner::candidate_dump`, generalized +/// over which resolver runs. +pub fn candidate_dump(html: &str, resolver: InlineAtomicResolver) -> Dump { + let document = Document::parse(html); + let elements = document.elements_in_document_order(); + let tree = BoxTree::build(&document); + let root_fragment = fragment::layout_with_resolvers( + &document, + &tree, + (VIEWPORT_WIDTH, VIEWPORT_HEIGHT), + layout::generated_width_resolve, + layout::generated_margin_classify, + layout::generated_margin_combine, + layout::generated_percentage_resolve, + layout::generated_absolute_resolve, + layout::generated_float_resolve, + layout::generated_relpos_resolve, + layout::generated_height_resolve, + layout::generated_containing_block_resolve, + layout::generated_transform_resolve, + resolver, + ); + + let mut rects: BTreeMap = BTreeMap::new(); + root_fragment.walk_absolute(0.0, 0.0, &mut |node, x, y, w, h| { + rects.insert(node, Rect { x, y, w, h }); + }); + + let mut index_of: BTreeMap = BTreeMap::new(); + for (i, &el) in elements.iter().enumerate() { + index_of.insert(el, i); + } + + let nodes = elements + .iter() + .enumerate() + .map(|(index, el)| { + let parent = el.dom_parent().map(|p| index_of[&p] as i64).unwrap_or(-1); + let mut depth = 0u32; + let mut cur = *el; + while let Some(p) = cur.dom_parent() { + depth += 1; + cur = p; + } + let rect = rects.get(el).copied(); + DumpNode { + index, + parent, + tag: el.tag(), + depth, + bounding: rect.unwrap_or(Rect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }), + rects: rect.map(|r| vec![r]).unwrap_or_default(), + scroll: None, + style: Default::default(), + } + }) + .collect(); + + Dump { + engine: "candidate".to_string(), + engine_version: "0".to_string(), + viewport: Viewport { width: 800, height: 600 }, + overflowed: false, + nodes, + } +} + +fn majority_value(m: &VoteMeasurement) -> Option { + match &m.consensus { + Consensus::Split => None, + Consensus::Unanimous => m.values.values().next().copied(), + Consensus::Majority { outlier } => { + m.values.iter().find(|(engine, _)| *engine != outlier).map(|(_, v)| *v) + } + } +} + +fn candidate_value(candidate: &Dump, m: &VoteMeasurement) -> Option { + let node = candidate.nodes.get(m.node_index)?; + Some(match m.metric { + Metric::Width => node.bounding.w, + Metric::Height => node.bounding.h, + Metric::OffsetX => relative_offset(candidate, node).0, + Metric::OffsetY => relative_offset(candidate, node).1, + Metric::FragmentCount => node.rects.len() as f64, + Metric::FragmentWidth(i) => node.rects.get(i)?.w, + Metric::FragmentHeight(i) => node.rects.get(i)?.h, + }) +} + +fn agrees_with_vote(candidate: &Dump, vote_report: &harness::compare::VoteReport) -> bool { + vote_report.measurements.iter().filter(|m| m.is_trustworthy()).all(|m| { + let Some(reference) = majority_value(m) else { return true }; + match candidate_value(candidate, m) { + Some(v) => (v - reference).abs() <= TOLERANCE_PX, + None => false, + } + }) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Outcome { + Passed, + Failed, + Untrustworthy, +} + +/// Scores one case and keeps the per-node numeric error alongside the verdict, the same +/// contract as `crate::runner::score_case`. The [`CaseReport`] is `None` when the case +/// never reached geometry comparison -- a discarded case, or node sequences that do not +/// line up at all. +fn score_case(case: &CachedCase, resolver: InlineAtomicResolver) -> (Outcome, Option) { + let Some(reference) = case.dumps.get("chromium").or_else(|| case.dumps.values().next()) else { + return (Outcome::Untrustworthy, None); + }; + let candidate = candidate_dump(&case.html, resolver); + + match compare(&candidate, reference) { + CaseComparison::Discarded(_) => (Outcome::Untrustworthy, None), + CaseComparison::ShapeMismatch(_) => (Outcome::Failed, None), + CaseComparison::Compared(pairwise) => { + let outcome = match case.vote() { + Ok(vote_report) => { + if agrees_with_vote(&candidate, &vote_report) { + Outcome::Passed + } else { + Outcome::Failed + } + } + Err(_) => { + if pairwise.passed { + Outcome::Passed + } else { + Outcome::Failed + } + } + }; + (outcome, Some(pairwise)) + } + } +} + +pub fn passing_case_ids(resolver: InlineAtomicResolver, cases: &[CachedCase]) -> Vec { + cases + .iter() + .filter(|case| score_case(case, resolver).0 == Outcome::Passed) + .map(|case| case.case_id.clone()) + .collect() +} + +pub fn evaluate( + registry: &[(&str, InlineAtomicResolver)], + cases: &[CachedCase], + regression_set: Option<&[String]>, +) -> Report { + let candidates = registry + .iter() + .filter_map(|(name, resolver)| { + // A candidate that panics mid-corpus would otherwise unwind out of this loop + // and take the whole batch's process down with it, losing every other + // candidate's score -- the same isolation `crate::transform_runner::evaluate` + // gives for the same reason. Easier to hit here than in most functions: a + // candidate that indexes its own `placements` by line rather than by box + // panics on the first case that breaks a line. + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + score_candidate(name, *resolver, cases, regression_set) + })) + .map_err(|_| { + println!("{name}: panicked while scoring, dropped from the batch"); + }) + .ok() + }) + .collect(); + + Report { candidates } +} + +/// One candidate's pass over the whole corpus. Split out of [`evaluate`] so a panicking +/// candidate can be caught and dropped without unwinding past the others. +fn score_candidate( + name: &str, + resolver: InlineAtomicResolver, + cases: &[CachedCase], + regression_set: Option<&[String]>, +) -> CandidateReport { + let mut cases_run = 0u64; + let mut cases_passed = 0u64; + let mut passed_ids: Vec = Vec::new(); + let mut per_category: BTreeMap = BTreeMap::new(); + let mut failing_case_diffs: Vec = Vec::new(); + + for case in cases { + let (outcome, case_report) = score_case(case, resolver); + if outcome == Outcome::Untrustworthy { + continue; + } + cases_run += 1; + let passed = outcome == Outcome::Passed; + if passed { + cases_passed += 1; + passed_ids.push(case.case_id.clone()); + } else if let Some(report) = &case_report { + failing_case_diffs.push(CaseGeometryDiff::from_case_report(&case.case_id, report)); + } + for category in &case.categories { + let tally = per_category.entry(category.clone()).or_default(); + tally.total += 1; + if passed { + tally.passed += 1; + } + } + } + + let regressed_cases: Vec = regression_set + .unwrap_or(&[]) + .iter() + .filter(|id| !passed_ids.contains(id)) + .cloned() + .collect(); + + CandidateReport { + name: name.to_string(), + cases_run, + cases_passed, + admitted: regressed_cases.is_empty(), + regressed_cases, + per_category, + passed_case_ids: passed_ids, + failing_case_diffs, + } +} + +/// Entry point a generated inline-atomic candidate crate's `main.rs` calls +/// (`crate::candidate_crate::write_for` with `Seam::InlineAtomic`), the same +/// environment-variable contract `crate::transform_runner::main` uses. +pub fn main(registry: &[(&str, InlineAtomicResolver)]) -> Result<()> { + let cache_dir = PathBuf::from( + std::env::var("GENERATION_CACHE_DIR") + .context("GENERATION_CACHE_DIR must name the ground-truth cache directory")?, + ); + let cases = crate::groundtruth::load_all(&cache_dir)?; + if cases.is_empty() { + bail!("no cached cases under {}; run the ground_truth_cache binary first", cache_dir.display()); + } + + let regression_set: Option> = match std::env::var("GENERATION_REGRESSION_SET") { + Ok(path) => { + let json = fs::read_to_string(&path).with_context(|| format!("reading {path}"))?; + Some(serde_json::from_str(&json).with_context(|| format!("parsing {path}"))?) + } + Err(_) => None, + }; + + let report = evaluate(registry, &cases, regression_set.as_deref()); + let json = serde_json::to_string_pretty(&report)?; + if let Ok(out) = std::env::var("GENERATION_REPORT_OUT") { + fs::write(&out, &json).with_context(|| format!("writing {out}"))?; + } + println!("{json}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use layout::inline_atomic::{ + InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox, + }; + + /// A one-line, left-aligned, baseline-only implementation: enough to place the + /// `OneLineEqualHeights` recipe correctly, used only to check this runner's own + /// scoring plumbing. It never breaks a line and ignores `vertical-align` and + /// `text-align`. Not registered anywhere the generation loop reads. + fn resolve_one_line(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let tallest = inputs.inlines.iter().map(|i| i.margin_box_height).fold(0.0, f64::max); + let height = tallest.max(inputs.line_height); + let mut x = 0.0; + let mut placements = Vec::with_capacity(inputs.inlines.len()); + for inline in &inputs.inlines { + placements.push(InlinePlacement { x, y: height - inline.margin_box_height }); + x += inline.margin_box_width; + } + let lines = if inputs.inlines.is_empty() { + Vec::new() + } else { + vec![LineBox { top: 0.0, height, baseline: height }] + }; + let last_baseline = lines.last().map(|line| line.top + line.baseline); + let total_height = if lines.is_empty() { 0.0 } else { height }; + InlineAtomicOutputs { placements, lines, total_height, last_baseline } + } + + const RESET: &str = "* { margin: 0; padding: 0; border: 0; box-sizing: content-box; }\nhtml, body { display: block; }\n"; + + fn find_index(html: &str, class: &str) -> usize { + let document = Document::parse(html); + document + .elements_in_document_order() + .into_iter() + .enumerate() + .find(|(_, e)| document.nodes()[e.index()].as_element().unwrap().attr("class") == Some(class)) + .map(|(i, _)| i) + .unwrap_or_else(|| panic!("no element with class={class:?}")) + } + + fn one_line_document() -> String { + format!( + "
" + ) + } + + #[test] + fn the_baseline_stacks_two_atomic_inlines_vertically() { + let html = one_line_document(); + let dump = candidate_dump(&html, layout::inline_atomic::resolve_baseline); + let a = &dump.nodes[find_index(&html, "a")]; + let b = &dump.nodes[find_index(&html, "b")]; + assert_eq!(relative_offset(&dump, a), (0.0, 0.0)); + assert_eq!(relative_offset(&dump, b), (0.0, 30.0), "under the first box, not beside it"); + } + + #[test] + fn a_candidate_that_builds_a_line_puts_them_side_by_side() { + let html = one_line_document(); + let dump = candidate_dump(&html, resolve_one_line); + let a = &dump.nodes[find_index(&html, "a")]; + let b = &dump.nodes[find_index(&html, "b")]; + assert_eq!(relative_offset(&dump, a), (0.0, 0.0)); + assert_eq!(relative_offset(&dump, b), (60.0, 0.0), "beside the first box, on the same line"); + assert_eq!((b.bounding.w, b.bounding.h), (60.0, 30.0), "placing a box never resizes it"); + } + + /// The container's auto height is the resolver's total height (CSS 2.1 10.6.3), so + /// which resolver runs changes the container's own geometry too, not just its + /// children's. + fn container_height(html: &str, resolver: InlineAtomicResolver) -> f64 { + candidate_dump(html, resolver).nodes[find_index(html, "c")].bounding.h + } + + #[test] + fn the_containers_auto_height_follows_the_resolvers_total() { + let html = one_line_document(); + assert_eq!(container_height(&html, layout::inline_atomic::resolve_baseline), 60.0, "two stacked boxes"); + assert_eq!(container_height(&html, resolve_one_line), 30.0, "one line box, as tall as its content"); + } + + /// A box on a line is still laid out as a block formatting context root inside, so + /// its own children stack exactly as they would anywhere else. + #[test] + fn an_atomic_inlines_own_children_still_stack() { + let html = format!( + "
" + ); + let dump = candidate_dump(&html, resolve_one_line); + let x = &dump.nodes[find_index(&html, "x")]; + let y = &dump.nodes[find_index(&html, "y")]; + assert_eq!(relative_offset(&dump, x), (0.0, 0.0)); + assert_eq!(relative_offset(&dump, y), (0.0, 20.0), "stacked, not put on a line of their own"); + } + + /// One candidate panicking partway through the corpus must not lose the other + /// candidates' scores -- `evaluate` catches and drops it, the rest of the batch + /// still runs and reports normally. + #[test] + fn a_panicking_candidate_is_dropped_and_the_rest_of_the_batch_still_scores() { + fn resolve_panics(_inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + panic!("candidate reached an input shape it cannot handle") + } + + let html = one_line_document(); + let reference = candidate_dump(&html, resolve_one_line); + let case = CachedCase { + case_id: "panic-isolation".to_string(), + seed: 0, + categories: vec!["OneLineEqualHeights".to_string()], + html, + dumps: BTreeMap::from([("chromium".to_string(), reference)]), + errors: BTreeMap::new(), + }; + + // Swap in a silent panic hook so the deliberate panic below doesn't print. + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let report = evaluate( + &[ + ("panicking", resolve_panics as InlineAtomicResolver), + ("one_line", resolve_one_line as InlineAtomicResolver), + ], + &[case], + None, + ); + std::panic::set_hook(previous_hook); + + assert_eq!(report.candidates.len(), 1, "the panicking candidate is dropped, not just failed"); + assert_eq!(report.candidates[0].name, "one_line", "the surviving candidate is the one that didn't panic"); + assert_eq!(report.candidates[0].cases_run, 1, "the surviving candidate still scores the one case"); + } +} diff --git a/crates/generation/src/lib.rs b/crates/generation/src/lib.rs index b2999245..3228c1e1 100644 --- a/crates/generation/src/lib.rs +++ b/crates/generation/src/lib.rs @@ -19,6 +19,7 @@ pub mod feature_runner; pub mod float_runner; pub mod groundtruth; pub mod height_runner; +pub mod inline_atomic_runner; pub mod margin_runner; pub mod model; pub mod orchestrate; diff --git a/crates/generation/src/margin_runner.rs b/crates/generation/src/margin_runner.rs index 5f8af4e5..099fc810 100644 --- a/crates/generation/src/margin_runner.rs +++ b/crates/generation/src/margin_runner.rs @@ -74,6 +74,10 @@ pub fn candidate_dump(html: &str, candidate: MarginCandidate) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/percentage_runner.rs b/crates/generation/src/percentage_runner.rs index 0a368706..b7d4381b 100644 --- a/crates/generation/src/percentage_runner.rs +++ b/crates/generation/src/percentage_runner.rs @@ -55,6 +55,10 @@ pub fn candidate_dump(html: &str, resolver: PercentageResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/prompt.rs b/crates/generation/src/prompt.rs index 03554567..8fdacf0e 100644 --- a/crates/generation/src/prompt.rs +++ b/crates/generation/src/prompt.rs @@ -950,6 +950,100 @@ const TRANSFORM_SEAM_CONTRACT: &str = "\n\n# Seam contract\n\n\ `transform` to anything but `none`, with a list of any length, and must return a \ matrix for every one of them rather than panicking or returning a placeholder.\n"; +const INLINE_ATOMIC_SEAM_CONTRACT: &str = "\n\n# Seam contract\n\n\ + Write exactly these two import lines, and no local `mod`, `struct`, `enum` or \ + `type` that redefines any of the names they bring in:\n\n\ + ```rust\n\ + use layout::inline_atomic::{AtomicInline, InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox};\n\ + use dom::computed::{LengthPercentage, TextAlign, VerticalAlign};\n\ + ```\n\n\ + Do not redefine any of those types locally. A local redefinition compiles on its \ + own but no longer matches `layout::inline_atomic`'s `InlineAtomicResolver` \ + function-pointer type, so the candidate crate fails to build. Import them from the \ + two lines above instead.\n\n\ + The only function this seam calls is `resolve`, with the exact signature shown \ + above; name it `resolve`, not anything else. `layout::inline_atomic` also exports \ + `baseline_shift`, a helper that turns a `VerticalAlign` into the plain shift in \ + pixels it stands for; `resolve` may use it or match the enum itself.\n\n\ + `VerticalAlign` has exactly six variants: `Baseline`, `Top`, `Bottom`, `Middle`, \ + `Length(LengthPercentage)` and `Other`. Every match on it must be exhaustive over \ + all six. The percentage form is already resolved before it reaches you, so a \ + `Length` always carries `LengthPercentage::Length(px)`; treat \ + `LengthPercentage::Percentage` and `LengthPercentage::Unresolved` as zero if you \ + match them at all. `Other` stands for a font-dependent keyword (`sub`, `super`, \ + `text-top`, `text-bottom`) the corpus never produces; treat it as `Baseline`. \ + `TextAlign` has exactly four variants: `Left`, `Right`, `Center` and `Other`; \ + `Other` is `justify`, which the corpus never produces -- treat it as `Left`.\n\n\ + What the inputs mean. `content_width` is the container's content box, which is how \ + wide every line box is. `line_height` is the strut's height: CSS 2.1 10.8 starts \ + every line box with a zero-width inline box carrying the container's font and \ + `line-height`, so no line box is ever shorter than this, and the strut's own \ + baseline sits at its MIDDLE -- exactly `line_height / 2` below the line box's top \ + when nothing pushes the line taller. (The corpus sets `font-size: 0` on every \ + container, which is why the strut's ascent and descent are both zero and the \ + midpoint is exact rather than font-dependent.) `text_align` distributes whatever \ + horizontal space a line has left over. `inlines` is every atomic inline in document \ + order: each one's `margin_box_width` and `margin_box_height` are the box's full \ + margin box (10.8 uses the margin box as an inline-block's height in a line box), \ + and `baseline` is that box's own baseline as a distance DOWN from its margin-box \ + top, already decided for you by 10.8.1.\n\n\ + What the outputs mean. `placements[i]` is where `inlines[i]`'s MARGIN BOX goes, as \ + an offset from the container's content-box origin -- one entry per input, in the \ + same order, so the two vectors always have the same length. `lines` is the line \ + boxes top to bottom: `top` from the container's content-box top, `height`, and \ + `baseline` as a distance down from that line's own `top`. `total_height` is the \ + whole stack's height, which becomes the container's content height when its \ + `height` is `auto` (10.6.3). `last_baseline` is the last line box's baseline \ + measured from the container's content-box top -- `top + baseline` of the last line \ + -- and `None` when there is no line box at all.\n\n\ + The rules, in the order they apply. Fill a line left to right, adding each box's \ + `margin_box_width`; when the next box would take the line past `content_width`, \ + start a new line box below and put it there instead. A box wider than \ + `content_width` goes alone on its own line and overflows it -- an atomic inline is \ + never split (9.4.2). A line box's height is the distance from the topmost box top \ + to the bottommost box bottom once every box on it is aligned, and never less than \ + `line_height`. Line boxes stack with no gap and never overlap, so each one's `top` \ + is the previous one's `top + height`.\n\n\ + Vertical alignment within a line, per 10.8.1. `Baseline` puts the box's own \ + `baseline` on the line's baseline. `Length(px)` does the same, then RAISES the box \ + by `px` (a negative value lowers it). `Middle` puts the box's vertical midpoint on \ + the line's baseline (the parent's x-height is zero here, so there is nothing to add \ + to it). `Top` puts the box's top edge on the line box's top edge, and `Bottom` puts \ + its bottom edge on the line box's bottom edge -- neither depends on the baseline, \ + which is what makes them circular with the line's own height: solve the \ + baseline-relative boxes and the strut first to get the line's extent, then place the \ + `Top` and `Bottom` boxes inside it, then grow the line if one of them still does not \ + fit, exactly as 10.8 says to minimize the line box height.\n\n\ + `resolve` is a total function of its inputs: it is called for every container whose \ + in-flow children are all `display: inline-block`, with any number of them, and must \ + return a placement for every one of them rather than panicking, indexing out of \ + bounds, or returning a short vector.\n"; + +/// [`build`]'s counterpart for the inline-atomic function (`layout::inline_atomic`). +pub fn build_inline_atomic(spec_text: &str, seam_source: &str, failing_cases: &[&CachedCase]) -> Prompt { + let normalized_spec = normalize_spec_text(spec_text); + + let mut text = String::new(); + text.push_str( + "# Spec text: CSS 2.1 9.4.2 inline formatting contexts, 10.8 and 10.8.1 line height calculations\n\n", + ); + text.push_str(&normalized_spec); + text.push_str("\n\n# Implement this exact signature\n\n"); + text.push_str(&layout_seam_as_a_candidate_sees_it(seam_source)); + text.push_str(INLINE_ATOMIC_SEAM_CONTRACT); + text.push_str( + "\n\n# Currently failing cases\n\n\ + Not shrunk to a minimal reproducer -- shrinking is not built yet \ + (roadmap.md -> \"Test input distribution\"). Each case's full document follows.\n\n", + ); + for case in failing_cases { + text.push_str(&format!("## {}\n\n```html\n{}\n```\n\n", case.case_id, case.html)); + } + + let hash = Hash::of(&text); + Prompt { text, hash } +} + /// [`build`]'s counterpart for the transform function (`layout::transform`). pub fn build_transform(spec_text: &str, seam_source: &str, failing_cases: &[&CachedCase]) -> Prompt { let normalized_spec = normalize_spec_text(spec_text); diff --git a/crates/generation/src/relpos_runner.rs b/crates/generation/src/relpos_runner.rs index 06dd69fc..2e25f35a 100644 --- a/crates/generation/src/relpos_runner.rs +++ b/crates/generation/src/relpos_runner.rs @@ -59,6 +59,10 @@ pub fn candidate_dump(html: &str, resolver: RelposResolver) -> Dump { // the identity here: its own function is measured by // `crate::transform_runner`, against its own corpus. layout::transform::resolve_baseline, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/generation/src/transform_runner.rs b/crates/generation/src/transform_runner.rs index d160c71e..9e4cd78a 100644 --- a/crates/generation/src/transform_runner.rs +++ b/crates/generation/src/transform_runner.rs @@ -55,6 +55,10 @@ pub fn candidate_dump(html: &str, resolver: TransformResolver) -> Dump { layout::generated_height_resolve, layout::generated_containing_block_resolve, resolver, + // This corpus never sets `display: inline-block`, so no box is ever placed on a + // line here: that function is measured by `crate::inline_atomic_runner`, against + // its own corpus. + layout::inline_atomic::resolve_baseline, ); let mut rects: BTreeMap = BTreeMap::new(); diff --git a/crates/harness/src/inline_atomic_cases.rs b/crates/harness/src/inline_atomic_cases.rs new file mode 100644 index 00000000..eee5b894 --- /dev/null +++ b/crates/harness/src/inline_atomic_cases.rs @@ -0,0 +1,577 @@ +//! Stratified generation of line-box test cases over atomic inlines, `crate::cases`'s +//! counterpart for the inline-atomic function (`specs/inline-atomic.md`). Reuses +//! `crate::cases`'s document-assembly machinery (`Elem`, `Source`, `px`, `document`, +//! `RESET`) rather than duplicating it, but keeps its own `Case`/`Category`/`make_case` +//! for the same reason `crate::absolute_cases` and `crate::transform_cases` do: +//! `crate::cases::Case::categories` is typed over a different domain than this corpus's +//! own [`InlineAtomicCategory`]. **Do not add to `crate::cases`'s own `RECIPES`** -- this +//! corpus's recipes live here, entirely separate from the seeded-random draw the other +//! tracks measure against. +//! +//! **Text-free by construction, and font-free by choice.** `crate::cases`'s `document()` +//! and `write_body()` emit nested `
`s with no whitespace between tags, so no text +//! node can ever appear between two atomic inlines; [`no_text_between_atomic_inlines`] +//! asserts that for every case this module produces. That leaves one way a font could +//! still reach the measurement: CSS 2.1 §10.8 starts every line box with a strut, a +//! zero-width inline box carrying the container's own font and `line-height`, whose +//! ascent and descent place the baseline inside the line height. So every inline +//! formatting context container here sets `font-size: 0` beside an explicit +//! `line-height` in px. With a zero-size font the strut's ascent and descent are both +//! zero, the half-leading is split equally above and below an empty content area, the +//! baseline sits exactly at the middle of the line height, and `vertical-align: middle` +//! -- which aligns on the parent's baseline plus half its x-height -- aligns on the +//! baseline itself. No case here depends on a font's metrics. +//! +//! That choice holds until `text-metrics-oracle` has measured whether the three engines +//! agree on real text. Until then a corpus that used a real font would be measuring the +//! engines' font stacks as much as their line-box arithmetic, and a case that failed the +//! three-engine vote would not say which. +//! +//! Every atomic inline has an explicit `width` and `height`: a `width: auto` +//! inline-block shrink-wraps its contents, which is `min-max-content`'s rule and not +//! this function's (`crates/layout/src/inline_atomic.rs`'s own "Out" list). Container +//! widths are kept well inside the 800x600 viewport so no document overflows it and +//! brings a scrollbar with it, which would change every containing block's width and +//! confound the measurement with a rule this corpus is not about. + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +use crate::cases::{self, Elem, Source}; + +/// A spec case category this corpus exercises, drawn from `specs/inline-atomic.md`'s own +/// category list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InlineAtomicCategory { + /// One line of equal-height atomic inlines: the simplest thing a line box does, and + /// the case the vertical-stacking baseline gets most obviously wrong. + OneLineEqualHeights, + /// Atomic inlines of different heights, all `vertical-align: baseline`: each box's + /// own baseline -- its bottom margin edge, having no line box inside it -- lands on + /// the line's, so the shorter boxes hang down from the taller one's bottom. + BaselineDifferentHeights, + /// `vertical-align: top`: the box's top edge meets the line box's top. + AlignTop, + /// `vertical-align: bottom`: the box's bottom edge meets the line box's bottom. + AlignBottom, + /// `vertical-align: middle`: the box's vertical midpoint meets the parent's + /// baseline, which with a zero-size font is the middle of the strut. + AlignMiddle, + /// `vertical-align: `: the box is raised above the baseline by that much, + /// or lowered when it is negative. + AlignLength, + /// `vertical-align: ` of the box's own `line-height`, which is the one + /// percentage here that resolves against a property rather than a box dimension. + AlignPercentage, + /// More atomic inlines than fit across the container: the row breaks onto a second + /// line box, which is stacked directly under the first. + LineBreak, + /// One atomic inline wider than the container's content box: it goes alone on its + /// own line and overflows it rather than being broken. + WiderThanContainer, + /// `text-align: right` on the container: the leftover space on each line goes to + /// the left end. + TextAlignRight, + /// `text-align: center`: the leftover space is split equally between the two ends. + TextAlignCenter, + /// Margins, padding and borders on the atomic inlines, which are what separate two + /// boxes on a line (§9.4.2) and what make the margin box taller than the border box. + EdgesOnAtomicInlines, + /// A `line-height` taller than every atomic inline on the line: the strut, not the + /// content, decides the line box's height. + StrutTallerThanContent, + /// An atomic inline with `overflow: hidden`: §10.8.1 puts its baseline at its bottom + /// margin edge whatever is inside it, which is what separates the two halves of the + /// inline-block baseline rule. + OverflowHiddenBaseline, + /// An inline-block that is itself an inline formatting context container: its own + /// baseline is its last line box's, so the boxes on the outer line align against + /// something computed inside it. + NestedInlineBlocks, +} + +impl InlineAtomicCategory { + /// Every category the generators are expected to cover + /// (`crate::cases::Category::ALL`'s own role, mirrored here). + pub const ALL: &'static [InlineAtomicCategory] = &[ + InlineAtomicCategory::OneLineEqualHeights, + InlineAtomicCategory::BaselineDifferentHeights, + InlineAtomicCategory::AlignTop, + InlineAtomicCategory::AlignBottom, + InlineAtomicCategory::AlignMiddle, + InlineAtomicCategory::AlignLength, + InlineAtomicCategory::AlignPercentage, + InlineAtomicCategory::LineBreak, + InlineAtomicCategory::WiderThanContainer, + InlineAtomicCategory::TextAlignRight, + InlineAtomicCategory::TextAlignCenter, + InlineAtomicCategory::EdgesOnAtomicInlines, + InlineAtomicCategory::StrutTallerThanContent, + InlineAtomicCategory::OverflowHiddenBaseline, + InlineAtomicCategory::NestedInlineBlocks, + ]; +} + +/// One generated test case -- `crate::cases::Case`'s counterpart, typed over +/// [`InlineAtomicCategory`] instead (this module's own doc comment explains why the two +/// `Case` types are not the same one). +#[derive(Debug, Clone)] +pub struct Case { + pub id: String, + pub html: String, + pub seed: u64, + pub categories: Vec, + pub element_count: usize, +} + +fn make_case(id: &str, seed: u64, categories: &[InlineAtomicCategory], root: Elem) -> Case { + let element_count = root.count(); + Case { id: id.to_string(), html: cases::document(root), seed, categories: categories.to_vec(), element_count } +} + +/// The declarations every inline formatting context container in this corpus carries: +/// an explicit content width, a zero font size and an explicit `line-height` in px, so +/// the strut's baseline sits at the middle of a known height and no font is consulted +/// (this module's own doc comment). +fn container(width: f64, line_height: f64, extra: Vec<(&'static str, String)>) -> Vec<(&'static str, String)> { + let mut decls = vec![ + ("width", cases::px(width)), + ("font-size", "0".into()), + ("line-height", cases::px(line_height)), + ]; + decls.extend(extra); + decls +} + +/// One atomic inline at an explicit size -- the only shape this function places +/// (this module's own doc comment). +fn atomic(width: f64, height: f64, extra: Vec<(&'static str, String)>) -> Elem { + let mut decls = vec![ + ("display", "inline-block".into()), + ("width", cases::px(width)), + ("height", cases::px(height)), + ]; + decls.extend(extra); + Elem::new(decls) +} + +type Recipe = for<'a> fn(&mut Source<'a>) -> (Elem, Vec, &'static str); + +fn recipe_one_line_equal_heights(src: &mut Source) -> (Elem, Vec, &'static str) { + let width = src.px(&[60.0, 80.0, 45.0]); + let height = src.px(&[30.0, 48.0, 22.0]); + let line_height = src.px(&[20.0, 36.0, 12.0]); + let boxes = vec![ + atomic(width, height, Vec::new()), + atomic(width, height, Vec::new()), + atomic(width, height, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::OneLineEqualHeights], "inline-atomic-one-line-equal-heights") +} + +fn recipe_baseline_different_heights(src: &mut Source) -> (Elem, Vec, &'static str) { + let tall = src.px(&[70.0, 90.0, 55.0]); + let middling = src.px(&[40.0, 52.0, 33.0]); + let short = src.px(&[15.0, 24.0, 9.0]); + let line_height = src.px(&[20.0, 30.0, 10.0]); + let boxes = vec![ + atomic(50.0, tall, Vec::new()), + atomic(50.0, middling, Vec::new()), + atomic(50.0, short, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::BaselineDifferentHeights], "inline-atomic-baseline-different-heights") +} + +/// `top` and `bottom` are measured against a line box whose height some *other* box +/// decides, so each of these recipes keeps one tall `baseline` box beside the aligned +/// one -- otherwise the aligned box would be the tallest thing on the line and every +/// alignment would land in the same place. +fn recipe_align_top(src: &mut Source) -> (Elem, Vec, &'static str) { + let anchor = src.px(&[80.0, 100.0, 64.0]); + let aligned = src.px(&[30.0, 44.0, 18.0]); + let line_height = src.px(&[20.0, 28.0, 14.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", "top".into())]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignTop], "inline-atomic-align-top") +} + +fn recipe_align_bottom(src: &mut Source) -> (Elem, Vec, &'static str) { + let anchor = src.px(&[80.0, 100.0, 64.0]); + let aligned = src.px(&[30.0, 44.0, 18.0]); + let line_height = src.px(&[20.0, 28.0, 14.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", "bottom".into())]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignBottom], "inline-atomic-align-bottom") +} + +fn recipe_align_middle(src: &mut Source) -> (Elem, Vec, &'static str) { + let anchor = src.px(&[80.0, 100.0, 64.0]); + let aligned = src.px(&[30.0, 44.0, 18.0]); + let line_height = src.px(&[24.0, 40.0, 16.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", "middle".into())]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignMiddle], "inline-atomic-align-middle") +} + +fn recipe_align_length(src: &mut Source) -> (Elem, Vec, &'static str) { + let shift = src.px(&[12.0, 25.0, -8.0]); + let anchor = src.px(&[70.0, 88.0, 56.0]); + let aligned = src.px(&[30.0, 42.0, 20.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic(60.0, aligned, vec![("vertical-align", cases::px(shift))]), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignLength], "inline-atomic-align-length") +} + +fn recipe_align_percentage(src: &mut Source) -> (Elem, Vec, &'static str) { + // The percentage is of the *aligned box's own* `line-height`, not the container's, + // so the box carries an explicit one of its own and the two differ: a candidate that + // resolves against the container's would land somewhere else. + let pct = src.pct(&["50%", "100%", "-25%"]); + let own_line_height = src.px(&[32.0, 48.0, 20.0]); + let anchor = src.px(&[70.0, 88.0, 56.0]); + let aligned = src.px(&[30.0, 42.0, 20.0]); + let boxes = vec![ + atomic(60.0, anchor, Vec::new()), + atomic( + 60.0, + aligned, + vec![("vertical-align", pct), ("line-height", cases::px(own_line_height))], + ), + atomic(60.0, aligned, Vec::new()), + ]; + let root = Elem::with_children(container(400.0, 20.0, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::AlignPercentage], "inline-atomic-align-percentage") +} + +fn recipe_line_break(src: &mut Source) -> (Elem, Vec, &'static str) { + // Five boxes of this width never fit across 300px, so the row always breaks; the + // widths are chosen so the break falls after a different box in each draw. + let width = src.px(&[90.0, 70.0, 110.0]); + let height = src.px(&[25.0, 40.0, 16.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes: Vec = (0..5).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children(container(300.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::LineBreak], "inline-atomic-line-break") +} + +fn recipe_wider_than_container(src: &mut Source) -> (Elem, Vec, &'static str) { + // The wide box cannot be broken, so it goes alone on its own line and overflows it + // (§9.4.2). The two narrow boxes around it show which line each one lands on. + let wide = src.px(&[360.0, 420.0, 310.0]); + let narrow = src.px(&[60.0, 90.0, 40.0]); + let height = src.px(&[25.0, 38.0, 18.0]); + let line_height = src.px(&[20.0, 28.0, 14.0]); + let boxes = vec![ + atomic(narrow, height, Vec::new()), + atomic(wide, height, Vec::new()), + atomic(narrow, height, Vec::new()), + ]; + let root = Elem::with_children(container(300.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::WiderThanContainer], "inline-atomic-wider-than-container") +} + +fn recipe_text_align_right(src: &mut Source) -> (Elem, Vec, &'static str) { + let width = src.px(&[70.0, 55.0, 95.0]); + let height = src.px(&[30.0, 44.0, 20.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes: Vec = (0..3).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children( + container(400.0, line_height, vec![("text-align", "right".into())]), + boxes, + ); + (root, vec![InlineAtomicCategory::TextAlignRight], "inline-atomic-text-align-right") +} + +fn recipe_text_align_center(src: &mut Source) -> (Elem, Vec, &'static str) { + // Two lines' worth, so the leftover space differs per line and a candidate that + // centres the whole block rather than each line lands wrong on at least one. + let width = src.px(&[90.0, 75.0, 110.0]); + let height = src.px(&[30.0, 44.0, 20.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let boxes: Vec = (0..5).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children( + container(300.0, line_height, vec![("text-align", "center".into())]), + boxes, + ); + (root, vec![InlineAtomicCategory::TextAlignCenter], "inline-atomic-text-align-center") +} + +fn recipe_edges_on_atomic_inlines(src: &mut Source) -> (Elem, Vec, &'static str) { + let margin = src.px(&[10.0, 18.0, 4.0]); + let padding = src.px(&[6.0, 12.0, 2.0]); + let border = src.px(&[3.0, 5.0, 1.0]); + let height = src.px(&[28.0, 40.0, 18.0]); + let line_height = src.px(&[20.0, 30.0, 12.0]); + let edged = |extra_height: f64| { + atomic( + 50.0, + extra_height, + vec![ + ("margin", cases::px(margin)), + ("padding", cases::px(padding)), + ("border-width", cases::px(border)), + ("border-style", "solid".into()), + ], + ) + }; + let boxes = vec![edged(height), edged(height + 20.0), edged(height)]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::EdgesOnAtomicInlines], "inline-atomic-edges") +} + +fn recipe_strut_taller_than_content(src: &mut Source) -> (Elem, Vec, &'static str) { + // Every box is shorter than the line height, so the strut alone decides the line + // box's height and the container's own auto height with it. + let line_height = src.px(&[80.0, 120.0, 60.0]); + let height = src.px(&[12.0, 20.0, 6.0]); + let width = src.px(&[60.0, 80.0, 45.0]); + let boxes: Vec = (0..3).map(|_| atomic(width, height, Vec::new())).collect(); + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::StrutTallerThanContent], "inline-atomic-strut-taller") +} + +fn recipe_overflow_hidden_baseline(src: &mut Source) -> (Elem, Vec, &'static str) { + // The two inline-blocks hold the same inner content; one clips and one does not, so + // §10.8.1's two halves land them at different heights on the same line. Without the + // inner line box there would be nothing for the `visible` one to align on and the + // pair would prove nothing. + let inner_height = src.px(&[20.0, 34.0, 12.0]); + let outer_height = src.px(&[60.0, 80.0, 48.0]); + let line_height = src.px(&[20.0, 30.0, 14.0]); + let holder = |overflow: Option<&'static str>| { + let mut decls = vec![ + ("display", "inline-block".into()), + ("width", cases::px(100.0)), + ("height", cases::px(outer_height)), + ("font-size", "0".into()), + ("line-height", cases::px(line_height)), + ]; + if let Some(value) = overflow { + decls.push(("overflow", value.into())); + } + Elem::with_children(decls, vec![atomic(40.0, inner_height, Vec::new())]) + }; + let boxes = vec![holder(None), holder(Some("hidden")), atomic(50.0, 90.0, Vec::new())]; + let root = Elem::with_children(container(400.0, line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::OverflowHiddenBaseline], "inline-atomic-overflow-hidden-baseline") +} + +fn recipe_nested_inline_blocks(src: &mut Source) -> (Elem, Vec, &'static str) { + // The outer inline-block's own baseline is its last line box's (§10.8.1), so where + // the two inner boxes sit decides where the whole outer box sits on the outer line. + let inner_a = src.px(&[24.0, 40.0, 14.0]); + let inner_b = src.px(&[48.0, 30.0, 60.0]); + let outer_line_height = src.px(&[20.0, 32.0, 12.0]); + let inner_line_height = src.px(&[16.0, 28.0, 10.0]); + let nested = Elem::with_children( + vec![ + ("display", "inline-block".into()), + ("width", cases::px(140.0)), + ("height", cases::px(90.0)), + ("font-size", "0".into()), + ("line-height", cases::px(inner_line_height)), + ], + vec![atomic(50.0, inner_a, Vec::new()), atomic(50.0, inner_b, Vec::new())], + ); + let boxes = vec![atomic(60.0, 70.0, Vec::new()), nested, atomic(60.0, 35.0, Vec::new())]; + let root = Elem::with_children(container(500.0, outer_line_height, Vec::new()), boxes); + (root, vec![InlineAtomicCategory::NestedInlineBlocks], "inline-atomic-nested-inline-blocks") +} + +const RECIPES: &[Recipe] = &[ + recipe_one_line_equal_heights, + recipe_baseline_different_heights, + recipe_align_top, + recipe_align_bottom, + recipe_align_middle, + recipe_align_length, + recipe_align_percentage, + recipe_line_break, + recipe_wider_than_container, + recipe_text_align_right, + recipe_text_align_center, + recipe_edges_on_atomic_inlines, + recipe_strut_taller_than_content, + recipe_overflow_hidden_baseline, + recipe_nested_inline_blocks, +]; + +/// The deterministic, reproducible set built directly from the case analysis -- one case +/// per recipe, mirroring `crate::cases::enumerated`. +pub fn enumerated() -> Vec { + RECIPES + .iter() + .map(|recipe| { + let mut src = Source::Fixed; + let (root, categories, name) = recipe(&mut src); + make_case(name, 0, &categories, root) + }) + .collect() +} + +/// `count` cases sampled over the same grammar as [`enumerated`], mirroring +/// `crate::cases::random`. +pub fn random(seed: u64, count: usize) -> Vec { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + (0..count) + .map(|i| { + let recipe = RECIPES[rng.gen_range(0..RECIPES.len())]; + let mut src = Source::Random(&mut rng); + let (root, categories, name) = recipe(&mut src); + let id = format!("random-inline-atomic-{seed:016x}-{i:04}-{name}"); + make_case(&id, seed, &categories, root) + }) + .collect() +} + +/// Whether `html` puts anything but a tag between two atomic inlines. `document()` +/// writes no whitespace between tags, so the only text in a case's markup is inside the +/// `\ +
"; + let document = Document::parse(html); + let tree = BoxTree::build(&document); + let (body, _) = body_box(&tree); + assert!(tree.establishes_inline_formatting_context(&document, body)); + for &child in &tree.boxes()[body.0].children { + assert!(tree.boxes()[child.0].inline_level); + } + } + + #[test] + fn a_mixed_container_establishes_none_and_neither_does_an_empty_one() { + let html = "\ +
"; + let document = Document::parse(html); + let tree = BoxTree::build(&document); + let (body, _) = body_box(&tree); + assert!( + !tree.establishes_inline_formatting_context(&document, body), + "a block-level sibling keeps the container stacking, out of this scope" + ); + + let empty = Document::parse("
"); + let empty_tree = BoxTree::build(&empty); + let (_, leaf) = (BoxId(0), &empty_tree.boxes()[0]); + assert!(leaf.children.is_empty(), "the innermost box is the empty div"); + assert!(!empty_tree.establishes_inline_formatting_context(&empty, BoxId(0))); + } + + /// CSS 2.1 9.7 and 9.3.1 both compute an out-of-flow box's `display` to a + /// block-level value, so neither a float nor an absolutely positioned box is + /// inline-level however it was written -- and neither keeps its container from + /// establishing an inline formatting context around the rest. + #[test] + fn an_out_of_flow_inline_block_is_not_inline_level() { + let html = "
"; + let document = Document::parse(html); + let tree = BoxTree::build(&document); + let (body, _) = body_box(&tree); + let inline_level: Vec = + tree.boxes()[body.0].children.iter().map(|c| tree.boxes()[c.0].inline_level).collect(); + assert_eq!(inline_level, vec![true, false, false]); + assert!( + !tree.establishes_inline_formatting_context(&document, body), + "a float or an absolutely positioned box beside an atomic inline is out of scope, \ + so the container keeps stacking" + ); + } } diff --git a/crates/layout/src/fragment.rs b/crates/layout/src/fragment.rs index 406d2a29..fd7fc7ca 100644 --- a/crates/layout/src/fragment.rs +++ b/crates/layout/src/fragment.rs @@ -82,6 +82,9 @@ use crate::containing_block::{ use crate::float::{self, Clear, FloatInputs, FloatResolver, PlacedFloat, Subject}; use crate::generated; use crate::height::{self, HeightInputs, HeightResolver}; +use crate::inline_atomic::{ + AtomicInline, InlineAtomicInputs, InlineAtomicResolver, InlineAtomicOutputs, +}; use crate::margin::{self, MarginClassifyResolver, MarginCombineResolver}; use crate::percentage::{self, PercentageInputs, PercentageResolver}; use crate::relpos::{RelposInputs, RelposResolver}; @@ -250,6 +253,7 @@ pub fn layout(document: &Document, tree: &BoxTree, viewport: (f64, f64)) -> Frag generated::height::resolve, generated::containing_block::resolve, generated::transform::resolve, + generated::inline_atomic::resolve, ) } @@ -281,6 +285,7 @@ pub fn layout_with_resolver( generated::height::resolve, generated::containing_block::resolve, generated::transform::resolve, + generated::inline_atomic::resolve, ) } @@ -306,6 +311,7 @@ pub fn layout_with_resolvers( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, ) -> Fragment { layout_box( document, @@ -336,6 +342,7 @@ pub fn layout_with_resolvers( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, Rect { x: 0.0, y: 0.0, width: viewport.0, height: viewport.1 }, None, ) @@ -439,6 +446,16 @@ struct LayoutResult { /// for a box that establishes its own context: its floats live in a context of its /// own whose frame it owns, so nothing about its position is needed to place them. needs_float_pass: bool, + /// The baseline of this box's last in-flow line box, as a distance down from its own + /// border-box top -- what CSS 2.1 10.8.1 makes this box's own baseline when it is an + /// atomic inline on someone else's line (`crate::inline_atomic`). `None` when it has + /// no in-flow line box, and `None` when its `overflow` computes to anything other + /// than `visible`, which are 10.8.1's two routes to the bottom-margin-edge fallback; + /// the caller placing this box on a line applies that fallback, since only it knows + /// where the margin edge is. `None` for every box in a document with no + /// `inline-block` in it, which is every case the corpus generated before + /// `specs/inline-atomic.md`. + last_line_baseline: Option, } /// The floats placed so far in one block formatting context, as margin boxes in the @@ -617,6 +634,12 @@ struct StackResult { /// `LayoutResult::needs_float_pass` for the box doing the stacking, before its own /// block-formatting-context status is applied. needs_float_pass: bool, + /// `LayoutResult::last_line_baseline` for the box doing the stacking, before its own + /// `overflow` is applied. `stack_children` carries its last in-flow child's through + /// (a box whose own children are block-level has no line box of its own, but one of + /// them may still end in one); `layout_inline_children` sets it from the line boxes + /// it just built. + last_line_baseline: Option, } /// Stacks `children_ids` inside a box whose own edges/margins are already known, @@ -654,6 +677,7 @@ fn stack_children( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, viewport: Rect, mut floats: Option>, ) -> StackResult { @@ -688,6 +712,7 @@ fn stack_children( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, None, )) @@ -738,6 +763,11 @@ fn stack_children( let mut cursor_y = padding_top + border_top; let mut placed = Vec::with_capacity(child_results.len()); let mut pending_absolute: Vec = Vec::new(); + // CSS 2.1 10.8.1's "last in-flow line box" for a box whose own children are + // block-level: it has no line box itself, but its last in-flow child may end in one, + // and that is the baseline this box hands upward if it is an atomic inline in turn. + // `None` for every box in a document with no `inline-block` in it. + let mut last_line_baseline: Option = None; for (&child_id, c) in children_ids.iter().zip(child_results) { let Some(c) = c else { @@ -923,6 +953,7 @@ fn stack_children( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, None, ); @@ -946,6 +977,7 @@ fn stack_children( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, Some(frame.nested(offset_x, offset_y)), ); @@ -996,6 +1028,9 @@ fn stack_children( placed.push(fragment); pending = c.escaping_bottom; real_child_found = true; + if let Some(b) = c.last_line_baseline { + last_line_baseline = Some(offset_y + b); + } } let escaping_top_result = if top_open { @@ -1037,6 +1072,176 @@ fn stack_children( self_collapsing, sibling_adjoin, needs_float_pass, + last_line_baseline, + } +} + +/// [`stack_children`]'s counterpart for a box that establishes an inline formatting +/// context (CSS 2.1 9.4.2, `crate::boxtree::BoxTree::establishes_inline_formatting_context`): +/// every in-flow child is an atomic inline, so they go on line boxes side by side rather +/// than stacking one under the other. Each child is laid out first, exactly the way +/// `stack_children` lays one out -- an `inline-block` establishes a block formatting +/// context for its own contents (CSS Display 3 §2.1), so its inside is ordinary block +/// layout -- and then the measurements go to `inline_atomic_resolver`, which decides +/// where on which line each one lands. Nothing here decides that: this function measures, +/// calls and places (`crate::inline_atomic`'s own module doc). +/// +/// Margins. §9.4.2 puts line boxes in the normal flow as this box's in-flow content, and +/// §8.3.1 gives a box with inline content no adjoining child margin to collapse with: an +/// atomic inline's own margins are part of its margin box on the line, never part of a +/// collapsing group. So this box's own margin-top and margin-bottom escape alone, +/// whatever `margin_classify` says about a box with block children, and an inline-block's +/// margins never collapse with anything. +#[allow(clippy::too_many_arguments)] +fn layout_inline_children( + document: &Document, + tree: &BoxTree, + id: BoxId, + node: NodeId, + children_ids: &[BoxId], + padding_left: f64, + padding_top: f64, + padding_bottom: f64, + border_left: f64, + border_top: f64, + border_bottom: f64, + own_margin_top: f64, + own_margin_bottom: f64, + establishes_bfc: bool, + height_is_auto: bool, + height_is_explicit_zero: bool, + child_available: &Available, + width_resolver: WidthResolver, + margin_classify: MarginClassifyResolver, + margin_combine: MarginCombineResolver, + percentage_resolver: PercentageResolver, + absolute_resolver: AbsoluteResolver, + float_resolver: FloatResolver, + relpos_resolver: RelposResolver, + height_resolver: HeightResolver, + containing_block_resolver: ContainingBlockResolver, + transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, + viewport: Rect, +) -> StackResult { + let mut laid_out: Vec = Vec::with_capacity(children_ids.len()); + let mut inlines: Vec = Vec::with_capacity(children_ids.len()); + for &child_id in children_ids { + let child_node = tree.boxes()[child_id.0].node; + let c = layout_box( + document, + tree, + child_id, + child_available, + width_resolver, + margin_classify, + margin_combine, + percentage_resolver, + absolute_resolver, + float_resolver, + relpos_resolver, + height_resolver, + containing_block_resolver, + transform_resolver, + inline_atomic_resolver, + viewport, + None, + ); + let margin_left = c.used_margin_left; + let margin_right = length_or_auto_zero(computed::margin_right(document, child_node)); + let margin_top = length_or_auto_zero(computed::margin_top(document, child_node)); + let margin_bottom = length_or_auto_zero(computed::margin_bottom(document, child_node)); + let margin_box_width = c.fragment.width + margin_left + margin_right; + let margin_box_height = c.fragment.height + margin_top + margin_bottom; + // CSS 2.1 10.8.1's baseline of an inline-block, measured from its margin-box top: + // its own last in-flow line box's, or -- when it has none, or its `overflow` is + // not `visible`, both of which `layout_box` has already folded into `None` -- + // its bottom margin edge. + let baseline = c.last_line_baseline.map_or(margin_box_height, |b| margin_top + b); + inlines.push(AtomicInline { + margin_box_width, + margin_box_height, + baseline, + vertical_align: resolved_vertical_align(document, child_node), + }); + laid_out.push(c); + } + + let InlineAtomicOutputs { placements, lines: _, total_height, last_baseline } = + inline_atomic_resolver(&InlineAtomicInputs { + content_width: child_available.inline_size, + // `line-height: normal` reads the font, which this function does not model + // (`crate::inline_atomic`'s own "Out" list); a zero strut is the honest + // stand-in, since a strut of no height constrains no line box. + line_height: computed::line_height(document, node).unwrap_or(0.0), + text_align: computed::text_align(document, node), + inlines, + }); + + let content_origin_x = padding_left + border_left; + let content_origin_y = padding_top + border_top; + let mut placed = Vec::with_capacity(laid_out.len()); + let mut pending_absolute: Vec = Vec::new(); + for (c, placement) in laid_out.into_iter().zip(placements) { + // The resolver places margin boxes; a fragment is a border box, one margin in + // from that -- the same split `stack_children` applies with `used_margin_left`. + let margin_left = c.used_margin_left; + let margin_top = length_or_auto_zero(computed::margin_top(document, c.fragment.node)); + let placed_x = content_origin_x + placement.x + margin_left + c.relative_shift_x; + let placed_y = content_origin_y + placement.y + margin_top + c.relative_shift_y; + let mut fragment = c.fragment; + fragment.offset_x = quantize_px(placed_x); + fragment.offset_y = quantize_px(placed_y); + for item in c.pending_absolute { + pending_absolute.push(item.translated(placed_x, placed_y)); + } + placed.push(fragment); + } + + let has_top_bp = padding_top > 0.0 || border_top > 0.0; + let has_bottom_bp = padding_bottom > 0.0 || border_bottom > 0.0; + let margin::MarginCollapseOutputs { self_collapsing: classified_self_collapsing, sibling_adjoin, .. } = + margin_classify(&margin::MarginCollapseInputs { + has_top_border_or_padding: has_top_bp, + has_bottom_border_or_padding: has_bottom_bp, + establishes_bfc, + height_is_auto, + height_is_explicit_zero, + // Line boxes are in-flow content (§9.4.2), so this box has some whenever it + // has an atomic inline to put on one -- which, by construction, it does. + no_in_flow_content: false, + }); + let self_collapsing = classified_self_collapsing && id != tree.root(); + + StackResult { + content_height_from_children: if self_collapsing { 0.0 } else { total_height.max(0.0) }, + // No child margin adjoins this box's own edges (this function's own doc comment). + escaping_top: vec![own_margin_top], + escaping_bottom: vec![own_margin_bottom], + children: placed, + pending_absolute, + self_collapsing, + sibling_adjoin, + // Floats inside an inline formatting context are out of scope + // (`crate::inline_atomic`'s own "Out" list), and the container itself never + // reads the enclosing context's float list, so no second pass is ever needed. + needs_float_pass: false, + last_line_baseline: last_baseline.map(|b| content_origin_y + b), + } +} + +/// One atomic inline's `vertical-align`, with a percentage resolved against its own +/// `line-height` the way CSS 2.1 10.8.1 defines it -- the one resolution +/// `crate::inline_atomic`'s candidates are not asked to do themselves (that module's own +/// doc comment). A percentage over a `line-height: normal` this build cannot measure +/// resolves against a zero line height, which is `baseline`. +fn resolved_vertical_align(document: &Document, node: NodeId) -> computed::VerticalAlign { + match computed::vertical_align(document, node) { + computed::VerticalAlign::Length(LengthPercentage::Percentage(fraction)) => { + let line_height = computed::line_height(document, node).unwrap_or(0.0); + computed::VerticalAlign::Length(LengthPercentage::Length(fraction * line_height)) + } + other => other, } } @@ -1052,8 +1257,10 @@ fn direction_of(document: &Document, node: NodeId) -> Direction { /// tree's root (which has nothing above it for a margin to escape into, so it is treated /// the way an `overflow: hidden` box is -- mirroring `crates/logic/src/facts.rs`'s /// synthetic root fact, checked there against live engines, -/// `docs/margin-collapse-experiment.md` -> "What was built"), a float, a `display: -/// flow-root` box, or any `overflow` other than `visible` on either axis. Decides both +/// `docs/margin-collapse-experiment.md` -> "What was built"), a float, an inline-level +/// box (9.4.1 names inline-blocks among the boxes that establish one, which is what +/// keeps an atomic inline's contents' margins from escaping onto the line it sits on), a +/// `display: flow-root` box, or any `overflow` other than `visible` on either axis. Decides both /// margin collapsing (`margin::MarginCollapseInputs::establishes_bfc`) and which box owns /// a `FloatContext`. Absolutely positioned boxes never reach this: `layout_absolute_box` /// forces the answer for them. @@ -1061,6 +1268,7 @@ fn is_bfc_root(document: &Document, tree: &BoxTree, id: BoxId) -> bool { let node = tree.boxes()[id.0].node; id == tree.root() || tree.boxes()[id.0].float.is_some() + || tree.boxes()[id.0].inline_level || computed::is_display_flow_root(document, node) || computed::overflow_x(document, node) != "visible" || computed::overflow_y(document, node) != "visible" @@ -1135,6 +1343,7 @@ fn layout_box( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, viewport: Rect, floats: Option>, ) -> LayoutResult { @@ -1254,37 +1463,77 @@ fn layout_box( let height_is_auto = matches!(resolved_height, LengthPercentageOrAuto::Auto); let height_is_explicit_zero = matches!(resolved_height, LengthPercentageOrAuto::Length(h) if h == 0.0); - let stacked = stack_children( - document, - tree, - id, - node, - children_ids, - padding_left, - padding_top, - padding_bottom, - border_left, - border_top, - border_bottom, - own_margin_top, - own_margin_bottom, - establishes_bfc, - height_is_auto, - height_is_explicit_zero, - &child_available, - width_resolver, - margin_classify, - margin_combine, - percentage_resolver, - absolute_resolver, - float_resolver, - relpos_resolver, - height_resolver, - containing_block_resolver, - transform_resolver, - viewport, - child_floats, - ); + // CSS 2.1 9.4.2: a box whose in-flow children are all inline-level lays them out on + // line boxes instead of stacking them (`crate::inline_atomic`), which is the one + // place this walk branches on which formatting context a box establishes for its + // contents. Every box in a document with no `inline-block` in it takes the stacking + // branch, exactly as before this function existed. + let stacked = if tree.establishes_inline_formatting_context(document, id) { + layout_inline_children( + document, + tree, + id, + node, + children_ids, + padding_left, + padding_top, + padding_bottom, + border_left, + border_top, + border_bottom, + own_margin_top, + own_margin_bottom, + establishes_bfc, + height_is_auto, + height_is_explicit_zero, + &child_available, + width_resolver, + margin_classify, + margin_combine, + percentage_resolver, + absolute_resolver, + float_resolver, + relpos_resolver, + height_resolver, + containing_block_resolver, + transform_resolver, + inline_atomic_resolver, + viewport, + ) + } else { + stack_children( + document, + tree, + id, + node, + children_ids, + padding_left, + padding_top, + padding_bottom, + border_left, + border_top, + border_bottom, + own_margin_top, + own_margin_bottom, + establishes_bfc, + height_is_auto, + height_is_explicit_zero, + &child_available, + width_resolver, + margin_classify, + margin_combine, + percentage_resolver, + absolute_resolver, + float_resolver, + relpos_resolver, + height_resolver, + containing_block_resolver, + transform_resolver, + inline_atomic_resolver, + viewport, + child_floats, + ) + }; // The height equation (CSS 2.1 10.6): plain-data in through `height_resolver`, the // seam this trivial layout no longer resolves inline @@ -1376,6 +1625,7 @@ fn layout_box( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, ); // That box's own out-of-flow descendants come back with their chains already @@ -1414,6 +1664,16 @@ fn layout_box( relative_shift_x, relative_shift_y, needs_float_pass: stacked.needs_float_pass && !establishes_bfc, + // CSS 2.1 10.8.1's second route to the bottom-margin-edge fallback: a box whose + // `overflow` computes to anything other than `visible` hands no baseline upward + // however many line boxes it holds (`LayoutResult::last_line_baseline`). + last_line_baseline: if computed::overflow_x(document, node) != "visible" + || computed::overflow_y(document, node) != "visible" + { + None + } else { + stacked.last_line_baseline + }, } } @@ -1517,6 +1777,7 @@ fn layout_absolute_box( height_resolver: HeightResolver, containing_block_resolver: ContainingBlockResolver, transform_resolver: TransformResolver, + inline_atomic_resolver: InlineAtomicResolver, viewport: Rect, ) -> (Fragment, Vec) { let cb_width = cb.width; @@ -1628,6 +1889,7 @@ fn layout_absolute_box( height_resolver, containing_block_resolver, transform_resolver, + inline_atomic_resolver, viewport, Some(FloatFrame { ctx: &mut own_float_context, x: 0.0, y: 0.0 }), ); @@ -1962,6 +2224,7 @@ mod tests { height::resolve_baseline, containing_block::resolve_baseline, transform::resolve_baseline, + crate::inline_atomic::resolve_baseline, ) } diff --git a/crates/layout/src/generated/inline_atomic.provenance.json b/crates/layout/src/generated/inline_atomic.provenance.json new file mode 100644 index 00000000..cb6969cf --- /dev/null +++ b/crates/layout/src/generated/inline_atomic.provenance.json @@ -0,0 +1,16 @@ +{ + "spec_url": "https://www.w3.org/TR/CSS21/visuren.html#inline-formatting", + "section_id": "9.4.2", + "spec_text_hash": "357c67e55641eb736801d55ab06531259a935cc7222f3abce20ba5aa2e643806", + "definition_closure_hash": "f884ef8b9719bad3599fcd058e72a130aa9eeba8754caa07c63b3cca216ea391", + "spec_level": "CSS 2.1", + "spec_revision": "2011-06-07", + "model": "qwen3.8-flash-next", + "prompt_hash": "0dd5e51a6adc28de5da73352c699f4af8a89004588feaeb77354120ea9a0dbf1", + "sampling_seed": 0, + "verification": { + "cases_run": 111, + "cases_passed": 111, + "regression_free": true + } +} \ No newline at end of file diff --git a/crates/layout/src/generated/inline_atomic.rs b/crates/layout/src/generated/inline_atomic.rs new file mode 100644 index 00000000..a6dfc204 --- /dev/null +++ b/crates/layout/src/generated/inline_atomic.rs @@ -0,0 +1,458 @@ +// Admitted candidate `model_0` (qwen3.8-flash-next, 111/111); provenance: inline_atomic.provenance.json +use crate::inline_atomic::{AtomicInline, InlineAtomicInputs, InlineAtomicOutputs, InlinePlacement, LineBox}; +use dom::computed::{LengthPercentage, TextAlign, VerticalAlign}; + +/// One candidate's implementation: plain data in, plain data out. +pub fn resolve(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let content_width = inputs.content_width; + let line_height = inputs.line_height; + let strut_baseline = line_height / 2.0; + + let mut placements: Vec = Vec::with_capacity(inputs.inlines.len()); + let mut lines: Vec = Vec::new(); + + if inputs.inlines.is_empty() { + return InlineAtomicOutputs { + placements, + lines, + total_height: 0.0, + last_baseline: None, + }; + } + + // Group inlines into lines + let mut current_line_indices: Vec = Vec::new(); + let mut current_line_width: f64 = 0.0; + let mut line_groups: Vec> = Vec::new(); + + for (i, inline) in inputs.inlines.iter().enumerate() { + let w = inline.margin_box_width; + + // If the current line is empty, always add the box (even if wider than container) + if current_line_indices.is_empty() { + current_line_indices.push(i); + current_line_width = w; + } else { + // Check if adding this box would exceed content_width + if current_line_width + w > content_width { + // Start a new line + line_groups.push(std::mem::take(&mut current_line_indices)); + current_line_width = 0.0; + current_line_indices.push(i); + current_line_width = w; + } else { + current_line_indices.push(i); + current_line_width += w; + } + } + } + // Push the last line if it has items + if !current_line_indices.is_empty() { + line_groups.push(current_line_indices); + } + + let mut y_offset = 0.0; + + for group in line_groups { + // For each line, we need to: + // 1. Determine the baseline position relative to the line box top. + // 2. Calculate the height of the line box. + // 3. Place each box horizontally (text-align) and vertically. + + // Step 1 & 2: Determine baseline and height. + // The strut is always present. Its baseline is at strut_baseline from the top of the strut. + // The strut's top is at some offset. Let's define the line box top as 0 for now, + // and find where the baseline sits within it. + + // We need to find the vertical position of the baseline for the line. + // Let B be the baseline position from the top of the line box. + // The strut occupies [B - strut_baseline, B - strut_baseline + line_height]. + // For baseline-aligned boxes: their baseline is at B. + // Box top = B - inline.baseline + // Box bottom = B - inline.baseline + inline.margin_box_height + // For top-aligned boxes: top edge at 0. + // For bottom-aligned boxes: bottom edge at H (line height). + // For middle-aligned boxes: midpoint at B. + // Top = B - margin_box_height / 2 + // Bottom = B + margin_box_height / 2 + // For length-aligned boxes: baseline at B - shift (shift positive raises box, so baseline moves up?) + // Wait, spec says: "raise (positive value) ... above the baseline". + // If baseline is at B, and we raise the box by shift, the box's baseline moves to B - shift. + // So Box top = (B - shift) - inline.baseline + + // We need to find B and H such that: + // - All boxes fit within [0, H] + // - H >= line_height + // - The strut fits: [B - strut_baseline, B - strut_baseline + line_height] within [0, H] + // So B >= strut_baseline and B - strut_baseline + line_height <= H => B <= H - line_height + strut_baseline + + // Strategy: + // 1. Compute preliminary extents assuming baseline alignment for baseline/middle/length boxes. + // Top-aligned boxes have top=0. Bottom-aligned boxes have bottom=H. + // This is circular for Top/Bottom because H depends on them and they depend on H. + + // Let's separate boxes into: + // - Baseline-relative: Baseline, Middle, Length, Other (treated as Baseline) + // - Top-aligned: Top + // - Bottom-aligned: Bottom + + // Let's compute the constraints on B (baseline position from line top) and H (line height). + + let mut min_top_from_baseline: f64 = 0.0; // Maximum distance ABOVE baseline (negative offset from baseline) + let mut max_bottom_from_baseline: f64 = 0.0; // Maximum distance BELOW baseline + + // Actually, let's track: + // For each baseline-relative box, its top relative to baseline is -inline.baseline. + // Its bottom relative to baseline is -inline.baseline + inline.margin_box_height. + + let mut min_rel_top: f64 = -strut_baseline; // Strut top relative to baseline + let mut max_rel_bottom: f64 = line_height - strut_baseline; // Strut bottom relative to baseline + + let mut has_top_aligned = false; + let mut has_bottom_aligned = false; + + // First pass: baseline-relative boxes + for &idx in &group { + let inline = &inputs.inlines[idx]; + match inline.vertical_align { + VerticalAlign::Baseline | VerticalAlign::Other => { + let rel_top = -inline.baseline; + let rel_bottom = -inline.baseline + inline.margin_box_height; + if rel_top < min_rel_top { min_rel_top = rel_top; } + if rel_bottom > max_rel_bottom { max_rel_bottom = rel_bottom; } + } + VerticalAlign::Length(lp) => { + let shift = match lp { + LengthPercentage::Length(px) => px, + _ => 0.0, + }; + // Positive shift raises the box. So the box's baseline is at B - shift. + // Top relative to B: (B - shift) - inline.baseline - B = -shift - inline.baseline + let rel_top = -shift - inline.baseline; + let rel_bottom = -shift - inline.baseline + inline.margin_box_height; + if rel_top < min_rel_top { min_rel_top = rel_top; } + if rel_bottom > max_rel_bottom { max_rel_bottom = rel_bottom; } + } + VerticalAlign::Middle => { + // Midpoint at B. + // Top = B - h/2. Rel top = -h/2. + // Bottom = B + h/2. Rel bottom = h/2. + let rel_top = -inline.margin_box_height / 2.0; + let rel_bottom = inline.margin_box_height / 2.0; + if rel_top < min_rel_top { min_rel_top = rel_top; } + if rel_bottom > max_rel_bottom { max_rel_bottom = rel_bottom; } + } + VerticalAlign::Top => { + has_top_aligned = true; + } + VerticalAlign::Bottom => { + has_bottom_aligned = true; + } + } + } + + // Now determine B and H. + // The line box top is 0. The baseline is at B. + // The strut occupies [B - strut_baseline, B - strut_baseline + line_height]. + // Baseline-relative boxes occupy [B + rel_top, B + rel_bottom]. + + // If there are no Top/Bottom aligned boxes: + // The line box must contain the strut and all baseline-relative boxes. + // Top of line box = min(0, min(B + rel_top for all boxes)) + // But we define line box top as 0. So we shift everything so the topmost point is 0. + // Let Top_extent = min(min_rel_top, -strut_baseline) (already included in min_rel_top) + // Let Bottom_extent = max(max_rel_bottom, line_height - strut_baseline) (already included) + + // The height H = Bottom_extent - Top_extent. + // The baseline B relative to line box top = -Top_extent. + // Because Top_extent is the most negative offset from baseline. + // If Top_extent is -10, then baseline is 10 units below the top of the line box. + + let mut top_extent = min_rel_top; // Most negative offset from baseline + let mut bottom_extent = max_rel_bottom; // Most positive offset from baseline + + // Handle Top-aligned boxes: + // They align their top edge with the line box top (0). + // Their bottom is at inline.margin_box_height. + // This box contributes a bottom extent of H - B (distance from baseline to bottom of box). + // Wait, if top is at 0, and baseline is at B, then top relative to baseline is -B. + // Bottom relative to baseline is H - B. + // So this box extends from -B to H-B relative to baseline. + // This imposes constraints on B and H. + + // Handle Bottom-aligned boxes: + // They align their bottom edge with the line box bottom (H). + // Their top is at H - inline.margin_box_height. + // Top relative to baseline: H - h - B. + // Bottom relative to baseline: H - B. + + // This is circular. Let's solve iteratively or analytically. + + // Let's assume we know B and H. + // For Top-aligned box i: + // Top at 0. Bottom at h_i. + // Constraint: h_i <= H (box fits in line) + // It affects bottom_extent: max(bottom_extent, H - B) + // It affects top_extent: min(top_extent, -B) + + // For Bottom-aligned box j: + // Bottom at H. Top at H - h_j. + // Constraint: H - h_j >= 0 => H >= h_j + // It affects bottom_extent: max(bottom_extent, H - B) + // It affects top_extent: min(top_extent, H - h_j - B) + + // We need to find B and H such that: + // 1. H >= line_height + // 2. H >= max_height_of_top_aligned_boxes (if any) + // 3. H >= max_height_of_bottom_aligned_boxes (if any) + // 4. The line box contains all boxes. + + // Let's try to find B first, assuming no Top/Bottom boxes, then adjust. + + // If no Top/Bottom boxes: + // H = bottom_extent - top_extent + // B = -top_extent + // Check if H >= line_height. If not, H = line_height. + // If H increases, B might need to adjust? + // The strut is fixed relative to baseline. + // If we just set H = max(bottom_extent - top_extent, line_height), + // and B = -top_extent, + // Then the strut is at [B - strut_baseline, B - strut_baseline + line_height]. + // The line box is [0, H]. + // We need B - strut_baseline >= 0 => B >= strut_baseline. + // And B - strut_baseline + line_height <= H. + + // Let's compute B and H for baseline-relative boxes first. + let mut B = -top_extent; + let mut H = bottom_extent - top_extent; + + if H < line_height { + H = line_height; + // If we increase H, we might need to adjust B to keep the strut centered? + // No, the strut's position is defined by the baseline. + // The baseline position B is determined by the content. + // If H > bottom_extent - top_extent, we have extra space. + // Where does it go? + // The spec says "minimize the line box height". + // So H should be exactly the extent of the content. + // But H must be at least line_height. + // If the content extent is less than line_height, the strut dictates the height. + // The strut is [B - strut_baseline, B - strut_baseline + line_height]. + // The line box is [0, H]. + // If H = line_height, and the content is smaller, the content is centered? + // No, the baseline is fixed by the content. + // If the content extent is 10px, and line_height is 20px. + // top_extent = -5, bottom_extent = 5. B = 5. H = 10. + // But H must be >= 20. So H = 20. + // The strut is at [5 - 10, 5 - 10 + 20] = [-5, 15]. + // The line box is [0, 20]. + // The strut overflows the top? No, the line box must contain the strut. + // So we must adjust B so that the strut fits in [0, H]. + // Strut top = B - strut_baseline >= 0 => B >= strut_baseline. + // Strut bottom = B - strut_baseline + line_height <= H => B <= H - line_height + strut_baseline. + + // If we set H = line_height, then B must be strut_baseline. + // Then the content is positioned relative to B = strut_baseline. + // So we recalculate top_extent and bottom_extent relative to this new B? + // No, the content's position relative to its own baseline is fixed. + // The baseline's position in the line box is B. + // If we force B = strut_baseline, then the content might overflow. + + // Correct approach: + // The line box height H is determined by the tallest content. + // If the content (including strut) requires more than line_height, H is that height. + // If the content requires less, H is line_height. + // In the latter case, the baseline is positioned such that the strut is centered? + // "The half-leading above and below the strut's content area is distributed equally". + // This implies the strut is centered in the line box if the line box is exactly line_height. + // So B = line_height / 2 = strut_baseline. + + if bottom_extent - top_extent < line_height { + B = strut_baseline; + H = line_height; + // Recalculate extents relative to this B? + // The content's position relative to B is fixed. + // So the actual top of content is B + min_rel_top. + // The actual bottom is B + max_rel_bottom. + // We need to ensure these are within [0, H]. + // Since the content extent is less than line_height, and B is centered, + // it should fit. + } + } + + // Now handle Top and Bottom aligned boxes. + // We need to adjust B and H to accommodate them. + + // For Top-aligned boxes: + // They want their top at 0. + // If their top relative to current B is T_rel = -B, then the box top is at B + T_rel = 0. + // This is consistent with B being the baseline position. + // The box bottom is at H_box = h_i. + // This box extends from 0 to h_i. + // Relative to baseline B, it extends from -B to h_i - B. + // So it contributes to top_extent: min(top_extent, -B) + // And bottom_extent: max(bottom_extent, h_i - B) + + // For Bottom-aligned boxes: + // They want their bottom at H. + // Box top is at H - h_j. + // Relative to baseline B, top is H - h_j - B. + // Bottom is H - B. + // Contributes to top_extent: min(top_extent, H - h_j - B) + // Contributes to bottom_extent: max(bottom_extent, H - B) + + // We need to find B and H that satisfy all constraints. + // Let's iterate. + + let mut changed = true; + let mut iterations = 0; + while changed && iterations < 100 { + changed = false; + iterations += 1; + + let mut new_top_extent = min_rel_top; + let mut new_bottom_extent = max_rel_bottom; + + // Include strut + if -strut_baseline < new_top_extent { new_top_extent = -strut_baseline; } + if line_height - strut_baseline > new_bottom_extent { new_bottom_extent = line_height - strut_baseline; } + + // Include baseline-relative boxes (already in min/max_rel) + + // Include Top-aligned + for &idx in &group { + let inline = &inputs.inlines[idx]; + if inline.vertical_align == VerticalAlign::Top { + let h = inline.margin_box_height; + // Top at 0. Relative to B: -B. + // Bottom at h. Relative to B: h - B. + if -B < new_top_extent { new_top_extent = -B; } + if h - B > new_bottom_extent { new_bottom_extent = h - B; } + } + } + + // Include Bottom-aligned + for &idx in &group { + let inline = &inputs.inlines[idx]; + if inline.vertical_align == VerticalAlign::Bottom { + let h = inline.margin_box_height; + // Bottom at H. Top at H - h. + // Relative to B: H - h - B, H - B. + if H - h - B < new_top_extent { new_top_extent = H - h - B; } + if H - B > new_bottom_extent { new_bottom_extent = H - B; } + } + } + + let new_B = -new_top_extent; + let new_H = new_bottom_extent - new_top_extent; + + if (new_B - B).abs() > 1e-9 || (new_H - H).abs() > 1e-9 { + B = new_B; + H = new_H; + changed = true; + } + } + + // Ensure H >= line_height + if H < line_height { + H = line_height; + // Re-center strut? + // If we just increase H, the baseline B might need to shift. + // The strut must fit. + // Strut top = B - strut_baseline >= 0 + // Strut bottom = B - strut_baseline + line_height <= H + // If we increase H, we can keep B the same if it still fits. + // If B - strut_baseline < 0, we need to increase B. + // If B - strut_baseline + line_height > H, we need to decrease B. + + if B - strut_baseline < 0.0 { + B = strut_baseline; + } + if B - strut_baseline + line_height > H { + B = H - line_height + strut_baseline; + } + } + + // Now we have B (baseline from line top) and H (line height). + + // Calculate horizontal placement + let total_width: f64 = group.iter().map(|&i| inputs.inlines[i].margin_box_width).sum(); + let remaining = content_width - total_width; + let mut x_start = 0.0; + match inputs.text_align { + TextAlign::Left | TextAlign::Other => { + x_start = 0.0; + } + TextAlign::Right => { + x_start = remaining; + } + TextAlign::Center => { + x_start = remaining / 2.0; + } + } + + let mut current_x = x_start; + + for &idx in &group { + let inline = &inputs.inlines[idx]; + let w = inline.margin_box_width; + let h = inline.margin_box_height; + + let y_pos; + match inline.vertical_align { + VerticalAlign::Baseline | VerticalAlign::Other => { + // Baseline at B. Box baseline at B. + // Box top = B - inline.baseline + y_pos = B - inline.baseline; + } + VerticalAlign::Length(lp) => { + let shift = match lp { + LengthPercentage::Length(px) => px, + _ => 0.0, + }; + // Baseline at B - shift. + // Box top = (B - shift) - inline.baseline + y_pos = B - shift - inline.baseline; + } + VerticalAlign::Middle => { + // Midpoint at B. + // Top = B - h/2 + y_pos = B - h / 2.0; + } + VerticalAlign::Top => { + // Top at 0 + y_pos = 0.0; + } + VerticalAlign::Bottom => { + // Bottom at H + y_pos = H - h; + } + } + + placements.push(InlinePlacement { + x: current_x, + y: y_offset + y_pos, + }); + + current_x += w; + } + + lines.push(LineBox { + top: y_offset, + height: H, + baseline: B, + }); + + y_offset += H; + } + + let total_height = y_offset; + let last_baseline = lines.last().map(|l| l.top + l.baseline); + + InlineAtomicOutputs { + placements, + lines, + total_height, + last_baseline, + } +} \ No newline at end of file diff --git a/crates/layout/src/generated/mod.rs b/crates/layout/src/generated/mod.rs index cb87635e..9d0f4a23 100644 --- a/crates/layout/src/generated/mod.rs +++ b/crates/layout/src/generated/mod.rs @@ -11,6 +11,13 @@ //! (`generation::admit::beats_committed`), which is the same gate every other file here is //! held to -- only the incumbent differs. //! +//! `inline_atomic` starts from its own function's deliberately wrong baseline too -- one +//! atomic inline per line, stacked down the container's left content edge, which is what +//! the engine did for a `display: inline-block` box before `crate::inline_atomic` +//! existed. Its header says `hand-written` and its verification counts are zero for that +//! reason; the first model candidate to score above it replaces it through the same gate +//! every other file here is held to. +//! //! `overflow` starts from its seam's deliberately wrong baseline -- no box ever scrolls, //! what the engine did before the seam existed -- rather than from a model batch or a //! correct-by-construction rule. Its header says `hand-written` and its verification @@ -21,6 +28,7 @@ pub mod absolute; pub mod containing_block; pub mod float; pub mod height; +pub mod inline_atomic; pub mod margin; pub mod overflow; pub mod percentage; diff --git a/crates/layout/src/inline_atomic.rs b/crates/layout/src/inline_atomic.rs new file mode 100644 index 00000000..5187569d --- /dev/null +++ b/crates/layout/src/inline_atomic.rs @@ -0,0 +1,281 @@ +//! CSS 2.1 inline formatting contexts over atomic inlines with no text -- §9.4.2 (line +//! boxes and how inline-level boxes are distributed among them), §10.8 and §10.8.1 +//! (`line-height`, the strut, and `vertical-align`), and the §10.6.3 auto height a stack +//! of line boxes gives its container -- isolated behind one function with plain-data +//! inputs and outputs (`roadmap.md` -> "Phase 1 — Engine bootstrap" step 4). A generated +//! candidate implements [`InlineAtomicResolver`] against the types in this module alone: +//! +//! - [`resolve`-shaped](InlineAtomicResolver): given the container's content width, its +//! strut `line-height` and `text-align`, and, per atomic inline in document order, its +//! margin-box width and height, its own baseline measured from its margin-box top and +//! its `vertical-align`, returns where each atomic inline's margin box goes in the +//! container's content box, the top, height and baseline of each line box, the total +//! height of the stack, and the last line box's baseline for an enclosing +//! inline-block. +//! +//! The container is a block container whose in-flow children are all `display: +//! inline-block` with explicit `width` and `height`, which is the one kind of inline +//! content that needs no font (`specs/inline-atomic.md`). `crates/layout/src/boxtree.rs` +//! decides which containers those are ([`boxtree::Box::inline_level`](crate::boxtree::Box::inline_level)), +//! and `crates/layout/src/fragment.rs` lays each atomic inline out as a block formatting +//! context root at its explicit size before handing the measurements here -- a candidate +//! never lays anything out itself, it only places boxes whose sizes are already known. +//! +//! Two things a candidate is not asked to do, because `fragment.rs` has already done +//! them. A `vertical-align` percentage is resolved against the box's own `line-height` +//! before it arrives, so [`VerticalAlign::Length`] here always carries +//! `LengthPercentage::Length` (the same already-resolved discipline +//! `crate::absolute::AbsoluteInputs` keeps). And an inline-block's own baseline is +//! measured for it: §10.8.1 puts that baseline at its last in-flow line box's, or at its +//! bottom margin edge when it has no line box or its `overflow` computes to something +//! other than `visible`, and that choice is `fragment.rs`'s, since only `fragment.rs` can +//! see inside the box. +//! +//! **The strut, and why no case here depends on a font.** §10.8 starts every line box +//! with a zero-width inline box carrying the container's own font and `line-height` -- the +//! strut. Its ascent and descent place the baseline inside the line height, so in general +//! the strut reads a font. Every container in this function's corpus sets `font-size: 0` +//! with an explicit `line-height` in px +//! (`crates/harness/src/inline_atomic_cases.rs`'s own module doc), which makes the +//! strut's ascent and descent both zero: its content area is empty, the half-leading is +//! split equally above and below it, and so the strut's baseline sits exactly at the +//! middle of [`InlineAtomicInputs::line_height`]. `middle` then aligns on the baseline +//! itself, since the parent's x-height is zero too. That is the whole of what +//! [`line_height`](InlineAtomicInputs::line_height) means to a candidate: a minimum line +//! box height whose baseline is at its midpoint. +//! +//! Scope. In: one line of atomic inlines and the line breaking that starts a new one when +//! the next box does not fit; an atomic inline wider than the container placed alone on +//! its own line; `vertical-align: baseline | top | bottom | middle | | +//! `; `line-height` on the container as the strut; `text-align: left | right +//! | center`; margins, padding and borders on the atomic inlines; an inline-block's own +//! baseline, and so inline-blocks nested inside inline-blocks; and the container's auto +//! height as the sum of its line boxes. +//! +//! Out, and not modelled anywhere in this module: text and every font-dependent value +//! (`sub`, `super`, `text-top`, `text-bottom`, `line-height: normal` over a non-zero font +//! size); `width: auto` inline-blocks, whose shrink-to-fit width belongs to +//! `min-max-content`; a container mixing block-level and inline-level children, which +//! CSS 2.1 §9.2.1.1 wraps in anonymous block boxes; floats and absolutely positioned +//! boxes inside the inline formatting context; `text-align: justify`; bidi and vertical +//! writing modes; `inline-table`, `inline-flex` and replaced elements. +//! +//! [`resolve_baseline`] is this function's deliberately wrong starting candidate: every +//! atomic inline is stacked vertically at the container's left content edge, one per +//! line, which is exactly what the engine did before this module existed -- `boxtree.rs` +//! gave a `display: inline-block` element one block-level box and `fragment.rs` stacked +//! it in normal flow like any other. + +use dom::computed::{LengthPercentage, TextAlign, VerticalAlign}; + +/// One atomic inline, measured. Sizes are margin-box sizes: §10.8 takes the height of an +/// inline-block in a line box to be the height of its margin box, and the horizontal +/// margins, borders and padding between two atomic inlines are respected the same way +/// (§9.4.2), so a candidate never needs the box's separate edges. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AtomicInline { + /// The box's margin-box width: content width plus its own padding, borders and + /// horizontal margins. This is the width it takes up on a line. + pub margin_box_width: f64, + /// The box's margin-box height, which §10.8 uses as its height in the line box. + pub margin_box_height: f64, + /// This box's own baseline, as a distance down from its margin-box top. §10.8.1: for + /// an inline-block it is the baseline of its last in-flow line box, or its bottom + /// margin edge -- equal to + /// [`margin_box_height`](AtomicInline::margin_box_height) -- when it has no in-flow + /// line box or its `overflow` computes to something other than `visible`. + /// `fragment.rs` has already made that choice (this module's own doc comment). + pub baseline: f64, + /// This box's computed `vertical-align`, with any percentage already resolved + /// against its own `line-height` into [`VerticalAlign::Length`] (this module's own + /// doc comment). [`VerticalAlign::Other`] names the font-dependent keywords this + /// function does not model and never reaches a candidate. + pub vertical_align: VerticalAlign, +} + +/// Plain-data inputs to one inline formatting context, narrowed to what §9.4.2 and §10.8 +/// read. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineAtomicInputs { + /// The container's own content width. Every line box is this wide -- no float can + /// narrow one here, floats inside an inline formatting context being out of scope -- + /// so it is both the space a line has to fill and the width `text-align` distributes + /// leftover space within. + pub content_width: f64, + /// The container's computed `line-height` in CSS pixels: the strut's height, and so + /// the minimum height of every line box, with the strut's baseline at its midpoint + /// (this module's own doc comment). + pub line_height: f64, + /// The container's computed `text-align`, which §9.4.2 applies to the leftover space + /// on a line. [`TextAlign::Other`] -- `justify` above all -- is out of scope and + /// never reaches a candidate. + pub text_align: TextAlign, + /// Every in-flow atomic inline the container holds, in document order. + pub inlines: Vec, +} + +/// Where one atomic inline's margin box goes, as an offset from the container's +/// content-box origin. `fragment.rs` adds the box's own top-left margin back out to reach +/// the border box it actually places, the same split +/// `crate::absolute::AbsoluteOutputs` uses between `left`/`top` and `margin_left`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct InlinePlacement { + pub x: f64, + pub y: f64, +} + +/// One line box. §9.4.2 stacks line boxes with no vertical separation and never overlaps +/// them, so `top` is the previous line box's `top + height` for every line but the first, +/// whose `top` is zero. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LineBox { + /// The line box's top edge, as a distance down from the container's content-box top. + pub top: f64, + /// The line box's height: §10.8's distance from the uppermost box top to the + /// lowermost box bottom, never less than the strut's + /// [`line_height`](InlineAtomicInputs::line_height). + pub height: f64, + /// This line box's baseline, as a distance down from its own + /// [`top`](LineBox::top) -- the baseline every `vertical-align: baseline` box on the + /// line, and the strut, align on. + pub baseline: f64, +} + +/// Where every atomic inline and every line box ended up, and what the container's own +/// height and baseline become. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineAtomicOutputs { + /// One placement per entry of [`InlineAtomicInputs::inlines`], in the same order. + pub placements: Vec, + /// The line boxes, top to bottom. Empty when the container holds no atomic inline at + /// all -- §9.4.2 generates no line box for content that is not there. + pub lines: Vec, + /// The height of the whole stack of line boxes, which §10.6.3 makes the container's + /// content height when its own `height` is `auto`. The last line box's `top + + /// height`, and zero when there is no line box. + pub total_height: f64, + /// The last line box's baseline, as a distance down from the container's content-box + /// top -- what §10.8.1 makes the container's own baseline when the container is + /// itself an inline-block. `None` when there is no line box, which sends §10.8.1 to + /// its bottom-margin-edge fallback instead. + pub last_baseline: Option, +} + +/// One candidate's implementation: plain data in, plain data out. Every candidate module +/// the generation loop compiles exports a function of this type named `resolve`. +pub type InlineAtomicResolver = fn(&InlineAtomicInputs) -> InlineAtomicOutputs; + +/// This function's deliberately wrong starting candidate (this module's own doc comment): +/// no line box ever holds more than one atomic inline, every box sits at the container's +/// left content edge, and `vertical-align`, `text-align` and the strut are all ignored -- +/// the vertical stacking the engine gave a `display: inline-block` box before this module +/// existed. +pub fn resolve_baseline(inputs: &InlineAtomicInputs) -> InlineAtomicOutputs { + let mut placements = Vec::with_capacity(inputs.inlines.len()); + let mut lines = Vec::with_capacity(inputs.inlines.len()); + let mut y = 0.0; + for inline in &inputs.inlines { + placements.push(InlinePlacement { x: 0.0, y }); + lines.push(LineBox { top: y, height: inline.margin_box_height, baseline: inline.baseline }); + y += inline.margin_box_height; + } + let last_baseline = lines.last().map(|line| line.top + line.baseline); + InlineAtomicOutputs { placements, lines, total_height: y, last_baseline } +} + +/// A `vertical-align` whose percentage has already been resolved, as the plain shift in +/// CSS pixels it stands for, or `None` for a keyword that is not a shift. Positive raises +/// the box above the parent's baseline, which is the direction §10.8.1 gives both +/// `` and ``. A helper rather than part of the function's own data, +/// kept here so a candidate crate compiling against this module alone can reach it. +pub fn baseline_shift(vertical_align: VerticalAlign) -> Option { + match vertical_align { + VerticalAlign::Length(LengthPercentage::Length(px)) => Some(px), + VerticalAlign::Baseline => Some(0.0), + VerticalAlign::Length(LengthPercentage::Percentage(_)) + | VerticalAlign::Length(LengthPercentage::Unresolved) + | VerticalAlign::Top + | VerticalAlign::Bottom + | VerticalAlign::Middle + | VerticalAlign::Other => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inline(width: f64, height: f64) -> AtomicInline { + AtomicInline { + margin_box_width: width, + margin_box_height: height, + baseline: height, + vertical_align: VerticalAlign::Baseline, + } + } + + fn inputs(inlines: Vec) -> InlineAtomicInputs { + InlineAtomicInputs { + content_width: 300.0, + line_height: 20.0, + text_align: TextAlign::Left, + inlines, + } + } + + #[test] + fn baseline_gives_every_box_its_own_line() { + let out = resolve_baseline(&inputs(vec![inline(50.0, 10.0), inline(60.0, 30.0)])); + assert_eq!(out.placements, vec![ + InlinePlacement { x: 0.0, y: 0.0 }, + InlinePlacement { x: 0.0, y: 10.0 }, + ]); + assert_eq!(out.lines.len(), 2, "two boxes that would fit side by side, stacked anyway"); + assert_eq!(out.total_height, 40.0); + } + + /// The wrongness that matters most: two boxes narrow enough to share a line never do. + #[test] + fn baseline_never_puts_two_boxes_on_one_line() { + let out = resolve_baseline(&inputs(vec![inline(10.0, 10.0), inline(10.0, 10.0)])); + assert_eq!(out.placements[1].y, 10.0, "below the first, not beside it"); + assert_eq!(out.placements[1].x, 0.0); + } + + /// The strut is ignored outright: a line box shorter than `line-height` stays short. + #[test] + fn baseline_ignores_the_strut() { + let out = resolve_baseline(&inputs(vec![inline(10.0, 4.0)])); + assert_eq!(out.lines[0].height, 4.0, "not the 20px line-height"); + assert_eq!(out.total_height, 4.0); + } + + #[test] + fn baseline_ignores_text_align() { + let mut i = inputs(vec![inline(10.0, 10.0)]); + i.text_align = TextAlign::Right; + assert_eq!(resolve_baseline(&i).placements[0].x, 0.0, "not flush right"); + } + + #[test] + fn no_inlines_is_no_line_box() { + let out = resolve_baseline(&inputs(Vec::new())); + assert!(out.lines.is_empty()); + assert_eq!(out.total_height, 0.0); + assert_eq!(out.last_baseline, None); + } + + #[test] + fn the_last_baseline_is_measured_from_the_content_top() { + let out = resolve_baseline(&inputs(vec![inline(10.0, 10.0), inline(10.0, 30.0)])); + assert_eq!(out.last_baseline, Some(40.0), "the second line's top plus its own baseline"); + } + + #[test] + fn baseline_shift_reads_a_resolved_length_and_nothing_else() { + assert_eq!(baseline_shift(VerticalAlign::Length(LengthPercentage::Length(6.0))), Some(6.0)); + assert_eq!(baseline_shift(VerticalAlign::Baseline), Some(0.0)); + assert_eq!(baseline_shift(VerticalAlign::Middle), None); + assert_eq!(baseline_shift(VerticalAlign::Length(LengthPercentage::Percentage(0.5))), None); + } +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9d7ef73e..1ac03d57 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -13,6 +13,7 @@ pub mod float; pub mod fragment; mod generated; pub mod height; +pub mod inline_atomic; pub mod margin; pub mod overflow; pub mod percentage; @@ -79,6 +80,7 @@ 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::inline_atomic::resolve as generated_inline_atomic_resolve; pub use generated::margin::classify as generated_margin_classify; pub use generated::margin::combine as generated_margin_combine; pub use generated::overflow::resolve as generated_overflow_resolve; diff --git a/crates/provenance/src/css21_inline_atomic.rs b/crates/provenance/src/css21_inline_atomic.rs new file mode 100644 index 00000000..4c25ef47 --- /dev/null +++ b/crates/provenance/src/css21_inline_atomic.rs @@ -0,0 +1,302 @@ +//! Spec text for the inline-atomic generated function (`layout::inline_atomic`, +//! `specs/inline-atomic.md`). One section feeds one function: +//! `layout::inline_atomic::InlineAtomicResolver` resolves CSS 2.1 §9.4.2 and §10.8 +//! through one function (`crates/layout/src/inline_atomic.rs`'s own doc comment). +//! Counterpart of `crate::css21_relative_positioning`, following the same +//! `Fixture: DefinitionIndex` pattern. + +use crate::closure::DefinitionIndex; + +/// `"9.4.2"` names this function's own primary section (CSS 2.1's own table of contents +/// lists it as "Inline formatting context") -- `Fixture::references` reaches every other +/// section the function draws on, §10.8 and §10.8.1 above all. +pub const SECTION_ID: &str = "9.4.2"; +pub const SPEC_URL: &str = "https://www.w3.org/TR/CSS21/visuren.html#inline-formatting"; +pub const SPEC_LEVEL: &str = "CSS 2.1"; + +/// CSS 2.1 §9.4.2's and §10.8's normative text on inline formatting contexts, line boxes +/// and vertical alignment, HTML-wrapped, narrowed to the atomic-inline, text-free case +/// this function's corpus exercises (`crates/harness/src/inline_atomic_cases.rs`). The +/// `` block and the "Changes" section are synthetic, added to exercise the +/// normalizer the same way `crate::css21_relative_positioning::SPEC_TEXT`'s is; +/// everything else is a faithful paraphrase of the sections' actual prose (the exact +/// upstream wording is not available here, mirroring that module's own note about its +/// own paraphrased passages). +pub const SPEC_TEXT: &str = r#" +

9.4.2 Inline formatting contexts

+ +

In an inline formatting context, boxes are laid out horizontally, one after the other, +beginning at the top of a containing block. Horizontal margins, borders and padding are +respected between these boxes. The boxes may be aligned vertically in different ways: +their bottoms or tops may be aligned, or the baselines of text within them may be +aligned. The rectangular area that contains the boxes that form a line is called a line +box.

+ +

The width of a line box is determined by a containing block and the presence of +floats. The height of a line box is determined by the rules given in the section on line +height calculations.

+ +

A line box is always tall enough for all of the boxes it contains. However, it may be +taller than the tallest box it contains (if, for example, boxes are aligned so that +baselines line up). When the height of a box B is less than the height of the line box +containing it, the vertical alignment of B within the line box is determined by the +'vertical-align' property.

+ +

When several inline-level boxes cannot fit horizontally within a single line box, they +are distributed among two or more vertically-stacked line boxes. Thus, a paragraph is a +vertical stack of line boxes. Line boxes are stacked with no vertical separation and they +never overlap.

+ +

In general, the left edge of a line box touches the left edge of its containing block +and the right edge touches the right edge of its containing block. However, floating +boxes may come between the containing block edge and the line box edge. Thus, although +line boxes in the same inline formatting context generally have the same width (that of +the containing block), they may vary in width if available horizontal space is reduced +because of floats.

+ +

An inline-level box that cannot be split -- an inline-block among them -- is placed +alone on a line box if it is wider than the line box: it overflows the line box rather +than being broken across two of them.

+ +

When an inline box exceeds the width of a line box, it is split into several boxes and +these boxes are distributed across several line boxes. An inline-block establishes a +block formatting context for its contents and is itself an atomic inline-level box: it +participates in its parent inline formatting context as a single opaque box that is never +split.

+ +

When the total width of the inline-level boxes on a line is less than the width of the +line box containing them, their horizontal distribution within the line box is determined +by the 'text-align' property. If that property has the value 'left', the boxes are placed +flush with the left edge of the line box and the leftover space is left at the right end; +if 'right', they are placed flush with the right edge and the leftover space is left at +the left end; if 'center', the leftover space is split equally between the two ends, so +that the boxes sit in the middle of the line box.

+ +

10.8 Line height calculations: the 'line-height' and +'vertical-align' properties

+ +

The height of each inline-level box in the line box is calculated. For replaced +elements, inline-block elements and inline-table elements, this is the height of their +margin box; for inline boxes, this is their 'line-height'.

+ +

The inline-level boxes are aligned vertically according to their 'vertical-align' +property. In case they are aligned 'top' or 'bottom', they must be aligned so as to +minimize the line box height. If such boxes are tall enough, there are multiple solutions +and CSS 2.1 does not define the position of the line box's baseline.

+ +

The line box height is the distance between the uppermost box top and the lowermost +box bottom. Empty inline elements generate empty inline boxes, but these boxes still have +margins, padding, borders and a line height, and thus influence these +calculations.

+ +

The 'line-height' property specifies the minimal height of line boxes within the +element. On a block container element whose content is composed of inline-level elements, +'line-height' specifies the minimal height of line boxes within the element. The minimum +height consists of a minimum height above the baseline and a minimum depth below it, +exactly as if each line box starts with a zero-width inline box with the element's font +and line height properties. That zero-width box is called the strut. The half-leading +above and below the strut's content area is distributed equally, so with a font whose +ascent and descent are both zero the strut's baseline sits at the middle of its line +height.

+ +

10.8.1 Leading and half-leading

+ +

This property affects the vertical positioning inside a line box of the boxes +generated by an inline-level element. The following values only have meaning with respect +to a parent inline-level element, or to a parent block container element's strut.

+ +

'baseline': align the baseline of the box with the baseline of the parent box. If the +box does not have a baseline, align the bottom margin edge with the parent's +baseline.

+ +

'middle': align the vertical midpoint of the box with the baseline of the parent box +plus half the x-height of the parent.

+ +

'top': align the top of the aligned subtree with the top of the line box.

+ +

'bottom': align the bottom of the aligned subtree with the bottom of the line +box.

+ +

<percentage>: raise (positive value) or lower (negative value) the box by this +distance (a percentage of the 'line-height' value) above or below the baseline of its +parent. The value '0%' means the same as 'baseline'.

+ +

<length>: raise (positive value) or lower (negative value) the box by this +distance above or below the baseline of its parent. The value '0cm' means the same as +'baseline'.

+ +

The baseline of an 'inline-block' is the baseline of its last line box in the normal +flow, unless it has either no in-flow line boxes or if its 'overflow' property has a +computed value other than 'visible', in which case the baseline is the bottom margin +edge.

+ +

10.6.3 Block-level non-replaced elements in normal flow when +'overflow' computes to 'visible'

+ +

If 'height' is 'auto', the height depends on whether the element has any block-level +children and whether it has padding or borders: the element's height is the distance from +its top content edge to the bottom edge of the bottom-most line box, if the element's +inline-level children form line boxes.

+ + +css/CSS2/linebox/vertical-align-001.html +css/CSS2/linebox/line-height-001.html + + +

Changes

+

Paraphrased rather than quoted verbatim, since the exact upstream text was not +available while writing this generated function; substantively the same rules 9.4.2, +10.8, 10.8.1 and 10.6.3 state, narrowed to the atomic-inline, text-free case this +function's corpus exercises. Text, and with it every font-dependent value -- 'sub', +'super', 'text-top', 'text-bottom' and a 'line-height' of 'normal' over a non-zero font +size -- is out of scope (`specs/inline-atomic.md`'s own "Out" list), as are floats and +absolutely positioned boxes inside the inline formatting context, 'text-align: justify', +bidi and vertical writing modes. No other normative effect.

+"#; + +/// Abbreviated stand-ins for the real definition text a webref `curated` fetch would +/// supply, mirroring `crate::css21_relative_positioning::Fixture`'s shape and depth. +/// `containing-block`/`computed-value`/`specified-value`/`normal-flow` are shared +/// verbatim with that module's own definitions -- the same terms, the same referents. +struct Definition { + id: &'static str, + text: &'static str, + references: &'static [&'static str], +} + +const DEFINITIONS: &[Definition] = &[ + Definition { + id: "containing-block", + text: "The rectangle that generates the containing block for its descendants is \ + called that container's content edge.", + references: &[], + }, + Definition { + id: "computed-value", + text: "The computed value is the value that results from resolving the specified \ + value, generally absolutizing it in preparation for inheritance.", + references: &["specified-value"], + }, + Definition { + id: "specified-value", + text: "The specified value for a property is determined according to the cascade, \ + inheritance and the property's initial value.", + references: &[], + }, + Definition { + id: "normal-flow", + text: "Boxes in the normal flow belong to a formatting context [...] and are laid \ + out one after the other.", + references: &[], + }, + Definition { + id: "line-box", + text: "The rectangular area that contains the boxes that form a line is called a \ + line box.", + references: &["inline-formatting-context", "strut"], + }, + Definition { + id: "inline-formatting-context", + text: "In an inline formatting context, boxes are laid out horizontally, one after \ + the other, beginning at the top of a containing block.", + references: &["normal-flow"], + }, + Definition { + id: "strut", + text: "Each line box starts with a zero-width inline box with the block container \ + element's font and line height properties; that zero-width box is called \ + the strut.", + references: &[], + }, + Definition { + id: "atomic-inline-level-box", + text: "An inline-level box that cannot be split across line boxes -- a replaced \ + element, an inline-block or an inline-table -- participates in its parent \ + inline formatting context as a single opaque box.", + references: &["line-box"], + }, + Definition { + id: "baseline", + text: "The baseline of an 'inline-block' is the baseline of its last line box in \ + the normal flow, unless it has either no in-flow line boxes or its \ + 'overflow' property computes to something other than 'visible', in which \ + case the baseline is the bottom margin edge.", + references: &["line-box"], + }, +]; + +fn find(id: &str) -> Option<&'static Definition> { + DEFINITIONS.iter().find(|d| d.id == id) +} + +/// The `DefinitionIndex` for CSS 2.1 §9.4.2's inline-formatting-context rules. +pub struct Fixture; + +impl DefinitionIndex for Fixture { + fn references(&self, section_id: &str) -> Vec { + if section_id == SECTION_ID { + vec![ + "containing-block".to_string(), + "computed-value".to_string(), + "line-box".to_string(), + "atomic-inline-level-box".to_string(), + "baseline".to_string(), + ] + } else { + Vec::new() + } + } + + fn definition_references(&self, definition_id: &str) -> Vec { + find(definition_id) + .map(|d| d.references.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default() + } + + fn definition_text(&self, definition_id: &str) -> Option { + find(definition_id).map(|d| d.text.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::closure::{closure_for, closure_hash}; + use crate::normalize::normalize_spec_text; + + #[test] + fn closure_reaches_the_transitive_definitions() { + let closure = closure_for(SECTION_ID, &Fixture); + assert_eq!( + closure, + std::collections::BTreeSet::from([ + "containing-block".to_string(), + "computed-value".to_string(), + "specified-value".to_string(), + "line-box".to_string(), + "inline-formatting-context".to_string(), + "normal-flow".to_string(), + "strut".to_string(), + "atomic-inline-level-box".to_string(), + "baseline".to_string(), + ]) + ); + } + + #[test] + fn normalized_spec_text_drops_synthetic_wpt_example_and_changes() { + let normalized = normalize_spec_text(SPEC_TEXT); + assert!(!normalized.contains("vertical-align-001")); + assert!(!normalized.contains("Paraphrased rather than quoted")); + assert!(normalized.contains("line box")); + assert!(normalized.contains("strut")); + } + + #[test] + fn definition_closure_hash_is_deterministic() { + let closure = closure_for(SECTION_ID, &Fixture); + let h1 = closure_hash(&closure, &Fixture); + let h2 = closure_hash(&closure, &Fixture); + assert_eq!(h1, h2); + } +} diff --git a/crates/provenance/src/lib.rs b/crates/provenance/src/lib.rs index 87d6f08b..36fffcfc 100644 --- a/crates/provenance/src/lib.rs +++ b/crates/provenance/src/lib.rs @@ -5,6 +5,7 @@ pub mod closure; pub mod css21_absolute_positioning; pub mod css21_containing_block; +pub mod css21_inline_atomic; pub mod css21_margin_collapse; pub mod css21_percentage_resolution; pub mod css21_relative_positioning; diff --git a/docs/generated-functions.md b/docs/generated-functions.md index a4265c8c..3d79f23e 100644 --- a/docs/generated-functions.md +++ b/docs/generated-functions.md @@ -16,7 +16,7 @@ flowchart LR fragment --> tree["fragment tree"] ``` -## The eleven generated functions +## The twelve generated functions Scores are on each function'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 @@ -35,6 +35,7 @@ corpora are per-function. | 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` | 106/106 | model-first | | transform | CSS Transforms 1 §3/§4/§5, the 2D transformation matrix | `qwen3.8-27b-fast` | 110/110 | model-first | +| inline atomic | 9.4.2, 10.8, 10.8.1 line boxes over atomic inlines | `qwen3.8-flash-next` | 111/111 | model-first | On the recipe corpus a function 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 @@ -47,6 +48,10 @@ is what `position: fixed` needed in order to exist at all. The transform function is the one whose answer is a matrix, and the only one so far not drawn from CSS 2.1. +The inline-atomic function is the one whose answer is a set of positions rather than a +single value, and the first to place more than one box per call: everything before it +decided one box's geometry at a time. + Which of these the engine's real entry points actually call is traced in `roadmap.md` → "Which resolver each generated function runs". @@ -161,6 +166,50 @@ own doc: 3D functions and `perspective`, `transform-box`, the separate `translate`/`rotate`/`scale` properties, a transformed box becoming a containing block or a stacking context, painting, and scrollable overflow. +**Inline atomic** (`crates/layout/src/inline_atomic.rs`), the function that gives the +engine line boxes. Before it, `boxtree.rs` gave every computed `display` other than `none` +one block-level box, so a `display: inline-block` element stacked in normal flow like a +block and `vertical-align`, `line-height` and `text-align` reached nothing at all. Now a +container whose in-flow children are all inline-level establishes an inline formatting +context, `fragment.rs` lays each atomic inline out as a block formatting context root at +its explicit size, and the function decides where on which line each margin box goes, how +tall each line box is, where its baseline sits, and what the container's auto height +becomes. + +Its answer is a set of positions rather than a single value — one placement per input box, +plus the line boxes themselves — which makes it the first function here that can be wrong +about one box and right about its neighbour on the same call, and the first that can panic +on a well-typed input by indexing its own output. The runner catches that the way the +transform runner does. + +Scope is what makes it possible without text. In: line breaking between atomic inlines, +`vertical-align` in all six of its font-free forms, `line-height` as the strut, +`text-align: left | right | center`, margins/padding/borders on the atomic inlines, an +inline-block's own baseline (CSS 2.1 10.8.1's last-in-flow-line-box rule and its +bottom-margin-edge fallback), and the container's auto height as the sum of its line +boxes. Out, and stated in the module's own doc: text and every font-dependent value, +`width: auto` inline-blocks, a container mixing block-level and inline-level children, +floats and absolutely positioned boxes inside the inline formatting context, +`text-align: justify`, bidi, vertical writing modes, `inline-table`, `inline-flex` and +replaced elements. + +The corpus's own design carries the weight. Every container sets `font-size: 0` beside an +explicit `line-height` in px, so the strut's ascent and descent are zero, its baseline sits +at the middle of a known height, and `vertical-align: middle` aligns on that baseline +rather than on half an x-height nobody can measure. `crates/harness/src/cases.rs` already +emits no whitespace between tags, so no text node can appear between two atomic inlines, +and the case file asserts that for every case it produces. 111 cases over fifteen +categories, none dropped for engine disagreement — which is the answer to the open question +this corpus was built around: the three engines agree on a zero-size font's strut. + +Admitted on the first batch at 111/111. Four candidates reached the whole corpus and each +came from a different one of the four gateway models, which makes this the third function +solved completely on its first batch, after floats and transforms. The two seeded-wrong +fixtures are worth more than the usual here: they are wrong about disjoint halves of the +function — one ignores `vertical-align`, the other never breaks a line — and between them +they pass all 111 cases, so the regression set covers the corpus from the first batch and +a candidate must pass everything to be admitted at all. + ## What the generated functions show - **The generated part is small by construction.** The hand-written walk decides which @@ -176,3 +225,8 @@ a stacking context, painting, and scrollable overflow. - **Common failure kinds:** invented types, replies cut off before the code, one plausible misreading of the spec fixed by a single prompt sentence, and harness bugs that only a known-correct candidate exposes. +- **A function's scope is a corpus decision before it is a code decision.** The + inline-atomic function exists at all because one shape of inline content — atomic + inlines at explicit sizes — can be measured without a font, and the corpus is built to + keep every font-dependent value out. Everything the three engines might disagree about + was designed out before the first oracle run rather than dropped after it. diff --git a/docs/model-first-cost.md b/docs/model-first-cost.md index 8d7f5857..208d2a0b 100644 --- a/docs/model-first-cost.md +++ b/docs/model-first-cost.md @@ -882,3 +882,85 @@ first batch's winner called them through the qualified path the prompt shows it once the file sits inside the `layout` crate. The rewrite now covers a qualified path anywhere in a line, and the batch was re-run so the committed file came through the fixed path. + +## Inline atomic: a corpus two wrong fixtures cover exactly + +The inline-atomic function (`specs/inline-atomic.md`, +`crates/layout/src/inline_atomic.rs`) is the first whose answer is a set of positions +rather than one value: a placement for every atomic inline the container holds, the line +boxes they sit on, the stack's total height, and the last line's baseline. + +`inline_atomic_ground_truth_cache` wrote 111 cases to +`corpus/inline-atomic-ground-truth/` (15 enumerated plus 96 seeded-random draws over +`crates/harness/src/inline_atomic_cases.rs`'s fifteen categories) and dropped 0 for engine +disagreement. That is the answer to the question the corpus was designed around. CSS 2.1 +10.8 starts every line box with a strut carrying the container's font, so a line box's +height and baseline normally depend on font metrics the three engines need not share; the +corpus sets `font-size: 0` with an explicit `line-height` in px on every container so the +strut's ascent and descent are zero and its baseline sits at the middle of a known height. +Whether Chromium, Firefox and WebKit would actually agree on that was untested until this +run. They do, on all fifteen categories, `AlignMiddle` and `StrutTallerThanContent` +included — the two that turn on the strut alone. Per-category kept counts: AlignBottom 9, +AlignLength 9, AlignMiddle 3, AlignPercentage 9, AlignTop 8, BaselineDifferentHeights 3, +EdgesOnAtomicInlines 10, LineBreak 12, NestedInlineBlocks 5, OneLineEqualHeights 8, +OverflowHiddenBaseline 7, StrutTallerThanContent 3, TextAlignCenter 8, TextAlignRight 12, +WiderThanContainer 5. + +`inline_atomic_generation_loop` seeds the permanent regression set from the union of every +hand-written fixture's passing cases, the same change `float_generation_loop` and +`transform_generation_loop` already carry. Here the union came out complete — 111 of 111 — +because the two seeded-wrong fixtures are wrong about disjoint halves of the function and +right about everything else, so between them they pass every case: + +| fixture | score | what it gets wrong | categories at 0 | +|---|---|---|---| +| `baseline` | 0/111 | everything: one box per line at the left content edge, the vertical stacking the engine did before the function existed | all fifteen | +| `aligns_every_bottom_edge` | 61/111, 50 regressions | every box's bottom margin edge on the line's baseline, `vertical-align` never read | AlignBottom, AlignLength, AlignMiddle, AlignPercentage, AlignTop, NestedInlineBlocks, OverflowHiddenBaseline | +| `never_breaks_a_line` | 86/111, 25 regressions | one line box however far past the content width it runs | LineBreak, TextAlignCenter, WiderThanContainer | + +Both are rejected, and the shape of the rejection is what makes the corpus trustworthy: +each fixture loses exactly the categories the other passes, so neither can be admitted by +being right about the easy half. A complete regression set also means the gate is as +strict as it can be — a candidate must pass all 111 to be admitted at all, not merely +avoid losing ground. + +The complete seed cost one change to the batch loop. Every other `*_real_model_run` samples +its prompt from the cases outside the regression set, which for every other function is the +same thing as the cases the committed candidate still fails, because the set was seeded +from that candidate's own passes. Here the two differ completely: the set covers the corpus +from the first batch while the committed candidate passes none of it, so the first run of +`inline_atomic_real_model_run` sampled an empty difference, drew nothing and exited in 0.0 +seconds. It now samples over the whole corpus when the difference is empty — the same dead +end `bin/height_real_model_run.rs` reached from the opposite direction, where the committed +candidate passed everything, and the same answer. The ordinary stop condition still applies +here, unlike there: a batch against a committed candidate at 0/111 can genuinely improve on +the one before it. + +One batch, four gateway models at once (`NEV_MODELS` as `specs/inline-atomic.md` gives it; +`NEV_PROVIDER` unset): + +| batch | compiled/drawn | admitted candidate | best score | per-category tally (best) | prompt tokens | output tokens | +|---|---|---|---|---|---|---| +| 1 | 18/82: flash-next 6/16, 27b-fast 4/32, 27b-fast-2 7/32, deepseek-v4.1-flash 1/2 | `model_0` (flash-next; all four models produced a 111/111 candidate) | 111/111 | every category full: AlignBottom 9/9, AlignLength 9/9, AlignMiddle 3/3, AlignPercentage 9/9, AlignTop 8/8, BaselineDifferentHeights 3/3, EdgesOnAtomicInlines 10/10, LineBreak 12/12, NestedInlineBlocks 5/5, OneLineEqualHeights 8/8, OverflowHiddenBaseline 7/7, StrutTallerThanContent 3/3, TextAlignCenter 8/8, TextAlignRight 12/12, WiderThanContainer 5/5 | 675,794 (27b-fast 328,032; 27b-fast-2 328,032; deepseek 19,730; flash-next reported none) | 372,707; slowest single reply 489.4 s (flash-next) | + +Four candidates reached 111/111 and every one of them came from a different model — +`model_0` from `qwen3.8-flash-next`, `model_40` from `qwen3.8-27b-fast`, `model_51` from +`qwen3.8-27b-fast-2`, `model_81` from `deepseek-v4.1-flash`. `model_62` missed by two +cases at 109/111, and the rest of the compiled field collapsed: 93/111 once, then 20, 15, +3, 3, 3, 3, 0, 0. That gap is the shape of the function — line boxes are either built or +they are not, and a candidate that misreads the strut or the baseline loses whole +categories at once rather than drifting by a pixel. + +`qwen3.8-flash-next` reported zero prompt and zero completion tokens on all 16 replies +while returning real source (the winner's own reply was 24,957 bytes), so its share is +missing from the token column. That is the same alias, and the same missing `usage` block, +recorded for the absolute-positioning batches; it behaved differently here from the +transform batch, where it returned no content at all. + +Two candidates panicked partway through the corpus and were caught and dropped rather than +taking the batch with them — `model_42` and `model_9`, both indexing their own output. +That failure mode is more available here than in any earlier function: a candidate returns +one vector of placements and another of line boxes, and getting their lengths out of step +with each other compiles cleanly and panics on the first case that breaks a line. The +isolation `crate::transform_runner::evaluate` introduced is what kept the other sixteen +candidates' scores. diff --git a/handoff.md b/handoff.md index 3d47534d..d42959aa 100644 --- a/handoff.md +++ b/handoff.md @@ -13,71 +13,51 @@ produced by a model batch the same way the landed functions were (width, block h floats, stacking, relative positioning, overflow, containing block, absolute positioning, transforms). -Today `crates/layout/src/boxtree.rs` treats every non-`none` `display` as generating one -block-level box, so an `inline-block` stacks in normal flow. Line boxes are the substrate -that text, table cell baselines and flexbox baseline alignment all sit on, which is why -`tables-fixed-layout` and `display-contents` wait for this track. - ## Current state -Nothing done beyond creating the track. No new modules, no corpus, no batch. The -dependencies `fixed-positioning` and `transforms2d` have both landed, so `boxtree.rs` -and the three `layout*` entry points are at their post-transform shape. +The hand-written side is complete and the corpus, the loop and one model batch have all +run. What exists: + +- `crates/dom/src/computed.rs`: a `Display` enum (`Block`, `FlowRoot`, `InlineBlock`, + `None`, `Other`) with `display()` and `Display::is_inline_level()`, plus + `vertical_align()`, `line_height()` and `text_align()`. `is_display_none` and + `is_display_flow_root` are unchanged and still used at their own call sites. +- `crates/layout/src/boxtree.rs`: `Box::inline_level` and + `BoxTree::establishes_inline_formatting_context`. +- `crates/layout/src/inline_atomic.rs`: `AtomicInline`, `InlineAtomicInputs`, + `InlinePlacement`, `LineBox`, `InlineAtomicOutputs`, `type InlineAtomicResolver`, + `resolve_baseline` (the deliberately wrong vertical stacking) and `baseline_shift`. +- `crates/layout/src/fragment.rs`: `layout_inline_children`, the branch in `layout_box` + that picks it, `LayoutResult::last_line_baseline` and `StackResult::last_line_baseline` + for CSS 2.1 10.8.1, and the new resolver argument on the three `layout*` entry points. +- `crates/harness/src/inline_atomic_cases.rs`: fifteen categories, `enumerated()`, + `random(seed, count)`, and `no_text_between_atomic_inlines`, which every case is + asserted against both in the case file's tests and again in the cache binary. +- `crates/generation/src/inline_atomic_runner.rs`, `Seam::InlineAtomic`, + `prompt::build_inline_atomic`, `admit::record_for_inline_atomic`, the three + `inline_atomic_*` binaries, and three fixtures under + `crates/generation/fixtures/inline-atomic-candidates/`. +- `crates/provenance/src/css21_inline_atomic.rs` and its `pub mod` line. +- `crates/layout/src/generated/inline_atomic.rs` + `.provenance.json`, `pub mod + inline_atomic;` in `generated/mod.rs`, and the three `layout*` entry points defaulting + to it. + +The numbers each run produced are under **Result** below. ## Next steps -1. Build the hand-written side, following `absolute.rs`/`absolute_cases.rs`/ - `absolute_runner.rs` as the closest precedent: - - `crates/dom/src/computed.rs`: a `Display` enum (`Block`, `FlowRoot`, `InlineBlock`, - `None`, `Other`) with a `display()` accessor that keeps `is_display_none` and - `is_display_flow_root` working, plus `vertical_align()`, `line_height()` and - `text_align()`. Additions only. - - `crates/layout/src/boxtree.rs`: each box records whether it is inline-level; a - container whose in-flow children are all inline-level establishes an inline - formatting context. Small hunk — other specs edit this file. - - `crates/layout/src/inline_atomic.rs`: `InlineAtomicInputs`, `InlineAtomicOutputs`, - `type InlineAtomicResolver`, and a deliberately wrong `resolve_baseline` that - stacks the atomic inlines vertically the way the engine does now. - - `crates/layout/src/fragment.rs`: lay each inline-block out as a block formatting - context root at its explicit size, hand the measurements to the resolver, place the - fragments where it says, and feed the resolver's total height to the container's - `HeightInputs::content_height_from_children` (`crates/layout/src/height.rs`). One - more resolver argument on the three `layout*` entry points. - - `crates/harness/src/inline_atomic_cases.rs`: own `Category` enum with - `pub const ALL`, `Case`, `enumerated()`, `random(seed, count)`, built on - `cases::{Elem, Source, px, RESET}`. Never add to `cases.rs` `RECIPES`. - - `crates/generation/src/inline_atomic_runner.rs`, a `Seam::InlineAtomic` variant in - `candidate_crate.rs`, `prompt::build_inline_atomic`, - `admit::record_for_inline_atomic`, the three `bin/` binaries, and one baseline plus - two seeded-wrong fixtures under - `crates/generation/fixtures/inline-atomic-candidates/`. -2. Ground truth: `cargo run --release -p generation --bin - inline_atomic_ground_truth_cache`. Record the case count and the drops for engine - disagreement. More than a fifth of a category dropping is a finding to record, not a - corpus bug to paper over. -3. Generation loop: `inline_atomic_generation_loop`. Both seeded-wrong fixtures must be - rejected; if one is admitted, the loop or the corpus is wrong, not the fixture. -4. Real model run: `inline_atomic_real_model_run` with `NEV_PROVIDER` unset and - `NEV_MODELS=qwen3.8-flash-next:16:16,qwen3.8-27b-fast:32:32,qwen3.8-27b-fast-2:32:16,deepseek-v4.1-flash:2:2:1800`. - Print per-model counts the way `overflow_real_model_run` does so provenance records - the winning model. `NEV_PROVIDER=claude-code` is the fallback only if the gateway does - not answer, and that fallback is recorded as a Decision. Never - `NEV_PROVIDER=anthropic`. -5. On admission, commit the candidate byte for byte as - `crates/layout/src/generated/inline_atomic.rs` with the header line - `generated/width.rs` uses, `inline_atomic.provenance.json` beside it, - `pub mod inline_atomic;` in `generated/mod.rs`, and the three `layout*` entry points - defaulting to it. Record every batch in `docs/model-first-cost.md` → "Batch scores and - token totals" and append the outcome to `roadmap.md` → "Phase 1 — Engine bootstrap" - step 4. -6. Documentation: a new provenance module whose file name contains `inline_atomic` (for - example `crates/provenance/src/css21_inline_atomic.rs`) carrying the spec text the - function is generated against, its one `pub mod` line in - `crates/provenance/src/lib.rs`, and the function's table row and "Per function" entry - in `docs/generated-functions.md`. - -Before every model or oracle run: `set -a; source .env; set +a` in the worktree — the -binaries do not load `.env` themselves. Never print or commit a value from it. +1. Read **Result**. If no candidate was admitted, the next step is more batches: + `cargo run --release -p generation --bin inline_atomic_real_model_run` with the + environment `specs/inline-atomic.md` gives (`set -a; source .env; set +a` first, + `NEV_PROVIDER` unset, `NEV_MODELS` as the spec lists it). The loop stops itself on two + flat batches or after six. +2. A hand-written candidate is allowed only after every batch has failed to admit one, + and is recorded as an escalation with the batch count that preceded it + (`specs/inline-atomic.md`). +3. A floor for this function in `bench/seam-floor.sh` is a hand change made after this + track lands, not part of it: the landing script refuses a diff under `bench/`, and a + score line in `seam_floor_check` with no cache rebuild behind it would fail every + later landing. ## Decisions @@ -94,10 +74,37 @@ binaries do not load `.env` themselves. Never print or commit a value from it. explicit `line-height` in px, so the strut's ascent and descent are zero, the baseline sits at the middle of the strut, and no case depends on a font's metrics. That holds until `text-metrics-oracle` has measured whether the three engines agree on real text. - The case file's module doc records the choice. + The case file's module doc records the choice. The ground-truth run measured that the + three engines do agree on it: 111 cases, 0 dropped. - `crates/harness/src/cases.rs` `document()`/`write_body()` write no whitespace between tags, so no text node can appear between atomic inlines; the new case file asserts that - for every case it produces. + for every case it produces, and `inline_atomic_ground_truth_cache` asserts it again on + the markup it is about to send to the browsers. +- A container establishes an inline formatting context only when it has at least one + child and *every* child is inline-level. A float or an absolutely positioned box beside + an atomic inline disqualifies the container, which keeps it on the stacking path it was + always on — both are out of this function's scope, and falling back is safer than + placing some of the children and dropping the rest. +- `is_bfc_root` now counts an inline-level box: CSS 2.1 9.4.1 names inline-blocks among + the boxes that establish a block formatting context, and without it an atomic inline's + contents' margins could escape onto the line it sits on. No document without an + `inline-block` in it can reach the new arm, so no existing score moves. +- An inline-block's own baseline (10.8.1) is `fragment.rs`'s to decide, not a candidate's: + `LayoutResult::last_line_baseline` carries its last in-flow line box's baseline up, or + `None` when it has none or its `overflow` is not `visible`, and the caller placing it on + a line applies the bottom-margin-edge fallback. `stack_children` propagates its last + in-flow child's, so an inline-block whose own children are block-level still ends in the + right place. +- `vertical-align` is not a longhand in this build's Stylo (0.21.0): it is CSS Inline 3's + shorthand over `alignment-baseline`/`baseline-shift`/`baseline-source`, so + `computed::vertical_align` reads the two longhands that carry the CSS 2.1 keywords and + recombines them. Its unit tests cover all six forms. +- `inline_atomic_real_model_run` samples its prompt over the whole corpus when the + regression set already covers it. Every other `*_real_model_run` samples the cases + outside that set, which elsewhere means the cases the committed candidate fails; here + the seed is complete from the first batch while the committed candidate passes none of + it, so the first run drew nothing and exited immediately. This is the same dead end + `bin/height_real_model_run.rs` reached from the opposite direction. - The provisional rule about lifting adjacent hand-written logic into an existing generated function does not apply: no existing generated function places anything on a line. @@ -105,10 +112,6 @@ binaries do not load `.env` themselves. Never print or commit a value from it. updated here. `docs/dashboard.md` asks for it, but those files belong to `specs/dashboard-history-graph.md`, and the landing script refuses any file outside `touches`. -- A floor for this function in `bench/seam-floor.sh` is a hand change made after this - track lands, not part of it: the landing script refuses a diff under `bench/`, and a - score line in `seam_floor_check` with no cache rebuild behind it would fail every later - landing. - Shared files take small additive hunks only: `fragment.rs`, `lib.rs`, `crates/generation/**`, `computed.rs`, `crates/harness/src/lib.rs`, `generated/mod.rs`, `roadmap.md`, `docs/model-first-cost.md`. @@ -118,25 +121,22 @@ binaries do not load `.env` themselves. Never print or commit a value from it. ## Key files & symbols - `specs/inline-atomic.md` — the authority. -- `crates/layout/src/absolute.rs`, `crates/harness/src/absolute_cases.rs`, - `crates/generation/src/absolute_runner.rs` — the closest precedent for the full set. -- `crates/layout/src/boxtree.rs` — module doc's "exactly one box" claim, the structural - change. -- `crates/dom/src/computed.rs` `is_display_none()`, `is_display_flow_root()` — the - accessors the new `Display` enum must keep working. -- `crates/layout/src/height.rs` `HeightInputs::content_height_from_children` — where the - resolver's total height goes. -- `crates/layout/src/generated/width.rs` — the generated-file header line to copy. -- `crates/generation/src/candidate_crate.rs` `Seam`, `prompt.rs`, `admit.rs`, - `orchestrate.rs` `print_per_model_summary()`, `best_per_model()`, `score_share()`, - `conclude_batch()` — the batch scaffolding. -- `crates/generation/src/bin/overflow_real_model_run.rs` — the per-model reporting shape - to follow. -- `crates/provenance/src/css21_containing_block.rs` — the pattern for the new provenance - module. +- `crates/layout/src/inline_atomic.rs` — the function's types, `resolve_baseline` and the + scope statement. +- `crates/layout/src/fragment.rs` `layout_inline_children()`, `resolved_vertical_align()` + — where the measurements are taken and the fragments placed. +- `crates/layout/src/boxtree.rs` `Box::inline_level`, + `BoxTree::establishes_inline_formatting_context()` — the structural change. +- `crates/dom/src/computed.rs` `display()`, `vertical_align()`, `line_height()`, + `text_align()` — the new accessors. +- `crates/harness/src/inline_atomic_cases.rs` `InlineAtomicCategory`, + `no_text_between_atomic_inlines()` — the corpus and its text-free assertion. +- `crates/generation/src/inline_atomic_runner.rs`, `prompt.rs` `build_inline_atomic()`, + `admit.rs` `record_for_inline_atomic()`, `candidate_crate.rs` `Seam::InlineAtomic` — + the batch scaffolding. +- `crates/provenance/src/css21_inline_atomic.rs` — the spec text the function is + generated against. - `docs/generated-functions.md`, `docs/model-first-cost.md`, `roadmap.md` — the records. -- `docs/infrastructure.md` → "Build lane", "Rig change log" — lanes and model - concurrencies. ## How to verify @@ -150,3 +150,53 @@ binaries do not load `.env` themselves. Never print or commit a value from it. - Every committed file is inside the spec's `touches`. **Result** + +What exists that did not before: a twelfth generated function, `inline_atomic`, and the +line boxes it needs. `boxtree.rs` now records whether a box is inline-level and which +containers establish an inline formatting context; `fragment.rs` lays those containers' +children out on lines through `layout_inline_children` instead of stacking them; `dom` has +`display()`, `vertical_align()`, `line_height()` and `text_align()`; and +`crates/harness/src/inline_atomic_cases.rs` generates a fifteen-category corpus that needs +no font. Before this, `display: inline-block` stacked in normal flow like a block and +`vertical-align`, `line-height` and `text-align` reached nothing at all. + +The runs, in order: + +- `inline_atomic_ground_truth_cache`: 111 cases written, **0 dropped for engine + disagreement**, every category intact. This answers the question the corpus was designed + around — Chromium, Firefox and WebKit agree on a zero-size font's strut, so the + `font-size: 0` plus explicit `line-height` construction holds. +- `inline_atomic_generation_loop`: both seeded-wrong fixtures rejected. + `aligns_every_bottom_edge` 61/111 losing 50, `never_breaks_a_line` 86/111 losing 25, + `baseline` 0/111 losing all 111. The two wrong fixtures pass disjoint halves of the + corpus and union to all 111, so the regression set covers everything and a candidate + must pass the whole corpus to be admitted. +- `inline_atomic_real_model_run`, batch 1 over the four gateway models: 18 of 82 compiled, + and **four candidates reached 111/111, one from each model**. `model_0` from + `qwen3.8-flash-next` is committed as + `crates/layout/src/generated/inline_atomic.rs` with `inline_atomic.provenance.json` + beside it. Two candidates panicked mid-corpus and were caught and dropped rather than + taking the batch down. + +Verify gate: `cargo test --workspace` passes (the whole workspace, `no_seam_word` +included). `fragment_check --random` is 227/227 with every category unchanged, both before +the change and with the admitted candidate running. `bench/seam-floor.sh` rebuilt all four +caches and every floor holds exactly: floats 109/109, stacking-order 105/105, +relative-positioning 104/104, block-height 105/105. + +Nothing needs a decision. Two things are worth knowing: + +- The build lane is configured but was unreachable for this whole session, so every run + fell back to this machine (`bench/on-lane.sh` reports it and falls back by design). The + model gateway itself answered normally. Nothing was worked around; the fallback is the + script's own behaviour. +- `qwen3.8-flash-next` reports zero prompt and zero completion tokens on every reply while + returning real source, so its share is missing from the token totals in + `docs/model-first-cost.md`. Same alias, same missing `usage` block, as the + absolute-positioning batches. + +The spec is satisfied as written, with one deviation worth naming: `bin/ +inline_atomic_real_model_run.rs` samples its prompt over the whole corpus when the +regression set already covers it. The spec assumes the prompt is sampled from the cases +the engine still fails, which for every earlier function is the same set; here it is not, +and sampling the empty difference drew no batch at all on the first attempt. diff --git a/roadmap.md b/roadmap.md index c4b4c7f2..7912b6ab 100644 --- a/roadmap.md +++ b/roadmap.md @@ -120,6 +120,7 @@ others held fixed; nothing on the real path calls them. | 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` | | transform | `box_transform()`, once per box whose `transform` is not `none` | `specs/transforms2d.md` | +| inline atomic | `layout_inline_children()`, once per container whose in-flow children are all inline-level | `specs/inline-atomic.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 generated function was checked to be live @@ -173,14 +174,25 @@ is generated. resolution, then new generated functions model-first. Each lands when its category stops improving. Per-function results: [`docs/generated-functions.md`](docs/generated-functions.md). - Latest: the transform function (CSS Transforms 1 §3/§4/§5), the first drawn from a spec + Latest: 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 (`specs/inline-atomic.md`). + + Before it: 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 (`specs/transforms2d.md`). - Before it: the containing-block function (CSS 2.1 10.1), which is what `position: fixed` + Before that: 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 -- 2.54.0