Skip to content

fix(cube): brush binned sources on their own bin grid - #134

Merged
jvdd merged 8 commits into
mainfrom
fix/cube-source-grid
Oct 1, 2026
Merged

jvdd merged 8 commits into
mainfrom
fix/cube-source-grid

Conversation

@jvdd

@jvdd jvdd commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Refs #133

A histogram or histogram2d source brushed the cube on a fixed grid (P = 2048, or 128 per axis) that ignored its bars and cells. With this PR the brushed trace owns the free-axis grid.

What changed

1. fix(hist): drop the upper-bound pad, the top clamp covers it

  • _HIST_BIN_EPSILON (1e-10 on the upper bin bound of a histogram) is removed from the display grid, the cube target dims and the plugin tests. The kernels' top clamp already puts a value at hi in the last bin, as for a histogram2d.
  • The bin range is now the row range of the viewport mask. Before, the cube of a zoomed histogram target kept a row in (hi, hi + 1e-10] that the display did not count.
  • TargetDimSpec.bin_variant only selected that pad and is removed.

2. fix(hist): draw a Date bar at its bin center

  • A histogram or histogram2d on a Date column emits its bin centers as Datetime("ms"). Before, each center was rounded to a whole day. Plotly draws and highlights a bar at its center, so a bar sat up to half a day off its bin, and bars narrower than a day stacked on one day. The hover of such a bar now shows the time of day of its center.

3. fix(cube): bin range free axes like the display kernel and snap brushes to the nearest edge

  • Every range free axis bins with _fixed_hist_bin_expr, the display kernel's rule (1e-9 round epsilon, top clamp to p - 1). The natural floor and the degenerate top bin P are gone. The cube content key version v is 2.
  • The Rust fixed_line_envelope2d bins its free axis with the same rule (hist2d_axis_scale, round epsilon, clamp to p - 1) and has no extra top free bin. Its x buckets do not change. The line-envelope build passes it the raw free value.
  • snap_brush (flexviz/cube.py) and fvCubeSnap (runtime/cube.js) select each bin whose center the brush covers, ends included, which is Plotly's highlight rule for bars. The centers are the ones the display draws, (i + 0.5) * step + lo: an analytic estimate is settled on them, so a brush end on a float-noisy center keeps that bar. A zero span (a constant column) draws every bar on lo: a brush over lo selects all bins, a brush beside it selects none.
  • The commit writes the kernel's bin boundaries: the first edge is lo, an inner edge is the first double that the kernel puts in bin k (found by bracketing and bisecting the doubles around the float lo + (k - 1e-9) * step along the kernel's rule floor((v - lo) * scale + eps), at most about 128 steps; near a zero crossing billions of doubles share one value of v - lo), and a brush that reaches the top bin ends at the domain top with closed="both". The ceil of an edge is then the first whole unit of a temporal bin.
  • The stored and rendered selection box sits on the committed edges.
  • A brush that covers no bin center clears the selection on commit. During the drag, such a frame shows the targets' pre-gesture state, also in overlay mode.
  • day_grid is removed for all sources, with the header fields w and p_eff, _day_free_bin_expr, fvCubeDayGrid, fvCubeSnapDay and fvCubeSnap2d.
  • The echo guard tolerance is half the source step.

4. fix(cube): brush a histogram source on its own bars

  • Histogram.get_cube_source_spec returns p = bins and, when zoomed, the viewport snapped to the bar lattice (snapped_domain, shared with the target spec).
  • Unzoomed, the engine resolves the free domain over the source's sibling histograms (_histogram_domain_cols_by_uid, as for targets), because the source's bars span that union.
  • The client reads the grid only from the cube header (free.p, free.domain, free.unit). fvCubeHeaderMatchesKey requires p or p + 1 bins on a zoomed axis and, on a numeric axis, a domain that covers the viewport.
  • _fvCubeSourceConstraint: when a histogram and a non-binned trace share the brushed column in one figure, the histogram is the source. _fvCubeSourceP is the one place that maps a source trace to its bin count.

