feat(line): add slopegraph variant for change between two states (#106)
This commit is contained in:
@@ -0,0 +1,754 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that a slopegraph's drawn slopes match the values it prints.
|
||||
|
||||
A slopegraph makes exactly one claim: **both axes carry the same scale**, so the
|
||||
angle of a line is a rate of change and two angles are comparable. Break that
|
||||
claim and nothing errors - 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
|
||||
endpoint against the number sitting beside it.
|
||||
|
||||
Six invariants, each of which has shipped broken in a draft of this type:
|
||||
|
||||
1. SHARED SCALE - the left and right axes must map value to y through the same
|
||||
linear transform. Both halves are checked, slope *and* origin: a second axis
|
||||
that is rescaled makes every slope wrong by a factor, and one that is merely
|
||||
shifted makes every slope wrong by a constant. Checking the slope alone would
|
||||
pass the shifted case, which is the easier mistake to make.
|
||||
|
||||
2. NO JITTER - each declared value must sit where the shared scale puts it. The
|
||||
draft this checker was written against nudged two series apart by 3px because
|
||||
their labels collided at identical values; both then read as different
|
||||
numbers. Crowded endpoint labels are data, not a coordinate problem.
|
||||
|
||||
3. UNTRANSFORMED GEOMETRY - the coordinates read here are raw attributes, so any
|
||||
`transform` on verified geometry, on its labels, or on an ancestor moves the
|
||||
rendered mark away from the number this checker validated. A single
|
||||
`transform="translate(0 80)"` on one series line slid its endpoint 80px and
|
||||
every check still passed. Transforms are rejected rather than resolved: a
|
||||
partial implementation of the SVG transform stack is worse than an honest
|
||||
refusal, because it looks like coverage.
|
||||
|
||||
4. COMPLETE PRINTED VALUES - the whole visible numeric token must match the
|
||||
declared one. Matching only the first fragment read the label "512,000" as
|
||||
512 and agreed with metadata that said 512.
|
||||
|
||||
5. LABELS BOUND TO MEANING - state captions belong to a named axis, and each
|
||||
series name belongs to a series and an end. Unbound, the two captions can be
|
||||
swapped - reversing the reading of the entire figure - or two names exchanged
|
||||
between rows, with every geometric check still green.
|
||||
|
||||
6. FAIL CLOSED - a file that presents as a slopegraph but yields nothing
|
||||
parseable is a finding, never a pass. A checker that reports OK because it
|
||||
found nothing to compare is the bug, not the gate. `verify-treemap.py`
|
||||
returned early on `len(cells) < 3` and that is precisely how an undersized
|
||||
cell went unverified.
|
||||
|
||||
The basis for geometry is the `data-from` / `data-to` pair each series line
|
||||
declares, never the rendered text. Deriving the basis from text is what let a
|
||||
treemap cell with no label drop silently out of the checked set; here a series
|
||||
whose label is missing or restyled stays in the set and its missing label is
|
||||
itself reported.
|
||||
|
||||
WHAT THIS DOES NOT CHECK, deliberately:
|
||||
|
||||
- **Direction.** An axis whose y grows with value is accepted. That is correct
|
||||
for a rank slopegraph, where rank 1 belongs at the top, and this checker has no
|
||||
way to tell an intended inversion from a mistaken one.
|
||||
- **Absolute truth.** Every check here is internal consistency. If every label
|
||||
and every declared value is wrong by the same constant, the figure is
|
||||
self-consistent and passes; nothing in the file could reveal otherwise. The
|
||||
source line is where the domain is stated to a reader, and prose is not parsed.
|
||||
- **Curvature below tolerance.** A value-to-y map that bends by less than
|
||||
RESIDUAL_TOLERANCE at every point is indistinguishable from a straight one.
|
||||
|
||||
Usage:
|
||||
python3 scripts/verify-slopegraph.py --all
|
||||
python3 scripts/verify-slopegraph.py skills/diagram-design/assets/example-slopegraph.html
|
||||
|
||||
Exit: 0 clean, 1 findings, 2 usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||
|
||||
LINE_RE = re.compile(r"<line\b(?P<attrs>[^>]*?)/?>", re.IGNORECASE)
|
||||
TEXT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<body>.*?)</text>", re.IGNORECASE | re.DOTALL)
|
||||
COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
||||
NAMED_RE = re.compile(r"<(?P<tag>title|desc)\b[^>]*>(?P<body>.*?)</(?P=tag)>",
|
||||
re.IGNORECASE | re.DOTALL)
|
||||
GROUP_OPEN_RE = re.compile(r"<(?:g|svg)\b(?P<attrs>[^>]*?)(?P<selfclose>/?)>", re.IGNORECASE)
|
||||
GROUP_CLOSE_RE = re.compile(r"</(?:g|svg)\s*>", re.IGNORECASE)
|
||||
STYLE_RE = re.compile(r"<style\b[^>]*>(?P<body>.*?)</style>", re.IGNORECASE | re.DOTALL)
|
||||
# `transform:` but not `text-transform:`. The full-editorial template uses the
|
||||
# latter, so an unguarded pattern would report every editorial variant.
|
||||
CSS_TRANSFORM_RE = re.compile(r"(?<![\w-])transform\s*:", re.IGNORECASE)
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
# The COMPLETE numeric token an author may print: sign, comma-grouped thousands,
|
||||
# decimals, leading-dot decimals, exponents. Matching only `-?\d+(\.\d+)?` read
|
||||
# "1e3" as 1 and "512,000" as 512, so a mangled label agreed with its metadata.
|
||||
NUMBER_RE = re.compile(
|
||||
r"[-+]?(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?"
|
||||
)
|
||||
DIGIT_RE = re.compile(r"\d")
|
||||
|
||||
# Both quote styles. Matching only double quotes made a single-quoted series
|
||||
# line invisible: the detector below sees `data-series` in the raw source either
|
||||
# way, but the attribute parser returned nothing, so the line was dropped from
|
||||
# the verified set without a word - a file carrying a 400px lie reported clean.
|
||||
ATTR_RE = re.compile(
|
||||
r"""(?P<name>[\w:-]+)\s*=\s*(?P<quote>["'])(?P<value>.*?)(?P=quote)""",
|
||||
re.DOTALL,
|
||||
)
|
||||
DECLARES_SERIES_RE = re.compile(r"\bdata-series\s*=", re.IGNORECASE)
|
||||
|
||||
# Coordinates ship rounded to one decimal, so a point can sit 0.05px from its
|
||||
# true position honestly; an author rounding to whole pixels can sit 0.5px off.
|
||||
# 1.0px clears both and still catches the smallest dishonest nudge worth making
|
||||
# (the draft's was 3px). In data units at the shipped 0.844 px-per-ms scale that
|
||||
# is 1.2ms of slack on values running 121-512 - about a quarter of one percent,
|
||||
# below the precision the labels themselves claim.
|
||||
RESIDUAL_TOLERANCE = 1.0 # px, per endpoint, against the shared scale
|
||||
ORIGIN_TOLERANCE = 0.5 # px, between the two axes at mid-domain
|
||||
SCALE_TOLERANCE = 1.0 # px, worst-case slope disagreement across the range
|
||||
VALUE_TOLERANCE = 0.001 # printed label vs declared attribute
|
||||
CAPTION_TOLERANCE = 0.5 # px, state caption x vs the axis it names
|
||||
ROW_TOLERANCE = 0.5 # px, slack before a label counts as another row
|
||||
|
||||
|
||||
class Series:
|
||||
__slots__ = ("name", "frm", "to", "x1", "y1", "x2", "y2", "offset")
|
||||
|
||||
def __init__(self, name, frm, to, x1, y1, x2, y2, offset):
|
||||
self.name = name
|
||||
self.frm, self.to = frm, to
|
||||
self.x1, self.y1 = x1, y1
|
||||
self.x2, self.y2 = x2, y2
|
||||
self.offset = offset
|
||||
|
||||
def value(self, end):
|
||||
return self.frm if end == "from" else self.to
|
||||
|
||||
def endpoint_y(self, end):
|
||||
return self.y1 if end == "from" else self.y2
|
||||
|
||||
def axis_x(self, end):
|
||||
return self.x1 if end == "from" else self.x2
|
||||
|
||||
|
||||
def line_of(source: str, offset: int) -> int:
|
||||
return source.count("\n", 0, offset) + 1
|
||||
|
||||
|
||||
def blank_comments(source: str) -> str:
|
||||
"""Comments out, length and line numbers preserved.
|
||||
|
||||
Markup inside a comment is not rendered, so treating it as data reports a
|
||||
commented-out old draft as a live defect. Replacing each comment with spaces
|
||||
of the same length keeps every later offset and line number honest.
|
||||
"""
|
||||
return COMMENT_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), source)
|
||||
|
||||
|
||||
def attrs_of(raw: str) -> dict:
|
||||
return {m.group("name"): m.group("value") for m in ATTR_RE.finditer(raw)}
|
||||
|
||||
|
||||
def plain(body: str) -> str:
|
||||
return html.unescape(TAG_RE.sub("", 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 satisfied 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 ("512ms"); 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 y = slope * value + intercept.
|
||||
|
||||
Theil-Sen - the median of all pairwise slopes - rather than least squares, so
|
||||
that one dishonest point does not drag the line it is being measured against.
|
||||
Returns (None, None) when every value is the same, which is legitimate on an
|
||||
indexed axis and handled by the caller.
|
||||
"""
|
||||
slopes = [
|
||||
(yb - ya) / (vb - va)
|
||||
for index, (va, ya) in enumerate(points)
|
||||
for vb, yb in points[index + 1:]
|
||||
if vb != va
|
||||
]
|
||||
if not slopes:
|
||||
return None, None
|
||||
slope = median(slopes)
|
||||
return slope, median([y - slope * v for v, y 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. Theil-Sen's breakdown point is around 29%,
|
||||
which is zero outliers on a three-point axis: an indexed three-series figure
|
||||
with one endpoint 3px wrong produced a whole-set fit that called all three
|
||||
points clean, because the contaminated slope passed within 1px of every one
|
||||
of them. Excluding the point under test removes that escape.
|
||||
|
||||
Returns [(index, drawn, expected)]. Needs four points, so each fit is made
|
||||
from at least three.
|
||||
"""
|
||||
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(source: str) -> str:
|
||||
"""The accessible name and description, where a figure says what it is."""
|
||||
return " ".join(plain(m.group("body")) for m in NAMED_RE.finditer(source)).casefold()
|
||||
|
||||
|
||||
def looks_like_slopegraph(path: Path, source: str) -> bool:
|
||||
"""Does this file present itself as a slopegraph?
|
||||
|
||||
Deliberately generous. Anything that claims the type in its name, its
|
||||
accessible description, or its markup is held to the contract even if it
|
||||
declares no parseable series - that combination is the fail-closed case, not
|
||||
a pass. The whole document is searched: a 4000-character window missed a file
|
||||
whose only declaration sat behind a long stylesheet.
|
||||
"""
|
||||
if path.name.startswith("example-slopegraph"):
|
||||
return True
|
||||
if DECLARES_SERIES_RE.search(source):
|
||||
return True
|
||||
described = named_text(source)
|
||||
return "slopegraph" in described or "slope graph" in described
|
||||
|
||||
|
||||
def transformed_spans(source: str) -> list:
|
||||
"""Offset ranges enclosed by a <g>/<svg> that carries a transform.
|
||||
|
||||
Element-level transforms are easy to see; an ancestor's is not, and shifts
|
||||
everything inside it identically - which is exactly the change that leaves
|
||||
all internal consistency intact while moving every mark on the page.
|
||||
"""
|
||||
events = []
|
||||
for match in GROUP_OPEN_RE.finditer(source):
|
||||
if match.group("selfclose"):
|
||||
continue
|
||||
events.append((match.start(), 0, "transform" in attrs_of(match.group("attrs"))))
|
||||
for match in GROUP_CLOSE_RE.finditer(source):
|
||||
events.append((match.start(), 1, False))
|
||||
events.sort()
|
||||
stack, spans = [], []
|
||||
for position, kind, transformed in events:
|
||||
if kind == 0:
|
||||
stack.append((position, transformed))
|
||||
elif stack:
|
||||
start, was_transformed = stack.pop()
|
||||
if was_transformed:
|
||||
spans.append((start, position))
|
||||
# An unclosed transformed group covers everything after it.
|
||||
for start, was_transformed in stack:
|
||||
if was_transformed:
|
||||
spans.append((start, len(source)))
|
||||
return spans
|
||||
|
||||
|
||||
def parse_series(source: str, findings: list, name: str) -> list:
|
||||
"""Series lines, with anything unparseable reported rather than dropped."""
|
||||
series = []
|
||||
for match in LINE_RE.finditer(source):
|
||||
raw = match.group("attrs")
|
||||
attrs = attrs_of(raw)
|
||||
label = attrs.get("data-series")
|
||||
if label is None:
|
||||
# A <line> with no data-series is scenery - an axis rule, a legend
|
||||
# swatch - and skipping it is correct. But one whose raw text DOES
|
||||
# declare data-series and still parsed to nothing is markup this
|
||||
# checker cannot read, and dropping it silently is how a lie ships.
|
||||
if DECLARES_SERIES_RE.search(raw):
|
||||
findings.append(
|
||||
"%s:%d: a <line> declares data-series but its attributes could "
|
||||
"not be parsed — the checker will not silently skip markup it "
|
||||
"cannot read. Use plain double-quoted attributes"
|
||||
% (name, line_of(source, match.start()))
|
||||
)
|
||||
continue
|
||||
missing = [key for key in ("data-from", "data-to", "x1", "y1", "x2", "y2")
|
||||
if key not in attrs]
|
||||
if missing:
|
||||
findings.append(
|
||||
"%s:%d: series %r declares data-series but is missing %s — a series "
|
||||
"line must state both values and both endpoints or it cannot be verified"
|
||||
% (name, line_of(source, match.start()), label, ", ".join(missing))
|
||||
)
|
||||
continue
|
||||
parsed = [number(attrs[key])
|
||||
for key in ("data-from", "data-to", "x1", "y1", "x2", "y2")]
|
||||
if any(value is None for value in parsed):
|
||||
findings.append(
|
||||
"%s:%d: series %r has a value or coordinate that is not a finite "
|
||||
"number — cannot verify its slope"
|
||||
% (name, line_of(source, match.start()), label)
|
||||
)
|
||||
continue
|
||||
series.append(Series(label, parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], match.start()))
|
||||
return series
|
||||
|
||||
|
||||
def check_transforms(source: str, findings: list, name: str) -> None:
|
||||
"""No transform may move verified geometry or a bound label."""
|
||||
spans = transformed_spans(source)
|
||||
|
||||
def enclosed(offset):
|
||||
return any(start <= offset <= end for start, end in spans)
|
||||
|
||||
def report(offset, what, how):
|
||||
findings.append(
|
||||
"%s:%d: %s carries %s — this checker validates raw 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, line_of(source, offset), what, how)
|
||||
)
|
||||
|
||||
for match in LINE_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
if "data-series" not in attrs:
|
||||
continue
|
||||
what = "series %r" % attrs["data-series"]
|
||||
if "transform" in attrs:
|
||||
report(match.start(), what, "transform=%r" % attrs["transform"])
|
||||
elif enclosed(match.start()):
|
||||
report(match.start(), what, "an ancestor <g>/<svg> transform")
|
||||
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
if "data-series" not in attrs and "data-axis" not in attrs:
|
||||
continue
|
||||
what = "a bound label (%s)" % plain(match.group("body"))[:20]
|
||||
if "transform" in attrs:
|
||||
report(match.start(), what, "transform=%r" % attrs["transform"])
|
||||
elif enclosed(match.start()):
|
||||
report(match.start(), what, "an ancestor <g>/<svg> transform")
|
||||
|
||||
for match in STYLE_RE.finditer(source):
|
||||
found = CSS_TRANSFORM_RE.search(match.group("body"))
|
||||
if found:
|
||||
findings.append(
|
||||
"%s:%d: a CSS `transform` 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, line_of(source, match.start("body") + found.start()))
|
||||
)
|
||||
|
||||
|
||||
def check_axes(series: list, findings: list, source: str, name: str) -> None:
|
||||
"""Every series must span the same two axis positions."""
|
||||
for attr, end in (("x1", "from"), ("x2", "to")):
|
||||
positions = sorted({round(s.axis_x(end), 3) for s in series})
|
||||
if len(positions) > 1:
|
||||
offenders = ", ".join("%s at %g" % (s.name, s.axis_x(end)) for s in series)
|
||||
findings.append(
|
||||
"%s:%d: series do not share one %s — found %s (%s). Every line must "
|
||||
"run between the same two axes or the slopes are not comparable"
|
||||
% (name, line_of(source, series[0].offset), attr,
|
||||
"/".join("%g" % p for p in positions), offenders)
|
||||
)
|
||||
|
||||
|
||||
def check_scale(series: list, findings: list, source: str, name: str) -> None:
|
||||
"""The two axes must share one linear value-to-y map, and no point may drift."""
|
||||
left = [(s.frm, s.y1) for s in series]
|
||||
right = [(s.to, s.y2) for s in series]
|
||||
combined = left + right
|
||||
values = [v for v, _ in combined]
|
||||
span = max(values) - min(values)
|
||||
mid = (max(values) + min(values)) / 2.0
|
||||
line = line_of(source, series[0].offset)
|
||||
|
||||
left_fit = fit(left)
|
||||
right_fit = fit(right)
|
||||
|
||||
if left_fit[0] is not None and right_fit[0] is not None:
|
||||
slope_drift = abs(left_fit[0] - right_fit[0]) * span
|
||||
if slope_drift > SCALE_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the two axes are not on the same scale — %.4g px/unit on the "
|
||||
"left against %.4g on the right, which puts the same value up to "
|
||||
"%.1f px apart across the plotted range. Both axes must use one scale "
|
||||
"or every slope is a lie"
|
||||
% (name, line, left_fit[0], right_fit[0], slope_drift)
|
||||
)
|
||||
return
|
||||
# Compared at mid-domain, not at value zero. Raw intercepts are the
|
||||
# predicted y AT zero, so on values in the billions two axes sharing one
|
||||
# transform disagreed by hundreds of pixels there through nothing but
|
||||
# float cancellation, and an honest figure was reported as shifted.
|
||||
origin_drift = abs((left_fit[0] * mid + left_fit[1])
|
||||
- (right_fit[0] * mid + right_fit[1]))
|
||||
if origin_drift > ORIGIN_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the two axes share a scale but not an origin — offset by "
|
||||
"%.1f px at mid-domain. A shifted axis tilts every slope by the same "
|
||||
"amount, so the comparison between series survives and every rate is "
|
||||
"wrong" % (name, line, origin_drift)
|
||||
)
|
||||
return
|
||||
# Both returns above are deliberate. Per-point residuals are measured
|
||||
# against "the" shared scale, so once the two axes disagree about what
|
||||
# that scale is there is nothing meaningful to measure against - every
|
||||
# point reports an error and the one finding that names the cause is
|
||||
# buried in its own consequences.
|
||||
elif left_fit[0] is None and right_fit[0] is None:
|
||||
findings.append(
|
||||
"%s:%d: neither axis has two distinct values, so the scale cannot be "
|
||||
"derived and no slope here is verifiable" % (name, line)
|
||||
)
|
||||
return
|
||||
|
||||
ends = ["from"] * len(left) + ["to"] * len(right)
|
||||
for index, drawn, expected in outliers(combined, RESIDUAL_TOLERANCE):
|
||||
s = series[index % len(series)]
|
||||
value = combined[index][0]
|
||||
findings.append(
|
||||
"%s:%d: series %r draws its %s endpoint (%g) at y=%g where the shared "
|
||||
"scale its peers describe puts %g at y=%.1f — off by %.1f px. Do not move "
|
||||
"a point to make room for its label"
|
||||
% (name, line_of(source, s.offset), s.name, ends[index], value, drawn,
|
||||
value, expected, abs(drawn - expected))
|
||||
)
|
||||
|
||||
|
||||
def misplaced_row(series: list, owner: str, end: str, y: float):
|
||||
"""A series whose endpoint is strictly nearer this label than its own owner's.
|
||||
|
||||
Nearer, not nearest. Two series holding the same value at an end sit at the
|
||||
same y by necessity - that is the honest rendering of equal values, and it is
|
||||
also what an indexed slopegraph does at every from-endpoint - so a tie must
|
||||
pass. Only a label that has drifted onto a row genuinely closer to someone
|
||||
else has been mislabelled.
|
||||
"""
|
||||
own = min(abs(y - s.endpoint_y(end)) for s in series if s.name == owner)
|
||||
for other in series:
|
||||
if other.name != owner and abs(y - other.endpoint_y(end)) < own - ROW_TOLERANCE:
|
||||
return other.name
|
||||
return None
|
||||
|
||||
|
||||
def collect_bound_labels(source: str, findings: list, name: str) -> dict:
|
||||
"""Bound labels keyed by (series, end, role), with duplicates reported."""
|
||||
found = {}
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
label, end = attrs.get("data-series"), attrs.get("data-end")
|
||||
if label is None or end is None:
|
||||
continue
|
||||
role = attrs.get("data-role", "value")
|
||||
key = (label, end, role)
|
||||
if key in found:
|
||||
# A second label for one endpoint used to overwrite the first, so a
|
||||
# figure could print two contradictory numbers for one point and be
|
||||
# judged on whichever came last.
|
||||
findings.append(
|
||||
"%s:%d: a second %s %s label for series %r — one endpoint, one "
|
||||
"label, or the figure states two things about one point"
|
||||
% (name, line_of(source, match.start()), end, role, label)
|
||||
)
|
||||
continue
|
||||
found[key] = (plain(match.group("body")), number(attrs.get("y")),
|
||||
match.start())
|
||||
return found
|
||||
|
||||
|
||||
def check_labels(series: list, source: str, findings: list, name: str) -> None:
|
||||
"""Printed values and names must exist, be unique, match, and sit on their row."""
|
||||
bound = collect_bound_labels(source, findings, name)
|
||||
declared = {s.name for s in series}
|
||||
|
||||
for (label, end, role), (body, y, offset) in sorted(
|
||||
bound.items(), key=lambda item: item[1][2]
|
||||
):
|
||||
if label not in declared:
|
||||
findings.append(
|
||||
"%s:%d: a %s label names series %r, which no line declares — a label "
|
||||
"with no mark is not verifiable and reads as data"
|
||||
% (name, line_of(source, offset), role, label)
|
||||
)
|
||||
continue
|
||||
if end not in ("from", "to"):
|
||||
findings.append(
|
||||
"%s:%d: label for series %r has data-end=%r, which is neither "
|
||||
"'from' nor 'to'" % (name, line_of(source, offset), label, end)
|
||||
)
|
||||
continue
|
||||
# Row placement. A label bound to one series but drawn beside another's
|
||||
# endpoint mislabels the row, and no value check would notice: two names
|
||||
# exchanged between rows leaves every number correct in isolation.
|
||||
if y is not None:
|
||||
sits_by = misplaced_row(series, label, end, y)
|
||||
if sits_by is not None:
|
||||
findings.append(
|
||||
"%s:%d: the %s %s label for series %r is drawn at y=%g, nearer "
|
||||
"%r's endpoint than its own — a label on the wrong row renames "
|
||||
"the line"
|
||||
% (name, line_of(source, offset), end, role, label, y, sits_by)
|
||||
)
|
||||
|
||||
for s in series:
|
||||
for end in ("from", "to"):
|
||||
value_entry = bound.get((s.name, end, "value"))
|
||||
if value_entry is None:
|
||||
findings.append(
|
||||
"%s:%d: series %r prints no %s endpoint value — a slopegraph must "
|
||||
"label both ends, or the reader has a slope and no magnitude"
|
||||
% (name, line_of(source, s.offset), s.name, end)
|
||||
)
|
||||
else:
|
||||
body, _y, offset = value_entry
|
||||
shown, reason = printed_number(body)
|
||||
if shown is None:
|
||||
findings.append(
|
||||
"%s:%d: the %s endpoint label for %r %s (%r) — label both "
|
||||
"endpoints with one complete value each"
|
||||
% (name, line_of(source, offset), end, s.name, reason,
|
||||
body[:28])
|
||||
)
|
||||
elif abs(shown - s.value(end)) > VALUE_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: series %r prints %r at its %s endpoint but declares "
|
||||
"%g — the label and the geometry must state one number"
|
||||
% (name, line_of(source, offset), s.name, body, end,
|
||||
s.value(end))
|
||||
)
|
||||
name_entry = bound.get((s.name, end, "name"))
|
||||
if name_entry is None:
|
||||
findings.append(
|
||||
"%s:%d: series %r has no %s name label (data-role=\"name\") — a "
|
||||
"slopegraph names every series at both ends, and an unbound name "
|
||||
"can be swapped with another row undetected"
|
||||
% (name, line_of(source, s.offset), s.name, end)
|
||||
)
|
||||
continue
|
||||
body, _y, offset = name_entry
|
||||
if body != s.name:
|
||||
findings.append(
|
||||
"%s:%d: a name label bound to series %r reads %r — the visible "
|
||||
"name and the binding must agree"
|
||||
% (name, line_of(source, offset), s.name, body[:28])
|
||||
)
|
||||
|
||||
|
||||
def check_captions(series: list, source: str, findings: list, name: str) -> None:
|
||||
"""Each state caption must name the axis it is drawn against."""
|
||||
seen = {}
|
||||
for match in TEXT_RE.finditer(source):
|
||||
attrs = attrs_of(match.group("attrs"))
|
||||
end = attrs.get("data-axis")
|
||||
if end is None:
|
||||
continue
|
||||
if end not in ("from", "to"):
|
||||
findings.append(
|
||||
"%s:%d: a state caption has data-axis=%r, which is neither 'from' "
|
||||
"nor 'to'" % (name, line_of(source, match.start()), end)
|
||||
)
|
||||
continue
|
||||
if end in seen:
|
||||
findings.append(
|
||||
"%s:%d: a second %r state caption — one axis, one caption"
|
||||
% (name, line_of(source, match.start()), end)
|
||||
)
|
||||
continue
|
||||
seen[end] = (number(attrs.get("x")), plain(match.group("body")),
|
||||
attrs.get("data-state"), match.start())
|
||||
|
||||
for end in ("from", "to"):
|
||||
if end not in seen:
|
||||
findings.append(
|
||||
"%s: no %r state caption (data-axis=%r) — an unbound caption pair can "
|
||||
"be swapped, which reverses the reading of the whole figure"
|
||||
% (name, end, end)
|
||||
)
|
||||
continue
|
||||
x, body, declared, offset = seen[end]
|
||||
expected = series[0].axis_x(end)
|
||||
if x is None or abs(x - expected) > CAPTION_TOLERANCE:
|
||||
findings.append(
|
||||
"%s:%d: the %r state caption (%r) is drawn at x=%s but its axis is at "
|
||||
"x=%g — the captions are swapped or misplaced, which reverses the "
|
||||
"direction every slope is read in"
|
||||
% (name, line_of(source, offset), end, body[:20],
|
||||
"%g" % x if x is not None else "?", expected)
|
||||
)
|
||||
# Position alone is not enough: swapping just the two visible strings
|
||||
# leaves both captions where they were and reverses the figure anyway.
|
||||
# data-state binds the text, exactly as data-series binds a series name.
|
||||
if declared is None:
|
||||
findings.append(
|
||||
"%s:%d: the %r state caption (%r) has no data-state — its visible "
|
||||
"text is unbound, so it can be exchanged with the other caption and "
|
||||
"nothing here would notice"
|
||||
% (name, line_of(source, offset), end, body[:20])
|
||||
)
|
||||
elif body != declared:
|
||||
findings.append(
|
||||
"%s:%d: the %r state caption reads %r but declares data-state=%r — the "
|
||||
"visible caption and its binding must agree"
|
||||
% (name, line_of(source, offset), end, body[:20], declared)
|
||||
)
|
||||
|
||||
if len(seen) == 2:
|
||||
first, second = seen["from"], seen["to"]
|
||||
if first[1] and first[1] == second[1]:
|
||||
findings.append(
|
||||
"%s:%d: both state captions read %r — two states the reader cannot "
|
||||
"tell apart is not a comparison"
|
||||
% (name, line_of(source, second[3]), first[1])
|
||||
)
|
||||
|
||||
|
||||
def check_source(path: Path, raw: str) -> list:
|
||||
"""Findings for one already-read document."""
|
||||
source = blank_comments(raw)
|
||||
findings: list = []
|
||||
series = parse_series(source, findings, path.name)
|
||||
|
||||
if len(series) < 2:
|
||||
findings.append(
|
||||
"%s: presents as a slopegraph but declares %d verifiable series — every "
|
||||
"line needs data-series with data-from and data-to. Refusing to report "
|
||||
"OK on a file this checker could not read" % (path.name, len(series))
|
||||
)
|
||||
return findings
|
||||
|
||||
check_transforms(source, findings, path.name)
|
||||
check_axes(series, findings, source, path.name)
|
||||
check_scale(series, findings, source, path.name)
|
||||
check_labels(series, source, findings, path.name)
|
||||
check_captions(series, source, findings, path.name)
|
||||
return findings
|
||||
|
||||
|
||||
def check(path: Path) -> list:
|
||||
"""Findings for one file on disk, or [] if it is not a slopegraph."""
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
if not looks_like_slopegraph(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 slopegraph slopes against the values they are labelled with."
|
||||
)
|
||||
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 slopegraph",
|
||||
)
|
||||
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 bar chart that was
|
||||
# skipped is a claim the run never made good on.
|
||||
if not looks_like_slopegraph(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 slopegraph finding(s) across %d file(s).%s"
|
||||
% (len(findings), checked, tail))
|
||||
return 1
|
||||
if not checked:
|
||||
print("OK slopegraph: no slopegraph found to check%s" % tail)
|
||||
return 0
|
||||
print("OK slopegraph: %d file(s), one shared scale on both axes, no transforms on "
|
||||
"verified geometry, and every printed value, name and caption bound to what "
|
||||
"it describes%s" % (checked, tail))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user