Skip to content

Fix mesh, image, and distribution autoscale [mpl compatibility] - #269

Closed
sselvakumaran wants to merge 3 commits into
agent/fix-pyplot-autoscale-marginsfrom
agent/fix-pyplot-hist2d-autoscale
Closed

Fix mesh, image, and distribution autoscale [mpl compatibility]#269
sselvakumaran wants to merge 3 commits into
agent/fix-pyplot-autoscale-marginsfrom
agent/fix-pyplot-hist2d-autoscale

Conversation

@sselvakumaran

@sselvakumaran sselvakumaran commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • teach pyplot's pre-build autoscale scan the three entry shapes that keep their coordinates inside kwargs instead of as top-level x/y: @mark/heatmap (hist2d, uniform-grid pcolormesh, specgram), ecdf, and box
  • derive sticky edges for mesh, image, and ECDF entries so their limits carry no margin, as Matplotlib's sticky artists do
  • reuse the core's own geometry helpers rather than re-deriving it — heatmap spans go through Figure._heatmap_axis_positions/_cell_edges, box spans through marks._distribution_stats
  • add tests/pyplot/test_mesh_autoscale_regressions.py (13 cases) covering every factory touched

Entirely inside the shim (python/xy/pyplot/_axes.py). No core change, so no non-pyplot chart changes behavior — the declarative API already put the correct span on its heatmap traces; only the shim's own pre-build view scan was wrong.

Root cause

Axes._iter_entry_arrays (python/xy/pyplot/_axes.py:2782) is the scanner that feeds _axis_is_dataless. It probes top-level entry["x"]/entry["y"] plus a small @mark factory table. hist2d registers neither:

# python/xy/pyplot/_plot_types.py:3191
entry = self._add(
    "@mark",
    {"factory": "heatmap", "args": (h.T,), "kwargs": mark_kwargs, "source_z": h.T},
)

Its bin centers live in mark_kwargs["x"]/["y"], so the top-level probe found nothing and the factory table had no heatmap row. The axis scanned as dataless, and _build_chart (_axes.py:4886) then materialized (0, 1) over the real geometry.

Why the existing heatmap branch did not cover it. _axes.py:2805 already had:

elif entry.get("kind") == "heatmap" and entry.get("extent") is not None:

That is imshow's entry shape — a plain heatmap kind carrying an explicit extent (_axes.py:2134). hist2d registers an @mark kind with factory heatmap and no extent at all, so it never reached that branch; it fell into the @mark arm and matched no factory. Two unrelated entry shapes share the word "heatmap", which is what made this look covered. The new code is a factory == "heatmap" arm inside the @mark block, with a comment naming the distinction so the next reader does not re-conflate them.

Getting the span right needs the cell edges, not the centers hist2d stores. marks.heatmap reconstructs them with Figure._cell_edges, extending half a cell past the first and last center — which is exactly what recovers the original bin edges. Calling that helper rather than re-deriving it is what makes get_xlim() land on the bin edges to the last float.

Sticky edges

Measured against Matplotlib 3.11.1: hist2d/pcolormesh set sticky edges, so their limits are the outer cell edges with no margin.

hist2d sticky_edges.x [-3.899421730054339, 3.0660367390488967]
hist2d xlim          (-3.899421730054339, 3.0660367390488967)

The engine knows only the zero-baseline rectangle anchor (Figure._zero_baseline_anchor), so the shim resolves this itself: _entry_sticky_edges gains mesh/image/ECDF spans, and _fully_sticky_domain ships a materialized domain in place of a margin when both ends are pinned. margin and domain never combine, per spec/design/pan-and-zoom-configuration.md §9. A one-sided sticky edge is the bar/histogram baseline and is deliberately excluded — those keep their margin and stay anchored by the engine (regression-tested).

Impact

PDSH pdsh_04_05_histograms_and_binnings.ipynb, the plt.hist2d cell:

before after Matplotlib
get_xlim() (0.0, 1.0) (-4.1515520873, 3.6958252221) (-4.1515520873, 3.6958252221)
get_ylim() (0.0, 1.0) (-5.4578953516, 5.3459615454) (-5.4578953516, 5.3459615454)
visible bins 3 x 1 of 30 x 30 30 x 30 30 x 30

