Product design skills

scripts/verify-bubble.py

A supporting file of the diagram-design skill, shown as source.

#!/usr/bin/env python3
"""Verify that a bubble chart's drawn geometry matches the values it declares.

A bubble chart makes three claims at once: x and y positions come off two
shared linear scales, and AREA — never radius — carries the third value. All
three can be broken without anything erroring: the figure renders, every label
is present, and the lie is in the geometry. `lint-skin.py` reads colors and
fonts, `verify-geometry.py` reads label masks against later-painted nodes, and
neither compares a drawn coordinate or radius against the value bound to it.

Seven invariants:

1. SHARED AXIS SCALES - every bubble's cx must sit where one linear x scale
   puts its declared x value, and cy likewise for y. A single bubble nudged
   aside "to stop it overlapping" reads as a different number, and crowded
   bubbles are data, not a coordinate problem.

2. AREA, NEVER RADIUS - r^2 must be proportional to the declared size across
   the whole set, i.e. r = K*sqrt(size) for one shared K. This is the type's
   one unforgivable error: a radius proportional to the value shows a 6x
   service as 36x the ink. Per-bubble consistency catches the global version
   too, because under r = c*size the ratio r^2/size is not constant.

3. ONE ACCENT BUBBLE - at most one bound circle may carry the accent stroke,
   on either skin. A second accent bubble is a second "focal" claim, and the
   one-accent rule is the whole colour system of the house style.

4. LARGEST FIRST - where two bubbles overlap, the one painted earlier must be
   the larger, or the smaller is buried under it and its area cannot be read.
   Ties pass: equal radii cannot bury each other in a way paint order fixes.

5. BOUND LABELS ON THEIR OWN BUBBLE - a label names the bubble it is bound to,
   its visible text matches the binding (case aside - labels ship small-caps),
   and it sits nearer its own bubble's centre than any other's. Two labels
   exchanged between bubbles rename both while every number stays correct.

6. BOUND TICKS ON THE SAME SCALE - each axis needs at least two tick labels
   bound with data-tick/data-value, each printing exactly the number it
   declares and drawn where the bubbles' own scale puts that value. Unbound
   axis numbers are the cheapest way to relabel a whole chart.

7. FAIL CLOSED - a file that presents as a bubble chart but yields fewer than
   four parseable bubbles is a finding, never a pass. Four because the outlier
   test is leave-one-out and each fit needs three points; below that nothing
   here is verifiable, and a checker that reports OK because it found nothing
   to compare is the bug, not the gate. The same rule covers a bubble whose
   peers all share one value on an axis: its leave-one-out fit is degenerate,
   so it would define the scale rather than be checked against it, and an
   unmeasurable mark is reported rather than skipped.

Markup is read through the stdlib html.parser.HTMLParser, never a regex, so a
tag is recognized exactly when a browser would recognize it: a quoted `>`
inside an attribute value does not end the tag, a repeated attribute keeps its
FIRST value and the rest are not in the document at all, unquoted values and
upper-case names parse as their canonical form, and a comment's contents are
never live markup. The regex tag matcher this replaced stopped at the first
`>` it saw, so `<g data-note=">" transform="translate(0 -80)">` hid its
transform from the checker while Chromium applied it to every bubble inside -
the same fail-open shape verify-block-registry.py retired for the same reason.

No transform may move verified geometry or a bound label, and a transform
reaches the renderer by three carriers: the `transform` attribute, an inline
`style="..."`, and a rule in a <style> block. All three are refused, on the
element and on any ancestor <g>/<svg>, following verify-beeswarm.py; the
property set in CSS_MOVES_MARK_RE is the invariant, covering what moves or
resizes a circle (`cx`/`cy`/`r`) and what moves a label or tick (`x`/`y`).
CSS comments are stripped before any carrier is read, as the browser strips
them: `/**/transform:` is a live declaration, not a quirk.

Scope is read from the raw text with HTML comments removed, BEFORE the parser,
so a <circle> whose broken quoting keeps the parser from emitting it still
claims the file and is reported rather than skipped - the parser is exactly
what an unclosed quote defeats.

The basis for geometry is the `data-x` / `data-y` / `data-size` triple each
bubble circle declares, never the rendered text. The scales are derived from
the set itself (Theil-Sen, leave-one-out), so one dishonest bubble cannot drag
the line it is measured against.

Deliberately NOT `data-series`: that attribute belongs to the slopegraph
contract, and `verify-slopegraph.py` claims any file that uses it. A bubble
file is detected by `data-size`, its filename, or its accessible description.

WHAT THIS DOES NOT CHECK, deliberately:

- **Absolute truth.** Every check is internal consistency; a figure wrong by
  one constant everywhere is self-consistent. The source line states the
  domain and the area scale to the reader, and prose is not parsed.
- **Axis direction.** A y that grows downward with the value is accepted; an
  inverted axis is a legitimate design (error rate reading "up is worse").
- **Zero-based axes.** Whether the domain includes zero is stated in the
  source line, which this checker cannot read. The ticks it CAN read are
  pinned to the same scale as the bubbles, which is the checkable half.

Usage:
    python3 scripts/verify-bubble.py --all
    python3 scripts/verify-bubble.py skills/diagram-design/assets/example-bubble.html

Exit: 0 clean, 1 findings, 2 usage.
"""

