Files
diagram-design/scripts/verify-dumbbell.py
T
29917724f8 feat(bar): add dumbbell as a Bar variant (#107)
Documents the dumbbell in type-bar.md's existing Variants section,
alongside grouped and stacked bars. Reference-only, matching the
precedent those two set and the one #100 landed for datalake: no
example assets, no gallery entries.

Not a new visual type. The count stays at 28 and SKILL.md is untouched
-- no selection-table row, no frontmatter hook, no manifest description
change.

Covers layout on the shared 1000x500 composition, an element pattern,
and five honesty rules: no truncated axis, one scale with both endpoints
labelled, the connector is a gap and not a trajectory, a stated row
order, and no imputed or silently dropped endpoints.

Two of those rules live in a formula rather than a drawing, so they ship
as a checker with adversarial fixtures per ADR 0005:

- Domain resolution is exhaustive over sign. A rule phrased as
  "all-positive / all-negative" silently drops data that only touches
  zero, and drops all-zero data entirely -- where floor == ceil == 0 and
  the position formula divides by zero. resolve_domain() partitions the
  space, guarantees ceil > floor, and returns a finite fallback span for
  all-zero input so every dot lands on the floor.
- Non-text contrast is carried by the boundary, not the fill. Accent on
  paper is 2.86:1 skin-wide, and shape redundancy does not waive WCAG
  1.4.11, so the solid endpoint takes a 1px ink stroke (11.82:1) and the
  connector 55% ink on light / 40% on dark (3.19:1 and 3.24:1). The
  checker reads these tokens out of the reference, so prose and
  thresholds cannot drift apart.

scripts/test-verify-dumbbell.py covers both polarities: all-zero,
zero-touching, mixed-sign, empty and non-finite input, observed-extremes
truncation, and the sub-3:1 treatments this replaces. Wired into CI and
CONTRIBUTING alongside the treemap and geometry gates.

Other things the geometry and house style forced: the focal bar pattern
(12% tint plus accent stroke) does not survive at a 6px dot, so the pair
separates by fill weight; a 64px row pitch from y=96 overflows the y=420
plot floor at seven rows, so pitch and origin are tabulated per row
count; rgb(45,49,66) IS the dark paper, so light-theme hairlines
composite to 1.000:1 and must invert; label offsets keyed to start/end
put both labels inside the pair on a decreasing row; a value on the
domain floor collided with its category label; and the value axis was
missing the Geist Mono 7px title every other cartesian type carries.

Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:49:39 -05:00

280 lines
10 KiB
Python

#!/usr/bin/env python3
"""Verify the two dumbbell rules that live in a formula rather than in a drawing.
Both rules in `references/type-bar.md` are formula-level: a generator implements
them arithmetically, and both fail in ways that render perfectly. Prose alone
cannot defend either, so both are executable here and the reference is checked
against this module rather than the other way round.
1. DOMAIN RESOLUTION - the axis bounds are a function of the data's range, never
of its observed extremes. Taking `min`/`max` as the bounds IS the truncation
the reference forbids: the plot width is fixed, so a narrowed domain raises
px-per-unit and draws every gap wider. The subtle case is zero. A sign-based
rule that says "all-positive" and "all-negative" silently omits data that
only touches zero, and omits the all-zero dataset entirely - where the naive
reading yields `floor == ceil == 0` and the position formula divides by zero.
`resolve_domain` partitions the space exhaustively and guarantees
`ceil > floor`, so coordinates are always finite.
2. NON-TEXT CONTRAST - the solid endpoint and the connector are the marks that
carry "which series" and "which pair". WCAG 1.4.11 asks 3:1 of a graphical
object required to understand the content, and shape redundancy does not
waive it: a reader still has to see the solid mark's boundary and the line
joining the pair. Accent-on-paper is 2.86:1 skin-wide and cannot carry that,
so the boundary is carried by a stroke and the connector by an alpha that
clears 3:1 in both themes. The thresholds are checked here against the tokens
the reference actually documents, so the two cannot drift apart.
The check FAILS CLOSED. A reference this cannot parse, or a token it cannot
find, is a finding - never a silent pass.
Usage:
python3 scripts/verify-dumbbell.py
python3 scripts/verify-dumbbell.py --reference path/to/type-bar.md
Exit: 0 clean, 1 findings, 2 usage.
"""
from __future__ import annotations
import argparse
import math
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
REFERENCE = ROOT / "skills/diagram-design/references/type-bar.md"
# --- composition constants, quoted from the reference -----------------------
PLOT_X0 = 200 # left edge of the plot area
PLOT_WIDTH = 760 # 200 -> 960
MIN_GAP_PX = 16 # below this two r=6 marks read as one blob
# All-zero data has no range to scale against. A finite fallback span keeps the
# position formula defined; every dot lands on the floor, which is the truth.
ALL_ZERO_SPAN = 1.0
# --- skin tokens ------------------------------------------------------------
PAPER_LIGHT, PAPER_DARK = "#f5f5f5", "#2d3142"
WCAG_NON_TEXT = 3.0 # 1.4.11, graphical objects
# (label, ink hex, alpha, paper) - the connector in each theme
CONNECTORS = [
("connector (light)", "#2d3142", 0.55, PAPER_LIGHT),
("connector (dark)", "#f5f5f5", 0.40, PAPER_DARK),
]
# (label, stroke hex, paper) - the boundary of the solid endpoint
ENDPOINT_STROKES = [
("solid endpoint stroke (light)", "#2d3142", PAPER_LIGHT),
("solid endpoint stroke (dark)", "#f5f5f5", PAPER_DARK),
]
# Tokens the reference must actually name, so prose and code cannot drift.
REQUIRED_TOKENS = [
"rgba(45,49,66,0.55)",
"rgba(245,245,245,0.40)",
]
class DomainError(ValueError):
"""Raised when a domain cannot be resolved at all."""
# === DOMAIN =================================================================
def _nice_magnitude(value: float) -> float:
"""Smallest round number >= |value|, from the 1 / 2 / 2.5 / 5 / 10 ladder."""
magnitude = abs(value)
if magnitude == 0:
return 0.0
exponent = math.floor(math.log10(magnitude))
base = 10.0 ** exponent
for step in (1.0, 2.0, 2.5, 5.0, 10.0):
candidate = step * base
if candidate >= magnitude - 1e-12:
return candidate
return 10.0 * base
def resolve_domain(values):
"""Return (floor, ceil) for a dumbbell's value axis.
Exhaustive over the sign of the data, including the two cases a sign-based
rule drops: data that only touches zero, and data that is entirely zero.
Guarantees ceil > floor, so the position formula never divides by zero.
"""
numbers = [float(v) for v in values]
if not numbers:
raise DomainError("no values: a dumbbell needs at least one pair")
if any(math.isnan(v) or math.isinf(v) for v in numbers):
raise DomainError("non-finite value in input")
low, high = min(numbers), max(numbers)
if low == 0.0 and high == 0.0:
floor, ceiling = 0.0, ALL_ZERO_SPAN # every dot sits on the floor
elif low >= 0.0:
floor, ceiling = 0.0, _nice_magnitude(high)
elif high <= 0.0:
floor, ceiling = -_nice_magnitude(low), 0.0
else:
floor, ceiling = -_nice_magnitude(low), _nice_magnitude(high)
if not ceiling > floor:
raise DomainError(
"degenerate domain %r..%r from values %r" % (floor, ceiling, numbers)
)
return floor, ceiling
def scale(value: float, floor: float, ceiling: float) -> float:
"""Map a value onto the plot's x axis. Mirrors the reference's formula."""
span = ceiling - floor
if span <= 0:
raise DomainError("non-positive span %r" % (span,))
return PLOT_X0 + (float(value) - floor) / span * PLOT_WIDTH
def is_truncated(values, floor: float, ceiling: float) -> bool:
"""True when the bounds hug the data instead of following its sign.
This is the failure the honesty rule exists to prevent: bounds taken from
the observed extremes rather than resolved from the data's range.
"""
resolved_floor, resolved_ceiling = resolve_domain(values)
return (floor, ceiling) != (resolved_floor, resolved_ceiling)
# === CONTRAST ===============================================================
def _channel(component: int) -> float:
fraction = component / 255.0
if fraction <= 0.04045:
return fraction / 12.92
return ((fraction + 0.055) / 1.055) ** 2.4
def relative_luminance(color: str) -> float:
value = color.lstrip("#")
red, green, blue = (int(value[i:i + 2], 16) for i in (0, 2, 4))
return 0.2126 * _channel(red) + 0.7152 * _channel(green) + 0.0722 * _channel(blue)
def contrast(foreground: str, background: str) -> float:
first, second = relative_luminance(foreground), relative_luminance(background)
lighter, darker = max(first, second), min(first, second)
return (lighter + 0.05) / (darker + 0.05)
def composite(foreground: str, alpha: float, background: str) -> str:
"""Flatten a translucent stroke onto its background."""
fore = [int(foreground.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
back = [int(background.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)]
blended = [round(fore[i] * alpha + back[i] * (1 - alpha)) for i in range(3)]
return "#%02x%02x%02x" % tuple(blended)
# === CHECKS =================================================================
def check_domain_rules():
"""Exercise the scaling path over every sign case, including the zero ones."""
findings = []
cases = [
("all zero", [0, 0, 0, 0]),
("zero-touching positive", [0, 0, 5, 12]),
("zero-touching negative", [-12, -5, 0, 0]),
("all positive", [18, 66, 34, 71]),
("all negative", [-71, -34, -66, -18]),
("crossing zero", [-20, 5, 40, -3]),
("single identical pair", [7, 7]),
("tiny magnitudes", [0.0004, 0.0009]),
]
for label, values in cases:
try:
floor, ceiling = resolve_domain(values)
except DomainError as error:
findings.append("%s: domain unresolved (%s)" % (label, error))
continue
if not ceiling > floor:
findings.append("%s: ceil %r not above floor %r" % (label, ceiling, floor))
continue
for value in values:
x = scale(value, floor, ceiling)
if not math.isfinite(x):
findings.append("%s: non-finite coordinate for %r" % (label, value))
elif not (PLOT_X0 - 1e-6 <= x <= PLOT_X0 + PLOT_WIDTH + 1e-6):
findings.append(
"%s: %r maps to %.3f, outside the plot area" % (label, value, x)
)
return findings
def check_contrast_rules():
findings = []
for label, ink, alpha, paper in CONNECTORS:
ratio = contrast(composite(ink, alpha, paper), paper)
if ratio < WCAG_NON_TEXT:
findings.append(
"%s: %.3f:1 against paper, under the %.1f:1 WCAG 1.4.11 asks"
% (label, ratio, WCAG_NON_TEXT)
)
for label, stroke, paper in ENDPOINT_STROKES:
ratio = contrast(stroke, paper)
if ratio < WCAG_NON_TEXT:
findings.append(
"%s: %.3f:1 against paper, under the %.1f:1 WCAG 1.4.11 asks"
% (label, ratio, WCAG_NON_TEXT)
)
return findings
def check_reference(path: Path):
"""Fail closed: the reference must name the tokens this module verifies."""
findings = []
if not path.is_file():
return ["reference not found: %s" % path]
text = path.read_text(encoding="utf-8")
if "Dumbbell" not in text:
return ["reference %s carries no dumbbell section to check" % path.name]
for token in REQUIRED_TOKENS:
if token not in text:
findings.append(
"reference does not document %s, so the checked value is not the "
"shipped one" % token
)
if not re.search(r"\bfloor\b", text) or not re.search(r"\bceil\b", text):
findings.append("reference does not name the domain bounds the formula uses")
return findings
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="Verify the dumbbell's domain and non-text-contrast rules."
)
parser.add_argument(
"--reference", type=Path, default=REFERENCE,
help="path to type-bar.md (default: the shipped reference)",
)
args = parser.parse_args(argv)
findings = []
findings += check_domain_rules()
findings += check_contrast_rules()
findings += check_reference(args.reference)
if findings:
for finding in findings:
sys.stderr.write("FAIL dumbbell: %s\n" % finding)
sys.stderr.write("Summary: %d finding(s).\n" % len(findings))
return 1
sys.stdout.write(
"OK dumbbell: domain resolves finitely over every sign case "
"(including all-zero), and the documented connector and endpoint "
"boundary clear 3:1 in both themes\n"
)
return 0
if __name__ == "__main__":
sys.exit(main())