5. fix(cube): brush a histogram2d source on its own cells

  • Histogram2D.get_cube_source_spec takes y_range and returns p = (x_bins, y_bins), each zoomed axis snapped to its lattice. The engine passes one viewport per select anchor. The composite free bin is bin_y * nx + bin_x.
  • FreeAxisSpec.p is int | tuple[int, int] and is validated per kind. _locate_box2d_axis, box2d_composite_stride and _FV_CUBE_BOX2D_P are removed.
  • decodeFVCube sizes the dense bin index (4 × (nx × ny + 1) bytes) before it allocates it. An entry over the client budget is refused without that allocation.

6. fix(trace): round Float32 range bounds without moving a value across them

  • _typed_range_bounds rounds a bound on a Float32 column like a bound on an integer column: a closed bound toward the interior of the range, an open bound away from it (_float32_toward). Before, the nearest Float32 could land on the far side of a value next to the bound. A whole-number bound arrives from JSON as an int and rounds too. This applies to committed selections and to zoomed viewport masks.

7. fix(predicates): round temporal selection bounds without moving a value across them

  • _temporal_bound_toward: a selection bound finer than a Date or Datetime("ms") column rounds to a whole unit by the same rule. Before, _typed_temporal_lit truncated it, so a range from Jan 1 13:00 kept Jan 1 (at 00:00), and [Jan 1 22:30, Jan 4 07:30) kept Jan 1 to 3 instead of Jan 2 to 4. This was a bug for every selection on such a column, not only the cube.
  • Only selection predicates use it. A viewport mask still truncates: rounding there would change which points a line keeps at the edge of a zoom.

8. fix(cube): commit Date bar edges so a restored selection box keeps them

  • _fvCubeCommitEdges commits a Date edge as a µs string of the exact bar edge, and the server rounds it to whole days (commit 7). A ms or µs edge stays a whole unit. The stored selection box is the committed range, and _fvCubeBoxEdges is removed.
  • /share keeps only the model fields of a selection, so a restore rebuilds the box from the predicate. Before, a Date box on bars narrower than two days came back on whole days: a box over bars 2 to 10 of 7 days in 16 bars came back as Jan 2 to Jan 6, so Plotly highlighted other bars than the rows.

Why

Measured on main before this PR:

  • A 200 x 200 hist2d source: a brush strictly inside one cell committed a box 1.56 cells wide per axis. Linked figures filtered 27 rows, the cell holds 12.

  • The default 20 x 20 grid: a brush inside cell [30, 35) committed x = [31.25, 34.37). Linked figures showed 471 rows instead of 964.

  • Cube size with build_cube + encode_fvcube on 2M rows, 20-bar histogram source (P = 2048 before, P = 20 after):

    target before after
    hist, 20 bins 227 KB 4.2 KB
    grouped hist, 5 groups 1.18 MB 21.7 KB
    hist2d, 20 x 20 2.16 MB 51.9 KB
  • A 20 x 20 hist2d source over a 20-bin hist target, 2M uniform rows: 326,930 cells (3.92 MB) at 128 x 128 against 8,000 cells (96.5 KB) on its own cells. A hist2d source finer than 128 x 128 gives a larger cube than before.

Found in review of the first version of this PR:

  • Date bars were drawn at day-rounded centers. In a simulation with one row per day and 20 bars, 28 % to 40 % of brushes over 13 to 30 days highlighted other rows than they committed (3 % over 365 days).
  • A float temporal edge put 6 % to 13 % of committed Datetime("us") edges one µs low when the span is not a round number. A row on that µs moved to another bar after a restore.
  • A constant column: a narrow brush over the value highlighted every bar and cleared the selection.
  • A zoomed histogram source or target: a row in (hi, hi + 1e-10] was in the cube but not in the display.
  • A histogram source next to a sibling histogram on another column: the free axis spanned only the source's own column, while its bars span the union. A brush snapped up to a whole bar away from the bars on screen.
  • A 500 x 500 box2d cube of 508 bytes allocated a 1 MB index before a 64 KB test budget refused it.