Matplotlib reference, before, and after

Honest scope of that image: the geometry and axis limits now match. Two differences remain in the after panel that this PR does not fix and does not claim — the colorbar's "counts in bin" label is clipped at the top of the colorbar rather than rotated alongside it, and the x tick density is higher than Matplotlib's. Both are independent of autoscale.

Every factory audited

I swept every factory the shim can emit, not just the reported one. Verdicts on this branch's base:

Factory / call Verdict
hist2d (uniform bins) was broken — fixed (the report)
hist2d(range=...) was broken — fixed (same heatmap factory)
pcolormesh, uniform grid was broken — fixed (same heatmap factory)
pcolormesh, regular 2-D X/Y was broken — fixed (same heatmap factory)
specgram was broken — fixed (same heatmap factory)
ecdf was broken — fixed (own factory; x margin + 0/1 sticky now exact)
boxplot was broken — fixed (own factory; value axis now exact, see caveat)
hexbin already correct — registers top-level x/y and args
contour / contourf already correct — has an explicit contour branch
imshow, with and without extent already correct — the kind == "heatmap" branch; gained sticky edges here so it no longer takes a margin
hist2d / pcolormesh, non-uniform bins already correct — delegates to triangle_mesh
tripcolor / triangle_mesh already correct
violinplot already correct — emits companion segments entries
errorbar already correct
step already correct
stairs, stem, fill_between broken on this base, but owned by #241 (stem) and #252 (stairs, area + baseline) — deliberately untouched here to avoid duplicating those stacks

Caveat found while auditing boxplot

boxplot's value axis is now exact against Matplotlib on both settings:

showfliers=True   xy (-4.257252770292894, 3.615030114955319)  mpl (-4.257252770292894, 3.615030114955319)
showfliers=False  xy (-2.924430068820657, 2.960242604583146)  mpl (-2.924430068820657, 2.960242604583146)

Its category axis is a separate, pre-existing defect this PR does not fix: with positions omitted, _plot_types.py:1876 forwards positions=None, so the core places boxes on 0-based ordinals, where Matplotlib uses 1-based positions (the sibling violinplot path at _plot_types.py:1992 already corrects this with np.arange(1, count + 1)). So get_xlim() is (-0.38, 1.38) against Matplotlib's (0.5, 2.5). The scanner change here faithfully mirrors where the core actually draws; fixing the placement is a one-line registration change with its own rendering consequences and belongs in its own PR. Documented as a known deviation in spec/matplotlib/compat.md.

Spec

  • spec/matplotlib/shim-todo.md — corrected a now-false claim: it stated sticky edges were "intentionally out of scope because xy artists do not expose sticky-edge metadata". They are now derived from the entry list instead of from artist metadata.
  • spec/design/pan-and-zoom-configuration.md §9 — new row in the margin/domain contract table for the both-ends-sticky case, and why it ships a domain rather than a margin.
  • spec/matplotlib/compat.mdhist2d/ecdf, imshow/pcolormesh, and boxplot rows record the new limit behavior and the boxplot position deviation.
  • spec/matplotlib/compat-changelog.md — dated entry naming which factories changed and which were already correct (dossier §28: autoscale decisions recorded, never silent).

Validation

Native core rebuilt in-worktree first (cargo build --release --offline); the prebuilt dylib reports ABI 37 against a wrapper expecting 39 and will not load.

$ PYTHONPATH=python .venv/bin/python -m pytest tests/pyplot/test_mesh_autoscale_regressions.py -q
13 passed in 0.08s

$ PYTHONPATH=python .venv/bin/python -m pytest tests/pyplot -q
74 failed, 481 passed, 65 skipped
# byte-identical failure set to the branch base (diffed) — all 74 are the
# missing JS bundles: `python/xy/static/standalone.js missing`. npm/node are
# not installed in this sandbox, so `node js/build.mjs` cannot run.