from __future__ import annotations

import argparse
import math
import re
import sys
from html.parser import HTMLParser
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"

# Every CSS property that can move or reshape a verified mark WITHOUT touching
# the attributes this checker reads. The enumeration IS the invariant - it is
# copied from verify-beeswarm.py, where it had already been wrong twice
# (`transform:` alone missed `style="transform: ..."`, and once that was fixed
# `style="translate: 80px 0"` walked past it because CSS Transforms Level 2
# splits the transform into four properties). Three families reach a mark:
#
#   transform / translate / rotate / scale   the four transform properties;
#       the individual three compose WITH `transform`, so each is its own door
#   cx / cy / r / x / y                      SVG geometry properties. CSS wins
#       over the presentation attribute, so `style="r: 40px"` on a bubble
#       replaces the very radius that was verified, and x/y do the same to a
#       bound label or tick - a more direct lie than any transform
#   offset and its path/distance/position/anchor/rotate longhands
#       CSS motion path, which places the element somewhere else entirely
#
# Anchored to a declaration start, so `text-transform:` (the editorial skin
# uses it), `display:` and `--custom:` never match, and the `rotate` inside
# `transform: rotate(45deg)` is read once as the property and never as the
# function in its value. A vendor prefix is optional so `-webkit-transform:`
# is not a free pass.
CSS_MOVES_MARK_RE = re.compile(
    r"(?:^|[{;}\n])\s*(?:-(?:webkit|moz|ms|o)-)?"
    r"(?P<prop>transform|translate|rotate|scale"
    r"|cx|cy|r|x|y"
    r"|offset(?:-(?:path|distance|position|anchor|rotate))?)"
    r"\s*:",
    re.IGNORECASE,
)
# The COMPLETE numeric token an author may print: sign, comma-grouped
# thousands, decimals, leading-dot decimals, exponents. Matching only the
# first fragment is how "512,000" once agreed with metadata that said 512.
NUMBER_RE = re.compile(
    r"[-+]?(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?"
)
DIGIT_RE = re.compile(r"\d")
# A CSS comment is whitespace to the browser, so `/**/transform:` is a live
# declaration. CSS_MOVES_MARK_RE allows only whitespace between a declaration
# boundary and the property name, which let a comment sitting there hide the
# property from it while Chromium applied it. Non-greedy and DOTALL: a comment
# spans lines, and a stylesheet holds many. See css_moves_mark.
CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
# Detection only - deliberately a text search over the raw bytes, HTML comments
# removed, so a file that so much as mentions the binding in live markup is
# held to the contract even when the parser cannot read the tag that binds it.
# See declares_bubble.
DECLARES_BUBBLE_RE = re.compile(r"\bdata-size\s*=", re.IGNORECASE)
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)

# The accent stroke on either skin, hex or rgba. The focal count keys on the
# STROKE because the fill is a translucent tint the paper shows through; the
# stroke is the mark's edge and the thing a reader identifies the accent by.
ACCENT_RE = re.compile(
    r"#eb6c36\b|#f08a59\b|rgba\(\s*235\s*,\s*108\s*,\s*54\b|rgba\(\s*240\s*,\s*138\s*,\s*89\b",
    re.IGNORECASE,
)

# Coordinates ship rounded to one decimal, so an honest point sits within
# 0.05px of true; whole-pixel rounding sits within 0.5px. 1.0px clears both
# and still catches the smallest dishonest nudge worth making.
RESIDUAL_TOLERANCE = 1.0   # px, drawn centre vs the shared axis scale
RADIUS_TOLERANCE = 1.0     # px, drawn radius vs the shared area scale
VALUE_TOLERANCE = 0.001    # printed tick label vs declared attribute
# Tick text is placed beside its gridline, not on it: an x tick's anchor sits
# on the tick, but a y tick's BASELINE hangs ~4px below the line it names.
# 6px absorbs that offset; a swapped tick pair is off by a full gridline gap.
TICK_TOLERANCE = 6.0       # px, tick position vs the scale the bubbles set
LABEL_TOLERANCE = 0.5      # px, slack before a label counts as another bubble's
OVERLAP_SLACK = 0.5        # px, separation below which two bubbles overlap