Found in the follow-up review:

  • Past 2^53 µs (the year 2255) the whole-µs correction loop did not end, because v - 1 === v. A 9999-12-31 sentinel in a Datetime("us") column puts even the first bar's upper edge there, so a brush over that bar froze the tab.
  • A Float32 column: bins 0 and 3 of a 7-bin example showed 2 rows but committed 1. The cause is the nearest-Float32 rounding of each bound, which predates this PR.
  • A Float64 value within a few ulps of lo + (k - 1e-9) * step could be on the other side of the committed edge than in the kernel.
  • A brush whose lower end sat exactly on a bar center excluded that bar. Plotly's box selection includes a point on its edge.
  • A sweep with values packed around every bin edge (300 grids x 20 brushes per dtype): before, 3035, 2996 and 321 of 6000 brushes committed other rows than the kernel counts for Float64, Float32 and Int64. After: 0 for each.

Found in the third follow-up review:

  • The previous version binned the line-envelope free axis twice: Python computed each row's bin, turned it into a midpoint and filtered the rows, and the kernel binned the midpoints again with its old rule. The build was about 30 % slower than on main. Now the kernel bins the free axis once. Release plugin, 4 threads, a 2048-bin free axis and 500 x buckets, medians of 39 alternating runs:

    Rows, free domain main previous version this PR
    2M rows, full domain 15.0 ms 19.3 ms 14.9 ms (1.00x)
    8M rows, full domain 43.1 ms 56.7 ms 44.2 ms (1.02x)

    A filter on the free domain before the collect saves time and memory on a zoomed source, but it costs a pass on every unzoomed build, so this PR leaves it out, as main does: Line-envelope cube: filter a zoomed free domain before the collect #140.

Found in the second follow-up review:

  • The one-ulp walk of the previous version needed about 4.3e9 steps for an edge next to zero ([-1, 1] in 20 bins, brush [0.02, 0.08]): a brush on an ordinary domain across zero froze the tab. The bisection returns in under 1 ms.
  • A whole-number Float32 bound above 2^24 arrives from JSON as an int and skipped the rounding: 2 rows committed where the kernel counts 1.
  • A shared Date selection lost its drawn bin edges (commits 7 and 8).
  • A brush end on a float-noisy drawn center (0.07500000000000001) dropped that bar.
  • The sweep with domains across zero added: 0 of 6000 brushes wrong per dtype, for two seeds.

