From 9e806d2113781dce5c5c60c210659dc1f2a45687 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 14:11:40 -0700 Subject: [PATCH 1/2] Release the legend-visible row cache, and put it in the report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_legend_visible_rows` memoizes the canonical rows outside the hidden-category set, keyed on (hidden set, column length). The cache is worth having — density_view runs per pan and zoom step, and the O(N) code scan must not repeat while the predicate holds — but it was never dropped, never invalidated and never reported. One legend click on a 4M-row three-category trace left 20.4 MB resident for the figure's lifetime; at 50M rows with one of eight categories hidden it is ~350 MB, and `memory_report()` claimed none of it. Two changes, both narrow: - `_legend_visible_rows` clears the cache on its `pred is None` early return. That branch is exactly the state in which nothing can consume the array — no category is hidden — so dropping it is provably free, and it is the state a user reaches by un-hiding the last hidden series. - `memory_report()` gains `legend_vis_cache_bytes` and folds it into `resident_array_bytes`, alongside `pyramid_bytes` and `bin_color_bytes`. Design dossier §27: if a memory number isn't in the report, it isn't real. Deliberately not done here: invalidating on append. The cache only exists for a trace with categorical color codes, and `append_data` rejects categorical channels outright, so that line would be unreachable today — and its key check already refuses a stale entry on length change. Nor is the dtype narrowed to uint32: NumPy fancy-indexing upcasts non-intp index arrays internally, which would trade this retention for a per-use temporary of the same size. Three new tests cover the release, the report line, and the resident total. --- python/xy/_figure.py | 2 ++ python/xy/interaction.py | 24 +++++++++++++++++ tests/test_legend_toggle.py | 54 +++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index b7f933e7..4aea4c08 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -1904,6 +1904,7 @@ def memory_report(self) -> dict[str, Any]: report["transport_bytes_per_point"] = len(blob) / n_total report["pyramid_bytes"] = interaction.pyramid_report_bytes(self) report["bin_color_bytes"] = interaction.bin_color_cache_bytes(self) + report["legend_vis_cache_bytes"] = interaction.legend_vis_cache_bytes(self) # Capacity, not live length: a streamed column's growth-buffer slack is # resident RAM (§27), and equals `canonical_bytes` when nothing appended. report["resident_array_bytes"] = ( @@ -1911,6 +1912,7 @@ def memory_report(self) -> dict[str, Any]: + report["channel_bytes"] + report["pyramid_bytes"] + report["bin_color_bytes"] + + report["legend_vis_cache_bytes"] ) report["backend"] = kernels.BACKEND return report diff --git a/python/xy/interaction.py b/python/xy/interaction.py index 940eef02..731d72b4 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -107,6 +107,11 @@ def _legend_visible_rows(t: "Trace") -> Optional[np.ndarray]: """ pred = _hidden_predicate(t) if pred is None: + # Unmasked: nothing can consume the cache, and an 8-byte-per-visible-row + # index array is far too large to keep on the chance the user hides a + # category again (350 MB for a 50M-row trace). Un-hiding everything is + # the one state where dropping it is provably free. + t._legend_vis_cache = None return None hidden, codes = pred key = (frozenset(t.hidden_categories), len(codes)) @@ -552,6 +557,25 @@ def bin_color_cache_bytes(fig: Any) -> int: return total +def legend_vis_cache_bytes(fig: Any) -> int: + """Memory-report line (design dossier §27): bytes held by cached + legend-visible row indices. + + One `np.intp` per visible row per masked trace. It survives every pan and + zoom by design — that is the point of the cache — so §27's rule applies: + if a number isn't in the report, it isn't real. + """ + total = 0 + for t in fig.traces: + cached = getattr(t, "_legend_vis_cache", None) + if cached is None: + continue + rows = cached[1] + if isinstance(rows, np.ndarray): + total += int(rows.nbytes) + return total + + def _ensure_pyramid(t: Trace) -> int | None: """Lazily build the trace's count pyramid (§5 Tier 3). Cached on the trace; 0 is remembered as "tried and not applicable" so we never rebuild. diff --git a/tests/test_legend_toggle.py b/tests/test_legend_toggle.py index a8af41f2..889b8c5f 100644 --- a/tests/test_legend_toggle.py +++ b/tests/test_legend_toggle.py @@ -564,3 +564,57 @@ def test_browser_legend_click_toggles_series() -> None: ("legend_toggle", 0, None, True), ("legend_toggle", 0, None, False), ], payload["sent"] + + +# --- the visible-row cache is bounded, and it is reported (§27) -------------- + + +def test_legend_vis_cache_is_dropped_when_nothing_is_hidden() -> None: + """Un-hiding everything releases the index, instead of pinning 8 B/row. + + The cache exists so a pan/zoom sequence does not repeat the O(N) code scan + while the predicate holds. Once no category is hidden there is no consumer + for it at all, so keeping it is pure retention — and it was never dropped, + never invalidated and never reported. + """ + from xy import interaction + + fig, _ = _density_fig(200_000) + trace = fig.traces[0] + assert trace._legend_vis_cache is None + + channel.handle_message( + fig, {"type": "legend_toggle", "trace": 0, "category": 1, "hidden": True} + ) + interaction.density_view(fig, trace.id, -4.0, 4.0, -4.0, 4.0, 256, 192) + cached = trace._legend_vis_cache + assert cached is not None and cached[1].size > 0 + # ...and it shows up in the report while it is held. + assert fig.memory_report()["legend_vis_cache_bytes"] == cached[1].nbytes + + channel.handle_message( + fig, {"type": "legend_toggle", "trace": 0, "category": 1, "hidden": False} + ) + interaction.density_view(fig, trace.id, -4.0, 4.0, -4.0, 4.0, 256, 192) + assert trace._legend_vis_cache is None + assert fig.memory_report()["legend_vis_cache_bytes"] == 0 + + +def test_legend_vis_cache_bytes_is_counted_in_resident_total() -> None: + """§27: if a number isn't in the report, it isn't real.""" + from xy import interaction + + fig, _ = _density_fig(200_000) + channel.handle_message( + fig, {"type": "legend_toggle", "trace": 0, "category": 1, "hidden": True} + ) + interaction.density_view(fig, fig.traces[0].id, -4.0, 4.0, -4.0, 4.0, 256, 192) + report = fig.memory_report() + assert report["legend_vis_cache_bytes"] > 0 + assert report["resident_array_bytes"] >= ( + report["canonical_capacity_bytes"] + + report["channel_bytes"] + + report["pyramid_bytes"] + + report["bin_color_bytes"] + + report["legend_vis_cache_bytes"] + ) From 38188c03fac61f6fe00321fe91e28a2ea5207fb1 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 16:08:00 -0700 Subject: [PATCH 2/2] Release the visible-row cache on the toggle, not on the next view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping it in `_legend_visible_rows` only helps if something calls that again, and it is the cache's only reader. Un-hiding the last category left the index resident until some later `density_view` happened to run — and a legend click restoring every series is a perfectly ordinary last interaction, after which nothing ran. A 200k-row trace held 1,065,040 bytes indefinitely, reported but never released. Clear it in `legend_toggle` when the hidden set becomes empty. The reader still clears it too, for the paths that reach unmasked without a toggle (a color encoding replaced under a stale mask). The test covering this asked for another density view before checking, which is exactly the thing that used to do the releasing, so it passed either way; it now asserts the release before any further view, alongside a partial un-hide that must re-key rather than drop, and the trace-level `hidden` switch that must not touch this cache at all. --- python/xy/interaction.py | 9 ++++++ tests/test_legend_toggle.py | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/python/xy/interaction.py b/python/xy/interaction.py index 731d72b4..0732c34d 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -161,6 +161,15 @@ def legend_toggle( t.hidden_categories.add(code) else: t.hidden_categories.discard(code) + if not t.hidden_categories: + # Release here, not on the next `_legend_visible_rows`. That is the + # cache's only reader, and un-hiding the last category is exactly + # the state where nothing needs to read it again — a toggle that is + # the last thing to happen to this trace would otherwise pin + # 8 bytes per visible row for the figure's lifetime. The reader + # still drops it too, for the paths that reach unmasked without a + # toggle (a color encoding replaced under a stale mask). + t._legend_vis_cache = None def pick( diff --git a/tests/test_legend_toggle.py b/tests/test_legend_toggle.py index 889b8c5f..852563d1 100644 --- a/tests/test_legend_toggle.py +++ b/tests/test_legend_toggle.py @@ -592,14 +592,71 @@ def test_legend_vis_cache_is_dropped_when_nothing_is_hidden() -> None: # ...and it shows up in the report while it is held. assert fig.memory_report()["legend_vis_cache_bytes"] == cached[1].nbytes + # The un-hide itself releases it. Waiting for the next `density_view` to + # notice would pin the index whenever that view never comes — and a legend + # click restoring every series is a perfectly ordinary last interaction. channel.handle_message( fig, {"type": "legend_toggle", "trace": 0, "category": 1, "hidden": False} ) + assert trace._legend_vis_cache is None + assert fig.memory_report()["legend_vis_cache_bytes"] == 0 + + # ...and a view afterwards neither resurrects nor re-pins it. interaction.density_view(fig, trace.id, -4.0, 4.0, -4.0, 4.0, 256, 192) assert trace._legend_vis_cache is None assert fig.memory_report()["legend_vis_cache_bytes"] == 0 +def test_legend_vis_cache_survives_a_partial_unhide() -> None: + """Only an empty hidden set releases it; with one category still hidden the + cache is still live and must be re-keyed, not dropped.""" + from xy import interaction + + fig, _ = _density_fig(200_000) + trace = fig.traces[0] + for code in (0, 1): + channel.handle_message( + fig, {"type": "legend_toggle", "trace": 0, "category": code, "hidden": True} + ) + interaction.density_view(fig, trace.id, -4.0, 4.0, -4.0, 4.0, 256, 192) + two_hidden = trace._legend_vis_cache + assert two_hidden is not None + + channel.handle_message( + fig, {"type": "legend_toggle", "trace": 0, "category": 0, "hidden": False} + ) + assert trace._legend_vis_cache is not None # still one category hidden + interaction.density_view(fig, trace.id, -4.0, 4.0, -4.0, 4.0, 256, 192) + one_hidden = trace._legend_vis_cache + assert one_hidden is not None + assert one_hidden[0] != two_hidden[0] # re-keyed on the new hidden set + assert one_hidden[1].size > two_hidden[1].size # and more rows are visible + + channel.handle_message( + fig, {"type": "legend_toggle", "trace": 0, "category": 1, "hidden": False} + ) + assert trace._legend_vis_cache is None + assert fig.memory_report()["legend_vis_cache_bytes"] == 0 + + +def test_whole_trace_hide_leaves_the_category_cache_alone() -> None: + """`hidden=True` with no category is the trace-level switch; it shares + nothing with the per-category predicate and must not drop its cache.""" + from xy import interaction + + fig, _ = _density_fig(200_000) + trace = fig.traces[0] + channel.handle_message( + fig, {"type": "legend_toggle", "trace": 0, "category": 1, "hidden": True} + ) + interaction.density_view(fig, trace.id, -4.0, 4.0, -4.0, 4.0, 256, 192) + assert trace._legend_vis_cache is not None + + channel.handle_message(fig, {"type": "legend_toggle", "trace": 0, "hidden": False}) + assert trace.hidden_categories == {1} + assert trace._legend_vis_cache is not None + + def test_legend_vis_cache_bytes_is_counted_in_resident_total() -> None: """§27: if a number isn't in the report, it isn't real.""" from xy import interaction