GROUP_TAGS = ("g", "svg")                     # the only ancestors whose transform is inherited
BODY_TAGS = ("text", "title", "desc", "style")  # elements whose character data is read
MARK_TAGS = ("circle",)                       # the element this contract binds


# === PARSING =================================================================


class Element:
    """One start tag this checker cares about, as the browser tokenized it."""

    __slots__ = ("tag", "attrs", "offset", "line", "body", "ancestor")

    def __init__(self, tag, attrs, offset, line, ancestor):
        self.tag = tag
        self.attrs = attrs          # first-wins dict, names lower-cased, values unescaped
        self.offset = offset        # offset of `<` in the source, for ordering
        self.line = line
        self.body = ""              # character data up to the matching end tag
        self.ancestor = ancestor    # how the nearest transformed <g>/<svg> moves it, or None


class Bubble:
    __slots__ = ("name", "x", "y", "size", "cx", "cy", "r", "accent", "line")

    def __init__(self, name, x, y, size, cx, cy, r, accent, line):
        self.name = name
        self.x, self.y, self.size = x, y, size
        self.cx, self.cy, self.r = cx, cy, r
        self.accent = accent
        self.line = line


def first_wins(attrs) -> dict:
    """Attributes as the browser keeps them: on a repeat, the FIRST wins.

    HTML parsing drops a duplicate attribute rather than overwriting the one
    already on the token, so a second `r` on a circle is not merely ignored -
    it is not in the document at all. A dict comprehension does the opposite,
    and that gap is a fail-open every caller inherits: a circle carrying a
    dishonest first `r` and an honest second renders the dishonest radius
    while a last-wins reader checks, and passes, bytes the browser threw away.
    A present-but-valueless attribute is an empty string, not an absent one.
    """
    seen = {}
    for name, value in attrs:
        seen.setdefault(name, "" if value is None else value)
    return seen


class _Scanner(HTMLParser):
    """Collect circles, texts, title/desc and style elements with ancestry.

    HTMLParser already lowercases tag and attribute names, tolerates unquoted
    values and whitespace around `=`, unescapes entities, keeps a quoted `>`
    inside the value it belongs to, and never invokes handle_starttag for
    tag-like text inside a comment or inside <script>/<style> raw text - each
    of those is exactly a case a regex tag matcher mishandles. Ancestry is
    tracked for <g>/<svg> only, the elements whose transform a child inherits.
    """

    def __init__(self, source: str):
        super().__init__(convert_charrefs=True)
        self.circles: list = []
        self.texts: list = []
        self.named: list = []      # <title> and <desc>
        self.styles: list = []
        self.error = None
        self._groups: list = []    # (tag, how) per open <g>/<svg>
        self._open: list = []      # (tag, Element) per open body element
        self._line_starts = [0]
        for index, char in enumerate(source):
            if char == "\n":
                self._line_starts.append(index + 1)
        try:
            self.feed(source)
            self.close()
        except Exception as exc:  # noqa: BLE001 - any parser failure fails closed
            self.error = "%s: %s" % (type(exc).__name__, exc)

    def _offset(self) -> int:
        line, column = self.getpos()
        return self._line_starts[line - 1] + column

    def _ancestor(self):
        for _tag, how in reversed(self._groups):
            if how is not None:
                return how
        return None

    def handle_starttag(self, tag, attrs):
        self._start(tag, first_wins(attrs), closes=False)

    def handle_startendtag(self, tag, attrs):
        self._start(tag, first_wins(attrs), closes=True)

    def _start(self, tag, attrs, closes):
        if tag in GROUP_TAGS:
            if not closes:
                how = None
                if "transform" in attrs:
                    how = "an ancestor <g>/<svg> transform"
                elif transform_carrier(attrs) is not None:
                    how = "an ancestor <g>/<svg> style transform"
                self._groups.append((tag, how))
            return
        if tag not in MARK_TAGS and tag not in BODY_TAGS:
            return
        element = Element(tag, attrs, self._offset(), self.getpos()[0], self._ancestor())
        if tag == "circle":
            self.circles.append(element)
            return
        if tag == "text":
            self.texts.append(element)
        elif tag == "style":
            self.styles.append(element)
        else:
            self.named.append(element)
        if not closes:
            self._open.append((tag, element))

    def handle_endtag(self, tag):
        stack = self._groups if tag in GROUP_TAGS else self._open if tag in BODY_TAGS else None
        if stack is None:
            return
        # Pop back to the matching open tag - an unclosed inner element ends
        # with its parent, as it does in the browser's tree.
        for index in range(len(stack) - 1, -1, -1):
            if stack[index][0] == tag:
                del stack[index:]
                return

    def handle_data(self, data):
        if self._open:
            # Innermost only: a <title> tooltip inside a <text> is not part of
            # the rendered label, and the browser does not draw it either.
            self._open[-1][1].body += data