Contract

  1. The brushed trace owns the free-axis grid. Histogram: p = bins, the snapped viewport zoomed (bins or bins + 1 bars), unzoomed the full domain unioned over its sibling histograms. Histogram2d: p = (x_bins, y_bins), each axis snapped. Line and box keep p = 2048.
  2. One bin rule: _fixed_hist_bin_expr on every free axis (and the same rule in the envelope kernel's free axis), the kernels' top clamp on every upper bound, no pad.
  3. One snap rule: snap_brush = fvCubeSnap, a bin is selected when the brush covers its drawn center, ends included; a zero span selects all bins or none. Each committed edge is the first double the kernel puts in its bin, found by a bounded search. A ms or µs edge is its ceil; a Date edge keeps its time of day. On the server a Float32, integer or finer-than-column temporal bound rounds without moving a value across it.
  4. No center covered: the commit clears the selection. Mid-drag, the targets show the pre-gesture state.
  5. The client reads the grid from the cube header only. A commit before the first header lands goes unsnapped through the normal POST.
  6. day_grid is gone for all sources.
  7. Header guard: a zoomed axis has p or p + 1 bins, and on a numeric axis a domain that covers the viewport.
  8. Source constraint: a histogram keeps the brushed column over a non-binned trace in the same figure.
  9. The selection box is the committed range, so a restore draws the same box. The echo guard tolerance is half the source step.
  10. A bar sits at its bin center, also on a Date axis.

Release

flexviz-polars must ship with the next flexviz release, and the flexviz-polars pin must rise: commit 3 changes the free-axis rule of fixed_line_envelope2d, and cube.py relies on it.

Known limits

Tests

  • ruff format, ruff check flexviz tests and cargo fmt --check: clean.

  • Every commit, with the release plugin built from commit 3 on. Non-browser is pytest tests with the default markers, plus flexviz_polars/tests from commit 3 on, where the kernel changes. Browser is pytest tests -m browser -p no:randomly:

    commit non-browser browser
    1 pad 2317 passed 354 passed
    2 Date 2318 passed 354 passed
    3 snap + envelope kernel 2459 passed 358 passed
    4 hist source 2466 passed 369 passed
    5 hist2d source 2474 passed 370 passed, 1 failed (see below)
    6 Float32 bounds 2484 passed 371 passed
    7 temporal bounds 2490 passed 371 passed
    8 Date edges 2490 passed 371 passed

    The machine ran at load 4 to 8 from other work. Browser runs with 4 or 6 workers hit page-load timeouts in a different set of tests each run. Each of those tests passed when run again, and each commit then passed a full run with 3 workers. The one exception is commit 5: test_cached_source_reset_issues_no_dashboard_update, which counts requests after a fixed 500 ms wait, failed in both full runs of that commit. It passed 5 of 5 runs on its own at commit 5 and at HEAD, and it passed in the full runs of commits 6 to 8.

  • make test-perf on HEAD: 7 passed. mkdocs build --strict passed. benchmarks/test_codspeed_engine.py -k build: 2 passed, including the new test_build_line_cubes.

  • TestDateHistSource, TestCubeGridGuards and TestTemporalSourceCube, 5 runs: all passed.

  • The edge sweep (values packed around every bin edge, domains across zero included): 0 of 6000 brushes wrong for Float64, Float32 and Int64, two seeds.

  • Each new regression test fails on the code without its fix: test_zoomed_hist_target_keeps_the_display_rows, test_date_bars_sit_on_their_bin_centers, test_zero_span_snap_covers_every_bar_or_none, test_brush_end_on_a_bar_center_includes_that_bar, test_committed_edges_keep_the_kernel_rows (Float64, Float32, Int64), test_temporal_commit_edges_are_the_kernel_bin_starts, test_constant_column_brush_selects_every_row, test_brush_highlights_the_rows_it_commits (both cases), test_sibling_hist_source_bins_on_its_display_grid, test_index_over_budget_is_sized_not_built, test_float32_bounds_keep_the_real_membership (float and whole-number bounds), test_float32_whole_number_bound_from_json_rounds, TestTemporalBoundRounding (the sub-unit cases), test_box_on_bar_edges_highlights_the_committed_bars (the restored box), test_free_value_on_an_edge_lands_in_the_display_bin (plugin), and test_hover_lookup_puts_edge_values_in_the_server_bin with BIN_EPS = 0. test_edge_next_to_zero_is_found_in_few_steps and test_commit_edges_past_exact_float_integers_return hang on the old code.

  • fvCubeSnap costs about 2 µs per call (Node, 20,000 calls).

@codspeed

codspeed Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 31 untouched benchmarks
🆕 1 new benchmark

Performance Changes

Benchmark BASE HEAD Efficiency
🆕 test_build_line_cubes N/A 33.3 ms N/A

Comparing fix/cube-source-grid (0b51914) with main (835c799)

Open in CodSpeed

@jvdd
jvdd force-pushed the fix/cube-source-grid branch from 682c636 to 0b51914 Compare October 1, 2026 08:17
@jvdd
jvdd merged commit 515d7e6 into main Oct 1, 2026
18 checks passed
@jvdd
jvdd deleted the fix/cube-source-grid branch October 1, 2026 09:03
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