Skip to content

Density surfaces wear mean point color (LOD doc §2)#226

Draft
masenf wants to merge 5 commits into
mainfrom
claude/density-mean-color
Draft

Density surfaces wear mean point color (LOD doc §2)#226
masenf wants to merge 5 commits into
mainfrom
claude/density-mean-color

Conversation

@masenf

@masenf masenf commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Tier-2 scatter aggregation now colors each density cell with the alpha-weighted mean of the point colors in that cell, averaged in linear light. The count still drives the alpha channel for opacity/coverage. This eliminates the visual lie of colormapping density counts and lets zoomed-out views truthfully represent the data's own colors.

Key changes

Rust core (src/):

  • Added bin_2d_mean_color_cells kernel that accumulates exact integer sums of alpha-weighted linear-light color alongside counts, enabling order-independent parallel reduction
  • New Pyramid::build_color path that builds mean-color planes alongside count pyramids; reduce_color_level merges them during pyramid construction
  • Static sRGB↔linear-light u16 lookup tables (SRGB_TO_LINEAR_U16, linear_u16_to_srgb_u8) for bitwise-deterministic color math independent of thread count
  • Bumped ABI_VERSION to 40; added xy_bin_2d_mean_color and xy_pyramid_build_color C exports

Python (python/xy/):

  • interaction.py: Channel-bearing traces now build mean-color pyramids; appends invalidate and rebuild (color domain changes require rescan)
  • channels.py: New quantize_unit_u8 and colormap_lut_rgba8 helpers for wire quantization and LUT export
  • _payload.py: Density updates now ship mean-color RGBA8 planes alongside counts; continuous channels quantize to u8 for wire efficiency
  • marks.py: Color channel survives aggregation as the density surface's per-cell mean; size/density_val channels are dropped (count is implicit in alpha)

