Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1904,13 +1904,15 @@ 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"] = (
report["canonical_capacity_bytes"]
+ report["channel_bytes"]
+ report["pyramid_bytes"]
+ report["bin_color_bytes"]
+ report["legend_vis_cache_bytes"]
)
report["backend"] = kernels.BACKEND
return report
Expand Down
33 changes: 33 additions & 0 deletions python/xy/interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -156,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(
Expand Down Expand Up @@ -552,6 +566,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.
Expand Down
111 changes: 111 additions & 0 deletions tests/test_legend_toggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,114 @@ 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

# 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

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"]
)
Loading