Skip to content

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

Open
masenf wants to merge 4 commits into
mainfrom
claude/density-mean-color
Open

Density surfaces wear mean point color (LOD doc §2)#226
masenf wants to merge 4 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 density surfaces reflect the colors of their source points. The main changes are:

  • Adds deterministic mean-color aggregation to the Rust kernels and pyramids.
  • Sends mean-color density planes through Python and binary payloads.
  • Renders coverage-weighted RGBA density textures in WebGL.
  • Skips requests when the current view fits inside an exact drill window.
  • Updates exporters, examples, documentation, and tests for the new behavior.

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

  • Ran pytest for tests/test_density_mean_color.py with a 300-second timeout, and the command completed with exit status 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
src/kernels.rs Adds deterministic alpha-weighted color binning in linear light.
src/tiles.rs Adds colored pyramid construction, reduction, and composition.
src/lib.rs Updates the native ABI and exports the mean-color kernel and pyramid APIs.
python/xy/interaction.py Builds colored density pyramids and rebuilds them after incompatible appends.
python/xy/_payload.py Adds mean-color RGBA planes and compact channel data to density payloads.
python/xy/channels.py Adds channel quantization and RGBA lookup-table helpers.
js/src/45_lod.ts Uploads and renders coverage-weighted mean-color density textures.
js/src/54_kernel.ts Elides density requests within cached exact drill windows.
examples/fastapi/live_drilldown.py Moves drilldown aggregation into the engine and returns XYBF binary frames.

Reviews (2): Last reviewed commit: "Elide density_view requests for zooms in..." | 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
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