def parse_document(source: str) -> _Scanner:
    return _Scanner(source)


def attrs_of(raw: str) -> dict:
    """First-wins attributes of one tag's raw attribute text, via the parser."""

    class _One(HTMLParser):
        def __init__(self):
            super().__init__(convert_charrefs=True)
            self.attrs = {}

        def handle_starttag(self, tag, attrs):
            self.attrs = first_wins(attrs)

        handle_startendtag = handle_starttag

    scanner = _One()
    scanner.feed("<x " + raw + ">")
    scanner.close()
    return scanner.attrs


def transform_carrier(attrs: dict):
    """How this element carries a transform, phrased for the finding, or None.

    A transform reaches the renderer by three carriers and the `transform`
    ATTRIBUTE is only the most visible one. Reading the attribute alone lets
    `style="transform: translateY(...)"` on a bubble, a bound label, a tick or
    an ancestor group move the rendered mark after its raw coordinates were
    validated. The third carrier, a rule in a <style> block, is reported
    separately because nothing here can tell which marks such a rule selects.
    """
    if "transform" in attrs:
        return "transform=%r" % attrs["transform"]
    style = attrs.get("style")
    if style is not None:
        found = css_moves_mark(style)
        if found is not None:
            return "style=%r (the %s property)" % (style, found.group("prop").lower())
    return None


def css_moves_mark(css: str):
    """The first mark-moving declaration in CSS text, read past comments, or None.

    Every carrier goes through here - an inline style, an ancestor's inline
    style, a <style> block - because the browser drops `/* ... */` before it
    tokenizes, and CSS_MOVES_MARK_RE must see what the browser sees:
    `style="/**/transform: translateX(80px)"` moved a bubble while the
    anchored regex walked past the comment. Each comment is blanked to
    whitespace of the SAME length, newlines kept, so the searched text is
    aligned with the original character for character: every offset in the
    returned match is an offset into `css` as written, and a <style> finding
    that counts newlines up to the property names the right line.
    """
    stripped = CSS_COMMENT_RE.sub(
        lambda found: re.sub(r"[^\n]", " ", found.group()), css)
    return CSS_MOVES_MARK_RE.search(stripped)


def declares_bubble(source: str) -> bool:
    """Does the raw text, HTML comments removed, declare data-size anywhere?

    Read BEFORE the parser, because the parser is exactly what a broken quote
    defeats: `<circle data-size="9` with no closing quote is character data
    to HTMLParser, no <circle> is ever emitted, and a file whose only bubble
    signal was that tag would otherwise be skipped as out of scope - a
    fail-open. The raw signal claims the file and check_source then reports
    the tag the parser could not read. Comments are stripped first so a
    commented-out draft bubble does not claim an unrelated file.
    """
    return DECLARES_BUBBLE_RE.search(HTML_COMMENT_RE.sub("", source)) is not None


def plain(body: str) -> str:
    return body.strip()


def number(value):
    """A finite float from a complete numeric token, or None.

    `float("nan")` succeeds and then every `abs(x) > tolerance` comparison is
    False, so a NaN coordinate silently satisfies every check in the file.
    Any non-finite value is treated as unreadable instead.
    """
    if value is None:
        return None
    try:
        parsed = float(str(value).replace(",", ""))
    except (TypeError, ValueError):
        return None
    return parsed if math.isfinite(parsed) else None


def printed_number(body: str):
    """(value, reason) for a label's visible text.

    Returns a value only when the text carries exactly one complete numeric
    token. Trailing units are fine ("500ms"); a second number, or digits the
    token did not consume, is ambiguous and reported rather than guessed at.
    """
    match = NUMBER_RE.search(body)
    if match is None:
        return None, "prints no number"
    outside = body[:match.start()] + body[match.end():]
    if DIGIT_RE.search(outside):
        return None, "prints more than one numeric token"
    value = number(match.group())
    if value is None:
        return None, "prints a number this checker cannot read"
    return value, None


def median(values: list) -> float:
    ordered = sorted(values)
    middle = len(ordered) // 2
    if len(ordered) % 2:
        return ordered[middle]
    return (ordered[middle - 1] + ordered[middle]) / 2.0


def fit(points: list) -> tuple:
    """Robust (slope, intercept) for coordinate = slope * value + intercept.

    Theil-Sen - the median of all pairwise slopes - rather than least squares,
    so one dishonest bubble does not drag the line it is measured against.
    Returns (None, None) when every value is the same, which the caller
    reports: an axis with one distinct value has no derivable scale.
    """
    slopes = [
        (cb - ca) / (vb - va)
        for index, (va, ca) in enumerate(points)
        for vb, cb in points[index + 1:]
        if vb != va
    ]
    if not slopes:
        return None, None
    slope = median(slopes)
    return slope, median([c - slope * v for v, c in points])


def outliers(points: list, tolerance: float) -> list:
    """Points that disagree with the line their PEERS describe.

    Leave-one-out, because measuring a point against a fit that includes it
    lets it hide inside its own influence. Needs four points, so each fit is
    made from at least three - which is why fewer than four bubbles is the
    fail-closed case rather than a smaller check.

    Returns [(index, drawn, expected)].
    """
    if len(points) < 4:
        return []
    found = []
    for index, (value, drawn) in enumerate(points):
        peers = points[:index] + points[index + 1:]
        slope, intercept = fit(peers)
        if slope is None:
            continue
        expected = slope * value + intercept
        if abs(drawn - expected) > tolerance:
            found.append((index, drawn, expected))
    return found


def named_text(doc: _Scanner) -> str:
    """The accessible name and description, where a figure says what it is."""
    return " ".join(plain(element.body) for element in doc.named).casefold()


def looks_like_bubble(path: Path, source: str) -> bool:
    """Does this file present itself as a bubble chart?

    Deliberately generous - anything that claims the type in its name, its
    accessible description, or its markup is held to the contract, and a file
    that claims it while declaring nothing parseable is the fail-closed case,
    not a pass. Scoped to bubble-specific signals only: `data-size` rather
    than `data-series`, so this checker and `verify-slopegraph.py` never claim
    one another's files. A declaration inside an HTML comment is not live
    markup and claims nothing.
    """
    if path.name.startswith("example-bubble"):
        return True
    if declares_bubble(source):
        return True
    described = named_text(parse_document(source))
    return "bubble chart" in described or "bubble plot" in described


def parse_bubbles(doc: _Scanner, findings: list, name: str) -> list:
    """Bubble circles, with anything unparseable reported rather than dropped."""
    bubbles = []
    for element in doc.circles:
        attrs = element.attrs
        line = element.line
        if "data-size" not in attrs:
            # A <circle> with no data-size is scenery - a paper underlay, a
            # legend swatch, a dot-grid cell - and skipping it is correct. The
            # parser reads every tag the browser reads, so there is no
            # "declared but unparseable" case left to report here.
            continue
        label = attrs.get("data-name")
        if label is None:
            findings.append(
                "%s:%d: a bubble declares data-size but no data-name — an unnamed "
                "bubble cannot be labelled or cross-checked"
                % (name, line)
            )
            continue
        missing = [key for key in ("data-x", "data-y", "cx", "cy", "r")
                   if key not in attrs]
        if missing:
            findings.append(
                "%s:%d: bubble %r is missing %s — a bubble must declare all three "
                "values and all three drawn quantities or it cannot be verified"
                % (name, line, label, ", ".join(missing))
            )
            continue
        parsed = [number(attrs[key])
                  for key in ("data-x", "data-y", "data-size", "cx", "cy", "r")]
        if any(value is None for value in parsed):
            findings.append(
                "%s:%d: bubble %r has a value or coordinate that is not a finite "
                "number — cannot verify its position or area"
                % (name, line, label)
            )
            continue
        if parsed[2] <= 0:
            # Area cannot encode a non-positive value; the honest treatment of
            # a zero or missing measurement is to omit the bubble and count the
            # omission in the source line, exactly as the reference says.
            findings.append(
                "%s:%d: bubble %r declares data-size=%g — area cannot encode a "
                "non-positive value. Omit the item and note the omission in the "
                "source line" % (name, line, label, parsed[2])
            )
            continue
        if parsed[5] <= 0:
            findings.append(
                "%s:%d: bubble %r has r=%g — a bubble with no area states no value"
                % (name, line, label, parsed[5])
            )
            continue
        accent = bool(ACCENT_RE.search(attrs.get("stroke", "")))
        bubbles.append(Bubble(label, parsed[0], parsed[1], parsed[2], parsed[3],
                              parsed[4], parsed[5], accent, line))
    return bubbles


# === CHECKS ==================================================================


def check_transforms(doc: _Scanner, findings: list, name: str) -> None:
    """No transform may move verified geometry or a bound label.

    All three carriers are held to that rule - the `transform` attribute, an
    inline `style="transform: ..."`, and a rule in a <style> block - on the
    element and on every <g>/<svg> above it, because a gate that closes one of
    three doorways guards nothing.
    """

    def report(element, what, how):
        findings.append(
            "%s:%d: %s carries %s — this checker validates raw cx/cy/r and x/y "
            "attributes, so a transform moves the rendered mark away from the "
            "number it was checked against. Bake the offset into the coordinates "
            "instead" % (name, element.line, what, how)
        )

    def check_element(element, what):
        how = transform_carrier(element.attrs)
        if how is None:
            how = element.ancestor
        if how is not None:
            report(element, what, how)

    for element in doc.circles:
        if "data-size" in element.attrs:
            check_element(element, "bubble %r" % element.attrs.get("data-name", "?"))

    for element in doc.texts:
        if "data-name" in element.attrs or "data-tick" in element.attrs:
            check_element(element, "a bound label (%s)" % plain(element.body)[:20])

    for element in doc.styles:
        found = css_moves_mark(element.body)
        if found:
            findings.append(
                "%s:%d: a CSS `%s` declaration — this checker cannot tell "
                "which marks it applies to, and a transform on verified geometry "
                "invalidates every coordinate here. Remove it, or bake the offset "
                "into the coordinates"
                % (name, element.line + element.body.count("\n", 0, found.start("prop")),
                   found.group("prop").lower())
            )


def check_axis(bubbles: list, axis: str, findings: list, name: str):
    """One shared linear scale per axis, and no bubble may drift off it.

    Returns the (slope, intercept) fit for the axis so the tick check can
    measure against the same scale, or (None, None) when none is derivable.
    """
    if axis == "x":
        points = [(b.x, b.cx) for b in bubbles]
        drawn_attr = "cx"
    else:
        points = [(b.y, b.cy) for b in bubbles]
        drawn_attr = "cy"
    line = bubbles[0].line

    slope, intercept = fit(points)
    if slope is None:
        findings.append(
            "%s:%d: the %s axis has no two distinct declared values, so its scale "
            "cannot be derived and no position on it is verifiable"
            % (name, line, axis)
        )
        return None, None

    # A bubble whose PEERS all share one value cannot be measured: the
    # leave-one-out fit for it is degenerate, so `outliers` skips it, and the
    # full-set fit passes exactly through wherever it was drawn — its position
    # would define the scale rather than be checked by it. Fail closed: that
    # is an unverifiable mark, not a pass. (Greptile flagged the silent skip.)
    for index, (value, _drawn) in enumerate(points):
        peer_values = {points[i][0] for i in range(len(points)) if i != index}
        if len(peer_values) < 2:
            b = bubbles[index]
            findings.append(
                "%s:%d: bubble %r holds the only distinct %s value (%g) — its "
                "peers cannot describe a scale to check it against, so its "
                "position would define the axis instead of being verified by "
                "it. An axis needs two distinct values among the other bubbles"
                % (name, b.line, b.name, axis, value)
            )

    for index, drawn, expected in outliers(points, RESIDUAL_TOLERANCE):
        b = bubbles[index]
        findings.append(
            "%s:%d: bubble %r declares %s=%g but draws %s=%g where the shared %s "
            "scale its peers describe puts it at %.1f — off by %.1f px. Crowded "
            "bubbles are data; never nudge one aside"
            % (name, b.line, b.name, axis,
               points[index][0], drawn_attr, drawn, axis, expected,
               abs(drawn - expected))
        )
    return slope, intercept


def check_area(bubbles: list, findings: list, name: str) -> None:
    """r must equal K*sqrt(size) for one K shared by the whole set.

    The ratio r^2/size is constant under honest area encoding and varies under
    every dishonest alternative - radius-proportional sizing makes it grow
    linearly with the value, and a single inflated bubble moves only its own
    ratio. Leave-one-out again, so the offender is measured against its peers.
    """
    if len(bubbles) < 4:
        return
    ratios = [b.r * b.r / b.size for b in bubbles]
    for index, b in enumerate(bubbles):
        peers = ratios[:index] + ratios[index + 1:]
        expected = math.sqrt(median(peers) * b.size)
        if abs(b.r - expected) > RADIUS_TOLERANCE:
            findings.append(
                "%s:%d: bubble %r declares size %g but draws r=%g where the area "
                "scale its peers share puts %.1f — area must be proportional to "
                "the value (r = K*sqrt(size)), and radius-proportional sizing "
                "shows a 6x value as 36x the ink"
                % (name, b.line, b.name, b.size, b.r, expected)
            )


def check_focal(bubbles: list, findings: list, name: str) -> None:
    """At most one bubble wears the accent."""
    accented = [b for b in bubbles if b.accent]
    for extra in accented[1:]:
        findings.append(
            "%s:%d: bubble %r also carries the accent stroke — one accent bubble "
            "max (%r already has it). A second focal mark is a second editorial "
            "claim, and the ink ramp exists so everything else stays ranked "
            "without spending the accent"
            % (name, extra.line, extra.name, accented[0].name)
        )