JavaScript (js/src/):

  • 45_lod.ts: lodWriteGridTexture now handles RGBA mean-color planes; premultiplies RGB by the log-tone-mapped alpha so bilinear filtering weights color by coverage
  • 54_kernel.ts: Zoom-in request elision (T12) — views inside an exact drill window skip the round-trip and use cached marks
  • 50_chartview.ts: Density traces no longer get a colormap gradient swatch in the legend (they wear the data's own colors, not a colormap)

FastAPI example (examples/fastapi/live_drilldown.py):

  • Simplified from custom density binning to delegating all LOD logic to the engine's Figure.density_view and Figure.pick
  • Removed DensityOverview class and integral-image math; the pyramid now lives in the engine
  • Uses encode_frame from xy.channel for XYBF binary transport (no base64)
  • Removed request bookkeeping; the render client handles stale-reply deduplication via seq

Documentation & tests:

  • spec/design/lod-architecture.md §2: Documented mean-color aggregation law and per-channel rules
  • New tests/test_density_mean_color.py: Kernel correctness vs NumPy oracle, wire shape, static exporter color law
  • New tests/test_drill_zoomin_elides_request.py: T12 elision (no re-request inside exact drill window)
  • Updated docstrings and examples to reflect the new density behavior

Implementation notes

  • Mean-color planes are optional (color_levels: Option<Vec<Vec<[u16; 4]>>> in Pyramid); count-only traces skip them
  • Color accumulation is exact integer arithmetic (sums, not means) so parallel merges are order-independent
  • Per-level rounding error is ≤ 0.5 lsb of u16 (recorded in LOD doc §28)
  • Density surfaces no longer carry a separate density_colormap field; the surface IS the data's colors
  • Zoom-in request elision (T12) bumps seq without sending a message, so stale replies from wider views are dropped correctly

https://claude.ai/code/session_01VYW3bncfneK8NbLoAFDMoo

masenf added 4 commits July 23, 2026 02:37
The Tier-2 density map colormapped the binned COUNT, ignoring the
points' own colors — a colored scatter's aggregate view matched neither
its marks nor its legend, and every density<->points zoom transition
visibly recolored the chart.

The surface now wears the data's colors (LOD doc §2): each cell carries
the alpha-weighted mean of its binned points' resolved colors — one law
for continuous, categorical, and direct-RGBA channels — averaged in
linear light through an integer-only pipeline (checked-in sRGB<->linear
u16 tables, u64 sums; bitwise deterministic across thread counts and
platforms). The log-tone-mapped count drives only the alpha channel:
more points, deeper color; fewer, lighter. Constant-color traces keep
the compact count-only wire and a client tint (the mean of a constant
is the constant).

- Rust core: bin_2d_mean_color kernel, with a fused cell accumulator
  that fans out under a points-per-cell gate (<=4 workers, exact
  integer merge — output identical to the serial scan); pyramid
  mean-color planes (build_color/compose_color; count grids
  bit-identical to the count-only compose; colored pyramids refuse
  appends and are invalidated + rebuilt lazily). C ABI v40.
- Python: channels.resolve_bin_colors maps every channel mode onto the
  kernel color source; initial emit + density_view (exact and pyramid
  paths) ship the RGBA plane as density.rgba with color_agg: "mean";
  color is no longer listed in dropped_channels; the windowed-exact
  spatial tier is gated to colorless traces (its index is
  position-only, §27 — the upsampled colored-pyramid grid stays
  instead); SVG/PNG exporters follow the same color law;
  continuous-channel density scatters render their colorbar again.
- Client: DENSITY_FS mean-color branch over a premultiplied RGBA8
  texture (count tone curve baked at upload so bilinear filtering
  weights color by coverage); exposure easing re-encodes color grids;
  the standalone to_html re-bin worker aggregates the sample's colors
  under the same law; the density-tier gradient legend swatch is
  removed (count is alpha, so a gradient would claim color == density;
  a named density trace falls through to the plain marker swatch,
  matching the static exporters).
- Handoff (§5): intensity-only. Hue is continuous by construction, so
  drilled points arrive in native colors, enter at their cell's
  count-alpha, and ease to native opacity; density_colormap left the
  points wire, and density_val (still u8 on the live wire, #221) now
  weights alpha instead of indexing a colormap LUT. The aggregate
  backdrop retires once a drill settles inside its window (T10
  amended: a mean-color wash under exact marks reads as data) and
  eases back fast when the view leaves the window, a refinement goes
  pending, or the drill dies — zoom-outs never blank and interleaved
  replies never flash.
- Spec: LOD doc §2 rules rewritten around the shipped mean-color law,
  pyramid §4 color planes and refused appends, T3/T10 amended, Phase
  1/3 progress; dossier §5 Tier-2, F5 verdict, and constants table;
  wire protocol records density.rgba/color_agg, the u8 unit-scalar
  encodings, and the slimmer points message; chart-kind contract
  covers the aggregate kernel pairing and the legend rule; rust-engine
  inventory row.
- Tests: kernel-vs-NumPy oracle suite (tests/test_density_mean_color,
  including a serial parallel-merge oracle and exporter pixel checks),
  wire and colorbar assertions, render-smoke probes for the mean-color
  surface (meancolor) and backdrop retirement (dretire). The pyramid
  color test asserts the direction the #153 area-weighted compose
  keeps invariant (nonzero count implies lit, unlit implies zero
  count) rather than exact mask equality, which boundary-bin count
  slivers can legitimately break.
The demo predated the kernel pyramid and carried two extra density
implementations to stay interactive at 100M points: a 6144^2
integral-image overview server-side and ~350 lines of page JS (browser
local re-bins, request parking/single-flight/timeout-retry, per-client
staleness maps, pending-status heuristics). Both were count-only, so
once density surfaces started wearing the mean point color, mid-zoom
windows flipped back to a count-colormapped green wash, flashing
old<->new across zoom levels. The engine has since absorbed every job
they existed to do: Figure.density_view serves wide windows from the
mean-color pyramid in O(visible cells) with drill bookkeeping and
recorded binning, and the render client debounces requests, seq-drops
stale replies, and keeps the best cached texture drawn while a reply is
in flight.

The module is now the pattern it demonstrates: build one figure
(pyramid warmed at startup), serve payload + bundled client as HTML,
and wire ChartView to a comm object that POSTs messages to one endpoint
dispatching density_view/pick under a lock. Page JS drops from ~400
lines to ~60 (fetch + status badge), and the HTML route reuses the
shared figure instead of regenerating 100M rows per GET.

Round-trips ship as XYBF binary frames (wire-protocol §7): the reply
message is the frame's compact JSON metadata and each numeric buffer
rides raw and 8-byte aligned, so the browser decodes one xy.decodeFrame
and hands the kernel zero-copy views with no base64 step on either side
(~33% smaller replies on the grids and point buffers that dominate a
drill). First paint still embeds its blob as base64 — inline HTML has
no binary channel (§6) — and malformed requests still return a JSON
error the client rejects on res.ok before decoding.

tests/test_drilldown_pan_alignment.py is removed with its subject: it
guarded the browser-local re-bin's window-clamp bug, and engine-served
grids always report exactly the window they binned, so that failure
mode is unreachable. The example test now decodes the XYBF frame and
asserts on its message and raw buffers.
Serve the fastapi example's live drilldown scatter — same seed-11
chunked columns, same mark config, same XY_LIVE_POINTS override — from
the reflex-xy adapter as one inline() token with zero transport code.
The fastapi app wires this chart through a custom HTTP transport (a
Starlette endpoint plus a comm bridge); the adapter's websocket
namespace and the kernel's density tiers replace all of it, so behavior
differences between the two hosts isolate what that custom code adds
versus what is fundamental to the engine (density tiers, pyramid
binning, drill-in).

- examples/reflex: §6 section + drilldown_chart()/drilldown_view(),
  README run notes (import-time build, memory, XY_LIVE_POINTS override)
- tests/test_example_apps.py: source markers for §6; the import test
  pins XY_LIVE_POINTS=50000 (same override the fastapi test uses) and
  asserts the inline token mints
- scripts/reflex_ws_smoke.py: count the seventh live source, wait for
  the drilldown payload, and pixel-check its density surface
- spec: reflex-integration §7 inventory + production-readiness example
  focus bullet name the deliberate cross-host overlap
Once a points reply has shipped its window exactly (reduction "none" —
the subset IS every point in the window), any view contained in that
window is already answered by the marks on the GPU: the smaller
window's points are a subset of the shipped ones. Drilling deeper
therefore no longer round-trips the kernel — _scheduleViewRequest skips
the trace's pending markers and its density_view message
(lodDrillServesView, re-checked at debounced send time), while still
bumping seq so an in-flight reply for an older, wider view dies stale
instead of yanking exact marks out from under a view it cannot improve.

Three conditions re-arm the request: leaving the window, a drill that
is dying or not exact (only reduction "none" arms the elision, recorded
as drill.exact), and depth — the shipped geometry is f32,
offset-encoded on the window midpoint (dossier §16), so once the view
span drops below 1/256 of the window span on either axis one request
goes out purely to re-center the encoding. Data changes cannot serve
stale marks through the elision: streaming append and full payload
updates rebuild the GPU trace, which drops the drill.

Recorded as invariant T12 in spec/design/lod-architecture.md §5, in the
wire protocol's density_view entry, and in dossier §16/§28; covered by
a headless-Chromium probe (tests/test_drill_zoomin_elides_request.py)
asserting the elision and each re-arm condition.
@masenf masenf closed this Jul 23, 2026
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes aggregated scatter density surfaces use the data’s own colors. The main changes are:

  • Adds deterministic mean-color aggregation and color-aware pyramids in Rust.
  • Ships mean-color density planes through Python payloads and exporters.
  • Renders premultiplied RGBA density textures in WebGL.
  • Elides redundant requests inside exact drill windows.
  • Simplifies the FastAPI and Reflex drilldown examples.

Confidence Score: 5/5

This looks safe to merge.

No blocking issues found in the changed code.

T-Rex T-Rex Logs

What T-Rex did

  • Documented the pre-change density treatment and Viridis legend gradient before recording.
  • Recorded and captured the poster showing completed blue/green density regions using source colors, and tracked changes in intensity and coverage rather than hue.
  • Performed screenshot analysis to quantify pixel counts for source-blue and source-green after the change (26,169 and 20,369 respectively) and verified there were zero pixels near the Viridis endpoints.
  • Collected exact generation and capture harness details along with browser, server, and test logs for full traceability.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
src/kernels.rs Adds deterministic alpha-weighted mean-color binning.
src/tiles.rs Adds optional color planes to density pyramids.
python/xy/_payload.py Ships mean-color RGBA planes with density counts.
python/xy/interaction.py Builds and refreshes color-aware density pyramids.
js/src/45_lod.ts Uploads count-only and premultiplied mean-color grid textures.
js/src/54_kernel.ts Skips redundant requests within exact drill windows.
examples/fastapi/live_drilldown.py Delegates drilldown behavior to the engine and uses XYBF responses.

Reviews (3): Last reviewed commit: "Merge origin/main (#228 separable pyrami..." | Re-trigger Greptile

@masenf masenf reopened this Jul 23, 2026
@masenf
masenf changed the base branch from claude/density-mean-color-on-issue-153 to main July 23, 2026 06:12
…y-mean-color

Both sides added a function after axis_weights: #228's transpose_to_taps
(the separable compose's x-pass taps) and this branch's compose_color.
Keep both. compose_color is unaffected by the count pass going separable:
its count grid comes from compose() itself (bit-identical by delegation),
and its color planes weight by the same axis_weights either way; it keeps
the one-pass 2D splat, noted in its doc comment.
@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 15.95%

⚡ 1 improved benchmark
✅ 101 untouched benchmarks
⏩ 1 skipped benchmark1

Performance Changes

Benchmark BASE HEAD Efficiency
test_select_lasso_message_1m 141.6 ms 122.1 ms +15.95%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/density-mean-color (96a5255) with main (dcf3781)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@masenf

masenf commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

This PR seems to have a problem inflating the wire transfer size of the drilldown payload significantly. Needs to be fixed before merging.

@masenf
masenf marked this pull request as draft July 23, 2026 17:49
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