Skip to content

fix(paper2poster): close five holes where the poster gates pass without checking#13

Open
Chenruishuo wants to merge 1 commit into
mainfrom
fix/paper2poster-gate-holes
Open

fix(paper2poster): close five holes where the poster gates pass without checking#13
Chenruishuo wants to merge 1 commit into
mainfrom
fix/paper2poster-gate-holes

Conversation

@Chenruishuo

Copy link
Copy Markdown
Collaborator

Five places where a check_poster.py gate 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.py and slack.py both skipped the zero-natural-size check when the src looked 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=…> naturalWidth × naturalHeight
valid SVG, viewBox="0 0 200 100" 300 × 150
valid SVG, no width/height/viewBox 300 × 150
unparseable file 0 × 0
404 0 × 0

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 the src string any more.

2. polish exits 2 on a template this skill ships

assets/layouts_portrait/full.html:1340 declares exactly one role — data-measure-role="column" on .method-hero, so slack walks into it — and leaves poster/columns/cards to the conventional classes. But the class→role shim bailed the moment any role existed anywhere:

if (document.querySelector('[data-measure-role]')) return false;

so nothing was injected, poster/card resolved to zero, and cmd_polish hard-failed its own "poster/columns/cards required" precondition. slack --with-polish silently skipped the polish pass on the same file.

assets/layouts_portrait/full.html   counts: {'column': 1}   -> polish EXIT 2, missing ['poster', 'card']
assets/layouts/full.html            counts: {'column': 2, 'poster': 1, 'card': 8}   -> proceeds

A declared role is additive, not exclusive — _SLACK_JS already 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-element hasAttribute guard 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_html reads 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. polish would then measure nothing, emit no warnings, and PASS. Under --json even the cards checked: 0 line 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 a card that card cannot select). Both cmd_polish and slack --with-polish now 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.py incremented a monotonic on-disk counter at the top of cmd_slack, before the browser launched, and never cleared it. Three consequences:

  • a converged poster inherited the debt of the rounds that converged it, so a touch-up could trip a breaker it had already earned its way out of;
  • a run that never produced geometry (nav timeout, MathJax settle failure, no columns found) burned budget it never used — the increment landed before the page was opened;
  • nothing expired, so a months-old count was still live.

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 mtime for pre-upgrade state — exactly where a large stale count sits), atomic writes.

--with-polish --strict now 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 so slack alone is genuinely converged (single section, fullRatio 0.909, no figures, clean verdict) while ORPHAN fails:

--strict     round 1 -> exit 1   count=1
             round 2 -> exit 1   count=2
             round 3 -> exit 3   count=3      <- breaker
no --strict  round 1 -> exit 0   cleared      <- polish stays advisory

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] FAIL still on stderr), with a flush between them so >log 2>&1 keeps the order.

5. New gate: CARD/INNER-VOID

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 (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 to CARD/TRAILING.

Calibrated on a poster composed from this repo's own pipeline (compose_poster.py --layout full --style solid):

                     card_h   gap   gap/card
problem                 603    36      6.0%   <- `.section h2 { margin: 0 0 0.45em }` at 60pt
motivation             2138    36      1.7%
key-result (injected   1347   439     32.6%   <- real void
  pinned tail)

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: getComputedStyle resolves margin-top: auto to its used value (the void itself), so netting them out would zero the exact signal.

Considered and dropped

  • A header/logo acceptance gate. I initially read fit_logos.py as growing without an upper bound, which would have left the baked header unverified (it runs after the last polish pass, and render_poster.py auto-runs it again). That's wrong: best_arrangement clamps the uniform height by both h_width and H_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.
  • A hero letterbox gate. 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: center cards. Their voids sit above and below the children rather than between them, so CARD/INNER-VOID structurally cannot see them and CARD/TRAILING skips 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.

…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.
@Chenruishuo

Copy link
Copy Markdown
Collaborator Author

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant