# Margin-collapse experiment
Runs `docs/logic-layer.md` -> "Experiment that settles it": CSS 2.1 8.3.1 margin
collapsing written as Datalog rules, checked against a live three-engine vote and scored
against deliberately broken copies of the rules (**mutants**), to decide whether a
logic-programming layer earns its place as an oracle for intermediate layout facts.
**Result: adopt.** The rules agree with the browsers on every measurement (1195/1195), and
on a seeded bug they usually know one level earlier than the geometry diff does — in one
case the geometry vote never notices at all.
```mermaid
flowchart LR
doc["case document"] --> facts["facts.rs
tree shape + style"]
facts --> rules["rules.rs
Datalog (ascent)"]
rules --> coll["collapsed
(which margins adjoin)"]
rules --> off["rel_top / rel_bottom
(offsets)"]
off --> vote{"matches the
3-engine vote?"}
coll --> fact{"mutant's collapsed
differs?"}
vote --> geom["geom-caught"]
fact --> fc["fact-caught"]
```
## What was built
`crates/logic`, which depends on `dom`, `layout` and `harness` and runs without a browser:
- `src/facts.rs` — turns a parsed document and its box tree into `Facts`: tree shape
(`parent_of`, `first_child`, `last_child`, `next_sibling`, `no_children`) plus margins,
padding, borders, height (explicit, auto or unresolved), `border-box` sizing, and
whether a box starts a new block formatting context. The box tree's root is treated as
`overflow: hidden`, since there is nothing above it for a margin to escape into.
- `src/rules.rs` — `MarginCollapseRules`, an `ascent` program: 8.3.1's four adjoining
cases, each gated by a `feature_*` relation a mutant can switch off; the collapsed value
of a group (largest positive plus most negative, via `max`/`min`/`sum` aggregators); how
much margin separates parent and child after escaping; a running max/min through chains
of self-collapsing siblings (`chain_pos`/`chain_neg`); and 10.6.3's auto and
`border-box` height. `run()` returns `rel_top`/`rel_bottom` (parent-relative offsets in
1/64 px) and `collapsed`.
- `src/mutants.rs` — 15 single-rule mutants, each switching off exactly one condition or
picking one wrong arithmetic arm, so a caught mutant points at one spec condition.
- `src/vote_check.rs` — compares the rules' offsets with the vote's trustworthy `OffsetY`
measurements within `harness`'s tolerance.
- `crates/harness/src/smt.rs` (feature `smt`) — Z3 sampling backward from 4 of the 9
margin recipes, the ones defined by independent sign/range constraints.
- `src/bin/crosscheck.rs`, `src/bin/mutation_score.rs` produce the numbers below;
`tests/against_cases.rs` runs the rules offline.
- The 1/64 px integer type these use now lives in `crates/dom/src/px64.rs`, shared with
`layout`.
`z3` is built through its `gh-release` feature (a prebuilt binary), because the `bundled`
feature needs `cmake`, which this host lacks without root.
## Agreement with the browsers
227 cases (27 enumerated plus 200 seeded-random, seed `0xC0FFEE`) against a live vote:
| Category | Agreed/Checked |
|---|---|
| WidthAuto | 28/28 |
| FillAvailable | 28/28 |
| MarginAutoLeft | 40/40 |
| MarginAutoRight | 24/24 |
| MarginAutoBoth | 32/32 |
| OverConstrained | 52/52 |
| ExplicitWidth | 176/176 |
| MinMaxClamp | 28/28 |
| MinExceedsMax | 52/52 |
| PercentageWidth | 32/32 |
| PercentageHeight | 28/28 |
| PercentageHeightAutoParent | 52/52 |
| CollapseSiblings | 30/30 |
| CollapseParentFirstChild | 70/70 |
| CollapseParentLastChild | 60/60 |
| CollapseSelfCollapsingEmpty | 54/54 |
| CollapseThroughChain | 126/126 |
| CollapseNegative | 105/105 |
| CollapseBfcRoot | 60/60 |
| BoxSizingBorderBox | 59/59 |
| BoxSizingContentBox | 32/32 |
| PaddingBorderPercentage | 28/28 |
| NestingDepth | 280/280 |
| FixedBox | 107/107 |
Overall: **1195/1195** trustworthy `OffsetY` measurements agreed, 0 with no verdict, 0
untrustworthy, 0 shape mismatches.
## Mutant detection
Per case, two checks: **fact-caught** — the mutant's `collapsed` relation differs from the
correct rules' (no browser needed); **geom-caught** — the mutant's final offsets disagree
with the vote for some box.
### Enumerated (n=27)
| mutant | fact-caught | geom-caught |
|---|---|---|
| bfc-blocks-top-off | 4/27 | 4/27 |
| bfc-blocks-bottom-off | 2/27 | 1/27 |
| parent-top-adjoin-off | 3/27 | 3/27 |
| parent-bottom-adjoin-off | 1/27 | 0/27 |
| sibling-adjoin-off | 4/27 | 1/27 |
| self-collapsing-off | 3/27 | 2/27 |
| self-collapsing-ignores-children | 4/27 | 1/27 |
| zero-height-not-self-collapsing | 1/27 | 1/27 |
| box-sizing-ignored | 0/27 | 1/27 |
| ignore-negative-sign | 2/27 | 2/27 |
| drop-negative-margins | 2/27 | 2/27 |
| drop-positive-margins | 8/27 | 7/27 |
| sum-instead-of-max-positive | 6/27 | 5/27 |
| sum-instead-of-min-negative | 1/27 | 1/27 |
| sign-flip | 9/27 | 8/27 |
### Seeded-random (n=200, seed `0xC0FFEE`)
| mutant | fact-caught | geom-caught |
|---|---|---|
| bfc-blocks-top-off | 31/200 | 31/200 |
| bfc-blocks-bottom-off | 19/200 | 8/200 |
| parent-top-adjoin-off | 23/200 | 23/200 |
| parent-bottom-adjoin-off | 11/200 | 0/200 |
| sibling-adjoin-off | 25/200 | 15/200 |
| self-collapsing-off | 15/200 | 9/200 |
| self-collapsing-ignores-children | 34/200 | 11/200 |
| zero-height-not-self-collapsing | 4/200 | 4/200 |
| box-sizing-ignored | 0/200 | 2/200 |
| ignore-negative-sign | 19/200 | 19/200 |
| drop-negative-margins | 19/200 | 19/200 |
| drop-positive-margins | 63/200 | 52/200 |
| sum-instead-of-max-positive | 29/200 | 25/200 |
| sum-instead-of-min-negative | 5/200 | 5/200 |
| sign-flip | 68/200 | 57/200 |
### SMT-sampled (n=20, seed `0xC0FFEE`, 4 of the 9 margin-collapsing recipes)
| mutant | fact-caught | geom-caught |
|---|---|---|
| bfc-blocks-top-off | 6/20 | 6/20 |
| bfc-blocks-bottom-off | 0/20 | 0/20 |
| parent-top-adjoin-off | 6/20 | 6/20 |
| parent-bottom-adjoin-off | 0/20 | 0/20 |
| sibling-adjoin-off | 11/20 | 8/20 |
| self-collapsing-off | 6/20 | 6/20 |
| self-collapsing-ignores-children | 6/20 | 0/20 |
| zero-height-not-self-collapsing | 0/20 | 0/20 |
| box-sizing-ignored | 0/20 | 0/20 |
| ignore-negative-sign | 3/20 | 3/20 |
| drop-negative-margins | 3/20 | 3/20 |
| drop-positive-margins | 20/20 | 20/20 |
| sum-instead-of-max-positive | 8/20 | 8/20 |
| sum-instead-of-min-negative | 0/20 | 0/20 |
| sign-flip | 20/20 | 20/20 |
Fact-caught is at or above geom-caught for every mutant either check catches, and
strictly above for most. **`parent-bottom-adjoin-off`** is the sharpest: the geometry vote
misses it in all 227 cases, the `collapsed` fact catches it in 1/27 and 11/200. SMT
inputs push `drop-positive-margins` and `sign-flip` to 20/20, against roughly a third on
the fixed sets, because every sampled case has a nonzero adjoining margin to get wrong.
## Closing the corpus gaps
An earlier run left three mutants at 0 on every check. Three recipes closed them:
- **`zero-height-not-self-collapsing`** (treats `height: 0` differently from `auto`): no
recipe gave a self-collapsing box an explicit `height: 0`.
`recipe_collapse_self_collapsing_zero_height` does; now 1/27 and 4/200 on both checks.
- **`box-sizing-ignored`**: `collapsed` never depends on `border-box` sizing, so the fact
check cannot catch it by construction. The geometry check could, but the only
`border-box` box had nothing after it. `recipe_box_sizing_border_box_with_sibling` adds
a sibling; now 1/27 and 2/200 on geometry.
- **`sum-instead-of-min-negative`** (sums negative margins instead of taking the most
negative): identical whenever a group has one negative margin, and no recipe had two.
`recipe_collapse_negative_sum` puts a parent's and its first child's negative top
margins in one group; now 1/27 and 5/200 on both checks.
## Verdict
`docs/logic-layer.md`'s rule: "If the rules catch or localize mutants the geometry corpus
misses, adopt them for the Phase 1 layout step." They do: 8 of 15 mutants are fact-caught
strictly more often than geom-caught, and `parent-bottom-adjoin-off` is caught only by the
facts. **Adopt**, narrowly, as an oracle for intermediate facts (`docs/logic-layer.md` ->
"Verdict"). The rules now gate margin-collapse admission in the generation loop
(`crates/generation/src/margin_runner.rs`).
## Caveats
- The 235-document Phase 0 baseline no longer regenerates; every number here is from the
227-case corpus (which grew from 222 through the three recipes above and two unrelated
ones, `recipe_border_width_without_style` and `recipe_display_none_with_child`).
- SMT sampling covers 4 of 9 margin recipes; the other five are built by nesting, which
backward solving does not suit (`docs/logic-layer.md` -> "Claimed benefit 2").
- The self-collapsing chain rules follow `next_sibling` only; a self-collapsing first
child with a later sibling is not covered.
- A percentage or `calc()` height gives no `border_box_height` fact, so that box and those
after it get no verdict — excluded, not counted as failures.
- Mutation scoring costs corpus size × mutant count; fine at 227 cases.