def check_paint_order(bubbles: list, findings: list, name: str) -> None:
    """Where two bubbles overlap, the larger must be painted first.

    Painted later means painted on top: a small bubble under a large one is
    invisible and its area unreadable. Ties pass - equal radii cannot bury
    each other in a way order fixes - and separation by at least a hairline
    is the honest fix for near-touching bubbles, so only true overlap counts.
    """
    for index, a in enumerate(bubbles):
        for b in bubbles[index + 1:]:
            gap = math.hypot(a.cx - b.cx, a.cy - b.cy) - (a.r + b.r)
            if gap < -OVERLAP_SLACK and a.r < b.r:
                findings.append(
                    "%s:%d: bubble %r (r=%g) overlaps %r (r=%g) but is painted "
                    "first — draw overlapping bubbles largest-first so the small "
                    "one stays on top and both areas stay readable"
                    % (name, a.line, a.name, a.r, b.name, b.r)
                )


def check_labels(bubbles: list, doc: _Scanner, findings: list, name: str) -> None:
    """Bound labels must name a real bubble, match it, and sit on it."""
    centres = {b.name: (b.cx, b.cy) for b in bubbles}
    seen = set()
    for element in doc.texts:
        attrs = element.attrs
        label = attrs.get("data-name")
        if label is None:
            continue
        line = element.line
        body = plain(element.body)
        if label not in centres:
            findings.append(
                "%s:%d: a label names bubble %r, which no circle declares — a "
                "label with no mark is not verifiable and reads as data"
                % (name, line, label)
            )
            continue
        if label in seen:
            findings.append(
                "%s:%d: a second label for bubble %r — one bubble, one label, or "
                "the figure states two things about one mark"
                % (name, line, label)
            )
            continue
        seen.add(label)
        # Case-insensitive: labels ship small-caps ("PAYMENTS" for a bubble
        # named "Payments"), and casing is presentation, not identity.
        if body.casefold() != label.casefold():
            findings.append(
                "%s:%d: a label bound to bubble %r reads %r — the visible text "
                "and the binding must agree"
                % (name, line, label, body[:28])
            )
            continue
        x, y = number(attrs.get("x")), number(attrs.get("y"))
        if x is None or y is None:
            findings.append(
                "%s:%d: the label for bubble %r has no readable x/y, so its "
                "placement cannot be checked"
                % (name, line, label)
            )
            continue
        own_cx, own_cy = centres[label]
        own = math.hypot(x - own_cx, y - own_cy)
        nearest = min(
            (other for other in centres if other != label),
            key=lambda other: math.hypot(x - centres[other][0],
                                         y - centres[other][1]),
            default=None,
        )
        if nearest is not None:
            near = math.hypot(x - centres[nearest][0], y - centres[nearest][1])
            if near < own - LABEL_TOLERANCE:
                findings.append(
                    "%s:%d: the label for bubble %r is drawn at (%g, %g), nearer "
                    "%r's centre than its own — a label on the wrong bubble "
                    "renames the mark"
                    % (name, line, label, x, y, nearest)
                )


def check_ticks(x_fit, y_fit, doc: _Scanner, findings: list, name: str) -> None:
    """Bound axis ticks must print their value and sit on the bubbles' scale."""
    ticks = {"x": [], "y": []}
    for element in doc.texts:
        attrs = element.attrs
        axis = attrs.get("data-tick")
        if axis is None:
            continue
        line = element.line
        if axis not in ("x", "y"):
            findings.append(
                "%s:%d: a tick label has data-tick=%r, which is neither 'x' nor "
                "'y'" % (name, line, axis)
            )
            continue
        declared = number(attrs.get("data-value"))
        if declared is None:
            findings.append(
                "%s:%d: a %s tick has no readable data-value — an unbound axis "
                "number is the cheapest way to relabel a whole chart"
                % (name, line, axis)
            )
            continue
        body = plain(element.body)
        shown, reason = printed_number(body)
        if shown is None:
            findings.append(
                "%s:%d: the %s tick for %g %s (%r) — print one complete number "
                "per tick" % (name, line, axis, declared, reason, body[:28])
            )
            continue
        if abs(shown - declared) > VALUE_TOLERANCE:
            findings.append(
                "%s:%d: a %s tick prints %r but declares %g — the label and the "
                "binding must state one number"
                % (name, line, axis, body[:28], declared)
            )
            continue
        position = number(attrs.get("x" if axis == "x" else "y"))
        if position is None:
            findings.append(
                "%s:%d: the %s tick for %g has no readable position"
                % (name, line, axis, declared)
            )
            continue
        ticks[axis].append((declared, position, line))

    for axis, fit_pair in (("x", x_fit), ("y", y_fit)):
        entries = ticks[axis]
        if len({value for value, _pos, _line in entries}) < 2:
            findings.append(
                "%s: the %s axis binds %d distinct tick value(s) — an axis needs "
                "at least two bound ticks (data-tick/data-value) or its printed "
                "scale is unverifiable against the drawn one"
                % (name, axis, len({v for v, _p, _l in entries}))
            )
            continue
        slope, intercept = fit_pair
        if slope is None:
            continue  # already reported by check_axis
        for value, position, line in entries:
            expected = slope * value + intercept
            if abs(position - expected) > TICK_TOLERANCE:
                findings.append(
                    "%s:%d: the %s tick for %g is drawn at %g but the scale the "
                    "bubbles themselves describe puts that value at %.1f — the "
                    "printed axis and the drawn positions disagree, so every "
                    "reading off this axis is wrong"
                    % (name, line, axis, value, position, expected)
                )


# === DRIVER ==================================================================


def check_source(path: Path, raw: str) -> list:
    """Findings for one already-read document."""
    findings: list = []
    doc = parse_document(raw)
    if doc.error is not None:
        findings.append(
            "%s: presents as a bubble chart but could not be parsed as HTML (%s) — "
            "refusing to report OK on a file this checker could not read"
            % (path.name, doc.error)
        )
        return findings
    # The raw text claimed a bound <circle> the parser never emitted: an
    # unclosed quote turned the tag into character data. Nothing downstream
    # can verify a mark that does not exist, so say which tag was lost rather
    # than count zero bubbles and leave the author hunting for a circle that
    # is plainly there.
    if declares_bubble(raw) and not any("data-size" in e.attrs for e in doc.circles):
        findings.append(
            "%s: declares data-size but no complete <circle> could be parsed — an "
            "unclosed attribute quote swallows the tag; fix the markup. Refusing "
            "to report OK on a file this checker could not read" % path.name
        )
        return findings
    bubbles = parse_bubbles(doc, findings, path.name)

    if len(bubbles) < 4:
        findings.append(
            "%s: presents as a bubble chart but declares %d verifiable bubble(s) "
            "— each needs data-name, data-x, data-y, data-size, cx, cy and r, "
            "and the leave-one-out scale test needs four. Refusing to report OK "
            "on a file this checker could not read" % (path.name, len(bubbles))
        )
        return findings

    check_transforms(doc, findings, path.name)
    x_fit = check_axis(bubbles, "x", findings, path.name)
    y_fit = check_axis(bubbles, "y", findings, path.name)
    check_area(bubbles, findings, path.name)
    check_focal(bubbles, findings, path.name)
    check_paint_order(bubbles, findings, path.name)
    check_labels(bubbles, doc, findings, path.name)
    check_ticks(x_fit, y_fit, doc, findings, path.name)
    return findings


def check(path: Path) -> list:
    """Findings for one file on disk, or [] if it is not a bubble chart."""
    raw = path.read_text(encoding="utf-8")
    if not looks_like_bubble(path, raw):
        return []
    return check_source(path, raw)


def targets(args: argparse.Namespace) -> list:
    if args.all:
        return sorted(ASSET_DIR.glob("example-*.html"))
    return [Path(p) for p in args.paths]


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Verify bubble positions and areas against the values they declare."
    )
    parser.add_argument("paths", nargs="*", help="HTML files to check")
    parser.add_argument(
        "--all", action="store_true",
        help="check every shipped example that presents as a bubble chart",
    )
    args = parser.parse_args()
    if not args.all and not args.paths:
        parser.print_help()
        return 2

    findings: list = []
    checked = 0
    skipped = 0
    for path in targets(args):
        if not path.is_file():
            print("error: %s is not a readable file" % path, file=sys.stderr)
            return 2
        try:
            raw = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError) as error:
            print("error: cannot read %s: %s" % (path, error), file=sys.stderr)
            return 2
        # Read once, then decide. Scope is reported separately from
        # verification in both modes: printing "1 file(s) verified" for a
        # scatter that was skipped is a claim the run never made good on.
        if not looks_like_bubble(path, raw):
            skipped += 1
            continue
        findings.extend(check_source(path, raw))
        checked += 1

    for finding in findings:
        print(finding)
    tail = " (%d file(s) skipped as out of scope)" % skipped if skipped else ""
    if findings:
        print("\n%d bubble finding(s) across %d file(s).%s"
              % (len(findings), checked, tail))
        return 1
    if not checked:
        print("OK bubble: no bubble chart found to check%s" % tail)
        return 0
    print("OK bubble: %d file(s), shared x and y scales, area proportional to every "
          "declared size, at most one accent bubble, largest-first paint order, and "
          "every bound label and tick agreeing with the mark it describes%s"
          % (checked, tail))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())