$ PYTHONPATH=python .venv/bin/python -m pytest tests/ -q --ignore=tests/pyplot <6 bundle-dependent modules>
106 failed, 1354 passed, 11 skipped
# also byte-identical to the base failure set (diffed)

$ uv run --no-project --with ruff ruff check .
All checks passed!

$ uv run --no-project --with ruff ruff format --check .
365 files already formatted

$ uv run --no-project --with ty ty check
Found 16 diagnostics
# byte-identical to the base (diffed); none in the changed file

$ uv run --no-project --with pre-commit pre-commit run --all-files
ruff check...............................................................Passed
ruff format..............................................................Passed
docs app codespell.......................................................Passed

Parity numbers in this description were measured against a real Matplotlib 3.11.1 in a throwaway venv, not asserted from memory.

Unverified: no JS/TS change is included, so nothing needed the bundles — but the ~180 bundle-dependent tests could not run here either way, and I baselined rather than blamed them. No browser/pixel smoke run.

Sequencing

Branched from and targets agent/fix-pyplot-autoscale-margins (#240), which rewrites _entry_extent / the auto-domain logic in this same file; a fix written against main would conflict. Rebased onto #240's current head 5c7418e (its in-flight commit) and neither rebased nor force-pushed that branch. Stacked alongside #253 and #256 on the same base.

Commits: 0eeaf61 is the fix, fc01a2b corrects the Matplotlib revision named in the changelog entry, and 198eb17 adds the comparison asset and is temporary — drop it before merge.

The pyplot shim materializes its own view before compiling to a core
Figure, so `Axes._iter_entry_arrays` has to recognize every entry shape
the shim can emit. It probed top-level `x`/`y` plus a small factory
table, and three shapes keep their coordinates inside `kwargs` instead:
`@mark`/`heatmap` (hist2d, uniform-grid pcolormesh, specgram), `ecdf`,
and `box`. Those axes scanned as dataless, so `_build_chart` pinned them
to the empty `(0, 1)` view and clipped the geometry — a 30x30 hist2d
rendered roughly 3x1 of its bins.

The pre-existing `kind == "heatmap"` branch did not cover hist2d: that
branch matches imshow's entry shape, which is a plain `heatmap` kind
carrying an explicit `extent`. hist2d registers an `@mark` kind with
factory `heatmap` and no `extent` at all, so it fell through to the
`@mark` factory table, which had no `heatmap` row.

Mirror the core rather than re-deriving the geometry: heatmap spans go
through `Figure._heatmap_axis_positions`/`_cell_edges`, which is what
recovers the original bin edges from the centers hist2d stores, and box
spans reuse `marks._distribution_stats` for whiskers and fliers.

Matplotlib gives these artists sticky edges, so their limits carry no
margin. The engine knows only the zero-baseline anchor, so derive sticky
edges for mesh, image, and ECDF entries and, when both ends are pinned,
ship a materialized `domain` instead of a `margin` — the two never
combine. One-sided rectangle baselines keep the `margin` and stay
anchored by the engine.

hexbin, contour/contourf, imshow, non-uniform hist2d/pcolormesh,
tripcolor, violinplot, errorbar, and step already scanned correctly.
TEMPORARY — for PR review only, to be removed before merge.

Three-up render of the PDSH 04.05 `plt.hist2d` cell: Matplotlib 3.11.1
reference, xy.pyplot before (30x30 bins computed, view pinned to the
dataless 0..1 box so roughly 3x1 of them are visible), and xy.pyplot
after.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b06496bb-4bdf-4ea6-9df7-25227dc2b4fb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/fix-pyplot-hist2d-autoscale

Comment @coderabbitai help to get the list of available commands.

…against

The limits in the new changelog entry were compared against Matplotlib
3.11.1, not 3.11.0.
@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 21.79%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 2 regressed benchmarks
✅ 100 untouched benchmarks
⏩ 2 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_select_lasso_message_1m 88.8 ms 122.2 ms -27.32%
test_pyramid_compose 9.3 ms 11 ms -15.85%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing agent/fix-pyplot-hist2d-autoscale (fc01a2b) with agent/fix-pyplot-autoscale-margins (5c7418e)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant