From e92592de4b6ccb7e11d57b8dfa0e4e5d1e43c7e0 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 21:42:19 -0700 Subject: [PATCH 01/11] Fix the audit's P0s: facet by= length, object-numeric columns, colormap stop order, category-axis type_, tick labels past 1e6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five silent-wrong-output defects found by the 0.0.7 audit, each reproduced before the fix and covered by a test after it. - facet_chart: a by= array whose length differs from the data's row count passed the DataFrame through unsplit, so every panel drew the whole dataset under its own label. It is now a ValueError naming both counts, for chart-level and mark-level data= tables alike. Mapping data keeps its documented pass-through for scalar and short config values. - Category detection: an object-dtype column whose non-missing values are all real numbers (a list holding a None, an object ndarray, Decimals, a pandas object Series with NaN) became a categorical axis with labels '1', '(missing)', '3' at positions 0, 1, 2, while the color channel classified the same input as continuous. `Figure._is_category_like` now applies the color channel's rule (`channels._object_array_is_real_numeric`); missing entries ingest as NaN through a shared `columns.object_missing_to_nan` (pandas NA included, which NumPy alone refuses). Strings, bytes, bools and mixed values stay categorical. - Colormap stops: out-of-order positioned stops were clamped CSS-style, so [(1, "red"), (0, "blue")] resolved to 255 red texels and one blue. A decreasing position is now a ValueError that points at the offending stop. - Axes: `x_axis(type_="time"|"log"|"symlog")` on an axis the marks made categorical was accepted; time turned the labels into 1970 epoch ticks and log put category 0 off the axis. `_axis_kind` raises a build-time error (G3: scale conflicts are errors, not coercions). - Tick labels: `_fmt_linear` / `fmtLinear` formatted every |v| >= 1e6 (or < 1e-4) tick with one mantissa decimal regardless of step, so a 50,000-step axis read "1.0e6, 1.1e6, 1.1e6, 1.2e6, 1.2e6, 1.3e6, 1.3e6" and 1,250,000 was labelled "1.2e6" — in the browser, SVG, PNG and PDF alike. The mantissa now carries the digits between the value's magnitude and the step's last significant digit, implemented identically in Python and TypeScript with a node-backed parity test. Spec: chart-grammar.md G3 (category/type conflict, value-based category detection, by= row coverage), styling.md (non-decreasing stops), renderer-architecture.md §6.2 (exponential label precision). --- js/src/30_ticks.ts | 19 +++++- python/xy/_figure.py | 21 +++++- python/xy/_svg.py | 23 ++++++- python/xy/_validate.py | 14 +++- python/xy/columns.py | 19 ++++++ python/xy/facets.py | 19 ++++-- spec/api/styling.md | 6 +- spec/design/chart-grammar.md | 14 +++- spec/design/renderer-architecture.md | 9 ++- tests/test_axis_type_conflicts.py | 47 +++++++++++++ tests/test_custom_ramps_and_palette.py | 15 +++++ tests/test_facets.py | 17 +++++ tests/test_object_numeric_columns.py | 81 +++++++++++++++++++++++ tests/test_tick_label_precision.py | 92 ++++++++++++++++++++++++++ 14 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 tests/test_axis_type_conflicts.py create mode 100644 tests/test_object_numeric_columns.py create mode 100644 tests/test_tick_label_precision.py diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index b1809b8e..59f5266c 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -175,9 +175,26 @@ function fmtTime(ms, step) { return `${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${pad(d.getUTCMilliseconds(), 3)}`; } +// Mantissa digits so exponential tick labels stay distinct at `step`: the +// digits between the value's magnitude and the step's last significant digit +// (1.25e6 at step 2.5e5 -> (6 - 5) + 1 = 2 -> "1.25e6"). One fixed decimal +// labelled a 50,000-step axis "1.0e6, 1.1e6, 1.1e6, 1.2e6, …". Mirrors +// `_exp_digits` in python/xy/_svg.py exactly. +function expDigits(av, step) { + if (!step || !Number.isFinite(step) || av === 0) return 1; + step = Math.abs(step); + const eStep = Math.floor(Math.log10(step)); + const mantissa = step / 10 ** eStep; + let k = 0; + while (k < 8 && Math.abs(Number(mantissa.toFixed(k)) - mantissa) > mantissa / 1000) k++; + return Math.max(1, Math.min(8, Math.floor(Math.log10(av)) - eStep + k)); +} + export function fmtLinear(v, step) { const av = Math.abs(v); - if (av >= 1e6 || (av !== 0 && av < 1e-4)) return v.toExponential(1).replace("e+", "e"); + if (av >= 1e6 || (av !== 0 && av < 1e-4)) { + return v.toExponential(expDigits(av, step)).replace("e+", "e"); + } let dec = step ? Math.max(0, Math.ceil(-Math.log10(Math.abs(step)))) : 0; while (dec < 8 && Math.abs(Number(step.toFixed(dec)) - step) > Math.abs(step) / 1000) dec++; return v.toFixed(Math.min(dec, 8)); diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 3f94bb5e..38ee624a 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1161,6 +1161,8 @@ def _real_float_array(arr: np.ndarray, label: str) -> np.ndarray: raise ValueError(f"{label} must be real numeric, not boolean") if np.issubdtype(arr.dtype, np.complexfloating): raise ValueError(f"{label} must be real numeric") + if arr.dtype == object: + arr = columns.object_missing_to_nan(arr) try: return arr.astype(np.float64, copy=False) except (TypeError, ValueError) as e: @@ -1230,7 +1232,14 @@ def _is_category_like(values: Any) -> bool: # O(n) copy of the column. values = values[:0].to_numpy(zero_copy_only=False) arr = np.asarray(values) - return arr.dtype.kind in ("U", "S", "O", "b") + if arr.dtype.kind == "O": + # An object column whose non-missing values are all real numbers + # (a list holding a None, a CSV column read as object, Decimals) + # is numeric data with holes, not a set of categories — the same + # rule the color channel applies (`channels._object_array_is_real_ + # numeric`). Strings, bytes, bools and mixed values stay categorical. + return not channels._object_array_is_real_numeric(arr.reshape(-1)) + return arr.dtype.kind in ("U", "S", "b") @staticmethod def _category_axis_labels(values: Any, axis: str) -> list[str]: @@ -1650,6 +1659,16 @@ def _axis_coord(self, axis_id: str, values: Any) -> np.ndarray: def _axis_kind(self, axis_id: str) -> str: axis = self._axis_dim(axis_id) forced = self.axis_options.get(axis_id, {}).get("type") + categories = self._axis_categories.get(axis_id) + if categories and forced in ("time", "log", "symlog"): + # A category axis is linear by construction (positions are the + # label indices). Forcing time turned the labels into 1970 epoch + # ticks; forcing log put category 0 off the axis. G3: a scale + # conflict is a build-time error, not a coercion. + raise ValueError( + f"{axis_id} axis is categorical ({len(categories)} categories from the " + f"marks) and cannot be a {forced} axis; drop type_= or pass numeric positions" + ) if forced == "time": return "time" if axis_id in self._axis_categories: diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 7d4301ae..df2f01db 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -672,10 +672,31 @@ def _fmt_time(ms: float, step: float) -> str: return f"{d.minute:02d}:{d.second:02d}.{d.microsecond // 1000:03d}" +def _exp_digits(av: float, step: float) -> int: + """Mantissa digits so exponential tick labels stay distinct at `step`. + + One fixed decimal labelled 1,000,000 … 1,300,000 at a 50,000 step as + "1.0e6, 1.1e6, 1.1e6, 1.2e6, 1.2e6, 1.3e6, 1.3e6". The mantissa needs the + digits between the value's magnitude and the step's last significant + digit: for 1.25e6 at step 2.5e5 that is (6 - 5) + 1 = 2 -> "1.25e6". + Mirrors `fmtLinear` in js/src/30_ticks.ts exactly. + """ + if not step or not np.isfinite(step) or av == 0: + return 1 + step = abs(step) + e_step = int(np.floor(np.log10(step))) + mantissa = step / 10.0**e_step + k = 0 + while k < 8 and abs(round(mantissa, k) - mantissa) > mantissa / 1000.0: + k += 1 + return max(1, min(8, int(np.floor(np.log10(av))) - e_step + k)) + + def _fmt_linear(v: float, step: float) -> str: av = abs(v) if av >= 1e6 or (av != 0 and av < 1e-4): - return f"{v:.1e}".replace("e+0", "e").replace("e-0", "e-").replace("e+", "e") + digits = _exp_digits(av, step) + return f"{v:.{digits}e}".replace("e+0", "e").replace("e-0", "e-").replace("e+", "e") dec = max(0, int(np.ceil(-np.log10(abs(step))))) if step else 0 # A non-nice step (pi/2, 0.3333…) needs enough decimals to keep adjacent # ticks distinct; widen until the step itself round-trips at that precision. diff --git a/python/xy/_validate.py b/python/xy/_validate.py index 93ce3f0c..5a7d9fbd 100644 --- a/python/xy/_validate.py +++ b/python/xy/_validate.py @@ -765,9 +765,19 @@ def colormap_stops(value: Any, label: str) -> list[list[int]]: anchors.setdefault(0, 0.0) anchors.setdefault(count - 1, 1.0) keys = sorted(anchors) - previous = 0.0 + previous = anchors[keys[0]] for i in keys: - previous = anchors[i] = max(anchors[i], previous) + # CSS clamps an out-of-order gradient stop to its predecessor; for + # a colormap that quietly turned `[(1, red), (0, blue)]` into 255 + # red texels and one blue one. A value→color map has no spatial + # reading to fall back on, so a decreasing position is an error. + if anchors[i] < previous: + raise ValueError( + f"{label} stop positions must be non-decreasing: {label}[{i}] at " + f"{anchors[i]:g} follows a stop at {previous:g}; reverse the stop order " + "instead" + ) + previous = anchors[i] positions = [0.0] * count for i0, i1 in itertools.pairwise(keys): v0, v1 = anchors[i0], anchors[i1] diff --git a/python/xy/columns.py b/python/xy/columns.py index 7d37a745..3ead8afc 100644 --- a/python/xy/columns.py +++ b/python/xy/columns.py @@ -582,6 +582,8 @@ def _canonicalize(data: Any) -> tuple[npt.NDArray[np.float64], str, int]: raise ValueError("columns must be real numeric or datetime-like") if arr.dtype == object and any(isinstance(value, (bool, np.bool_)) for value in arr): raise ValueError("columns must be real numeric or datetime-like, not boolean") + if arr.dtype == object: + arr = object_missing_to_nan(arr) try: arr, copies = _astype_counted(arr, np.float64, copies) except (TypeError, ValueError) as e: @@ -625,6 +627,23 @@ def _is_datetime_object_array(arr: npt.NDArray[Any]) -> bool: return False +def object_missing_to_nan(arr: npt.NDArray[Any]) -> npt.NDArray[Any]: + """Object array with None / pandas NA / NaT / NaN entries replaced by NaN. + + NumPy turns `None` into NaN on `astype(float)` but refuses `pd.NA`; a + numeric object column with either kind of hole must ingest as numeric with + NaN (§19: nulls are NaN, never a category). + """ + if arr.dtype != object: + return arr + missing = np.fromiter((_is_object_missing(v) for v in arr.flat), dtype=bool, count=arr.size) + if not missing.any(): + return arr + out = arr.astype(object, copy=True) + out.reshape(-1)[missing] = np.nan + return out + + def _is_object_missing(value: Any) -> bool: if value is None: return True diff --git a/python/xy/facets.py b/python/xy/facets.py index 69d2c5a6..9317ca10 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -20,17 +20,28 @@ from ._png import png_truecolor from ._raster import render_raster +_FACET_ROWS_MISMATCH = ( + "facet_chart by= has {n} values but {what} has {rows} rows; " + "by= must name a column of the data or supply one value per row" +) + def _subset_data(data: Any, mask: np.ndarray, n: int) -> Any: """Row-subset a table for one facet panel. Only 1-D columns of exactly `n` rows are masked; scalars and short config - values pass through untouched. Multi-dimensional columns whose first axis - happens to equal `n` are ambiguous (row-masking would corrupt e.g. a - heatmap z matrix), so they raise instead of silently guessing. + values in a mapping pass through untouched. A DataFrame is rows and nothing + else, so one whose row count differs from the `n` facet values cannot be + split by them at all — passing it through unsplit handed every panel the + whole dataset under its own label, so it raises. Multi-dimensional columns + whose first axis happens to equal `n` are ambiguous (row-masking would + corrupt e.g. a heatmap z matrix), so they raise instead of silently + guessing. """ if hasattr(data, "iloc"): - return data.iloc[mask] if len(data) == n else data + if len(data) != n: + raise ValueError(_FACET_ROWS_MISMATCH.format(n=n, rows=len(data), what="data")) + return data.iloc[mask] if isinstance(data, Mapping): out: dict[Any, Any] = {} for key, value in data.items(): diff --git a/spec/api/styling.md b/spec/api/styling.md index 2bbf64c8..6fdae2d7 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -490,7 +490,11 @@ from the public API and exists for hand-authored specs. The gradient form shares `mark_fill`'s CSS stop-position grammar and therefore its 2–8 stop bound; the sequence forms take up to 256. A direction keyword (`to top`) is refused rather than ignored — a colormap maps values to colors and -has no spatial axis, so reverse the stop order instead. +has no spatial axis, so reverse the stop order instead. Positioned stops in the +sequence form must be non-decreasing: CSS clamps a stop placed before its +predecessor, which for a colormap silently turned `[(1, "red"), (0, "blue")]` +into 255 red texels and one blue, so a decreasing position is a `ValueError` +(reverse the stop order). Every form normalizes to **evenly spaced 8-bit RGB stops** — the shape the built-in tables already use — so the WebGL client, the SVG writer, and the diff --git a/spec/design/chart-grammar.md b/spec/design/chart-grammar.md index 03b6c08a..6632ea51 100644 --- a/spec/design/chart-grammar.md +++ b/spec/design/chart-grammar.md @@ -56,7 +56,15 @@ not break. auto-inferred from marks (time columns → time; bar categories → category) but overridable on the axis node. Mixing marks whose natural scales conflict (bar-category + scatter-linear x) is a build-time error with a - fix-it message, not a coercion. + fix-it message, not a coercion. So is forcing `type_="time"|"log"|"symlog"` + onto an axis whose marks made it categorical (`Figure._axis_kind`): category + positions are label indices, and coercing them produced 1970 epoch ticks or + put category 0 off a log axis. Category detection is by value, not by + container: an object-dtype column whose non-missing values are all real + numbers (a list holding a `None`, a CSV column read as `object`, Decimals) is + numeric with NaN holes — the rule the color channel already applied + (`channels._object_array_is_real_numeric`) — while strings, bytes, bools and + mixed values become categories. - **G4 — Chrome reads, never owns.** Legend derives entries from mark channel modes (already true); axes derive from scales; tooltips derive from the hovered mark's readout row. Adding a mark kind never edits chrome @@ -267,6 +275,10 @@ integer, and `gap` a non-negative one. `max(120, (width - (cols - 1) * gap) // cols)` pixels wide (`FacetGrid.rows`, `FacetGrid.panel_width`, `facets.py:146-154`). Each panel's chart title is its facet label. +- **`by=` must cover every row.** A `by=` array whose length differs from the + data's row count (chart-level or mark-level `data=`) is a `ValueError` + (`facets._subset_data`); it used to pass the table through unsplit, so every + panel drew the whole dataset under its own label. - **`share_x` / `share_y` are global, not per-panel.** For each shared axis id the grid takes every panel's `_range(axis_id)` and applies the merged `(min, max)` to all panels (`components.py:3657-3666`). Categorical axes diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 79336f1d..fe731d92 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -368,8 +368,13 @@ so a pathological domain cannot produce an unbounded loop or DOM label count. With no `format=` on the axis, labels come from the step: -- `fmtLinear` switches to one-decimal exponential (with `e+` normalized to `e`) - when `|v| ≥ 1e6` or `0 < |v| < 1e-4`. Otherwise it derives the decimal count +- `fmtLinear` switches to exponential (with `e+` normalized to `e`) when + `|v| ≥ 1e6` or `0 < |v| < 1e-4`. The mantissa carries as many digits as sit + between the value's magnitude and the step's last significant digit + (`expDigits`; `1.25e6` at step `2.5e5` → `(6 − 5) + 1 = 2` → `1.25e6`), at + least one and at most eight — a fixed single decimal labelled a 50,000-step + axis `1.0e6, 1.1e6, 1.1e6, 1.2e6, …`. `python/xy/_svg.py::_exp_digits` is + the same function, so the PNG and the browser agree. Otherwise it derives the decimal count from the tick step — `ceil(−log10(step))`, then increments while the step is not representable at that precision to within a thousandth of itself — and caps at 8 decimals. Ticks on one axis therefore share a decimal count. diff --git a/tests/test_axis_type_conflicts.py b/tests/test_axis_type_conflicts.py new file mode 100644 index 00000000..dca1c904 --- /dev/null +++ b/tests/test_axis_type_conflicts.py @@ -0,0 +1,47 @@ +"""Forcing a time/log/symlog type onto a categorical axis is a build error (G3). + +`xy.x_axis(type_="time")` on a bar chart with string categories used to ship +`{"kind": "time", "range": [-0.45, 1.45]}` — categories gone, ticks in 1970 — +and `type_="log"` left category 0 off the axis. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 + + +@pytest.mark.parametrize("type_", ["time", "log", "symlog"]) +def test_forced_scale_on_category_axis_is_an_error(type_: str) -> None: + chart = xy.bar_chart(xy.bar(x=["a", "b"], y=[1, 2]), xy.x_axis(type_=type_)) + with pytest.raises(ValueError, match=f"x axis is categorical .*cannot be a {type_} axis"): + chart.figure().build_payload() + + +def test_forced_scale_on_numeric_axis_and_linear_on_category_axis_still_work() -> None: + spec, _ = ( + xy.bar_chart(xy.bar(x=["a", "b"], y=[1, 2]), xy.x_axis(type_="linear")) + .figure() + .build_payload() + ) + assert spec["axes"]["x"]["kind"] == "category" and spec["axes"]["x"]["categories"] == ["a", "b"] + spec, _ = ( + xy.scatter_chart(xy.scatter(x=[1, 10, 100], y=[1, 2, 3]), xy.x_axis(type_="log")) + .figure() + .build_payload() + ) + assert spec["axes"]["x"].get("scale") == "log" + # The y axis is untouched by an x-axis conflict. + spec, _ = ( + xy.bar_chart(xy.bar(x=["a", "b"], y=[1, 10]), xy.y_axis(type_="log")) + .figure() + .build_payload() + ) + assert spec["axes"]["y"].get("scale") == "log" and spec["axes"]["x"]["kind"] == "category" diff --git a/tests/test_custom_ramps_and_palette.py b/tests/test_custom_ramps_and_palette.py index 84931dac..585e341a 100644 --- a/tests/test_custom_ramps_and_palette.py +++ b/tests/test_custom_ramps_and_palette.py @@ -609,3 +609,18 @@ def test_text_switch_does_not_collide_with_tick_labels(): axis = xy.x_axis(tick_values=(0.0, 1.0), tick_labels=("lo", "hi"), text=False) assert axis.tick_labels == ["lo", "hi"] assert axis.style["tick_label_color"] == "#00000000" + + +def test_colormap_positions_must_be_non_decreasing() -> None: + """Out-of-order stops were clamped CSS-style, so [(1, red), (0, blue)] + silently became 255 red texels and one blue.""" + from xy import channels + + with pytest.raises(ValueError, match=r"non-decreasing.*reverse the stop order"): + channels.resolve_colormap([(1.0, "red"), (0.0, "blue")]) + with pytest.raises(ValueError, match=r"colormap\[2\] at 0.2 follows a stop at 0.5"): + channels.resolve_colormap([(0.5, "red"), "white", (0.2, "blue")]) + # Ordered stops resolve as before, and equal positions (a hard stop) stay legal. + lut = channels.resolve_colormap([(0.0, "blue"), (1.0, "red")]) + assert lut[0] == [0, 0, 255] and lut[-1] == [255, 0, 0] + channels.resolve_colormap([(0.0, "blue"), (0.5, "red"), (0.5, "white"), (1.0, "black")]) diff --git a/tests/test_facets.py b/tests/test_facets.py index 14964575..f969df79 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -380,3 +380,20 @@ def test_stairs_tooltip_channels_are_not_mislabeled() -> None: assert tooltip["aliases"] == {"v": "y", "e": "x"} assert tooltip["sources"]["v"] == [{"trace": 0, "channel": "y"}] assert tooltip["sources"]["e"] == [{"trace": 0, "channel": "x"}] + + +def test_facet_by_array_must_match_row_count() -> None: + """A by= array of the wrong length used to hand every panel the full + table (two panels, each drawing all three rows) instead of erroring.""" + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [10.0, 20.0, 30.0]}) + with pytest.raises(ValueError, match="by= has 2 values but data has 3 rows"): + xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b"], data=df).figure() + with pytest.raises(ValueError, match="by= has 4 values but data has 3 rows"): + xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b", "c", "d"], data=df).figure() + # Mark-level data= tables are split by the same by= and must match too. + with pytest.raises(ValueError, match="by= has 2 values but data has 3 rows"): + xy.facet_chart(xy.scatter(x="x", y="y", data=df), by=["a", "b"]).figure() + # The right length still works and actually subsets. + grid = xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b", "a"], data=df).figure() + assert [t.n_points for fig in grid.figures for t in fig.traces] == [2, 1] diff --git a/tests/test_object_numeric_columns.py b/tests/test_object_numeric_columns.py new file mode 100644 index 00000000..462ac2af --- /dev/null +++ b/tests/test_object_numeric_columns.py @@ -0,0 +1,81 @@ +"""Object-dtype columns holding real numbers are numeric, not categorical. + +A list with a `None`, an object ndarray, a CSV column read as `object`, or a +column of Decimals used to turn into a category axis with labels +'1', '(missing)', '3' at positions 0, 1, 2 — silently — while the color +channel classified the very same input as continuous. Both now apply +`channels._object_array_is_real_numeric`; missing entries become NaN (§19). +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +import xy # noqa: E402 +from xy._figure import Figure # noqa: E402 + + +def _x_axis(chart): + spec, _ = chart.figure().build_payload() + return spec["axes"]["x"] + + +def test_object_numeric_column_is_a_numeric_axis() -> None: + for values in ( + [1, None, 3], + np.array([1, 2, 3], dtype=object), + [Decimal("1.5"), Decimal("2"), Decimal("3")], + [1.0, float("nan"), 3.0], + ): + axis = _x_axis(xy.scatter_chart(xy.scatter(x=values, y=[1, 2, 3]))) + assert axis["kind"] == "linear", (values, axis) + assert "categories" not in axis, (values, axis) + + +def test_object_numeric_missing_values_are_nan_not_categories() -> None: + pd = pytest.importorskip("pandas") + fig = Figure() + fig.scatter(np.array([1, None, 3], dtype=object), [1.0, 2.0, 3.0]) + col = fig.traces[0].x + assert col.kind == "float" + assert np.isnan(col.values[1]) and col.values[0] == 1.0 and col.values[2] == 3.0 + # pandas' NA scalar is a hole too, not a category or a TypeError. + fig2 = Figure() + fig2.scatter(np.array([1, pd.NA, 3], dtype=object), [1.0, 2.0, 3.0]) + assert np.isnan(fig2.traces[0].x.values[1]) + # A pandas object Series of numbers with a NaN hole (the common + # `df["x"].astype(object)` / mixed-source idiom) is numeric too. + series = pd.Series([1, np.nan, 3], dtype=object) + axis = _x_axis(xy.scatter_chart(xy.scatter(x=series, y=[1, 2, 3]))) + assert axis["kind"] == "linear" and "categories" not in axis + + +def test_strings_bools_and_mixed_object_columns_stay_categorical() -> None: + # Bools are excluded from "real number" on purpose, so a bool-bearing + # object column is categories too (as it was before). + for values in ( + ["1", "2", "3"], + ["a", None, "c"], + [1, "a", 3], + [b"a", b"b", b"c"], + np.array([True, 1, 2], dtype=object), + ): + axis = _x_axis(xy.bar_chart(xy.bar(x=values, y=[1, 2, 3]))) + assert axis["kind"] == "category", (values, axis) + + +def test_axis_and_color_channel_agree_on_object_numeric_input() -> None: + values = [1, None, 3] + spec, _ = ( + xy.scatter_chart(xy.scatter(x=values, y=[1, 2, 3], color=values)).figure().build_payload() + ) + assert spec["axes"]["x"]["kind"] == "linear" + assert spec["traces"][0]["color"]["mode"] == "continuous" diff --git a/tests/test_tick_label_precision.py b/tests/test_tick_label_precision.py new file mode 100644 index 00000000..8d64744a --- /dev/null +++ b/tests/test_tick_label_precision.py @@ -0,0 +1,92 @@ +"""Exponential tick labels stay distinct past 1e6, identically in Python and JS. + +`_fmt_linear` / `fmtLinear` formatted every |v| >= 1e6 (or < 1e-4) tick with +one mantissa decimal regardless of the step, so a 50,000-step axis read +"1.0e6, 1.1e6, 1.1e6, 1.2e6, 1.2e6, 1.3e6, 1.3e6" and 1,250,000 was labelled +"1.2e6". The mantissa now carries the digits between the value's magnitude and +the step's last significant digit; the two implementations are asserted equal +so a PNG never disagrees with the browser. +""" + +from __future__ import annotations + +import base64 +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +from xy._svg import _fmt_linear # noqa: E402 + +CASES: list[tuple[list[float], float]] = [ + ([1e6, 1.05e6, 1.1e6, 1.15e6, 1.2e6, 1.25e6, 1.3e6], 5e4), + ([1e6, 1e6 + 20, 1e6 + 40, 1e6 + 60, 1e6 + 80, 1e6 + 100], 20.0), + ([0.0, 5e5, 1e6, 1.5e6, 2e6], 5e5), + ([1e6, 1.25e6, 1.5e6, 1.75e6, 2e6], 2.5e5), + ([1e-5, 1.02e-5, 1.04e-5], 2e-7), + ([-1e-12, -5e-13, 0.0, 5e-13, 1e-12], 5e-13), + ([1e300, 2e300, 3e300], 1e300), + ([1e6, 2e6, 3e6], 1e6), + ([12345678.0, 12345679.0, 12345680.0], 1.0), +] + + +def test_exponential_labels_are_distinct_at_the_tick_step() -> None: + for ticks, step in CASES: + labels = [_fmt_linear(v, step) for v in ticks] + assert len(set(labels)) == len(labels), (ticks, step, labels) + assert [_fmt_linear(v, 5e4) for v in (1e6, 1.05e6, 1.3e6)] == ["1.00e6", "1.05e6", "1.30e6"] + assert _fmt_linear(1.25e6, 2.5e5) == "1.25e6" + assert _fmt_linear(1e6 + 20, 20.0) == "1.00002e6" + # Below the exponential threshold nothing changed. + assert [_fmt_linear(v, 0.25) for v in (0.0, 0.25, 0.5)] == ["0.00", "0.25", "0.50"] + assert _fmt_linear(5e-13, 5e-13) == "5.0e-13" + # Degenerate steps fall back to one decimal instead of failing. + assert _fmt_linear(2e6, 0.0) == "2.0e6" + assert _fmt_linear(2e6, float("nan")) == "2.0e6" + + +def test_python_and_client_formatters_agree() -> None: + node = shutil.which("node") + if node is None: + pytest.skip("node not available for the fmtLinear parity check") + ticks_ts = (ROOT / "js" / "src" / "30_ticks.ts").resolve().as_uri() + payload = base64.b64encode(json.dumps(CASES).encode()).decode("ascii") + script = ( + f'const m = await import("{ticks_ts}");' + f'const cases = JSON.parse(Buffer.from("{payload}", "base64").toString());' + "console.log(JSON.stringify(cases.map(([ticks, step]) => ticks.map((v) => m.fmtLinear(v, step)))));" + ) + completed = subprocess.run( + [node, "--no-warnings", "--input-type=module", "--eval", script], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + check=True, + ) + js_labels = json.loads(completed.stdout) + py_labels = [[_fmt_linear(v, step) for v in ticks] for ticks, step in CASES] + assert js_labels == py_labels + + +def test_exported_axis_labels_are_distinct_past_a_million() -> None: + import xml.etree.ElementTree as ET + + import xy + + chart = xy.line_chart( + xy.line([0.0, 1.0, 2.0], [1e6, 1e6 + 1.0, 1e6 + 2.0]), width=400, height=300 + ) + root = ET.fromstring(chart.to_svg()) + texts = [el.text for el in root.iter("{http://www.w3.org/2000/svg}text") if el.text] + y_labels = [t for t in texts if t.endswith("e6")] + assert len(y_labels) >= 3 and len(set(y_labels)) == len(y_labels), texts + assert np.all(np.diff([float(t.replace("e6", "e+6")) for t in y_labels]) != 0) From 08de14a786e46206c8de4b5fafacee8cb2daeb76 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 21:48:20 -0700 Subject: [PATCH 02/11] Add news fragment for the P0 audit fixes (#507) --- news/507.bugfix.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 news/507.bugfix.md diff --git a/news/507.bugfix.md b/news/507.bugfix.md new file mode 100644 index 00000000..02c121b8 --- /dev/null +++ b/news/507.bugfix.md @@ -0,0 +1,13 @@ +Five silent wrong-output defects are fixed. `facet_chart` now rejects a `by=` +array whose length differs from the data's row count instead of drawing the +whole dataset in every panel. An object-dtype column whose values are all real +numbers (a list holding a `None`, Decimals, a pandas object Series with NaN) +is ingested as numeric with NaN holes rather than becoming a categorical axis +with a `(missing)` label — the rule the color channel already applied. +Out-of-order positioned colormap stops raise instead of being clamped into a +near-solid ramp. `x_axis(type_="time"|"log"|"symlog")` on an axis the marks +made categorical is a build-time error instead of turning the labels into 1970 +epoch ticks. And automatic tick labels past 1e6 (or below 1e-4) keep enough +mantissa digits to stay distinct at the tick step — "1.00e6, 1.05e6, 1.10e6" +instead of "1.0e6, 1.1e6, 1.1e6" — identically in the browser and every static +export. From 7881e34da7c5c5a5b919000ccf994d335503229b Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:03:09 -0700 Subject: [PATCH 03/11] Reject a forced scale on an empty categorical axis too (review) --- python/xy/_figure.py | 4 +++- tests/test_axis_type_conflicts.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 38ee624a..3bb9a5b3 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1660,7 +1660,9 @@ def _axis_kind(self, axis_id: str) -> str: axis = self._axis_dim(axis_id) forced = self.axis_options.get(axis_id, {}).get("type") categories = self._axis_categories.get(axis_id) - if categories and forced in ("time", "log", "symlog"): + # Membership, not truthiness: an empty categorical mark registers the + # axis with no labels yet, and it is still a category axis. + if categories is not None and forced in ("time", "log", "symlog"): # A category axis is linear by construction (positions are the # label indices). Forcing time turned the labels into 1970 epoch # ticks; forcing log put category 0 off the axis. G3: a scale diff --git a/tests/test_axis_type_conflicts.py b/tests/test_axis_type_conflicts.py index dca1c904..083e08ae 100644 --- a/tests/test_axis_type_conflicts.py +++ b/tests/test_axis_type_conflicts.py @@ -10,6 +10,7 @@ import sys from pathlib import Path +import numpy as np import pytest ROOT = Path(__file__).resolve().parents[1] @@ -45,3 +46,10 @@ def test_forced_scale_on_numeric_axis_and_linear_on_category_axis_still_work() - .build_payload() ) assert spec["axes"]["y"].get("scale") == "log" and spec["axes"]["x"]["kind"] == "category" + + +def test_empty_category_axis_still_rejects_forced_scale() -> None: + # An empty object column registers the axis as categorical with no labels. + chart = xy.bar_chart(xy.bar(x=np.array([], dtype=object), y=[]), xy.x_axis(type_="time")) + with pytest.raises(ValueError, match="x axis is categorical .*cannot be a time axis"): + chart.figure().build_payload() From fda0501571e38e41e8a67f5ef520acffa0b16401 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:03:29 -0700 Subject: [PATCH 04/11] Use a raw regex in the empty-category test (ruff RUF043) --- tests/test_axis_type_conflicts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_axis_type_conflicts.py b/tests/test_axis_type_conflicts.py index 083e08ae..09573c00 100644 --- a/tests/test_axis_type_conflicts.py +++ b/tests/test_axis_type_conflicts.py @@ -51,5 +51,5 @@ def test_forced_scale_on_numeric_axis_and_linear_on_category_axis_still_work() - def test_empty_category_axis_still_rejects_forced_scale() -> None: # An empty object column registers the axis as categorical with no labels. chart = xy.bar_chart(xy.bar(x=np.array([], dtype=object), y=[]), xy.x_axis(type_="time")) - with pytest.raises(ValueError, match="x axis is categorical .*cannot be a time axis"): + with pytest.raises(ValueError, match=r"x axis is categorical .*cannot be a time axis"): chart.figure().build_payload() From f938af619d1144b931e08693d1191d3954c96997 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:03:59 -0700 Subject: [PATCH 05/11] Cover every forced scale on an empty categorical axis --- tests/test_axis_type_conflicts.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_axis_type_conflicts.py b/tests/test_axis_type_conflicts.py index 09573c00..1551ca71 100644 --- a/tests/test_axis_type_conflicts.py +++ b/tests/test_axis_type_conflicts.py @@ -48,8 +48,9 @@ def test_forced_scale_on_numeric_axis_and_linear_on_category_axis_still_work() - assert spec["axes"]["y"].get("scale") == "log" and spec["axes"]["x"]["kind"] == "category" -def test_empty_category_axis_still_rejects_forced_scale() -> None: +@pytest.mark.parametrize("type_", ["time", "log", "symlog"]) +def test_empty_category_axis_still_rejects_forced_scale(type_: str) -> None: # An empty object column registers the axis as categorical with no labels. - chart = xy.bar_chart(xy.bar(x=np.array([], dtype=object), y=[]), xy.x_axis(type_="time")) - with pytest.raises(ValueError, match=r"x axis is categorical .*cannot be a time axis"): + chart = xy.bar_chart(xy.bar(x=np.array([], dtype=object), y=[]), xy.x_axis(type_=type_)) + with pytest.raises(ValueError, match=f"x axis is categorical .*cannot be a {type_} axis"): chart.figure().build_payload() From ba368f02cec0a83d365df946283673f9dfae494d Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:05:01 -0700 Subject: [PATCH 06/11] Check the category/forced-scale conflict before the log range computation --- python/xy/_figure.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 3bb9a5b3..20dc886d 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1466,6 +1466,8 @@ def y_range(self) -> tuple[float, float]: return self._range("y") def _range(self, axis_id: str, *, use_domain: bool = True) -> tuple[float, float]: + # Before the log branch below can complain about positive values. + self._check_forced_scale(axis_id) opts = self.axis_options.get(axis_id, {}) fixed = opts.get("domain") if use_domain and fixed is not None: @@ -1656,21 +1658,24 @@ def _axis_coord(self, axis_id: str, values: Any) -> np.ndarray: return np.sign(v) * np.log1p(np.abs(v) / constant) return v - def _axis_kind(self, axis_id: str) -> str: - axis = self._axis_dim(axis_id) + def _check_forced_scale(self, axis_id: str) -> None: + """A category axis is linear by construction (positions are the label + indices): forcing time turned the labels into 1970 epoch ticks and + forcing log put category 0 off the axis. G3: a scale conflict is a + build-time error, not a coercion. Membership, not truthiness — an empty + categorical mark registers the axis with no labels yet.""" forced = self.axis_options.get(axis_id, {}).get("type") categories = self._axis_categories.get(axis_id) - # Membership, not truthiness: an empty categorical mark registers the - # axis with no labels yet, and it is still a category axis. if categories is not None and forced in ("time", "log", "symlog"): - # A category axis is linear by construction (positions are the - # label indices). Forcing time turned the labels into 1970 epoch - # ticks; forcing log put category 0 off the axis. G3: a scale - # conflict is a build-time error, not a coercion. raise ValueError( f"{axis_id} axis is categorical ({len(categories)} categories from the " f"marks) and cannot be a {forced} axis; drop type_= or pass numeric positions" ) + + def _axis_kind(self, axis_id: str) -> str: + axis = self._axis_dim(axis_id) + self._check_forced_scale(axis_id) + forced = self.axis_options.get(axis_id, {}).get("type") if forced == "time": return "time" if axis_id in self._axis_categories: From fc0fdc9c578123d954fc993b59de9df19cf3db14 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:09:05 -0700 Subject: [PATCH 07/11] Address review on the P0 fixes: mapping facet columns, copy accounting, exponential label edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - facet_chart: a mapping-backed column that a mark channel names as row data must have the by= length too (checked in _facet_check_mark_channels, where the channel->column linkage is known); short config values in the mapping keep passing through. - columns: the object->NaN hole-filling pass is counted as an ingest copy (§29 honest accounting). - Exponential tick labels: clamp the step exponent so 10**e cannot underflow to zero on subnormal steps; raise the mantissa cap from 8 to 15 so a 1e-3 step at 1e6 magnitude still labels distinctly; format the mantissa with JavaScript's half-up tie rounding on the exact binary value (Python's :e is half-even, so 1.25e6 at one digit read 1.3e6 live and 1.2e6 exported). Parity test cases added for all three. --- js/src/30_ticks.ts | 10 ++++++-- python/xy/_svg.py | 36 ++++++++++++++++++++++++---- python/xy/columns.py | 5 +++- python/xy/components.py | 32 +++++++++++++++++++++++-- spec/design/renderer-architecture.md | 9 ++++--- tests/test_facets.py | 8 +++++++ tests/test_object_numeric_columns.py | 5 ++++ tests/test_tick_label_precision.py | 14 +++++++++++ 8 files changed, 106 insertions(+), 13 deletions(-) diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index 59f5266c..4dfea1aa 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -180,14 +180,20 @@ function fmtTime(ms, step) { // (1.25e6 at step 2.5e5 -> (6 - 5) + 1 = 2 -> "1.25e6"). One fixed decimal // labelled a 50,000-step axis "1.0e6, 1.1e6, 1.1e6, 1.2e6, …". Mirrors // `_exp_digits` in python/xy/_svg.py exactly. +// Cap: enough for a 1e-3 step on a 1e6-magnitude axis (9), short of f64's +// ~15.9 significant digits where more would only print representation noise. +const EXP_DIGITS_MAX = 15; + function expDigits(av, step) { if (!step || !Number.isFinite(step) || av === 0) return 1; step = Math.abs(step); - const eStep = Math.floor(Math.log10(step)); + // Clamped so 10 ** eStep cannot underflow to 0 (a step below 1e-300 needs + // no more label digits than the cap allows anyway). + const eStep = Math.max(Math.floor(Math.log10(step)), -300); const mantissa = step / 10 ** eStep; let k = 0; while (k < 8 && Math.abs(Number(mantissa.toFixed(k)) - mantissa) > mantissa / 1000) k++; - return Math.max(1, Math.min(8, Math.floor(Math.log10(av)) - eStep + k)); + return Math.max(1, Math.min(EXP_DIGITS_MAX, Math.floor(Math.log10(av)) - eStep + k)); } export function fmtLinear(v, step) { diff --git a/python/xy/_svg.py b/python/xy/_svg.py index df2f01db..611a920b 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -23,6 +23,7 @@ import re from collections.abc import Callable, Sequence from datetime import UTC, datetime +from decimal import ROUND_HALF_UP, Decimal from itertools import pairwise from os import PathLike from typing import Any, NamedTuple, Optional, cast @@ -672,6 +673,12 @@ def _fmt_time(ms: float, step: float) -> str: return f"{d.minute:02d}:{d.second:02d}.{d.microsecond // 1000:03d}" +# Mantissa digits an exponential label may carry: enough for a 1e-3 step on +# a 1e6-magnitude axis (9), short of f64's ~15.9 significant digits where +# further digits would only print representation noise. +_EXP_DIGITS_MAX = 15 + + def _exp_digits(av: float, step: float) -> int: """Mantissa digits so exponential tick labels stay distinct at `step`. @@ -679,24 +686,43 @@ def _exp_digits(av: float, step: float) -> int: "1.0e6, 1.1e6, 1.1e6, 1.2e6, 1.2e6, 1.3e6, 1.3e6". The mantissa needs the digits between the value's magnitude and the step's last significant digit: for 1.25e6 at step 2.5e5 that is (6 - 5) + 1 = 2 -> "1.25e6". - Mirrors `fmtLinear` in js/src/30_ticks.ts exactly. + Mirrors `expDigits` in js/src/30_ticks.ts exactly. """ if not step or not np.isfinite(step) or av == 0: return 1 step = abs(step) - e_step = int(np.floor(np.log10(step))) + # Clamped so 10**e_step cannot underflow to 0 (a step below 1e-300 needs + # no more label digits than the cap allows anyway). + e_step = max(int(np.floor(np.log10(step))), -300) mantissa = step / 10.0**e_step k = 0 while k < 8 and abs(round(mantissa, k) - mantissa) > mantissa / 1000.0: k += 1 - return max(1, min(8, int(np.floor(np.log10(av))) - e_step + k)) + return max(1, min(_EXP_DIGITS_MAX, int(np.floor(np.log10(av))) - e_step + k)) + + +def _fmt_exponential(v: float, digits: int) -> str: + """`v` as `d.ddde±N` with JavaScript's `toExponential` rounding. + + Exact ties round half-up on the magnitude ("pick the larger n" in the + ECMAScript spec); Python's own `:e` rounds them half-even, so 1.25e6 at + one digit would read "1.3e6" in the browser and "1.2e6" in the PNG. + `Decimal(float)` is the exact binary value, so both sides see the same tie. + """ + exact = Decimal(abs(v)) + exponent = exact.adjusted() + quantum = Decimal(1).scaleb(-digits) + mantissa = exact.scaleb(-exponent).quantize(quantum, rounding=ROUND_HALF_UP) + if mantissa >= 10: # 9.99 -> 10.0 carries into the exponent + mantissa = mantissa.scaleb(-1).quantize(quantum, rounding=ROUND_HALF_UP) + exponent += 1 + return f"{'-' if v < 0 else ''}{mantissa}e{exponent}" def _fmt_linear(v: float, step: float) -> str: av = abs(v) if av >= 1e6 or (av != 0 and av < 1e-4): - digits = _exp_digits(av, step) - return f"{v:.{digits}e}".replace("e+0", "e").replace("e-0", "e-").replace("e+", "e") + return _fmt_exponential(v, _exp_digits(av, step)) dec = max(0, int(np.ceil(-np.log10(abs(step))))) if step else 0 # A non-nice step (pi/2, 0.3333…) needs enough decimals to keep adjacent # ticks distinct; widen until the step itself round-trips at that precision. diff --git a/python/xy/columns.py b/python/xy/columns.py index 3ead8afc..a6211ff7 100644 --- a/python/xy/columns.py +++ b/python/xy/columns.py @@ -583,7 +583,10 @@ def _canonicalize(data: Any) -> tuple[npt.NDArray[np.float64], str, int]: if arr.dtype == object and any(isinstance(value, (bool, np.bool_)) for value in arr): raise ValueError("columns must be real numeric or datetime-like, not boolean") if arr.dtype == object: - arr = object_missing_to_nan(arr) + cleaned = object_missing_to_nan(arr) + if cleaned is not arr: + copies += 1 # the hole-filling pass is a real copy (§29) + arr = cleaned try: arr, copies = _astype_counted(arr, np.float64, copies) except (TypeError, ValueError) as e: diff --git a/python/xy/components.py b/python/xy/components.py index f365432d..afdc75f8 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -5325,10 +5325,24 @@ def _strict_bool(value: Any, label: str) -> bool: _FACET_CHANNEL_PROPS = ("color", "size", "upper", "yerr", "xerr", "base", "z", "x", "group") -def _facet_check_mark_channels(mark: Mark, n: int) -> None: +def _facet_check_mark_channels(mark: Mark, n: int, data: Any = None) -> None: + from .facets import _FACET_ROWS_MISMATCH + items = [("x", mark.x), ("y", mark.y)] items.extend((key, mark.props.get(key)) for key in _FACET_CHANNEL_PROPS) + table = mark.data if mark.data is not None else data for channel, value in items: + if isinstance(value, str) and isinstance(table, Mapping) and value in table: + # A mapping table keeps short config values alongside row columns + # (`facets._subset_data`), so a column is only checked once a mark + # channel names it as row data — then its length must be the + # by= length, or every panel would draw the whole column. + rows = _facet_row_count(table[value]) + if rows is not None and rows != n: + raise ValueError( + _FACET_ROWS_MISMATCH.format(n=n, rows=rows, what=f"column {value!r}") + ) + continue if value is None or isinstance(value, (str, bytes)) or np.isscalar(value): continue try: @@ -5342,6 +5356,20 @@ def _facet_check_mark_channels(mark: Mark, n: int) -> None: ) +def _facet_row_count(column: Any) -> Optional[int]: + """Row count of a 1-D column value, None for scalars and matrices.""" + if hasattr(column, "to_numpy"): + column = column.to_numpy() + elif isinstance(column, (list, tuple)): + try: + column = np.asarray(column) + except ValueError: + return None + if isinstance(column, np.ndarray) and column.ndim == 1: + return len(column) + return None + + def _facet_mark(mark: Mark, mask: np.ndarray, n: int) -> Mark: """Panel copy of a mark: mark-level data= tables subset with the panel's row mask (when row-aligned) so panels do not repeat the full dataset.""" @@ -5432,7 +5460,7 @@ def figure(self) -> Any: base_title = self.props.get("title") for child in self.children: if isinstance(child, Mark): - _facet_check_mark_channels(child, n) + _facet_check_mark_channels(child, n, data) masks = [codes == code for code in range(len(unique_labels))] def build_panels(preseed: dict[str, list[str]]) -> list[Figure]: diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index fe731d92..91b1eea0 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -372,9 +372,12 @@ With no `format=` on the axis, labels come from the step: `|v| ≥ 1e6` or `0 < |v| < 1e-4`. The mantissa carries as many digits as sit between the value's magnitude and the step's last significant digit (`expDigits`; `1.25e6` at step `2.5e5` → `(6 − 5) + 1 = 2` → `1.25e6`), at - least one and at most eight — a fixed single decimal labelled a 50,000-step - axis `1.0e6, 1.1e6, 1.1e6, 1.2e6, …`. `python/xy/_svg.py::_exp_digits` is - the same function, so the PNG and the browser agree. Otherwise it derives the decimal count + least one and at most fifteen — a fixed single decimal labelled a + 50,000-step axis `1.0e6, 1.1e6, 1.1e6, 1.2e6, …`. `python/xy/_svg.py:: + _exp_digits` is the same function, and `_fmt_exponential` reproduces + `toExponential`'s half-up tie rounding on the exact binary value (Python's + `:e` is half-even: `1.25e6` at one digit would otherwise read `1.3e6` live + and `1.2e6` in the PNG), so the two agree label for label. Otherwise it derives the decimal count from the tick step — `ceil(−log10(step))`, then increments while the step is not representable at that precision to within a thousandth of itself — and caps at 8 decimals. Ticks on one axis therefore share a decimal count. diff --git a/tests/test_facets.py b/tests/test_facets.py index f969df79..9ff187f6 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -391,6 +391,14 @@ def test_facet_by_array_must_match_row_count() -> None: xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b"], data=df).figure() with pytest.raises(ValueError, match="by= has 4 values but data has 3 rows"): xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b", "c", "d"], data=df).figure() + # Mapping data keeps its short-config pass-through, so a mapping column is + # checked where a mark channel names it as row data. + with pytest.raises(ValueError, match="column 'x' has 3 rows"): + xy.facet_chart( + xy.scatter(x="x", y="y"), + by=["a", "b"], + data={"x": [1.0, 2.0, 3.0], "y": [1.0, 2.0, 3.0], "config": [1, 2]}, + ).figure() # Mark-level data= tables are split by the same by= and must match too. with pytest.raises(ValueError, match="by= has 2 values but data has 3 rows"): xy.facet_chart(xy.scatter(x="x", y="y", data=df), by=["a", "b"]).figure() diff --git a/tests/test_object_numeric_columns.py b/tests/test_object_numeric_columns.py index 462ac2af..9ead1f8b 100644 --- a/tests/test_object_numeric_columns.py +++ b/tests/test_object_numeric_columns.py @@ -47,6 +47,11 @@ def test_object_numeric_missing_values_are_nan_not_categories() -> None: col = fig.traces[0].x assert col.kind == "float" assert np.isnan(col.values[1]) and col.values[0] == 1.0 and col.values[2] == 3.0 + # §29: the hole-filling pass is a copy and is reported as one, on top of + # the object->f64 cast every object column pays. + clean = Figure() + clean.scatter(np.array([1, 2, 3], dtype=object), [1.0, 2.0, 3.0]) + assert col.ingest_copies == clean.traces[0].x.ingest_copies + 1 # pandas' NA scalar is a hole too, not a category or a TypeError. fig2 = Figure() fig2.scatter(np.array([1, pd.NA, 3], dtype=object), [1.0, 2.0, 3.0]) diff --git a/tests/test_tick_label_precision.py b/tests/test_tick_label_precision.py index 8d64744a..776cbdd3 100644 --- a/tests/test_tick_label_precision.py +++ b/tests/test_tick_label_precision.py @@ -35,6 +35,14 @@ ([1e300, 2e300, 3e300], 1e300), ([1e6, 2e6, 3e6], 1e6), ([12345678.0, 12345679.0, 12345680.0], 1.0), + # A 1e-3 step at 1e6 magnitude needs nine mantissa digits (cap was 8). + ([1000000.001, 1000000.002, 1000000.003], 1e-3), + # Exact binary ties: JS toExponential rounds half-up, Python :e half-even. + ([1.25e6, 1.75e6, 2.25e6], 5e5), + ([9.95e6, 9.85e6], 1e5), + # Subnormal steps: 10**e_step must not underflow to zero. + ([1e-310, 2e-310, 3e-310], 1e-310), + ([5e-320, 1e-319], 5e-320), ] @@ -45,6 +53,12 @@ def test_exponential_labels_are_distinct_at_the_tick_step() -> None: assert [_fmt_linear(v, 5e4) for v in (1e6, 1.05e6, 1.3e6)] == ["1.00e6", "1.05e6", "1.30e6"] assert _fmt_linear(1.25e6, 2.5e5) == "1.25e6" assert _fmt_linear(1e6 + 20, 20.0) == "1.00002e6" + assert _fmt_linear(1000000.002, 1e-3) == "1.000000002e6" + # Ties round half-up like JavaScript, not half-even like Python's :e. + assert _fmt_linear(1.25e6, 5e5) == "1.3e6" + assert _fmt_linear(-1.25e6, 5e5) == "-1.3e6" + assert _fmt_linear(9.95e6, 1e6) == "1.0e7" + assert _fmt_linear(2e-310, 1e-310) == "2.0e-310" # Below the exponential threshold nothing changed. assert [_fmt_linear(v, 0.25) for v in (0.0, 0.25, 0.5)] == ["0.00", "0.25", "0.50"] assert _fmt_linear(5e-13, 5e-13) == "5.0e-13" From 0eaadac713cf338416ce5ba3558929f01ad9644f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:14:13 -0700 Subject: [PATCH 08/11] Add before/after evidence for the exponential tick label fix --- spec/assets/tick-labels-1e6-before-after.png | Bin 0 -> 45524 bytes spec/design/renderer-architecture.md | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 spec/assets/tick-labels-1e6-before-after.png diff --git a/spec/assets/tick-labels-1e6-before-after.png b/spec/assets/tick-labels-1e6-before-after.png new file mode 100644 index 0000000000000000000000000000000000000000..09213a64fd06a66c969dc43923b5e7261791cb62 GIT binary patch literal 45524 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 Date: Tue, 1 Sep 2026 22:25:59 -0700 Subject: [PATCH 09/11] Address review: plugin columns and color literals in the facet check, 16-digit label cap, node guard - _facet_check_mark_channels also checks a plugin mark's declared columns, and skips a color channel whose value is a CSS color literal even when the mapping holds a key of that name (paint, not a row column). - Exponential tick labels may carry 16 fractional digits (17 significant), so two adjacent f64 values at a one-ulp step still print distinctly; parity case added. - The TS/Python parity test skips on node < 22.6 and passes --experimental-strip-types on 22.x instead of failing at the import. --- js/src/30_ticks.ts | 7 ++++--- python/xy/_svg.py | 9 +++++---- python/xy/components.py | 16 ++++++++++++++++ spec/design/renderer-architecture.md | 3 ++- tests/test_facets.py | 26 ++++++++++++++++++++++++++ tests/test_tick_label_precision.py | 23 ++++++++++++++++++++++- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index 4dfea1aa..cc197ddf 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -180,9 +180,10 @@ function fmtTime(ms, step) { // (1.25e6 at step 2.5e5 -> (6 - 5) + 1 = 2 -> "1.25e6"). One fixed decimal // labelled a 50,000-step axis "1.0e6, 1.1e6, 1.1e6, 1.2e6, …". Mirrors // `_exp_digits` in python/xy/_svg.py exactly. -// Cap: enough for a 1e-3 step on a 1e6-magnitude axis (9), short of f64's -// ~15.9 significant digits where more would only print representation noise. -const EXP_DIGITS_MAX = 15; +// Cap: 16 fractional digits are 17 significant, the most any two adjacent f64 +// values need to print distinctly (1e6 and its next float at a one-ulp step); +// beyond that only representation noise would print. +const EXP_DIGITS_MAX = 16; function expDigits(av, step) { if (!step || !Number.isFinite(step) || av === 0) return 1; diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 611a920b..6bc01a53 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -673,10 +673,11 @@ def _fmt_time(ms: float, step: float) -> str: return f"{d.minute:02d}:{d.second:02d}.{d.microsecond // 1000:03d}" -# Mantissa digits an exponential label may carry: enough for a 1e-3 step on -# a 1e6-magnitude axis (9), short of f64's ~15.9 significant digits where -# further digits would only print representation noise. -_EXP_DIGITS_MAX = 15 +# Mantissa digits an exponential label may carry: 16 fractional digits are 17 +# significant, the most any two adjacent f64 values need to print distinctly +# (1e6 and its next float at a one-ulp step); beyond that only representation +# noise would print. +_EXP_DIGITS_MAX = 16 def _exp_digits(av: float, step: float) -> int: diff --git a/python/xy/components.py b/python/xy/components.py index afdc75f8..3433ebc9 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -5330,8 +5330,16 @@ def _facet_check_mark_channels(mark: Mark, n: int, data: Any = None) -> None: items = [("x", mark.x), ("y", mark.y)] items.extend((key, mark.props.get(key)) for key in _FACET_CHANNEL_PROPS) + plugin = plugins.get_mark_plugin(mark.kind) + if plugin is not None: + # Plugin marks resolve their declared columns from data= too. + items.extend((key, mark.props.get(key)) for key in plugin.columns) table = mark.data if mark.data is not None else data for channel, value in items: + if channel == "color" and isinstance(value, str) and _is_css_color_literal(value): + # `color="red"` is paint on every mark kind, even when the mapping + # happens to hold a "red" key; never a row column to length-check. + continue if isinstance(value, str) and isinstance(table, Mapping) and value in table: # A mapping table keeps short config values alongside row columns # (`facets._subset_data`), so a column is only checked once a mark @@ -5356,6 +5364,14 @@ def _facet_check_mark_channels(mark: Mark, n: int, data: Any = None) -> None: ) +def _is_css_color_literal(value: str) -> bool: + try: + _validate.css_color(value, "color") + except (TypeError, ValueError): + return False + return True + + def _facet_row_count(column: Any) -> Optional[int]: """Row count of a 1-D column value, None for scalars and matrices.""" if hasattr(column, "to_numpy"): diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 5a641e24..5337d102 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -372,7 +372,8 @@ With no `format=` on the axis, labels come from the step: `|v| ≥ 1e6` or `0 < |v| < 1e-4`. The mantissa carries as many digits as sit between the value's magnitude and the step's last significant digit (`expDigits`; `1.25e6` at step `2.5e5` → `(6 − 5) + 1 = 2` → `1.25e6`), at - least one and at most fifteen — a fixed single decimal labelled a + least one and at most sixteen (17 significant digits, what two adjacent f64 + values need to stay distinct) — a fixed single decimal labelled a 50,000-step axis `1.0e6, 1.1e6, 1.1e6, 1.2e6, …`. `python/xy/_svg.py:: _exp_digits` is the same function, and `_fmt_exponential` reproduces `toExponential`'s half-up tie rounding on the exact binary value (Python's diff --git a/tests/test_facets.py b/tests/test_facets.py index 9ff187f6..0788fced 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -405,3 +405,29 @@ def test_facet_by_array_must_match_row_count() -> None: # The right length still works and actually subsets. grid = xy.facet_chart(xy.scatter(x="x", y="y"), by=["a", "b", "a"], data=df).figure() assert [t.n_points for fig in grid.figures for t in fig.traces] == [2, 1] + + +def test_facet_row_check_covers_plugin_columns_and_skips_color_literals() -> None: + """Plugin marks resolve their declared columns from data= too, so those + are length-checked; a CSS color literal is paint even when the mapping + happens to hold a key of the same name.""" + + def build(ctx): + return [xy.scatter(x=ctx.columns["t"], y=ctx.columns["v"], name=ctx.name)] + + xy.register_mark(xy.MarkPlugin(name="facetprobe", columns=("t", "v"), build=build)) + try: + with pytest.raises(ValueError, match="column 't' has 3 rows"): + xy.facet_chart( + xy.mark("facetprobe", t="t", v="v"), + by=["a", "b"], + data={"t": [0.0, 1.0, 2.0], "v": [1.0, 2.0, 3.0]}, + ).figure() + finally: + xy.unregister_mark("facetprobe") + grid = xy.facet_chart( + xy.line(x="x", y="y", color="red"), + by=["a", "b", "a"], + data={"x": [0.0, 1.0, 2.0], "y": [1.0, 2.0, 3.0], "red": [9.0]}, + ).figure() + assert len(grid.figures) == 2 diff --git a/tests/test_tick_label_precision.py b/tests/test_tick_label_precision.py index 776cbdd3..0ae528ba 100644 --- a/tests/test_tick_label_precision.py +++ b/tests/test_tick_label_precision.py @@ -40,6 +40,11 @@ # Exact binary ties: JS toExponential rounds half-up, Python :e half-even. ([1.25e6, 1.75e6, 2.25e6], 5e5), ([9.95e6, 9.85e6], 1e5), + # Adjacent f64 values at a one-ulp step need all 17 significant digits. + ( + [1e6, np.nextafter(1e6, np.inf), np.nextafter(np.nextafter(1e6, np.inf), np.inf)], + np.spacing(1e6), + ), # Subnormal steps: 10**e_step must not underflow to zero. ([1e-310, 2e-310, 3e-310], 1e-310), ([5e-320, 1e-319], 5e-320), @@ -67,10 +72,26 @@ def test_exponential_labels_are_distinct_at_the_tick_step() -> None: assert _fmt_linear(2e6, float("nan")) == "2.0e6" +def _node_type_stripping(node: str) -> list[str]: + """Flags that let this node import a .ts file, or None if it cannot. + + Type stripping shipped behind --experimental-strip-types in 22.6 and is on + by default from 23.6 (and 22.18); older versions fail at the import. + """ + version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout + major, minor = (int(part) for part in version.strip().lstrip("v").split(".")[:2]) + if (major, minor) < (22, 6): + return None + return ["--experimental-strip-types"] if major == 22 else [] + + def test_python_and_client_formatters_agree() -> None: node = shutil.which("node") if node is None: pytest.skip("node not available for the fmtLinear parity check") + strip_types = _node_type_stripping(node) + if strip_types is None: + pytest.skip("node >= 22.6 needed to import the TypeScript source directly") ticks_ts = (ROOT / "js" / "src" / "30_ticks.ts").resolve().as_uri() payload = base64.b64encode(json.dumps(CASES).encode()).decode("ascii") script = ( @@ -79,7 +100,7 @@ def test_python_and_client_formatters_agree() -> None: "console.log(JSON.stringify(cases.map(([ticks, step]) => ticks.map((v) => m.fmtLinear(v, step)))));" ) completed = subprocess.run( - [node, "--no-warnings", "--input-type=module", "--eval", script], + [node, "--no-warnings", *strip_types, "--input-type=module", "--eval", script], cwd=ROOT, capture_output=True, text=True, From fec925904dbac4f8bd3140a22e8b7380d6f85a9d Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:34:29 -0700 Subject: [PATCH 10/11] Pass --experimental-strip-types on every node below 23.6 (review) --- tests/test_tick_label_precision.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_tick_label_precision.py b/tests/test_tick_label_precision.py index 0ae528ba..153d2576 100644 --- a/tests/test_tick_label_precision.py +++ b/tests/test_tick_label_precision.py @@ -76,13 +76,15 @@ def _node_type_stripping(node: str) -> list[str]: """Flags that let this node import a .ts file, or None if it cannot. Type stripping shipped behind --experimental-strip-types in 22.6 and is on - by default from 23.6 (and 22.18); older versions fail at the import. + by default from 23.6; older versions fail at the import. The flag is still + accepted where stripping is already on, so every version below 23.6 gets it + (22.x included, whether or not it is a 22.18+ default-on release). """ version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout major, minor = (int(part) for part in version.strip().lstrip("v").split(".")[:2]) if (major, minor) < (22, 6): return None - return ["--experimental-strip-types"] if major == 22 else [] + return ["--experimental-strip-types"] if (major, minor) < (23, 6) else [] def test_python_and_client_formatters_agree() -> None: From 5cfcc48bd5bfc5872586c1ffe183afb3cb85b96d Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 1 Sep 2026 22:39:24 -0700 Subject: [PATCH 11/11] Scale subnormal tick steps in two stages instead of clamping the exponent (review) --- js/src/30_ticks.ts | 10 ++++++---- python/xy/_svg.py | 10 ++++++---- tests/test_tick_label_precision.py | 5 ++++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/js/src/30_ticks.ts b/js/src/30_ticks.ts index cc197ddf..b3bce92d 100644 --- a/js/src/30_ticks.ts +++ b/js/src/30_ticks.ts @@ -188,10 +188,12 @@ const EXP_DIGITS_MAX = 16; function expDigits(av, step) { if (!step || !Number.isFinite(step) || av === 0) return 1; step = Math.abs(step); - // Clamped so 10 ** eStep cannot underflow to 0 (a step below 1e-300 needs - // no more label digits than the cap allows anyway). - const eStep = Math.max(Math.floor(Math.log10(step)), -300); - const mantissa = step / 10 ** eStep; + const eStep = Math.floor(Math.log10(step)); + // 10 ** eStep underflows to 0 below 1e-308 and 10 ** -eStep overflows above + // 1e308, so a subnormal step is scaled in two stages instead of clamped — + // clamping threw the step's real exponent away and collapsed labels on a + // (legal) subnormal axis. + const mantissa = eStep < -300 ? (step * 1e300) * 10 ** (-eStep - 300) : step / 10 ** eStep; let k = 0; while (k < 8 && Math.abs(Number(mantissa.toFixed(k)) - mantissa) > mantissa / 1000) k++; return Math.max(1, Math.min(EXP_DIGITS_MAX, Math.floor(Math.log10(av)) - eStep + k)); diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 6bc01a53..e6d4e0b8 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -692,10 +692,12 @@ def _exp_digits(av: float, step: float) -> int: if not step or not np.isfinite(step) or av == 0: return 1 step = abs(step) - # Clamped so 10**e_step cannot underflow to 0 (a step below 1e-300 needs - # no more label digits than the cap allows anyway). - e_step = max(int(np.floor(np.log10(step))), -300) - mantissa = step / 10.0**e_step + e_step = int(np.floor(np.log10(step))) + # 10**e_step underflows to 0 below 1e-308 and 10**-e_step overflows above + # 1e308, so a subnormal step is scaled in two stages instead of clamped — + # clamping threw the step's real exponent away and collapsed labels on a + # (legal) subnormal axis. + mantissa = (step * 1e300) * 10.0 ** (-e_step - 300) if e_step < -300 else step / 10.0**e_step k = 0 while k < 8 and abs(round(mantissa, k) - mantissa) > mantissa / 1000.0: k += 1 diff --git a/tests/test_tick_label_precision.py b/tests/test_tick_label_precision.py index 153d2576..8241f981 100644 --- a/tests/test_tick_label_precision.py +++ b/tests/test_tick_label_precision.py @@ -45,8 +45,10 @@ [1e6, np.nextafter(1e6, np.inf), np.nextafter(np.nextafter(1e6, np.inf), np.inf)], np.spacing(1e6), ), - # Subnormal steps: 10**e_step must not underflow to zero. + # Subnormal steps: 10**e_step must not underflow to zero, and the step's + # real exponent still sets the digit count (1.00e-310 vs 1.05e-310). ([1e-310, 2e-310, 3e-310], 1e-310), + ([1.00e-310, 1.05e-310, 1.10e-310], 5e-312), ([5e-320, 1e-319], 5e-320), ] @@ -64,6 +66,7 @@ def test_exponential_labels_are_distinct_at_the_tick_step() -> None: assert _fmt_linear(-1.25e6, 5e5) == "-1.3e6" assert _fmt_linear(9.95e6, 1e6) == "1.0e7" assert _fmt_linear(2e-310, 1e-310) == "2.0e-310" + assert _fmt_linear(1.05e-310, 5e-312) == "1.05e-310" # Below the exponential threshold nothing changed. assert [_fmt_linear(v, 0.25) for v in (0.0, 0.25, 0.5)] == ["0.00", "0.25", "0.50"] assert _fmt_linear(5e-13, 5e-13) == "5.0e-13"