render-pipeline: two adjacent lists share one \addvspace, not two (#706) - #712
Conversation
What changed:
`\end{<list>}` is `\@endparenv`'s `\addvspace\@topsepadd` and the
`\begin{<list>}` beside it is `\@trivlist`'s `\addvspace\@topsep`. Both are
`\addvspace`, so they keep the larger natural skip; the pipeline summed them.
`\@endparenv` also leaves TeX in vertical mode, so the second `\begin` is read
in vertical mode and takes `\partopsep` with no blank line in between; the
pipeline read it as horizontal mode and dropped `\partopsep`.
At 10pt that made a list/list boundary 10pt + 8pt = 18pt where pdflatex puts
max(10pt, 10pt) = 10pt. Every list after the first sat 8pt low and the error
grew 6pt at each further boundary (4 adjacent lists: +7.971, +13.948,
+19.925 bp).
The environment never mattered: `itemize`, `enumerate` and `description` are
all `\list` with `\@listI`'s skips, and `list_seps_from` does not branch on the
name for vertical spacing. #706 saw only `itemize` match because `itemize` came
first in its fixture.
New `tests/adjacent_list_skips.rs` pins the adjacency coverage #705's oracle
left out: all six orderings of the three environments, all nine ordered pairs,
four of a kind and a mixed chain of four, plus the `\topsep`-only vs
`\topsep`+`\partopsep` pair that separates the two lengths.
What was run:
cargo test -p render-pipeline --test adjacent_list_skips
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
same test on unfixed origin/main (dbe3cac), to show it catches the bug:
test result: FAILED. 1 passed; 3 failed; 0 ignored; 0 measured
ied: bravo: 164.653 bp, pdflatex 156.682 bp (+7.970)
28-document pdflatex sweep (MacTeX 2026, PyMuPDF glyph origins), engine vs
pdflatex: worst |dy| = 0.0001 bp, worst |dx| = 0.0379 bp, all within 0.5 bp.
Next step:
Full `cargo test -p render-pipeline` diffed against origin/main for regressions.
Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
d-q222
left a comment
There was a problem hiding this comment.
Independent verification — VERIFIED (with one claim corrected)
Reviewed on a detached worktree of refs/review/pr-712 (head 6763a098), private
CARGO_TARGET_DIR=/Users/dqi26/flashtex/target-verify-712, CARGO_BUILD_JOBS=4.
Diff is crates/render-pipeline/src/adapter.rs + crates/render-pipeline/tests/adjacent_list_skips.rs
only — nothing under vendor/, no generated artefact.
1. I rebuilt the oracle rather than trusting the recorded numbers
Not three documents — all 28, freshly, against /Library/TeX/texbin/pdflatex
with PyMuPDF glyph origins (span["origin"][1], bp from the page top). Same
preamble as the test's HEAD (10pt article, T1, lmodern, \pagestyle{empty}),
same probe words, same 28 bodies: six orderings of itemize/enumerate/description,
all nine ordered pairs, four-of-a-kind ×3 plus iedi, and each list alone /
after a paragraph / after a blank line.
$ python3 oracle712.py
ied alpha=134.765 bravo=156.682 charlie=178.600
eid alpha=134.765 bravo=156.682 charlie=178.600
die alpha=134.765 bravo=156.682 charlie=178.600
ide alpha=134.765 bravo=156.682 charlie=178.600
edi alpha=134.765 bravo=156.682 charlie=178.600
dei alpha=134.765 bravo=156.682 charlie=178.600
ii alpha=134.765 bravo=156.682
ie alpha=134.765 bravo=156.682
id alpha=134.765 bravo=156.682
ei alpha=134.765 bravo=156.682
ee alpha=134.765 bravo=156.682
ed alpha=134.765 bravo=156.682
di alpha=134.765 bravo=156.682
de alpha=134.765 bravo=156.682
dd alpha=134.765 bravo=156.682
iiii alpha=134.765 bravo=156.682 charlie=178.600 delta=200.518
eeee alpha=134.765 bravo=156.682 charlie=178.600 delta=200.518
dddd alpha=134.765 bravo=156.682 charlie=178.600 delta=200.518
iedi alpha=134.765 bravo=156.682 charlie=178.600 delta=200.518
i-alone alpha=134.765
i-after-par alpha=154.690 Preceding=134.765
i-after-blank alpha=156.682 Preceding=134.765
e-alone alpha=134.765
e-after-par alpha=154.690 Preceding=134.765
e-after-blank alpha=156.682 Preceding=134.765
d-alone alpha=134.765
d-after-par alpha=154.690 Preceding=134.765
d-after-blank alpha=156.682 Preceding=134.765
Every recorded constant reproduces exactly: FIRST = 134.765, the constant
ADJACENT = 21.9175 step regardless of environment or order, 154.690
(= +19.925, \topsep only, horizontal mode) after a paragraph, and 156.682
(= +21.918, \topsep + \partopsep) after a blank line. The paragraph pair does
isolate \partopsep as 2 pt, as the header claims. So the oracle is honest.
2. The tests fail without the fix — 3 of the 4
$ cd /Users/dqi26/flashtex-wt/verify-712
$ git checkout HEAD^ -- crates/render-pipeline/src/adapter.rs
$ cd crates/render-pipeline
$ CARGO_TARGET_DIR=/Users/dqi26/flashtex/target-verify-712 CARGO_BUILD_JOBS=4 \
cargo test --test adjacent_list_skips
running 4 tests
test every_ordered_pair_of_lists_has_a_single_boundary_skip ... FAILED
test a_chain_of_four_lists_does_not_accumulate_error ... FAILED
test three_adjacent_lists_step_by_topsep_plus_partopsep_in_every_order ... FAILED
test partopsep_applies_only_when_the_begin_is_read_in_vertical_mode ... ok
thread 'every_ordered_pair_of_lists_has_a_single_boundary_skip' panicked at tests/adjacent_list_skips.rs:66:5:
ii: bravo: 164.653 bp, pdflatex 156.682 bp (+7.970)
thread 'a_chain_of_four_lists_does_not_accumulate_error' panicked at tests/adjacent_list_skips.rs:66:5:
iiii: bravo: 164.653 bp, pdflatex 156.682 bp (+7.970)
thread 'three_adjacent_lists_step_by_topsep_plus_partopsep_in_every_order' panicked at tests/adjacent_list_skips.rs:66:5:
ied: bravo: 164.653 bp, pdflatex 156.682 bp (+7.970)
test result: FAILED. 1 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s
and pass with it:
$ git checkout HEAD -- crates/render-pipeline/src/adapter.rs
$ CARGO_TARGET_DIR=/Users/dqi26/flashtex/target-verify-712 CARGO_BUILD_JOBS=4 \
cargo test --test adjacent_list_skips
running 4 tests
test a_chain_of_four_lists_does_not_accumulate_error ... ok
test three_adjacent_lists_step_by_topsep_plus_partopsep_in_every_order ... ok
test partopsep_applies_only_when_the_begin_is_read_in_vertical_mode ... ok
test every_ordered_pair_of_lists_has_a_single_boundary_skip ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s
The measured +7.970 matches the PR's +7.971. Note for the record that
partopsep_applies_only_when_the_begin_is_read_in_vertical_mode is green on the
parent commit — it is a regression guard for the horizontal-mode side, not a
reproducer. The other three are genuine reproducers of #706.
3. One claim does not reproduce: worst |dy| = 0.0001 bp
The PR body line
cases: 28 worst |dx| = 0.0379 bp worst |dy| = 0.0001 bp ALL WITHIN 0.5 bp
overstates the y agreement. I instrumented check() to print got - expect and
then compared FlashTeX's baselines at full precision against full-precision
PyMuPDF origins:
FlashTeX pdflatex delta
134.764632 134.765015 -0.000383
156.682441 156.682007 +0.000434
178.600249 178.599976 +0.000273
200.518057 200.517944 +0.000113
19.925281 (dy) 19.924987 (dy) +0.000294
21.917809 (dy) 21.916992 (dy) +0.000817
Worst |dy| is ~0.0008 bp, roughly 8× the stated figure (part of the spread is
PyMuPDF's float32 origins, which quantise at ~3e-5). This changes nothing about
the verdict — it is still ~600× inside the project's 0.5 bp gate, and the
committed test asserts WORD_TOL_BP = 0.5 anyway, not 0.0001 — but the body
should say worst |dy| < 0.001 bp rather than 0.0001 bp. Please don't let the
number get quoted downstream as a precision guarantee.
4. Code review
adapter.rs:2900-2915— themaxsemantics are right for\addvspace, and
theSome(end) if end.0 >= open.0 => end/_ => openarms compare natural
skips only and carry that branch's own stretch/shrink, which is what
\addvspacedoes (it does not componentwise-max the glue). Ties go toend,
which is harmless since the naturals are equal.list_end_skip.take()is correct: the pending\@topsepaddmust be consumed
by the boundary that absorbs it, otherwise theif let Some(skip) = list_end_skip
flush atadapter.rs:2962would emit it a second time. I checked the other
take()atadapter.rs:2924is pre-existing and on a disjoint branch.adapter.rs:2864-2873— adding|| gap_has_list_end(before).is_some()to
list_vmodeis the\@endparenv-leaves-vertical-mode half, and it is ORed in
after the existinghas_blank_line/find_command(before, "par")tests, so
it can only turnlist_vmodeon, never off. No ordering hazard.- No new
unwrap/expecton user input; no indexing that can go out of range;
thef64arithmetic is additive only.
5. No regression in the neighbouring list geometry
list_items: test result: ok. 4 passed; 0 failed
nested_list_end_skips: test result: ok. 3 passed; 0 failed
description_env: test result: ok. 1 passed; 0 failed
description_display: test result: ok. 1 passed; 0 failed
itemize_label_offset: test result: ok. 1 passed; 0 failed
enumitem_keys_oracle: test result: ok. 1 passed; 0 failed
amsthm_topsep: test result: ok. 2 passed; 0 failed
enumitem_nextline: test result: ok. 3 passed; 0 failed
theorem_items: test result: ok. 1 passed; 0 failed
Verdict
VERIFIED. The oracle is real — I rebuilt all 28 documents against pdflatex
myself and every constant matches. The fix is correct and the tests reproduce
the bug. The only correction is the worst |dy| = 0.0001 bp line, which should
read < 0.001 bp.
— independently verified by daniel-parent's review lane
|
Correction to this PR's body, from an independent verification lane. The body states The conclusion is unaffected: that is still roughly 600× inside the 0.5 bp gate, and every recorded oracle constant matched exactly (134.765 / 156.682 / 178.600 / 200.518, and 154.690 after a paragraph). Three of the four tests were confirmed to fail on the parent commit at +7.970 bp, so the tests are real. But the body line should read — independently verified by daniel-parent's review lane |
…two (flash-tex#712 follow-up) What changed: flash-tex#712 fixed the list-to-list boundary. Neither half of that fix was about lists: `\@endparenv` is what every `\endtrivlist` ends with, and article.cls builds `center`/`flushleft`/`flushright` as `\trivlist \centering \item\relax`, `quote`/`quotation`/`verse` as `\list{}{...}\item\relax`, and amsthm builds a theorem as a `\trivlist`. Measured against pdflatex, three boundaries were off: * `\end{<list>}` then `\begin{center|quote|quotation|verse|<theorem>}` summed the list's closing `\@topsepadd` onto the environment's opening `\@topsep`: 10pt + 8pt = 18pt where pdflatex puts max(10, 10) = 10pt, +7.971 bp; * `\end{<theorem>}` then any `\trivlist` environment, and every pair the pipeline reads as one `ParaStyle` run (`center`/`center`, `quote`/`verse`, ...), was read in horizontal mode and dropped `\partopsep`: -1.992 bp; * both accumulated. Six `center`s drifted -1.992/-3.985/-5.978/-7.970/-9.963 bp; alternating `center`/`itemize` drifted +5.978/+3.985/+9.963/+7.970 bp. `gap_has_trivlist_end` extends flash-tex#712's "the previous `\end` left vertical mode" test from the four `LIST_ENVS` to every `\trivlist`-derived environment, and feeds both the list `\begin`'s `list_vmode` and the styled `\begin`'s `EnvOpen::vmode`. In `typeset`, the environment's own opening `\addvspace` now resolves against the `\lastskip` that `addvspace_before` already raised, instead of being added on top of it -- `\@xaddvskip` keeps the larger skip whole, so its flex comes with it and the other's does not. An amsthm theorem is the one case that legitimately steps by `\topsep` alone: `\@thm` assigns `\@topsep`/`\@topsepadd` from `\thm@preskip`/`\thm@postskip`, so it never takes `\partopsep`. Two adjacent theorems stay 19.925 bp apart and that is pinned, so this fix does not sweep them up. Pipeline-only, as flash-tex#706/flash-tex#712 were: nothing under vendor/ changed, no re-pin. What was run: cargo test --test vmode_boundary_skips test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out the same test on the unfixed base (6763a09, flash-tex#712 head): test result: FAILED. 2 passed; 4 failed; 0 ignored; 0 measured thm -> itemize (adjacent): bravo: 154.690 bp, pdflatex 156.682 bp (-1.993) itemize -> center: bravo: 164.653 bp, pdflatex 156.682 bp (+7.970) center -> center: bravo: 154.690 bp, pdflatex 156.682 bp (-1.993) ["center" x6]: bravo: 154.690 bp, pdflatex 156.682 bp (-1.993) cargo test --no-fail-fast, this branch: 479 passed; 13 failed cargo test --no-fail-fast, base 6763a09: 473 passed; 13 failed same 13 names both sides (frame_env, letter_class_geometry x5, uline x2, underline_sout x3, xcolor_oracle x2); +6 passed is this commit's new test. 0 new failures. hw2_sets_three_pages_with_the_reference_breaks ... ok pdflatex oracle: MacTeX 2026 pdfTeX 1.40, 10pt T1 Latin Modern, PyMuPDF glyph origins; 101 probe documents (pairs both directions with and without a blank line, six-deep chains, solo/paragraph/blank-line controls). Next step: None; the boundary is measured in both directions and the chains are flat. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…lash-tex#722 follow-up) What changed: flash-tex#722 closed the \trivlist boundary for center/flushleft/flushright/quote/ quotation/verse and every \newtheorem, and left two gaps open on purpose: `verbatim` and `abstract` are \trivlist-derived too but were never measured, so they were deliberately not added to TRIVLIST_ENVS; and only the 10 pt base size was swept. Both are now measured, over 918 probe documents against pdflatex at 10/11/12 pt. Three of the four things a "they're all trivlists" guess would have predicted were already right; the two real bugs differ: * verbatim diverged only where the pipeline reads the pair as one ParaStyle run. \@verbatim is `\trivlist \item\relax ...` and \endverbatim is \endtrivlist, so \end{verbatim} leaves vertical mode and the next \begin takes \partopsep: verbatim/verbatim and verbatim/flushleft were short by exactly that, -1.992 bp at 10 pt and -2.989 bp at 11 and 12 pt, accumulating to -9.963 bp over six verbatims. verbatim against center/quote/quotation/verse/flushright, a theorem or any list was already right in both directions. * abstract diverged on both sides, for two different reasons. \end{abstract} is \endquotation -> \endlist -> \endtrivlist, and what followed it was short by one \partopsep (-1.992/-2.989/-0.996 bp). The other direction is not a TRIVLIST_ENVS problem: the pipeline inserts the \small \abstractname head as a block in front of the body the compiler produced, and the boundary skip the compiler had hung on that body stayed there and fired a second time below the head, so \end{itemize}\begin{abstract} was +5.305/+5.729/+6.273 bp long. \end{center}\begin{abstract} was already right, because a center's closing skip rides on its own env_close rather than on the next block. TRIVLIST_ENVS gains verbatim, verbatim* and abstract; `take_lead` moves the abstract body's leading skip to the head that now occupies its position. No boundary diverged only at 11 pt or 12 pt, so the size sweep found no third bug -- but it did find one boundary that is not size-invariant at all, which a 10 pt-only sweep would have pinned as a single rule: \end{abstract}\begin{thm} is \topsep-only at 10 pt and 11 pt but 26.401 bp at 12 pt, because the abstract's \@topsepadd was fixed inside \small (6/9/12 pt) while a theorem opens with \thm@preskip = \normalsize's \topsep (8/9/10 pt) and no \partopsep, and which \addvspace wins changes with the class option. That is pinned as its own constant. Pipeline-only, as flash-tex#706/flash-tex#712/flash-tex#722 were: nothing under vendor/ changed, no re-pin. What was run: cargo test --test vmode_boundary_skips test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out cargo test --test abstract_env test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out cargo test --no-fail-fast, this branch: 480 passed; 13 failed (107 suites) cargo test --no-fail-fast, base 237b95f: 479 passed; 13 failed (107 suites) diffing every `test <name> ... <result>` line between the two runs gives exactly one line, the added an_abstract_shares_one_addvspace_with_the_environment_on_either_side ... ok. Same 13 failures both sides (every_line_is_where_pdflatex_put_it, letter_class_geometry x3, the_raggedleft_group_closes_no_trivlist, shared_colorbox_path_draws_border_with_content_inside, uline x2, sout/underline x4, xcolor_fixtures_match_pdflatex) -- the known adapter.rs:1308 vendor/compiler pin. 0 new failures. hw2_sets_three_pages_with_the_reference_breaks ... ok on both. pdflatex oracle: MacTeX 2026 pdfTeX 1.40, T1 Latin Modern, PyMuPDF glyph origins; 918 probe documents at the 10pt/11pt/12pt article base sizes (every ordered pair of 9 shapes x 12 partners, with and without a blank line, six-deep chains, solo/paragraph/blank-line controls), plus nine twocolumn probes. Next step: Two measured divergences are left and deliberately not fixed here, both documented at the top of tests/vmode_boundary_skips.rs: a \begin{abstract} on the line directly after body text is not recognised at all (the compiler does not break the paragraph there, so it needs a vendor/ change and a re-pin), and the twocolumn branch sets an abstract body after a list 3.985/4.483/4.981 bp low -- base and branch print byte-identical baselines for all nine twocolumn probes, so that one is untouched by this commit. Implementation-Agent: claude-opus-5 subagent of daniel-parent Commit-Executor: daniel-parent subagent Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
What
Fixes #706. Two defects at a list/list boundary in
crates/render-pipeline/src/adapter.rs:\addvspaces were summed instead of maxed.\end{<list>}is\endtrivlist→\@endparenv's\addvspace\@topsepadd; the\begin{<list>}beside it is
\@trivlist's\addvspace\@topsep. Both are\addvspace, so TeXkeeps the larger natural skip. The pipeline computed the closing skip into
list_end_skipand then added it on top of the opening one — the sibling\itembranch already didlist_end_skip.take()and took the max, the\beginbranch never did.\partopsepwas dropped at that boundary.\@endparenvleaves TeX invertical mode, so the following
\beginis read in vertical mode and takes\partopsepwith no blank line in between.list_vmodeonly looked for ablank line or
\par, so it read horizontal mode.At 10 pt a boundary became 10 pt + 8 pt = 18 pt where pdflatex puts
max(10 pt, 10 pt) = 10 pt.
The error accumulates per boundary: +8 pt at the first, +6 pt at each later
one. For four adjacent lists the baselines were +7.971, +13.948, +19.925 bp
below pdflatex. #706 reported a flat 7.97 pt because its fixture has only two
boundaries and it read the third list against the wrong reference.
The environment never mattered.
itemize,enumerateanddescriptionareall
\listwith\@listI's skips, andlist_seps_fromdoes not branch on theenvironment name for vertical spacing. All six orderings of the three lists give
byte-identical baselines. #706 saw "
itemizematches" only becauseitemizecame first in its fixture — the first list is always right; every later one was
wrong regardless of kind.
New
crates/render-pipeline/tests/adjacent_list_skips.rscloses the adjacencygap in #705's oracle: all six orderings of the three environments, all nine
ordered pairs, four-of-a-kind and a mixed chain of four, plus the
\topsep-only vs\topsep+\partopseppair.Why
#705's oracle deliberately puts each environment on its own page, so the
divergence was invisible there. Adjacent lists are the common real-world case,
and the error compounds down the page.
This is pipeline-side only —
crates/render-pipeline/src/adapter.rs, not thecompiler. No vendor re-pin is needed; nothing under
vendor/was touched andthe behaviour is visible as soon as this merges.
Overlap check
python3 scripts/coord.py claims— no claim on this code. ClaimedGH-706-ADJACENT-LIST-SKIPbefore starting.gh pr list --state open --search "list"— render-pipeline: consume compiler list structure; description, verse, quote in lists (list gate 39/39) #152, render-pipeline: a verbatim body inside a list is a trivlist inside that list (14/17) #268, compiler: \labelitemi..\labelitemiv as the article default markers #422, compiler: \setlist* (enumitem starred form) is unsupported (#563) #571, render-pipeline: secnumdepth, description, \baselinestretch and run-in headings #292, compiler: \addvspace inserts vertical space (documented simplification) #504touch lists, none touches the
\@endparenv/\@topsepboundary inadapter.rs. render-pipeline tests:\item[<label>]geometry pinned against pdflatex — already 1:1 (#676, PR 2 of 2) #705/compiler:\item[<label>]is a text run — nested math, styles, no silent drops (#676, PR 1 of 2) #702 (same lane, lists: \item[…] optional labels drop math and styles — \item[this is $2x$] renders "this is2x", \item[$\alpha$] renders nothing #676) are\item[<label>]geometry, notinter-list skips.
tests/nested_list_end_skips.rscovers the nested close path (that max wasalready right); this PR fixes the sibling path and leaves that test passing
unchanged.
Test results
New oracle, on this branch:
The same test on unfixed
origin/main(dbe3cac), showing it catches the bug:Measured oracle sweep — 28 documents, pdfTeX 1.40 (MacTeX 2026), 10 pt T1 Latin
Modern, PyMuPDF glyph origins, engine vs pdflatex:
pdflatex reference (identical for every environment and every order):
Before this fix the engine gave
[134.765, 164.653, 192.548, 220.443](steps 29.888 / 27.895 / 27.895 bp = 30 pt then 28 pt).
Full suite,
cargo test -p render-pipeline --no-fail-fast, this branch diffedagainst
origin/maindbe3cac built the same way:The +4 is exactly the new oracle.
letter_class_geometryis claimed separatelyas
GH-242-LETTER-CLASS.HW1/HW2 baselines did not move:
tests/list_items.rs(loadsfixtures/real-world/hw2/HW2.texand asserts its page count),tests/heading_macros.rs,tests/math_text.rs,tests/math_grids.rs,tests/long_arrows.rsandtests/math_font_kerns.rsall pass unchanged on bothbase and this branch.
Not done
\begin{<list>}-after-\end{<list>}gap sets vertical mode here. A\begin{<list>}directly after\end{center}/\end{quote}(also\trivlist,also leaving vertical mode) is not covered — no measurement was taken for it,
so it is left alone rather than changed speculatively.
vendor/re-pin: the cause was entirely pipeline-side.🤖 Generated with Claude Code
https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz