fix(paper2poster): close five holes where the poster gates pass without checking#13
Open
Chenruishuo wants to merge 1 commit into
Open
fix(paper2poster): close five holes where the poster gates pass without checking#13Chenruishuo wants to merge 1 commit into
Chenruishuo wants to merge 1 commit into
Conversation
…ut checking
Each of these is a gate that reports success on a poster it did not
actually verify, or a breaker that counts the wrong thing. Found by
diffing this skill's gates against posterly's, then reproducing each
against the shipped templates.
1. The broken-image check skipped SVGs (polish.py, slack.py).
Both gates skipped the zero-natural-size check when the src looked
like an SVG, on the stated theory that "a vector image can
legitimately report zero natural size while rendering fine". Chromium
-- the only renderer these gates run in -- never does that: it
resolves an <img>'d SVG with no width/height/viewBox to the CSS
default replaced size, 300x150. Zero means a 404 or an unparseable
file. So the exemption only ever spared genuinely broken vector art
and let it white-print, which the old comment half-admitted ("an SVG
behind an extensionless URL still slips through"). Dropped it; the
check no longer guesses from the src string at all.
2. polish exited 2 on a template this skill ships (render.py,
preflight.py).
The class->role shim bailed entirely the moment ANY
data-measure-role existed. assets/layouts_portrait/full.html declares
exactly one (`column`, on .method-hero) and leaves poster/columns/
cards to classes, so the shim did nothing, poster+card resolved to
zero, and cmd_polish hard-failed its own precondition -- while
`slack --with-polish` silently skipped the polish pass. A declared
role is ADDITIVE (slack's own selector is a union of `.col`,
`.mid-wide` and `[data-measure-role="column"]`), so the shim now
fills in any conventional-class element that lacks a role and never
re-tags one that has it.
3. The gates trusted a static estimate of the role markup (render.py,
polish.py, slack.py).
has_required_roles_in_html reads the file text, so it can claim a
card the browser does not render (an element matching a conventional
class may already declare a different role). polish would then
measure nothing, emit no warnings and PASS -- invisible under --json,
which drops the "cards checked: 0" line. Added render.count_roles(),
an exact count on the live page, and both entry points now fail
closed when poster/card/column resolve to zero there. The static scan
stays as the cheap pre-browser fail-fast.
4. The circuit breaker counted invocations, not failures (slack.py).
It incremented a monotonic on-disk counter before the browser
launched and never cleared it, so a converged poster inherited the
debt of the rounds that converged it, and a run that never produced
geometry (nav timeout, MathJax settle failure) burned budget it never
used. It now counts CONSECUTIVE non-converged measurements: read up
front, written only once the verdict is known, cleared on
convergence, with a 12h staleness expiry (falling back to the file's
mtime for pre-upgrade state, which is exactly where a large stale
count sits) and atomic writes. Under `--with-polish --strict` the
polish gates now join the convergence decision -- otherwise a poster
that is FULL everywhere but keeps failing e.g. ORPHAN cleared its
budget every round and the breaker could never bound that loop at
all. Their output is buffered so the breaker can see the verdict
first, then replayed on its original stream at the position it always
occupied.
5. New: CARD/INNER-VOID (polish.py).
Nothing saw a void in the MIDDLE of a card. CARD/TRAILING skips any
card whose justify-content distributes space, and reads ~0 when a
tail is anchored to the bottom; Gate C's SPACE-BETWEEN only looks
between cards in a column. The shipped .scan-hero and .scan-banner
fall straight through that seam. The new check is pure geometry
(largest inter-child gap minus the stated row-gap) and fires only on
cards that hold content against their bottom edge -- a bottom-reaching
tail, or vertical space-* distribution -- so a plain top-packed card
stays CARD/TRAILING's job. Threshold calibrated on a composed poster:
heading rhythm peaks at 6% of card height (`.section h2` ships a real
36px margin), a genuine void runs 33-83%; 0.15 sits between with >2x
headroom either way.
Adds tests/ (55 tests). The DOM tests skip themselves without Playwright.
Collaborator
Author
|
@microsoft-github-policy-service agree |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five places where a
check_poster.pygate reports success on a poster it did not actually verify, or the circuit breaker counts the wrong thing. Each was reproduced against the templates this skill ships, and each fix is independent — happy to split into separate PRs if you'd prefer.I found these by diffing these gates against a sibling HTML-poster skill I maintain, then checking whether each difference was a real hole here. Several candidates did not survive that check and are deliberately absent (see "Considered and dropped" below).
1. The broken-image check skipped SVGs — so broken vector art white-printed
polish.pyandslack.pyboth skipped the zero-natural-size check when thesrclooked like an SVG, on the stated theory that "a vector image (SVG) can legitimately report zero natural size while rendering fine".Measured in Chromium — the only renderer these gates run in:
<img src=…>viewBox="0 0 200 100"Chromium resolves a viewBox-less
<img>'d SVG to the CSS default replaced size and never reports 0. Zero means the image failed. So the exemption only ever spared genuinely broken vector art — the blind spot the old comment half-admitted ("an SVG behind an extensionless URL still slips through"). Dropped it; neither gate reasons about thesrcstring any more.2.
polishexits 2 on a template this skill shipsassets/layouts_portrait/full.html:1340declares exactly one role —data-measure-role="column"on.method-hero, soslackwalks into it — and leaves poster/columns/cards to the conventional classes. But the class→role shim bailed the moment any role existed anywhere:so nothing was injected,
poster/cardresolved to zero, andcmd_polishhard-failed its own "poster/columns/cards required" precondition.slack --with-polishsilently skipped the polish pass on the same file.A declared role is additive, not exclusive —
_SLACK_JSalready treats it that way (.columns > .col, .columns > .mid-wide, [data-measure-role="column"]is a union). The shim now fills in any conventional-class element that lacks a role, and the per-elementhasAttributeguard is what protects author intent: an element that declares a role is never re-tagged.3. The gates trusted a static estimate of the role markup
has_required_roles_in_htmlreads the file text, so it can report a card the browser never renders — an element matching a conventional class may already declare a different role.polishwould then measure nothing, emit no warnings, and PASS. Under--jsoneven thecards checked: 0line is dropped, so nothing surfaces at all.Added
render.count_roles(page)— an exact count on the live page, keyed on the raw attribute value so it means exactly what the gates'[data-measure-role="card"]selectors mean (trimming would report acardthatcardcannot select). Bothcmd_polishandslack --with-polishnow fail closed when poster/card/column resolve to zero there, including when the query itself fails. The static scan stays as the cheap pre-browser fail-fast.4. The circuit breaker counted invocations, not failures
slack.pyincremented a monotonic on-disk counter at the top ofcmd_slack, before the browser launched, and never cleared it. Three consequences:Now it counts consecutive non-converged measurements: read up front, written only once the verdict is known, cleared on convergence, 12h staleness expiry (falling back to the file's
mtimefor pre-upgrade state — exactly where a large stale count sits), atomic writes.--with-polish --strictnow folds the polish verdict into convergence. Without that, a poster that is FULL everywhere but keeps failing a polish gate cleared its budget every single round, so the breaker could never bound that loop at all. Verified on a poster tuned soslackalone is genuinely converged (single section,fullRatio0.909, no figures, clean verdict) whileORPHANfails:The polish output is buffered so the breaker can see the verdict first, then replayed on its original stream at the position it always occupied (stdout report after the slack verdict;
[polish] FAILstill on stderr), with a flush between them so>log 2>&1keeps the order.5. New gate:
CARD/INNER-VOIDNothing saw a void in the middle of a card.
CARD/TRAILINGskips any card whosejustify-contentdistributes space, and reads ~0 when a tail is anchored to the bottom; Gate C'sSPACE-BETWEENonly looks between cards in a column. The shipped.scan-hero(center) and.scan-banner(space-between) fall straight through that seam.Pure geometry (largest inter-child gap minus the stated row-gap), so the mechanism doesn't matter. It fires only on cards that hold content against their bottom edge — a bottom-reaching tail, or vertical
space-*distribution — leaving a plain top-packed card toCARD/TRAILING.Calibrated on a poster composed from this repo's own pipeline (
compose_poster.py --layout full --style solid):Heading rhythm peaks at 6%, a genuine void runs 33–83%; the 0.15 threshold sits between with >2× headroom either way, and the tail condition removes the false positives entirely rather than relying on the margin.
Note those margins cannot simply be subtracted:
getComputedStyleresolvesmargin-top: autoto its used value (the void itself), so netting them out would zero the exact signal.Considered and dropped
fit_logos.pyas growing without an upper bound, which would have left the baked header unverified (it runs after the lastpolishpass, andrender_poster.pyauto-runs it again). That's wrong:best_arrangementclamps the uniform height by bothh_widthandH_logo / (r * (1 + gap_frac)), so it cannot overflow the zone by construction, and the QR re-home already self-verifies and rolls back. No gate needed.polish.py's[data-measure-role="hero"]branch is dead code — no template emits that role and the class shim doesn't map it. Rather than add a gate for a branch that never runs, I left it alone. Flagging it in case it's an intentional extension point or an oversight worth removing.justify-content: centercards. Their voids sit above and below the children rather than between them, soCARD/INNER-VOIDstructurally cannot see them andCARD/TRAILINGskips them. Documented as still open rather than implied to be covered.Testing
tests/test_gates.py, 55 tests,pytest ResearchStudio-Reel/skills/paper2poster/tests/ -q. DOM tests skip themselves without Playwright/Chromium; the rest are browser-free. The stream-ordering test runs the real CLI through a merged pipe (it fails if the flush is removed). A poster composed from this repo's pipeline reports an unchanged 7 warnings before and after.