From 7e9809c49b4368162ca8b1aba78859fe81db8fc8 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:19 +0000 Subject: [PATCH 01/11] Color density surfaces by mean point color; count becomes alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 25 ++ js/src/00_header.ts | 8 +- js/src/40_gl.ts | 36 ++- js/src/45_lod.ts | 169 +++++++--- js/src/46_worker.ts | 54 +++- js/src/50_chartview.ts | 46 ++- js/src/54_kernel.ts | 59 +++- python/xy/_native.py | 212 ++++++++++++- python/xy/_payload.py | 23 +- python/xy/_raster.py | 28 +- python/xy/_svg.py | 14 +- python/xy/_trace.py | 3 + python/xy/channels.py | 82 +++++ python/xy/components.py | 9 +- python/xy/config.py | 11 +- python/xy/interaction.py | 98 ++++-- python/xy/kernels.py | 6 + python/xy/marks.py | 21 +- scripts/abi_smoke.py | 190 +++++++++++ scripts/render_smoke_nonumpy.py | 74 ++++- spec/api/chart-kind-contract.md | 13 +- spec/api/chart-roadmap.md | 9 +- spec/design-dossier.md | 59 +++- spec/design/lod-architecture.md | 248 ++++++++++----- spec/design/renderer-architecture.md | 2 +- spec/design/rust-engine.md | 36 ++- spec/design/wire-protocol.md | 45 ++- src/kernels.rs | 429 +++++++++++++++++++++++++ src/lib.rs | 176 ++++++++++- src/tiles.rs | 454 +++++++++++++++++++++++++-- tests/test_declarative_colorbar.py | 11 +- tests/test_density_mean_color.py | 326 +++++++++++++++++++ tests/test_scatter.py | 50 ++- 33 files changed, 2712 insertions(+), 314 deletions(-) create mode 100644 tests/test_density_mean_color.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8fa687..d41b9a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,31 @@ in the README). `memory_report`), and `Chart.figure()` remains as an advanced escape hatch to the internal engine object. +### Changed +- **Density surfaces now wear the data's own colors (LOD doc §2).** A Tier-2 + scatter's aggregated view colors each cell with the alpha-weighted **mean of + its binned points' resolved colors** (continuous colormap, categorical + palette, and direct-RGBA channels alike; averaged in linear light through a + deterministic integer pipeline), while the binned **count now drives only + the alpha channel** — more points, deeper color; fewer points, lighter. + Previously the count itself was colormapped, so a colored scatter's density + view matched neither its points nor its legend and every density⇄points + zoom transition recolored the chart. The wire ships a per-cell RGBA plane + (`density.rgba`, recorded as `color_agg: "mean"`); constant-color traces + keep the compact count-only grid and a client-side tint. The count pyramid + gained matching mean-color planes (`xy_pyramid_build_color` / + `xy_pyramid_compose_color`; colored pyramids refuse in-place appends and + rebuild lazily, and their base scan fans out ≤4 workers so a 100M-point + build lands in about a quarter of the time), the SVG/PNG exporters and the + standalone `to_html` re-bin worker follow the same law, and the drill + handoff is now intensity-only — drilled points arrive in their native + colors (`density_colormap` left the points wire), and the aggregate + backdrop retires once a drill settles inside its window (T10), returning + the moment the view leaves it or a refinement goes pending. The color + channel is no longer listed in `dropped_channels` at Tier 2, and a + continuous-channel density scatter renders its colorbar again. C ABI v40 + (`xy_bin_2d_mean_color` + pyramid color entry points). + ### Added - **Export format parity and a unified export API (ENG-10447).** `to_image(format=...)` and extension-inferred, atomic `write_image(path)` diff --git a/js/src/00_header.ts b/js/src/00_header.ts index 16b50f8f..b67da7d3 100644 --- a/js/src/00_header.ts +++ b/js/src/00_header.ts @@ -11,9 +11,11 @@ * - per-point size: constant or continuous (mapped to a px range) * - GPU picking → exact-row hover tooltip (§7/§17 Tier-0 hover; exact values * come from the kernel's f64 canonical store, §16) - * - Tier-2 density surface for massive scatter (§5): a kernel-binned count grid - * uploaded as a log-normalized R8 texture and colormapped at composite time, - * re-binned on zoom via a kernel round-trip (stale grid stays drawn until + * - Tier-2 density surface for massive scatter (§5): a kernel-binned count + * grid whose log-normalized count drives the ALPHA channel; channel-bearing + * traces add a per-cell mean point-color plane (premultiplied RGBA8 + * texture), constant-color traces tint a 1-byte count texture (LOD doc §2). + * Re-binned on zoom via a kernel round-trip (stale grid stays drawn until * then, §17) * * Dependency-free: this file is the whole client. DOM is used only for chrome — diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 602d620d..782e7f16 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -219,7 +219,7 @@ float xyMarkerSdf(vec2 d, int shape) { export const POINT_FS = `#version 300 es precision highp float; precision highp int; uniform vec4 u_color; uniform int u_colorMode; uniform sampler2D u_lut; uniform float u_opacity; -uniform sampler2D u_dlut; uniform float u_dblend; +uniform float u_dblend; uniform int u_symbol; uniform vec4 u_ptStroke; uniform float u_ptStrokeWidth; uniform int u_ptStrokeFace; uniform int u_strokeMode; uniform float u_strokeOpacity; uniform int u_selActive; uniform vec4 u_selColor; uniform vec4 u_unselColor; @@ -248,12 +248,6 @@ void main() { if (shapeCov <= 0.001) discard; vec4 paint = u_colorMode == 3 ? v_rgba : (u_colorMode == 0 ? u_color : vec4(texture(u_lut, vec2(clamp(v_lutCoord, 0.0, 1.0), 0.5)).rgb, 1.0)); vec3 rgb = paint.rgb; - // Drill handoff (§5): near the density boundary, paint by local density with - // the density ramp; ease into native colors as the zoom deepens (u_dblend->0). - if (u_dblend > 0.001) { - vec3 drgb = texture(u_dlut, vec2(clamp(v_dval, 0.0, 1.0), 0.5)).rgb; - rgb = mix(rgb, drgb, u_dblend); - } // §34 selected/unselected recolor: when a selection is active, tint each point // toward its state color (.a is the mix weight; 0 = keep native color). if (u_selActive == 1) { @@ -262,6 +256,15 @@ void main() { } float intrinsicAlpha = paint.a; float fillAlpha = (v_style.y >= 0.0 ? v_style.y : intrinsicAlpha) * v_style.x * u_opacity; + // Drill handoff (§5): the density surface already wears the mean point + // color (LOD doc §2), so hue never jumps at the texture->marks swap — only + // intensity hands off. Near the boundary each mark enters at its cell's + // count-alpha (v_dval through the texture's own tone curve) and eases to + // native opacity as the zoom deepens (u_dblend -> 0). + if (u_dblend > 0.001) { + float dalpha = clamp(v_dval * 1.35, 0.0, 1.0); + fillAlpha *= mix(1.0, dalpha, u_dblend); + } vec4 px = vec4(rgb * fillAlpha, fillAlpha); // premultiplied fill // Uniform (u_ptStroke) and per-item (v_stroke) stroke paint ship straight // alpha and go through the same artist-alpha/opacity stack, so a scalar @@ -391,18 +394,35 @@ void main() { // Density grids are binned uniformly in scale coordinates (§28), so // u_gridRange arrives as *scale coordinates* of the grid's raw x/y ranges and // the fragment's uv is a straight affine map of v_coord — no inverse needed. +// +// Color law (LOD doc §2): the surface wears the DATA's colors, count drives +// only the alpha. Mean-color grids (u_meanColor) sample a premultiplied RGBA8 +// texture whose rgb is the per-cell mean point color and whose alpha carries +// the log count tone curve (baked at upload so bilinear filtering weights +// color by coverage — no dark skirts at occupied/empty seams). Constant-color +// traces keep the 1-byte count texture and tint with u_color; the LUT branch +// remains as the fallback for count-only grids that ship neither (hand-built +// or legacy specs). export const DENSITY_FS = `#version 300 es precision highp float; uniform sampler2D u_grid; uniform sampler2D u_lut; uniform vec4 u_gridRange; // coord(gx0),coord(gx1),coord(gy0),coord(gy1) uniform float u_opacity; uniform vec4 u_color; uniform int u_constantColor; +uniform int u_meanColor; in vec2 v_coord; out vec4 outColor; void main() { vec2 uv = vec2((v_coord.x - u_gridRange.x) / (u_gridRange.y - u_gridRange.x), (v_coord.y - u_gridRange.z) / (u_gridRange.w - u_gridRange.z)); if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard; - float t = texture(u_grid, uv).r; + vec4 s = texture(u_grid, uv); + if (u_meanColor == 1) { + float alpha = s.a * u_opacity; + if (alpha <= 0.004) discard; + outColor = vec4(s.rgb * u_opacity, alpha); + return; + } + float t = s.r; if (t <= 0.0) discard; vec4 paint = u_constantColor == 1 ? u_color diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index f9372d9c..f2475fd2 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -50,23 +50,57 @@ export function lodCopyGrid(f32) { return f32.slice ? f32.slice() : new Float32Array(f32); } -// Log tone-mapped grid upload (R8): stable perception across renormalization, -// and the u_max swings between rebins compress logarithmically (§5/§F6). -export function lodWriteGridTexture(gl, tex, f32, w, h, maxVal, filter) { - const data = new Uint8Array(f32.length); +// Log tone-mapped grid upload: stable perception across renormalization, and +// the u_max swings between rebins compress logarithmically (§5/§F6). +// +// Count-only grids upload as R8 (the shader tints/LUTs them). Mean-color +// grids (LOD doc §2) pass the straight-alpha RGBA plane shipped by the +// kernel: rgb = per-cell mean point color, a = mean point alpha. The texture +// bakes the count tone curve into the alpha and stores rgb PREMULTIPLIED — +// in sRGB space, exactly like the mark shaders' outputs — so bilinear +// filtering weights color by coverage instead of dragging occupied cells +// toward transparent-black neighbors. Exposure easing re-calls this per +// norm step; rgb is re-premultiplied against the eased alpha each time. +// `filter` picks the sampling the reply asked for (spatial-exact grids ship +// "nearest"); it applies to both texture layouts. +export function lodWriteGridTexture(gl, tex, f32, w, h, maxVal, rgba = null, filter = "linear") { const denom = Math.log1p(Math.max(0, maxVal || 0)); - if (denom > 0) { - for (let i = 0; i < f32.length; i++) { - const c = f32[i]; - if (c > 0 && Number.isFinite(c)) { - data[i] = Math.max(1, Math.min(255, Math.round(255 * Math.log1p(c) / denom))); + let data; + if (rgba) { + data = new Uint8Array(f32.length * 4); + if (denom > 0) { + for (let i = 0; i < f32.length; i++) { + const c = f32[i]; + if (!(c > 0) || !Number.isFinite(c)) continue; + const t = Math.min(1, Math.log1p(c) / denom); + const shaped = Math.min(1, t * 1.35) * (rgba[i * 4 + 3] / 255); + if (shaped <= 0) continue; + const a = Math.max(1, Math.round(255 * shaped)); + data[i * 4] = Math.round(rgba[i * 4] * a / 255); + data[i * 4 + 1] = Math.round(rgba[i * 4 + 1] * a / 255); + data[i * 4 + 2] = Math.round(rgba[i * 4 + 2] * a / 255); + data[i * 4 + 3] = a; + } + } + } else { + data = new Uint8Array(f32.length); + if (denom > 0) { + for (let i = 0; i < f32.length; i++) { + const c = f32[i]; + if (c > 0 && Number.isFinite(c)) { + data[i] = Math.max(1, Math.min(255, Math.round(255 * Math.log1p(c) / denom))); + } } } } gl.bindTexture(gl.TEXTURE_2D, tex); const align = gl.getParameter(gl.UNPACK_ALIGNMENT); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); + if (rgba) { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); + } else { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, data); + } gl.pixelStorei(gl.UNPACK_ALIGNMENT, align); // "nearest" for a full-screen-resolution grid (exact deep-zoom detail — crisp, // no interpolation bleed); "linear" (default) smooths an upsampled aggregate. @@ -106,7 +140,8 @@ function lodStartNormAnim(view, g, start, target) { g.density.normMax = target; g.densityNormMax = target; lodWriteGridTexture( - view.gl, g.density.tex, g.density.grid, g.density.w, g.density.h, target, g.density.filter + view.gl, g.density.tex, g.density.grid, g.density.w, g.density.h, target, + g.density.rgba, g.density.filter, ); return; } @@ -130,7 +165,7 @@ function lodStepNorm(view, g) { if (rel > 0.004 || t >= 1) { d.normMax = norm; g.densityNormMax = norm; - lodWriteGridTexture(view.gl, d.tex, d.grid, d.w, d.h, norm, d.filter); + lodWriteGridTexture(view.gl, d.tex, d.grid, d.w, d.h, norm, d.rgba, d.filter); } if (t < 1) { view.draw(); @@ -452,10 +487,11 @@ export function lodApplyDrill(view, g, upd, buffers) { gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); } view._pointMarkStyle(d, d.trace); - // Color-continuous handoff (§5): per-point local log-density + a blend - // weight. Fresh at the boundary (blend≈1) the marks wear the aggregate's - // colormap, so the texture->marks swap doesn't recolor the chart; deeper - // zooms ship smaller blends and the native colors ease in. + // Intensity-continuous handoff (§5): per-point local log-density + a blend + // weight. The density surface already wears the mean point color (LOD doc + // §2), so hue is continuous by construction; fresh at the boundary + // (blend≈1) each mark enters at its cell's count-alpha and deeper zooms + // ship smaller blends, easing marks to native opacity. if (upd.density_val && upd.density_val.buf !== undefined) { const dvalValues = upd.density_val.dtype === "u8" ? view._asU8(buffers[upd.density_val.buf]) @@ -464,15 +500,14 @@ export function lodApplyDrill(view, g, upd, buffers) { view._tagChannelBuf(d.dBuf, dvalValues, true); gl.bindBuffer(gl.ARRAY_BUFFER, d.dBuf); gl.bufferData(gl.ARRAY_BUFFER, dvalValues, gl.STATIC_DRAW); - d.dlut = view._lut(upd.density_colormap || "viridis"); const first = d.lodBlend === undefined; d.lodBlend = Math.min(1, upd.lod_blend ?? 0); // The kernel's blend weight assumes level-by-level zooms; a fast zoom // skips levels and the first marks land with a mostly-native weight — - // a visible recolor at the texture→marks swap. The BOUNDARY is the - // transition itself: fresh marks appear wearing the aggregate's colormap + // a visible intensity pop at the texture→marks swap. The BOUNDARY is the + // transition itself: fresh marks appear at the aggregate's count-alpha // (blend 1) and the tween eases them to the kernel's weight, so the swap - // never recolors regardless of how many levels the zoom skipped (§5). + // never pops regardless of how many levels the zoom skipped (§5). d._lodBlendNative = d.lodBlend; if (fresh) d.lodBlendShown = 1; else if (first) d.lodBlendShown = d.lodBlend; // no tween-from-zero on refresh @@ -550,6 +585,8 @@ export function lodDropDrill(view, g) { g._drillShownAlpha = null; g._drillDying = false; g._drillDiedInsideWin = false; + g._drillBackdropShown = 1; // next drill enters over a full backdrop (T10) + g._drillBackdropTick = 0; view._hoverId = -1; // drilled indices are dead; don't reuse a cached row view._lastRow = null; // The freed drill may have been the only pickable geometry (a density-only @@ -617,8 +654,8 @@ function lodDrillShownAlpha(view, g) { // Switch to the entry (fade-in) clock, seeded so it starts at the shown alpha. function lodEnterDrillContinuous(view, g) { - // A revived/held drill eases back to its native colors (the exit ramp - // below may have retargeted the blend at the aggregate's colormap). + // A revived/held drill eases back to its native intensity (the exit ramp + // below may have retargeted the blend at the aggregate's count-alpha). if (g.drill && g.drill.dBuf && g.drill._lodBlendNative !== undefined) { g.drill.lodBlend = g.drill._lodBlendNative; } @@ -631,10 +668,11 @@ function lodEnterDrillContinuous(view, g) { // Switch to the exit (fade-out) clock, seeded the same way. function lodBeginDrillExitContinuous(view, g) { - // Exit recolor (§5): dying/exiting marks converge to the aggregate's - // colormap as they fade, so they melt INTO the texture instead of a - // differently-colored cluster blinking out over it. The blend tween - // (τ=90ms) does the easing; revives restore the native weight. + // Exit re-intensity (§5): dying/exiting marks converge to the aggregate's + // local count-alpha as they fade, so they melt INTO the texture instead of + // a differently-weighted cluster blinking out over it (hue already matches + // — the texture wears the mean point color). The blend tween (τ=90ms) does + // the easing; revives restore the native weight. if (g.drill && g.drill.dBuf) g.drill.lodBlend = 1; if (g._drillExitFadeStart != null) return; // already exiting — keep its clock const alpha = lodDrillShownAlpha(view, g); @@ -654,6 +692,9 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { const grid = d.enc === "log-u8" ? lodDecodeLogU8(buffers[d.buf], d.max) : lodCopyGrid(view._asF32(buffers[d.buf])); + // Mean point color plane (LOD doc §2), copied because exposure easing + // re-reads it on every norm step, after the wire buffer may be gone. + const rgba = d.rgba !== undefined ? new Uint8Array(view._asU8(buffers[d.rgba])) : null; const normStart = lodNormMax(g, d.max); const normMax = view._prefersReducedMotion() ? d.max : normStart; g.densityNormMax = normMax; @@ -664,8 +705,10 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap, color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color, xRange: d.x_range, yRange: d.y_range, - grid, filter, - tex: view._uploadGrid(grid, d.w, d.h, normMax, filter), + grid, + rgba, + filter, + tex: view._uploadGrid(grid, d.w, d.h, normMax, rgba, filter), lut: g.density.lut, }; // Exact scans include a view-specific sample and replace the overlay. @@ -711,17 +754,40 @@ function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { view._drawDensity(g, density, opacityScale); } -// The tier's frame: the aggregate texture is the CONTINUOUS BACKDROP at every -// state (T10) — marks draw over it while the view sits inside a live drilled -// window, fade over it during transitions (drill-in entry fade, dying drill -// exit fade, stale-while-revalidate hold), and it stands alone otherwise. -// Never blank, never a hard cut (§5 smooth transitions): the background of a -// drilled frame and a density frame is the same texture, so every drill -// transition is a marks-layer fade, not a full-frame swap. (Previously marks -// "owned the frame" once their entry fade completed — the backdrop flipped to -// the blank chart background, and interleaved density/points replies during a -// continuous zoom flashed green-texture ⇄ points-on-blank, the live-drilldown -// flicker.) +// The drilled frame's backdrop opacity, eased continuously (T10). The +// aggregate texture stays painted through every transition — entry fade, +// hold, exit fade, revive — and retires once a drill is settled inside its +// window: the marks are exact for that window, and a mean-color wash under +// exact points reads as data. Retirement eases out gently after the entry +// fade lands and eases back fast when the drill exits, holds, or dies, so +// zoom-outs never blank (T1) and interleaved replies never flash. Time-based +// exponential decay, same shape as the lod_blend tween; reduced motion snaps. +function lodDrillBackdropScale(view, g, target) { + let shown = g._drillBackdropShown; + if (shown === undefined || shown === null) shown = 1; + if (Math.abs(shown - target) <= 0.005 || view._prefersReducedMotion()) { + g._drillBackdropShown = target; + g._drillBackdropTick = 0; + return target; + } + const now = view._now(); + const dt = g._drillBackdropTick ? Math.min(100, now - g._drillBackdropTick) : 16; + g._drillBackdropTick = now; + const tau = target > shown ? 45 : 80; + shown += (target - shown) * (1 - Math.exp(-dt / tau)); + g._drillBackdropShown = shown; + view.draw(); // keep the retire/restore animating on settled views + return shown; +} + +// The tier's frame: the aggregate texture is the continuous backdrop through +// every transitional drill state (T10) — marks fade over it entering, held, +// dying, and exiting — and it stands alone otherwise, so a representation +// change is a marks-layer fade over a stable frame, never a blank or a hard +// cut (§5). Once a drill is settled inside its window the backdrop retires +// (lodDrillBackdropScale above): the marks are exact, and the §28 aggregate +// context returns the instant the view leaves the window or a refinement +// goes pending. export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { lodStepNorm(view, g); const d = g.drill; @@ -753,7 +819,12 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { g._drillExitFadeStart = null; const fade = lodFade(view, g._drillFadeStart); g._drillShownAlpha = fade; - if (density && density.tex) lodDrawDensityWithFade(view, g, density); + // Settled (entry fade landed): the marks are exact — retire the backdrop. + // Refreshes inside a settled drill keep it retired (no per-reply flash). + const backdrop = lodDrillBackdropScale(view, g, fade >= 1 ? 0 : 1); + if (density && density.tex && backdrop > 0.004) { + lodDrawDensityWithFade(view, g, density, backdrop); + } if (fade < 1) { drawMarks(fade); view.draw(); @@ -764,11 +835,12 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { } else if (density && density.tex) { if (lodHoldPendingDrill(view, g, d)) { // Held marks continue their own entry fade over the backdrop — a hold - // engaging mid-fade must not snap. + // engaging mid-fade must not snap. The backdrop eases back in if the + // settled drill had retired it (the view has left the exact window). lodEnterDrillContinuous(view, g); const fade = lodFade(view, g._drillFadeStart); g._drillShownAlpha = fade; - lodDrawDensityWithFade(view, g, density); + lodDrawDensityWithFade(view, g, density, lodDrillBackdropScale(view, g, 1)); if (fade < 1) { drawMarks(fade); } else { @@ -794,7 +866,9 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { const exitFade = exitingDrill ? lodDrillExitFade(view, g) : 1; if (d) g._drillShownAlpha = exitingDrill && exitFade < 1 ? 1 - exitFade : 0; if (exitingDrill && exitFade < 1) { - lodDrawDensityWithFade(view, g, density); + // A retired backdrop (settled drill) eases back in under the exiting + // marks — the fast restore keeps the frame from ever reading blank. + lodDrawDensityWithFade(view, g, density, lodDrillBackdropScale(view, g, 1)); drawMarks(1 - exitFade); view.draw(); } else { @@ -809,7 +883,14 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { if (g.drill && g._drillEverInside && lodDrillOutgrown(view, g, d)) { lodDropDrill(view, g); } - lodDrawDensityWithFade(view, g, density); + // Ease toward full while a (retired) drill lingers; without one the + // aggregate owns the frame outright. + const backdrop = g.drill ? lodDrillBackdropScale(view, g, 1) : 1; + if (!g.drill) { + g._drillBackdropShown = 1; + g._drillBackdropTick = 0; + } + lodDrawDensityWithFade(view, g, density, backdrop); view._drawDensitySample(g, x0, x1, y0, y1); } } else if (d) { diff --git a/js/src/46_worker.ts b/js/src/46_worker.ts index a72c2375..a412b2b3 100644 --- a/js/src/46_worker.ts +++ b/js/src/46_worker.ts @@ -9,38 +9,82 @@ // same LOD plumbing as a kernel density_update and recorded as a reduction // badge ("zoom re-binned from sample") — never silent. // +// Channel-bearing traces also init the worker with the sample's resolved +// straight-alpha RGBA8 point colors; each rebin then returns a mean-color +// plane alongside the counts (LOD doc §2): per cell, the alpha-weighted mean +// point color averaged in linear light — the same law as the kernel's +// bin_2d_mean_color — so a standalone zoom keeps the surface wearing the +// data's own colors while count keeps driving only the alpha. +// // The worker script travels inside the bundle and boots from a Blob URL (the // standalone CSP allows worker-src blob:). Environments without workers (or a // stricter CSP) fall back to the old stretched-overview behavior. // --------------------------------------------------------------------------- const XY_REBIN_WORKER_SRC = ` +// sRGB byte -> linear-light (0..1); built once, mirrors the kernel's table. +const LIN = new Float64Array(256); +for (let i = 0; i < 256; i++) { + const c = i / 255; + LIN[i] = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} +const SRGB = (v) => { + const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; + return Math.max(0, Math.min(255, Math.round(c * 255))); +}; const DATA = new Map(); self.onmessage = (e) => { const m = e.data; if (m.type === "init") { - DATA.set(m.trace, { x: new Float64Array(m.x), y: new Float64Array(m.y) }); + DATA.set(m.trace, { + x: new Float64Array(m.x), + y: new Float64Array(m.y), + rgba: m.rgba ? new Uint8Array(m.rgba) : null, + }); return; } const d = DATA.get(m.trace); if (!d) return; const w = m.w, h = m.h; const grid = new Float32Array(w * h); + const sums = d.rgba ? new Float64Array(w * h * 4) : null; // aR, aG, aB, sum(a) const sx = w / ((m.x1 - m.x0) || 1); const sy = h / ((m.y1 - m.y0) || 1); let max = 0; - const X = d.x, Y = d.y, n = X.length; + const X = d.x, Y = d.y, C = d.rgba, n = X.length; for (let i = 0; i < n; i++) { const cx = (X[i] - m.x0) * sx; const cy = (Y[i] - m.y0) * sy; if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue; - const v = ++grid[(cy | 0) * w + (cx | 0)]; + const cell = (cy | 0) * w + (cx | 0); + const v = ++grid[cell]; if (v > max) max = v; + if (sums) { + const a = C[i * 4 + 3]; + sums[cell * 4] += a * LIN[C[i * 4]]; + sums[cell * 4 + 1] += a * LIN[C[i * 4 + 1]]; + sums[cell * 4 + 2] += a * LIN[C[i * 4 + 2]]; + sums[cell * 4 + 3] += a; + } + } + let rgba = null; + if (sums) { + rgba = new Uint8Array(w * h * 4); + for (let cell = 0; cell < w * h; cell++) { + const count = grid[cell]; + const weight = sums[cell * 4 + 3]; + if (!(count > 0) || !(weight > 0)) continue; + rgba[cell * 4] = SRGB(sums[cell * 4] / weight); + rgba[cell * 4 + 1] = SRGB(sums[cell * 4 + 1] / weight); + rgba[cell * 4 + 2] = SRGB(sums[cell * 4 + 2] / weight); + rgba[cell * 4 + 3] = Math.min(255, Math.round(weight / count)); + } } self.postMessage( { type: "grid", seq: m.seq, trace: m.trace, w, h, max, - x0: m.x0, x1: m.x1, y0: m.y0, y1: m.y1, grid: grid.buffer }, - [grid.buffer] + x0: m.x0, x1: m.x1, y0: m.y0, y1: m.y1, grid: grid.buffer, + rgba: rgba ? rgba.buffer : null }, + rgba ? [grid.buffer, rgba.buffer] : [grid.buffer] ); }; `; diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 21d89f30..eeac5ca3 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1721,9 +1721,12 @@ export class ChartView { const items = []; if (s.show_legend !== false) { for (const t of s.traces) { - if (t.tier === "density") { - items.push({ swatch: "gradient", cmap: t.density.colormap, name: t.name || "density" }); - } else if (t.color && t.color.mode === "categorical") { + // A density-tier surface encodes count as alpha and wears the mean + // point color (LOD doc §2), so it gets no colormap gradient swatch — + // a gradient would claim color == density. A named density trace + // falls through to the plain marker swatch below, matching the + // static SVG/raster exporters. + if (t.color && t.color.mode === "categorical") { t.color.categories.forEach((cat, i) => items.push({ swatch: t.color.palette[i], name: cat, symbol: t.kind === "scatter" ? (t.style?.symbol || "circle") : null, style: t.style || {} })); } else if (t.color && t.color.mode === "continuous") { @@ -2095,14 +2098,22 @@ export class ChartView { const meta = this.spec.columns[d.buf]; const raw = this._columnView(buffer, meta); const grid = d.enc === "log-u8" ? lodDecodeLogU8(raw, d.max) : raw; + // Mean point color plane (LOD doc §2), copied because exposure + // re-encodes outlive the payload buffer; absent for constant-color + // traces, which tint the count texture instead. + const rgba = d.rgba !== undefined + ? new Uint8Array(this._columnView(buffer, this.spec.columns[d.rgba])) + : null; g.densityNormMax = d.max; const filter = d.filter || "linear"; g.density = { w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap, color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null, xRange: d.x_range, yRange: d.y_range, - grid: lodCopyGrid(grid), filter, - tex: this._uploadGrid(grid, d.w, d.h, d.max, filter), + grid: lodCopyGrid(grid), + rgba, + filter, + tex: this._uploadGrid(grid, d.w, d.h, d.max, rgba, filter), lut: this._lut(d.colormap), }; g.sampleOverlay = this._buildDensitySample(t, d.sample, buffer); @@ -2849,10 +2860,10 @@ export class ChartView { return tex; } - _uploadGrid(f32, w, h, maxVal, filter) { + _uploadGrid(f32, w, h, maxVal, rgba = null, filter = "linear") { const gl = this.gl; const tex = gl.createTexture(); - lodWriteGridTexture(gl, tex, f32, w, h, maxVal, filter); + lodWriteGridTexture(gl, tex, f32, w, h, maxVal, rgba, filter); return tex; } @@ -3245,10 +3256,12 @@ export class ChartView { gl.bindTexture(gl.TEXTURE_2D, g.lut); gl.uniform1i(u("u_lut"), 0); } - // Drill handoff (§5): blend from the density ramp toward native colors. - // The shown weight eases toward the kernel's target so successive drill - // updates recolor smoothly instead of stepping. Time-based decay (τ=90ms) - // — a per-frame factor would converge 2.4× faster on a 144Hz display. + // Drill handoff (§5): blend from the aggregate's local count-alpha toward + // native opacity (hue already matches — the texture wears the mean point + // color, LOD doc §2). The shown weight eases toward the kernel's target + // so successive drill updates re-weight smoothly instead of stepping. + // Time-based decay (τ=90ms) — a per-frame factor would converge 2.4× + // faster on a 144Hz display. const blendTarget = g.lodBlend ?? 0; let blend = g.lodBlendShown ?? blendTarget; if (Math.abs(blend - blendTarget) > 0.005 && !this._prefersReducedMotion()) { @@ -3263,12 +3276,7 @@ export class ChartView { g._blendTick = 0; } gl.uniform1f(u("u_dblend"), blend); - const blendOn = blend > 0.001 && g.dBuf && g.dlut; - if (blendOn) { - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, g.dlut); - } - gl.uniform1i(u("u_dlut"), 1); // sampler must always point at a valid unit + const blendOn = blend > 0.001 && g.dBuf; this._bindVao( g, @@ -3442,6 +3450,10 @@ export class ChartView { this._axisCoord(yAxis, d.yRange[0]), this._axisCoord(yAxis, d.yRange[1]), ); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale); + // Mean-color grids carry their colors in the texture (LOD doc §2); + // count-only grids tint with the constant trace color or, failing that, + // fall back to the LUT ramp (hand-built/legacy specs). + gl.uniform1i(u("u_meanColor"), d.rgba ? 1 : 0); const constant = d.color; gl.uniform1i(u("u_constantColor"), constant ? 1 : 0); gl.uniform4f(u("u_color"), ...(constant || [1, 1, 1, 1])); diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 8de436be..12b3d8db 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -1,4 +1,6 @@ import { payloadBuffers } from "./00_header"; +import { buildLutData } from "./10_colormaps"; +import { parseColor } from "./20_theme"; import { lodApplyDensityUpdate, lodApplyDrill, lodDropDrill, lodRememberDensity } from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -123,7 +125,7 @@ Object.assign(ChartView.prototype, { const hd = g._homeDensity; this._applySampleRebinGrid(g, { ...hd, - tex: this._uploadGrid(hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.filter), + tex: this._uploadGrid(hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.rgba, hd.filter), }, false); } return; @@ -139,7 +141,9 @@ Object.assign(ChartView.prototype, { this._rebinInit = new Set(); } if (!this._rebinInit.has(g.trace.id)) { - // Decode the offset-encoded sample once (f64, §16); the worker keeps it. + // Decode the offset-encoded sample once (f64, §16); the worker keeps it, + // along with the sample's resolved point colors so re-binned grids keep + // the mean-color surface (LOD doc §2). const cpu = g.sampleOverlay._cpu; const n = Math.min(cpu.x.length, cpu.y.length); const xs = new Float64Array(n); @@ -148,9 +152,13 @@ Object.assign(ChartView.prototype, { xs[i] = this._decodeValue(cpu.x, cpu.xMeta, i); ys[i] = this._decodeValue(cpu.y, cpu.yMeta, i); } + const rgba = this._sampleBinColors(g, n); this._rebinWorker.postMessage( - { type: "init", trace: g.trace.id, x: xs.buffer, y: ys.buffer }, - [xs.buffer, ys.buffer] + { + type: "init", trace: g.trace.id, x: xs.buffer, y: ys.buffer, + rgba: rgba ? rgba.buffer : null, + }, + rgba ? [xs.buffer, ys.buffer, rgba.buffer] : [xs.buffer, ys.buffer] ); this._rebinInit.add(g.trace.id); } @@ -163,17 +171,58 @@ Object.assign(ChartView.prototype, { }); }, + // Straight-alpha RGBA8 per retained-sample point, resolved from the + // overlay's shipped channel exactly as the point shader draws it — the + // worker's mean-color source (LOD doc §2). Constant-color traces return + // null: their count-only grid is tinted by the draw path instead. + _sampleBinColors(g, n) { + const overlay = g.sampleOverlay; + const cpu = overlay && overlay._cpu; + const spec = overlay && overlay.trace && overlay.trace.color; + if (!cpu || !spec || spec.mode === "constant" || spec.mode === "match_fill") return null; + if (spec.mode === "direct_rgba" && cpu.rgba) { + return new Uint8Array(cpu.rgba.subarray(0, n * 4)); + } + if (!cpu.color) return null; + const out = new Uint8Array(n * 4); + if (spec.mode === "continuous") { + const lut = buildLutData(spec.colormap); + for (let i = 0; i < n; i++) { + const at = Math.round(Math.max(0, Math.min(1, cpu.color[i])) * 255) * 4; + out[i * 4] = lut[at]; + out[i * 4 + 1] = lut[at + 1]; + out[i * 4 + 2] = lut[at + 2]; + out[i * 4 + 3] = 255; + } + return out; + } + if (spec.mode === "categorical" && Array.isArray(spec.palette) && spec.palette.length) { + const palette = spec.palette.map((c) => parseColor(this.root, c, [0, 0, 0, 1])); + for (let i = 0; i < n; i++) { + const p = palette[Math.round(cpu.color[i]) % palette.length]; + out[i * 4] = Math.round(p[0] * 255); + out[i * 4 + 1] = Math.round(p[1] * 255); + out[i * 4 + 2] = Math.round(p[2] * 255); + out[i * 4 + 3] = Math.round(p[3] * 255); + } + return out; + } + return null; + }, + _onRebinResult(msg) { if (this._destroyed || this._glLost || !msg || msg.type !== "grid" || msg.seq !== this.seq) return; const g = this.gpuTraces.find((t) => t.trace.id === msg.trace && t.tier === "density"); if (!g) return; const grid = new Float32Array(msg.grid); + const rgba = msg.rgba ? new Uint8Array(msg.rgba) : null; this._applySampleRebinGrid(g, { w: msg.w, h: msg.h, max: msg.max, normMax: msg.max, colormap: g.density.colormap, xRange: [msg.x0, msg.x1], yRange: [msg.y0, msg.y1], grid, - tex: this._uploadGrid(grid, msg.w, msg.h, msg.max || 1), + rgba, + tex: this._uploadGrid(grid, msg.w, msg.h, msg.max || 1, rgba), lut: g.density.lut, }, true); }, diff --git a/python/xy/_native.py b/python/xy/_native.py index 09d80d8b..e0298da5 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 39 +ABI_VERSION = 40 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. @@ -452,6 +452,23 @@ def _load() -> ctypes.CDLL: ctypes.c_void_p, ctypes.c_void_p, ] + lib.xy_bin_2d_mean_color.restype = ctypes.c_int32 + lib.xy_bin_2d_mean_color.argtypes = [ + ctypes.c_void_p, # x + ctypes.c_void_p, # y + ctypes.c_size_t, # len + ctypes.c_void_p, # idx (or NULL) + ctypes.c_void_p, # rgba (or NULL) + ctypes.c_void_p, # lut (with idx) + ctypes.c_size_t, # lut_len + ctypes.c_double, # x0 + ctypes.c_double, # x1 + ctypes.c_double, # y0 + ctypes.c_double, # y1 + ctypes.c_size_t, # w + ctypes.c_size_t, # h + ctypes.c_void_p, # out rgba8 + ] lib.xy_bin_2d_sample_range.restype = ctypes.c_size_t lib.xy_bin_2d_sample_range.argtypes = [ ctypes.c_void_p, # x @@ -622,6 +639,34 @@ def _load() -> ctypes.CDLL: ctypes.c_size_t, # max_upsample ctypes.c_void_p, ] + lib.xy_pyramid_build_color.restype = ctypes.c_uint64 + lib.xy_pyramid_build_color.argtypes = [ + ctypes.c_void_p, # x + ctypes.c_void_p, # y + ctypes.c_size_t, # len + ctypes.c_void_p, # idx (or NULL) + ctypes.c_void_p, # rgba (or NULL) + ctypes.c_void_p, # lut (with idx) + ctypes.c_size_t, # lut_len + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_uint32, + ] + lib.xy_pyramid_compose_color.restype = ctypes.c_int32 + lib.xy_pyramid_compose_color.argtypes = [ + ctypes.c_uint64, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_double, + ctypes.c_size_t, + ctypes.c_size_t, + ctypes.c_size_t, # max_upsample + ctypes.c_void_p, # out counts f32 + ctypes.c_void_p, # out rgba8 + ] lib.xy_pyramid_free.restype = ctypes.c_int32 lib.xy_pyramid_free.argtypes = [ctypes.c_uint64] lib.xy_local_log_density.restype = ctypes.c_int32 @@ -2045,6 +2090,85 @@ def bin_2d_f32( return out +def _color_source_args( + n: int, + idx: "npt.NDArray[np.uint8] | None", + rgba: "npt.NDArray[np.uint8] | None", + lut: "npt.NDArray[np.uint8] | None", +) -> tuple: + """Validate and marshal a mean-color source: exactly one of `idx` + (per-point LUT index + `lut` of 1..=256 RGBA8 rows) or `rgba` (per-point + straight-alpha RGBA8). Returns the four C arguments plus the arrays kept + alive through the call.""" + if (idx is None) == (rgba is None): + raise ValueError("exactly one of idx or rgba must be provided") + if idx is not None: + idx = np.ascontiguousarray(idx, dtype=np.uint8) + if idx.shape != (n,): + raise ValueError(f"idx must be 1-D length {n}, got shape {idx.shape}") + if lut is None: + raise ValueError("idx colors need a lut") + lut = np.ascontiguousarray(lut, dtype=np.uint8) + if lut.ndim != 2 or lut.shape[1] != 4 or not 1 <= lut.shape[0] <= 256: + raise ValueError(f"lut must be (1..=256, 4) u8, got shape {lut.shape}") + return idx.ctypes.data, None, lut.ctypes.data, lut.shape[0], (idx, lut) + rgba = np.ascontiguousarray(rgba, dtype=np.uint8) + if rgba.shape not in {(n, 4), (n * 4,)}: + raise ValueError(f"rgba must be ({n}, 4) u8, got shape {rgba.shape}") + return None, rgba.ctypes.data, None, 0, (rgba,) + + +def bin_2d_mean_color( + x: npt.NDArray[np.float64], + y: npt.NDArray[np.float64], + x0: float, + x1: float, + y0: float, + y1: float, + w: int, + h: int, + *, + idx: "npt.NDArray[np.uint8] | None" = None, + rgba: "npt.NDArray[np.uint8] | None" = None, + lut: "npt.NDArray[np.uint8] | None" = None, +) -> npt.NDArray[np.uint8]: + """Mean-color companion grid to `bin_2d` (§5 Tier 2, LOD doc §2): (h, w, 4) + straight-alpha RGBA8, row 0 = bottom. Each occupied cell carries the + alpha-weighted mean of its points' resolved colors (averaged in linear + light) and the plain mean of their straight alpha; cell membership is + bit-identical to `bin_2d`.""" + w = _bounded_positive_int(w, "w") + h = _bounded_positive_int(h, "h") + x0, x1 = _finite_increasing(x0, x1, "x range") + y0, y1 = _finite_increasing(y0, y1, "y range") + x = _as_f64(x, "x") + y = _as_f64(y, "y") + if len(x) != len(y): + raise ValueError("x and y must have equal length") + idx_ptr, rgba_ptr, lut_ptr, lut_len, _keepalive = _color_source_args(len(x), idx, rgba, lut) + out = np.zeros((h, w, 4), dtype=np.uint8) + if len(x): + ok = _lib.xy_bin_2d_mean_color( + _ptr_f64(x), + _ptr_f64(y), + len(x), + idx_ptr, + rgba_ptr, + lut_ptr, + lut_len, + x0, + x1, + y0, + y1, + w, + h, + out.ctypes.data, + ) + if not ok: + raise ValueError("invalid bin_2d_mean_color arguments") + return out + + def bin_2d_indices( x: npt.NDArray[np.float64], y: npt.NDArray[np.float64], @@ -2614,6 +2738,52 @@ def pyramid_build( ) +def pyramid_build_color( + x: "npt.NDArray[np.float64]", + y: "npt.NDArray[np.float64]", + x0: float, + x1: float, + y0: float, + y1: float, + base_dim: int, + *, + idx: "npt.NDArray[np.uint8] | None" = None, + rgba: "npt.NDArray[np.uint8] | None" = None, + lut: "npt.NDArray[np.uint8] | None" = None, +) -> int: + """Build a pyramid with mean-color planes (LOD doc §2/§4.1) so zoomed-out + density views of a channel-bearing trace keep the mean point color without + an O(N) rescan. Returns a handle, 0 on failure. Color source as in + `bin_2d_mean_color`. Colored pyramids refuse `pyramid_append` — callers + invalidate and lazily rebuild instead.""" + base_dim = _pyramid_base_dim(base_dim) + x0, x1 = _finite_increasing(x0, x1, "x range") + y0, y1 = _finite_increasing(y0, y1, "y range") + x = _as_f64(x, "x") + y = _as_f64(y, "y") + if len(x) != len(y): + raise ValueError("x and y must have equal length") + if len(x) == 0: + return 0 + idx_ptr, rgba_ptr, lut_ptr, lut_len, _keepalive = _color_source_args(len(x), idx, rgba, lut) + return int( + _lib.xy_pyramid_build_color( + x.ctypes.data, + y.ctypes.data, + len(x), + idx_ptr, + rgba_ptr, + lut_ptr, + lut_len, + x0, + x1, + y0, + y1, + base_dim, + ) + ) + + def pyramid_append( handle: int, x: "npt.NDArray[np.float64]", @@ -2695,6 +2865,46 @@ def pyramid_compose( return out, int(level) +def pyramid_compose_color( + handle: int, + lo_x: float, + hi_x: float, + lo_y: float, + hi_y: float, + w: int, + h: int, + max_upsample: int = 2, +) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint8], int] | None: + """(counts f32 [h*w], mean-color rgba8 [h, w, 4], level) from a colored + pyramid, or None when the window outresolves it beyond ``max_upsample`` + or the pyramid carries no color planes (caller falls back to an exact + re-bin, §28). Counts are bit-identical to `pyramid_compose` with the + same ``max_upsample``.""" + handle = _pyramid_handle(handle) + lo_x, hi_x = _finite_increasing(lo_x, hi_x, "x range") + lo_y, hi_y = _finite_increasing(lo_y, hi_y, "y range") + w = _bounded_positive_int(w, "w") + h = _bounded_positive_int(h, "h") + max_upsample = _positive_int(max_upsample, "max_upsample") + out = np.zeros(w * h, dtype=np.float32) + out_rgba = np.zeros((h, w, 4), dtype=np.uint8) + level = _lib.xy_pyramid_compose_color( + ctypes.c_uint64(handle), + lo_x, + hi_x, + lo_y, + hi_y, + w, + h, + max_upsample, + out.ctypes.data, + out_rgba.ctypes.data, + ) + if level < 0: + return None + return out, out_rgba, int(level) + + def pyramid_free(handle: int) -> bool: return _lib.xy_pyramid_free(ctypes.c_uint64(_pyramid_handle(handle))) == 1 diff --git a/python/xy/_payload.py b/python/xy/_payload.py index a7f20279..a992b0ef 100644 --- a/python/xy/_payload.py +++ b/python/xy/_payload.py @@ -1051,16 +1051,18 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d grid, sel = kernels.bin_2d_indices(bx, by, bx0, bx1, by0, by1, w, h) visible = int(len(sel)) encoded_grid, gmax = kernels.density_log_u8(grid) - # Honor the user's colormap for the density ramp even though the per-point - # color *data* can't survive count-aggregation (needs the §5-F5 algebra). - # Constant channels carry it too — colormap= without color data means - # exactly this ramp. Categorical has no ramp, so it keeps the default. + # The density surface wears the data's own colors (LOD doc §2): count + # is the alpha channel, and per-point color channels aggregate to a + # per-cell mean shipped as an RGBA plane below. `colormap` stays on + # the wire only for the client's count-only LUT fallback (hand-built + # specs); no shipped path colormaps counts. cmap = ( t.color_ch.colormap if (t.color_ch and t.color_ch.mode in ("constant", "continuous")) else channels.DEFAULT_COLORMAP ) dropped_channels = list(t.per_item_channel_names()) + bin_colors = channels.resolve_bin_colors(t.color_ch, None, DEFAULT_PALETTE) density = { "buf": pw.ship_u8(encoded_grid), "w": w, @@ -1070,9 +1072,18 @@ def _density_trace_spec(self, t: Trace, xr, yr, w, h, pw: "_PayloadWriter") -> d "colormap": cmap, "x_range": list(xr), "y_range": list(yr), - "channels_dropped": bool(dropped_channels), # compatibility boolean - "dropped_channels": dropped_channels, # complete, actionable list (§28) } + if bin_colors is not None: + # Mean point color per cell, straight-alpha RGBA8: the color the + # points themselves would downsample to (averaged in linear + # light). The channel is aggregated, recorded via `color_agg`, + # and therefore leaves the dropped list. + rgba_grid = kernels.bin_2d_mean_color(bx, by, bx0, bx1, by0, by1, w, h, **bin_colors) + density["rgba"] = pw.ship_u8(rgba_grid.reshape(-1)) + density["color_agg"] = "mean" + dropped_channels.remove("color") + density["channels_dropped"] = bool(dropped_channels) # compatibility boolean + density["dropped_channels"] = dropped_channels # complete, actionable list (§28) if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None: density["color"] = t.color_ch.constant if oversized: diff --git a/python/xy/_raster.py b/python/xy/_raster.py index d075af94..ce325ec8 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -37,6 +37,7 @@ _column, _corner_radii, _css, + _density_column, _heatmap_rgba_grid, _legend_layout, _lut, @@ -1890,6 +1891,31 @@ def _emit_grid( elif g.get("enc") == "log-u8": w, h = int(g["w"]), int(g["h"]) meta = cols[g["buf"]] + xr, yr = g["x_range"], g["y_range"] + dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy) + if g.get("rgba") is not None: + # Mean point color per cell (LOD doc §2): rgb from the shipped + # plane; count drives only the alpha ramp, scaled by the cell's + # mean point alpha — the same law as _svg._density_image and the + # client's DENSITY_FS. Precomposed here and emitted as a plain + # image op; the count→LUT density op cannot express per-cell color. + rgba_meta = cols[g["rgba"]] + mean = np.frombuffer( + blob, dtype=np.uint8, count=rgba_meta["len"], offset=rgba_meta["byte_offset"] + ).reshape(h, w, 4) + counts = _density_column(blob, meta, g).reshape(h, w) + gmax = float(g.get("max") or 1.0) or 1.0 + tnorm = np.clip(counts / gmax, 0.0, 1.0) + alpha = ( + np.clip(tnorm * 1.35, 0, 1) + * 255 + * _fill_opacity(style, 0.85) + * (mean[..., 3].astype(np.float64) / 255.0) + ).astype(np.uint8) + alpha[tnorm <= 0] = 0 + rgba = np.ascontiguousarray(np.dstack([mean[..., :3], alpha])[::-1]) + cmd.image(dx, dy, dw, dh, w, h, rgba.tobytes(), nearest=False) + return paint_alpha = 1.0 if g.get("color") is not None: red, green, blue, alpha = _parse_color(g["color"]) @@ -1897,8 +1923,6 @@ def _emit_grid( paint_alpha = alpha / 255.0 else: stops = np.asarray(_colormap_stops(g.get("colormap", "viridis")), dtype=np.uint8) - xr, yr = g["x_range"], g["y_range"] - dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy) cmd.density_image( dx, dy, diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3421267c..f26707e3 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2577,8 +2577,18 @@ def _density_image( grid = _density_column(blob, cols[d["buf"]], d).reshape(h, w) gmax = float(d.get("max") or 1.0) or 1.0 tnorm = np.clip(grid / gmax, 0.0, 1.0) - paint_alpha = 1.0 - if d.get("color") is not None: + paint_alpha: float | np.ndarray = 1.0 + if d.get("rgba") is not None: + # Mean point color per cell (LOD doc §2): rgb from the shipped plane, + # count drives only the alpha ramp, scaled by the cell's mean point + # alpha — the same law as the client's DENSITY_FS. + meta = cols[d["rgba"]] + mean = np.frombuffer( + blob, dtype=np.uint8, count=meta["len"], offset=meta["byte_offset"] + ).reshape(h, w, 4) + rgb = mean[..., :3] + paint_alpha = mean[..., 3].astype(np.float64) / 255.0 + elif d.get("color") is not None: red, green, blue, alpha8 = _paint_rgba8(d["color"]) rgb = np.empty((h, w, 3), dtype=np.uint8) rgb[:] = (red, green, blue) diff --git a/python/xy/_trace.py b/python/xy/_trace.py index 7d1d94a5..73629a7d 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -74,7 +74,10 @@ class Trace: # Count-pyramid cache (§5 Tier 3), managed by `interaction.py`: None = # never tried, 0 = tried and not applicable, otherwise the native handle. # The finalizer frees the native side when the trace is collected. + # `_pyr_colored` records whether the handle carries mean-color planes + # (LOD doc §2) — compose must ask for them, and the memory report counts them. _pyr_handle: Optional[int] = field(default=None, init=False, repr=False, compare=False) + _pyr_colored: bool = field(default=False, init=False, repr=False, compare=False) _pyr_finalizer: Optional[Any] = field(default=None, init=False, repr=False, compare=False) _pyr_base_dim: int = field(default=0, init=False, repr=False, compare=False) # Optional Tier-3 spatial index (xy._spatial.SpatialIndex) for O(window) diff --git a/python/xy/channels.py b/python/xy/channels.py index bf2bb9b7..d5417853 100644 --- a/python/xy/channels.py +++ b/python/xy/channels.py @@ -523,6 +523,88 @@ def quantize_unit_u8(values: npt.NDArray[np.float64], domain: tuple[float, float return np.rint(np.clip(unit, 0.0, 1.0) * 255.0).astype(np.uint8) +def colormap_lut_rgba8(colormap: str) -> npt.NDArray[np.uint8]: + """The client's 256-texel colormap LUT as (256, 4) straight-alpha RGBA8. + + Built from the same stop tables the SVG exporter mirrors from + `js/src/10_colormaps.ts`, so a value binned through this LUT wears the + byte-identical color its drawn point does.""" + from . import _svg # deferred: channels is core, _svg owns the stop tables + + lut = np.empty((256, 4), dtype=np.uint8) + lut[:, :3] = _svg._lut(colormap, np.linspace(0.0, 1.0, 256)) + lut[:, 3] = 255 + return lut + + +def palette_rgba8(palette: list[str], n_categories: int) -> npt.NDArray[np.uint8]: + """Categorical palette colors as straight-alpha RGBA8 LUT rows. + + One row per category up to 256; beyond that callers fold codes modulo the + base palette instead (`resolve_bin_colors`), which is the same repeat rule + `ship_color_channel` applies.""" + rows = min(n_categories, MAX_CATEGORIES) + lut = np.empty((max(rows, 1), 4), dtype=np.uint8) + for i in range(lut.shape[0]): + status, rgba = kernels.css_check(kernels.CSS_COLOR, str(palette[i % len(palette)])) + if status != 1 or rgba is None: + rgba = (0.0, 0.0, 0.0, 1.0) + lut[i] = [round(c * 255) for c in rgba] + return lut + + +def bins_mean_color(cc: Optional[ColorChannel]) -> bool: + """Whether this channel aggregates to a mean-color density plane at + Tier 2 (LOD doc §2) instead of being dropped. Cheap predicate — no + arrays are touched — for warning/spec sites; `resolve_bin_colors` is + gated on exactly this.""" + return cc is not None and cc.mode in ("continuous", "categorical", "direct_rgba") + + +def resolve_bin_colors(cc: Optional[ColorChannel], sel: Any, palette: list[str]) -> Optional[dict]: + """Kernel color source for mean-color density binning (LOD doc §2). + + Returns `kernels.bin_2d_mean_color`-style kwargs — ``{"idx", "lut"}`` for + palette/colormap channels, ``{"rgba"}`` for direct RGBA — resolved to the + straight-alpha RGBA8 each point *draws* with, so the aggregated surface + and the drawn marks share one color story. Constant channels return + ``None``: their mean is the constant, so the count-only grid plus the + client-side tint reproduces it exactly with no per-cell color plane. + """ + if not bins_mean_color(cc): + return None + assert cc is not None + if cc.mode == "direct_rgba": + rgba = cc.rgba + if rgba is None: + raise ValueError("direct RGBA color channel missing values") + values = rgba if sel is None else rgba[sel] + return {"rgba": np.rint(np.clip(values, 0.0, 1.0) * 255.0).astype(np.uint8)} + if cc.mode == "continuous": + values = cc.values + domain = cc.domain + if values is None or domain is None: + raise ValueError("continuous color channel missing values or domain") + vals = values if sel is None else values[sel] + # Same normalization the wire ships, quantized to the nearest of the + # client's 256 LUT texels. + unit = normalize_to_unit(vals, domain) + idx = np.rint(np.asarray(unit, dtype=np.float64) * 255.0).astype(np.uint8) + return {"idx": idx, "lut": colormap_lut_rgba8(cc.colormap)} + code_values = cc.codes + categories = cc.categories + if code_values is None or categories is None: + raise ValueError("categorical color channel missing codes or categories") + codes = code_values if sel is None else code_values[sel] + if codes.dtype == np.uint8: + return {"idx": codes, "lut": palette_rgba8(palette, len(categories))} + # >256 categories ship wide codes; palette colors repeat every + # len(palette) categories, so folding the codes onto the base palette + # bins each point with exactly the color it draws with. + folded = (codes % len(palette)).astype(np.uint8) + return {"idx": folded, "lut": palette_rgba8(palette, len(palette))} + + def ship_channels( trace: Any, sel: Any, diff --git a/python/xy/components.py b/python/xy/components.py index ccff2a9e..65578cb2 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -3833,11 +3833,10 @@ def _declarative_colorbar_options(mark: Mark, traces: list[Any]) -> Optional[dic channel = trace.color_ch if channel is None or channel.mode != "continuous": continue - # A density-tier scatter colors aggregate counts; its original - # per-row color channel is explicitly dropped, so advertising that - # channel's domain beside the density ramp would be misleading. - if trace.kind == "scatter" and trace.use_density(): - continue + # A density-tier scatter wears the channel's own colors — each + # cell shows the mean of its points' colormapped values (LOD doc + # §2) — so the channel's domain⇄colormap colorbar is truthful in + # both representations and renders as for a direct scatter. domain = channel.domain colormap = channel.colormap if domain is None or colormap is None: diff --git a/python/xy/config.py b/python/xy/config.py index b6abdef2..60dd4310 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -23,13 +23,16 @@ # Scatter above this many points switches to Tier-2 density aggregation (§5): # instead of shipping/drawing every point (fill-rate + the ~1 GB single-alloc -# cliff, §5 F3), the kernel bins the viewport into a density grid and the client -# colormaps it. Screen-bounded transport and VRAM regardless of point count. +# cliff, §5 F3), the kernel bins the viewport into a density grid the client +# draws with the trace's own colors — count drives only the alpha (LOD doc +# §2). Screen-bounded transport and VRAM regardless of point count. SCATTER_DENSITY_THRESHOLD = 200_000 # Absolute direct-draw ceiling; above this, density is forced even if the user -# asked for per-point channels (they can't survive count-aggregation without the -# §5-F5 aggregation algebra — we warn and drop them, never silently mislead). +# asked for per-point channels. The color channel survives as the surface's +# per-cell mean point color (LOD doc §2); the rest (size, stroke, styles) have +# no honest per-cell aggregate yet (§5 F5) — we warn and drop them, never +# silently mislead. DIRECT_SOFT_CEILING = 2_000_000 # Stable-key matching retains a browser-side identity table for only bounded diff --git a/python/xy/interaction.py b/python/xy/interaction.py index fd9c3826..114d2616 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -380,7 +380,13 @@ def _pyramid_base_dim_for(t: Trace) -> int: 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. - Only worth the memory for genuinely large traces.""" + Only worth the memory for genuinely large traces. + + Channel-bearing traces build mean-color planes alongside the counts + (LOD doc §2) so pyramid-served zoom-outs keep the mean point color; those + pyramids refuse native appends and are invalidated + lazily rebuilt + instead (the appended rows' colors and a possibly moved channel domain + both require a rescan).""" handle = getattr(t, "_pyr_handle", None) if handle is not None: return handle or None @@ -398,9 +404,16 @@ def _ensure_pyramid(t: Trace) -> int | None: x1 += (x1 - x0) * 1e-9 y1 += (y1 - y0) * 1e-9 base_dim = _pyramid_base_dim_for(t) - handle = kernels.pyramid_build(t.x.values, t.y.values, x0, x1, y0, y1, base_dim) + bin_colors = channels.resolve_bin_colors(t.color_ch, None, DEFAULT_PALETTE) + if bin_colors is not None: + handle = kernels.pyramid_build_color( + t.x.values, t.y.values, x0, x1, y0, y1, base_dim, **bin_colors + ) + else: + handle = kernels.pyramid_build(t.x.values, t.y.values, x0, x1, y0, y1, base_dim) t._pyr_handle = handle t._pyr_base_dim = base_dim + t._pyr_colored = bool(handle) and bin_colors is not None if handle: # §27: the pyramid is native-side memory owned by this trace. Tie its # lifetime to the Trace object so a discarded Figure (the notebook @@ -425,12 +438,14 @@ def _free_pyramid(t: Trace) -> None: t._pyr_handle = None -def _pyramid_resident_bytes(base_dim: int = PYRAMID_BASE_DIM) -> int: - """Exact native bytes of one count pyramid: u32 levels from base_dim² - halving per side down to 1² (mirrors tiles.rs level construction).""" +def _pyramid_resident_bytes(base_dim: int = PYRAMID_BASE_DIM, *, colored: bool = False) -> int: + """Exact native bytes of one pyramid: u32 count levels from base_dim² + halving per side down to 1² (mirrors tiles.rs level construction), plus + the [u16; 4] mean-color planes when the trace bins colors.""" + per_cell = 4 + (8 if colored else 0) total, dim = 0, base_dim while True: - total += dim * dim * 4 + total += dim * dim * per_cell if dim == 1: return total dim >>= 1 @@ -438,9 +453,13 @@ def _pyramid_resident_bytes(base_dim: int = PYRAMID_BASE_DIM) -> int: def pyramid_report_bytes(fig: Any) -> int: """Memory-report line (design dossier §27): native bytes held by live - trace pyramids, at each trace's actual (possibly adaptive) base dim.""" + trace pyramids, at each trace's actual (possibly adaptive) base dim, + including the mean-color planes of colored pyramids.""" return sum( - _pyramid_resident_bytes(getattr(t, "_pyr_base_dim", None) or PYRAMID_BASE_DIM) + _pyramid_resident_bytes( + getattr(t, "_pyr_base_dim", None) or PYRAMID_BASE_DIM, + colored=bool(getattr(t, "_pyr_colored", False)), + ) for t in fig.traces if getattr(t, "_pyr_handle", 0) ) @@ -538,9 +557,9 @@ def _density_sample_update( def _quantize_dval(dval: np.ndarray) -> np.ndarray: """Quantize per-point local log-density ([0,1] by construction) to u8. - The value is only a LUT coordinate for the density→points color handoff - (§5) — 256 levels exceed what the crossfade can show, at a quarter of the - f32 wire bytes (§29).""" + The value only weights the density→points intensity handoff (§5) — 256 + levels exceed what the crossfade can show, at a quarter of the f32 wire + bytes (§29).""" return np.rint(np.clip(dval, 0.0, 1.0) * 255.0).astype(np.uint8) @@ -608,7 +627,6 @@ def _ship_index_points( "size": size_spec, "density_val": {"buf": dval_buf, "dtype": "u8"}, "lod_blend": lod_blend, - "density_colormap": channels.DEFAULT_COLORMAP, "drill_seq": drill_seq, "style": dict(t.style), } @@ -646,6 +664,10 @@ def density_view( # budget we fall through to the exact scan that drilling needs anyway. binning = "exact" grid = None + # Mean point color plane (LOD doc §2): channel-bearing traces ship it + # alongside the counts wherever a grid is produced below. + rgba_grid: np.ndarray | None = None + bin_colors = channels.resolve_bin_colors(t.color_ch, None, DEFAULT_PALETTE) # Texture sampling for the density grid: "nearest" when the grid is at full # screen resolution (exact deep-zoom detail — crisp, no interpolation bleed), # "linear" when it is upsampled from a coarser tier (smooth aggregate). @@ -677,9 +699,19 @@ def density_view( False, aggregate_reduction="pyramid-count", ) - res = kernels.pyramid_compose( - pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample - ) + # Colored pyramids compose the mean-color plane with the counts + # (same level, same max_upsample); both refusals (outresolved + # window, missing planes) fall through to the paths below. + if getattr(t, "_pyr_colored", False): + res_color = kernels.pyramid_compose_color( + pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample + ) + res = (res_color[0], res_color[2]) if res_color is not None else None + rgba_grid = res_color[1] if res_color is not None else None + else: + res = kernels.pyramid_compose( + pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample + ) if res is not None: grid, level = res visible = plan.visible @@ -690,6 +722,10 @@ def density_view( # (upsampled), re-bin it *exactly* from just its in-window points — as long # as that count is affordable to read. This is what turns a blocky deep zoom # into real detail (streets), and it gets cheaper the deeper you go. + # The derived index is position-only (§27): it can neither ship channels + # with a points drill nor bin a mean-color plane (LOD doc §2), so a + # color-channelled trace skips this tier and keeps its upsampled + # colored-pyramid grid — blurry but truthful, recorded via `binning`. sidx = getattr(t, "_spatial_index", None) # `window_count` is a cheap (offsets-only, no point reads) upper bound — # whole overlapping cells, which at tight zoom overshoots the true in-window @@ -697,6 +733,7 @@ def density_view( # are affordable, gather **once** and decide by the *actual* in-window count. if ( sidx is not None + and bin_colors is None and (grid is None or binning.endswith("-upsampled")) and sidx.window_count(lo_x, hi_x, lo_y, hi_y) <= SPATIAL_EXACT_MAX_POINTS ): @@ -739,9 +776,16 @@ def density_view( visible = plan.visible w, h = plan.grid_w, plan.grid_h grid = kernels.bin_2d(xv, yv, lo_x, hi_x, lo_y, hi_y, w, h) + if bin_colors is not None: + # This branch is already the O(N) correctness net; the mean-color + # pass (LOD doc §2) rides the same full-column scan cost. + rgba_grid = kernels.bin_2d_mean_color( + xv, yv, lo_x, hi_x, lo_y, hi_y, w, h, **bin_colors + ) binning = "bin2d-oversized" lod.exit_drill(t) if grid is None: + rgba_grid = None sel = kernels.range_indices(xv, yv, lo_x, hi_x, lo_y, hi_y) plan = lod.plan_view_lod(request, len(sel), SCATTER_DENSITY_THRESHOLD, t.drill_mode) visible = plan.visible @@ -753,6 +797,10 @@ def density_view( bx, (bx0, bx1) = fig._binning_coords(t.x_axis, xv, (lo_x, hi_x)) by, (by0, by1) = fig._binning_coords(t.y_axis, yv, (lo_y, hi_y)) grid = kernels.bin_2d(bx, by, bx0, bx1, by0, by1, w, h) + if bin_colors is not None: + # Mean point color per cell (LOD doc §2): same window, same + # binning space, occupied cells match the count grid exactly. + rgba_grid = kernels.bin_2d_mean_color(bx, by, bx0, bx1, by0, by1, w, h, **bin_colors) else: plan = lod.plan_view_lod( request, @@ -782,6 +830,9 @@ def density_view( "x_range": [lo_x, hi_x], "y_range": [lo_y, hi_y], } + if rgba_grid is not None: + density["rgba"] = writer.add_u8(np.ascontiguousarray(rgba_grid).reshape(-1)) + density["color_agg"] = "mean" if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None: density["color"] = t.color_ch.constant if sample is not None: @@ -822,8 +873,10 @@ def _drill_points( ship in the direct-scatter wire shape, normalized over their *global* domain so colors/sizes stay stable across views; offsets re-center on the window midpoint (§16); each point carries its local log-density plus a - `lod_blend` weight (visible/budget) so the density→points handoff is - color-continuous instead of a palette jump (§5).""" + `lod_blend` weight (visible/budget) so the density→points handoff stays + continuous (§5). The surface wears the mean point color (LOD doc §2), so + the handoff is intensity-only: fresh marks enter at their cell's + count-alpha and ease to native opacity, with hue continuous throughout.""" xs, ys = t.x.values[sel], t.y.values[sel] writer = lod.BufferWriter() x_ref, y_ref = lod.add_window_xy( @@ -853,13 +906,11 @@ def _drill_points( _quantize_dval(lod.local_log_density(dx, dy, d_x0, d_x1, d_y0, d_y1, gw, gh)) ) drill_seq = lod.enter_drill(t, sel) - # 1.0 right at the boundary → density-colored points; →0 as zoom deepens. + # 1.0 right at the boundary → points enter at the density surface's + # local count-alpha; →0 as zoom deepens. The surface wears the mean point + # color (LOD doc §2), so marks arrive in their native colors and only + # intensity eases. lod_blend = float(min(1.0, visible / SCATTER_DENSITY_THRESHOLD)) - cmap = ( - t.color_ch.colormap - if (t.color_ch and t.color_ch.mode == "continuous") - else channels.DEFAULT_COLORMAP - ) trace_update = { "id": t.id, "mode": "points", @@ -878,7 +929,6 @@ def _drill_points( "size": size_spec, "density_val": {"buf": dval_buf, "dtype": "u8"}, "lod_blend": lod_blend, - "density_colormap": cmap, "drill_seq": drill_seq, "style": dict(t.style), } diff --git a/python/xy/kernels.py b/python/xy/kernels.py index 56b11664..e53cedf3 100644 --- a/python/xy/kernels.py +++ b/python/xy/kernels.py @@ -51,6 +51,7 @@ bin_2d = _impl.bin_2d bin_2d_f32 = _impl.bin_2d_f32 bin_2d_indices = _impl.bin_2d_indices +bin_2d_mean_color = _impl.bin_2d_mean_color bin_2d_sample_range = _impl.bin_2d_sample_range bin_2d_stratified_sample_range_u8_counted = _impl.bin_2d_stratified_sample_range_u8_counted histogram_uniform = _impl.histogram_uniform @@ -70,9 +71,11 @@ triangle_edges = _impl.triangle_edges local_log_density = _impl.local_log_density pyramid_build = _impl.pyramid_build +pyramid_build_color = _impl.pyramid_build_color pyramid_append = _impl.pyramid_append pyramid_count = _impl.pyramid_count pyramid_compose = _impl.pyramid_compose +pyramid_compose_color = _impl.pyramid_compose_color pyramid_free = _impl.pyramid_free polygon_triangles = _impl.polygon_triangles quad_mesh_triangles = _impl.quad_mesh_triangles @@ -94,6 +97,7 @@ "bin_2d", "bin_2d_f32", "bin_2d_indices", + "bin_2d_mean_color", "bin_2d_sample_range", "bin_2d_stratified_sample_range_u8_counted", "correlation", @@ -120,7 +124,9 @@ "polygon_triangles", "pyramid_append", "pyramid_build", + "pyramid_build_color", "pyramid_compose", + "pyramid_compose_color", "pyramid_count", "pyramid_free", "quad_mesh_triangles", diff --git a/python/xy/marks.py b/python/xy/marks.py index 336282c8..33463875 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -1453,14 +1453,29 @@ def scatter( force_density=density, ) - dropped_channels = trace.per_item_channel_names() + # The color channel survives aggregation as the density surface's + # per-cell mean point color (LOD doc §2); every other per-item + # channel is dropped at Tier 2 — allowed, never silent (§28). + color_aggregates = channels.bins_mean_color(trace.color_ch) + dropped_channels = tuple( + name + for name in trace.per_item_channel_names() + if not (color_aggregates and name == "color") + ) + mean_color_note = ( + " The color channel is kept as the surface's per-cell mean point color" + " (count drives the alpha)." + if color_aggregates + else "" + ) if density is None and dropped_channels and n > DIRECT_SOFT_CEILING: warnings.warn( f"scatter has {n:,} points with per-point styles — above the " f"direct ceiling ({DIRECT_SOFT_CEILING:,}). Falling back to a " f"density surface; dropped channels: {', '.join(dropped_channels)} " "(aggregating arbitrary instance styles needs the §5-F5 aggregation algebra, not yet " - "implemented). Pass density=False to keep direct draw at your risk.", + f"implemented).{mean_color_note} " + "Pass density=False to keep direct draw at your risk.", RuntimeWarning, stacklevel=2, ) @@ -1469,7 +1484,7 @@ def scatter( warnings.warn( f"scatter has {n:,} points above the soft ceiling " f"({DIRECT_SOFT_CEILING:,}); using a density surface for the " - "initial render.", + f"initial render.{mean_color_note}", RuntimeWarning, stacklevel=2, ) diff --git a/scripts/abi_smoke.py b/scripts/abi_smoke.py index 42613ab3..66768ac3 100644 --- a/scripts/abi_smoke.py +++ b/scripts/abi_smoke.py @@ -1393,6 +1393,196 @@ def ok(cond: bool, msg: str) -> None: ok(lib.xy_pyramid_free(ctypes.c_uint64(handle)) == 1, "pyramid free") ok(lib.xy_pyramid_free(ctypes.c_uint64(handle)) == 0, "double free is an error code") + # Mean-color density (LOD doc §2): per-cell mean point color + count-only + # alpha. One red and one blue point per side of a 2x1 grid, then both in + # one cell: pure cells keep exact colors, the mixed cell averages in + # linear light (255,0,0)+(0,0,255) -> (188,0,188). + ZZ = ctypes.c_size_t + DD = ctypes.c_double + lib.xy_bin_2d_mean_color.restype = ctypes.c_int32 + lib.xy_bin_2d_mean_color.argtypes = [ + F64P, + F64P, + ZZ, + U8P, + U8P, + U8P, + ZZ, + DD, + DD, + DD, + DD, + ZZ, + ZZ, + U8P, + ] + mc_x = array("d", [0.25, 0.75]) + mc_y = array("d", [0.5, 0.5]) + mc_idx = array("B", [0, 1]) + mc_lut = array("B", [255, 0, 0, 255, 0, 0, 255, 255]) + mc_out = array("B", bytes(2 * 1 * 4)) + ok( + lib.xy_bin_2d_mean_color( + _ptr(mc_x, ctypes.c_double), + _ptr(mc_y, ctypes.c_double), + 2, + _ptr(mc_idx, ctypes.c_uint8), + U8P(), + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 1.0, + 0.0, + 1.0, + 2, + 1, + _ptr(mc_out, ctypes.c_uint8), + ) + == 1, + "bin_2d_mean_color ok flag", + ) + ok(list(mc_out[0:4]) == [255, 0, 0, 255], "mean color pure red cell") + ok(list(mc_out[4:8]) == [0, 0, 255, 255], "mean color pure blue cell") + mc_y_one = array("d", [0.5, 0.5]) + mc_x_one = array("d", [0.5, 0.5]) + mc_one = array("B", bytes(4)) + ok( + lib.xy_bin_2d_mean_color( + _ptr(mc_x_one, ctypes.c_double), + _ptr(mc_y_one, ctypes.c_double), + 2, + _ptr(mc_idx, ctypes.c_uint8), + U8P(), + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + _ptr(mc_one, ctypes.c_uint8), + ) + == 1 + and list(mc_one) == [188, 0, 188, 255], + "mean color mixed cell averages in linear light", + ) + ok( + lib.xy_bin_2d_mean_color( + _ptr(mc_x_one, ctypes.c_double), + _ptr(mc_y_one, ctypes.c_double), + 2, + _ptr(mc_idx, ctypes.c_uint8), + _ptr(mc_lut, ctypes.c_uint8), # both sources set: invalid + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + _ptr(mc_one, ctypes.c_uint8), + ) + == 0, + "mean color rejects ambiguous color source", + ) + + # Colored pyramid: same counts as the plain build, mean-color plane on + # compose, appends refused (colors unknown; caller rebuilds lazily). + lib.xy_pyramid_build_color.restype = ctypes.c_uint64 + lib.xy_pyramid_build_color.argtypes = [ + F64P, + F64P, + ZZ, + U8P, + U8P, + U8P, + ZZ, + DD, + DD, + DD, + DD, + ctypes.c_uint32, + ] + lib.xy_pyramid_compose_color.restype = ctypes.c_int32 + lib.xy_pyramid_compose_color.argtypes = [ + ctypes.c_uint64, + DD, + DD, + DD, + DD, + ZZ, + ZZ, + ZZ, # max_upsample + F32P, + U8P, + ] + pc_idx = array("B", [1 if px[i] >= 4.0 else 0 for i in range(n_p)]) + chandle = lib.xy_pyramid_build_color( + _ptr(px, ctypes.c_double), + _ptr(py, ctypes.c_double), + n_p, + _ptr(pc_idx, ctypes.c_uint8), + U8P(), + _ptr(mc_lut, ctypes.c_uint8), + 2, + 0.0, + 8.0, + 0.0, + 8.0, + 8, + ) + ok(chandle != 0, "colored pyramid build returns a handle") + cgrid = array("f", bytes(4 * 8 * 8)) + crgba = array("B", bytes(8 * 8 * 4)) + ok( + lib.xy_pyramid_compose_color( + ctypes.c_uint64(chandle), + 0.0, + 8.0, + 0.0, + 8.0, + 8, + 8, + 2, + _ptr(cgrid, ctypes.c_float), + _ptr(crgba, ctypes.c_uint8), + ) + == 0, + "colored compose full window uses level 0", + ) + ok(sum(cgrid) == float(n_p), "colored compose conserves the count") + left_ok = all( + list(crgba[(r * 8 + c) * 4 : (r * 8 + c) * 4 + 4]) == [255, 0, 0, 255] + for r in range(8) + for c in range(4) + ) + right_ok = all( + list(crgba[(r * 8 + c) * 4 : (r * 8 + c) * 4 + 4]) == [0, 0, 255, 255] + for r in range(8) + for c in range(4, 8) + ) + ok(left_ok and right_ok, "colored compose keeps per-side colors exact") + ok( + lib.xy_pyramid_append( + ctypes.c_uint64(chandle), + _ptr(append_x, ctypes.c_double), + _ptr(append_y, ctypes.c_double), + len(append_x), + ) + == 0, + "colored pyramid refuses appends (rebuilds lazily)", + ) + ok( + lib.xy_pyramid_compose( + ctypes.c_uint64(chandle), 0.0, 8.0, 0.0, 8.0, 8, 8, 2, _ptr(grid_p, ctypes.c_float) + ) + == 0, + "count-only compose still serves a colored pyramid", + ) + ok(lib.xy_pyramid_free(ctypes.c_uint64(chandle)) == 1, "colored pyramid free") + # rasterize: caller-owned RGBA8 framebuffer; empty command buffer clears to # transparent, a malformed op is rejected, and a null out is refused. null_u8 = U8P() diff --git a/scripts/render_smoke_nonumpy.py b/scripts/render_smoke_nonumpy.py index c83035ca..5222d819 100644 --- a/scripts/render_smoke_nonumpy.py +++ b/scripts/render_smoke_nonumpy.py @@ -560,7 +560,7 @@ def main() -> None: x_range:[5000,5010],y_range:[5000,5010], x:{{buf:0,len:n3,offset:5005,scale:1}},y:{{buf:1,len:n3,offset:5005,scale:1}}, color:{{mode:"continuous",colormap:"viridis",buf:2}},size:{{mode:"constant",size:8}}, - density_val:{{buf:3}},lod_blend:0.85,density_colormap:"magma"}}]}}, + density_val:{{buf:3}},lod_blend:0.85}}]}}, [xs3.buffer,ys3.buffer,cs3.buffer,ds3.buffer]); const pendingDensity0=v._drawDensity; const pendingPoints0=v._drawPoints; @@ -576,15 +576,16 @@ def main() -> None: x_range:[5000,5010],y_range:[5000,5010], x:{{buf:0,len:n3,offset:5005,scale:1}},y:{{buf:1,len:n3,offset:5005,scale:1}}, color:{{mode:"continuous",colormap:"viridis",buf:2}},size:{{mode:"constant",size:8}}, - density_val:{{buf:3}},lod_blend:0.85,density_colormap:"magma",drill_seq:5}}]}}, + density_val:{{buf:3}},lod_blend:0.85,drill_seq:5}}]}}, [xs3.buffer,ys3.buffer,cs3.buffer,ds3.buffer]); const drilled=(gd.drill && gd.drill.n===n3 && gd.drill.colorMode===1 && v._viewInside(gd.drill.win)===true)?1:0; - // Color-continuous handoff: the drill carries local density + blend - // weight. Fresh marks ARRIVE wearing the aggregate's colormap (shown - // blend seeds at 1 — the texture->marks swap must not recolor even when - // a fast zoom skipped levels) and ease toward the kernel's native weight. - const dblend=(gd.drill && gd.drill.dBuf && gd.drill.dlut + // Intensity-continuous handoff: the drill carries local density + blend + // weight. Fresh marks ARRIVE at the aggregate's count-alpha (shown blend + // seeds at 1 — the texture->marks swap must not pop even when a fast + // zoom skipped levels) and ease toward the kernel's native weight. Hue + // needs no handoff: the surface wears the mean point color (LOD doc §2). + const dblend=(gd.drill && gd.drill.dBuf && Math.abs(gd.drill.lodBlend-0.85)<1e-6 && gd.drill.lodBlendShown===1)?1:0; // Staff-review invariants: subset version stored, stale hover cache @@ -631,7 +632,7 @@ def main() -> None: x_range:[5000,5010],y_range:[5000,5010], x:{{buf:0,len:n3,offset:5005,scale:1}},y:{{buf:1,len:n3,offset:5005,scale:1}}, color:{{mode:"continuous",colormap:"viridis",buf:2}},size:{{mode:"constant",size:8}}, - density_val:{{buf:3}},lod_blend:0.7,density_colormap:"magma",drill_seq:6}}]}}, + density_val:{{buf:3}},lod_blend:0.7,drill_seq:6}}]}}, [xs3.buffer,ys3.buffer,cs3.buffer,ds3.buffer]); const refresh=(gd.drill && gd.drill.seq===6 && gd._drillFadeStart===null && gd._drillDying!==true)?1:0; @@ -640,6 +641,22 @@ def main() -> None: v._drawNow(); const hit3=v._pickAt(v.plot.w/2, v.plot.h/2); const dpick=(hit3 && hit3.trace===gd.trace.id)?1:0; + // T10 retirement: settled inside the drilled window the aggregate + // backdrop eases out and STOPS drawing (the marks are exact; a mean-color + // wash under them reads as data). Mid-ease it still draws; fully retired + // it must not. The hold/exit probes below then draw it again (restore). + let retireDraws=0; + const retire0=v._drawDensity; + v._drawDensity=function(gg,dd,op){{if(gg===gd)retireDraws++;return retire0.call(this,gg,dd,op);}}; + gd._drillBackdropShown=0.5; gd._drillBackdropTick=0; // mid-retire: still painted + v._drawNow(); + const retireMid=retireDraws; + gd._drillBackdropShown=0; gd._drillBackdropTick=0; // retired: skipped + retireDraws=0; + v._drawNow(); + const retireDone=retireDraws; + v._drawDensity=retire0; + const dretire=(retireMid>0 && retireDone===0 && gd._drillBackdropShown===0)?1:0; // Tiny drill-to-drill zoom-outs keep the resident drilled marks for the // short wait when the pending target is still under direct budget — over // the aggregate backdrop (T10): the texture never leaves the frame, so @@ -1116,7 +1133,34 @@ def main() -> None: const gLn=vSm.gpuTraces[0], gAr=vSm.gpuTraces[1]; const msmooth=(gLn.n===65 && gLn._cpu.x.length===5 && gAr.n===65 && gAr._cpu.base.length===5)?1:0; vSm.destroy();holderSm.remove(); - const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}}`; + // Mean-color density (LOD doc §2): the surface must wear the shipped + // per-cell mean point colors while count drives only the alpha. Two + // cells, equal counts: left mean-red, right mean-blue — the drawn pixels + // must follow the rgba plane, not any count colormap. + const mcBuf=new ArrayBuffer(64); const mcCols=[]; let mcOff=0; + const mcu8=(vals)=>{{new Uint8Array(mcBuf,mcOff,vals.length).set(vals); + mcCols.push({{byte_offset:mcOff,len:vals.length,dtype:"u8"}}); + mcOff+=vals.length+(4-(vals.length%4))%4; return mcCols.length-1;}}; + const mcSpec={{protocol:{PROTOCOL_VERSION},width:200,height:160,title:"",backend:"none", + show_legend:false,show_modebar:false, + x_axis:{{kind:"linear",label:"",range:[0,2]}}, + y_axis:{{kind:"linear",label:"",range:[0,1]}}, + traces:[{{id:0,kind:"scatter",name:"mc",tier:"density",n_points:20, + style:{{opacity:1.0}}, + density:{{buf:mcu8([255,255]),rgba:mcu8([255,0,0,255, 0,0,255,255]),color_agg:"mean", + w:2,h:1,max:10.0,enc:"log-u8",colormap:"viridis", + x_range:[0,2],y_range:[0,1],channels_dropped:false,dropped_channels:[]}}}}], + columns:mcCols}}; + const holderMc=document.createElement("div");document.body.appendChild(holderMc); + const vMc=xy.renderStandalone(holderMc,mcSpec,mcBuf); + vMc._drawNow(); + const gMc=vMc.gl,WM=gMc.drawingBufferWidth,HM=gMc.drawingBufferHeight; + const lpx=new Uint8Array(4), rpx=new Uint8Array(4); + gMc.readPixels(Math.round(WM*0.35),Math.round(HM*0.5),1,1,gMc.RGBA,gMc.UNSIGNED_BYTE,lpx); + gMc.readPixels(Math.round(WM*0.8),Math.round(HM*0.5),1,1,gMc.RGBA,gMc.UNSIGNED_BYTE,rpx); + const meancolor=(lpx[0]>60 && lpx[0]>lpx[2]*3 && rpx[2]>60 && rpx[2]>rpx[0]*3)?1:0; + vMc.destroy();holderMc.remove(); + const base=`XY_OK lit=${{lit}} total=${{w*h}} labels=${{labels}} pick=${{hits}} row=${{hasXY}} selAll=${{selAll}} selSome=${{selSome}} active=${{active}} btns=${{btns}} modebarHidden=${{modebarHiddenAtRest}} modebarTopLeft=${{modebarTopLeft}} modebarHover=${{modebarHoverReveal}} modebarNoCollapse=${{modebarNoCollapse}} modebarMenu=${{modebarMenu}} modebarDrag=${{modebarDrag}} modebarSelect=${{modebarSelect}} lassoEdit=${{lassoEdit}} modebarExport=${{modebarExport}} panToggle=${{panToggle}} zin=${{zin}} smooth=${{smooth}} labelThrottle=${{labelThrottle}} hoverSkip=${{hoverSkip}} zanch=${{zanch}} retarget=${{retarget}} nosnap=${{nosnap}} prefetch=${{prefetch}} maxwait=${{maxwait}} box=${{boxOk}} xonly=${{xonly}} zmode=${{zmode}} densityLit=${{densityLit}} drill=${{drilled}} pending=${{pending}} dblend=${{dblend}} dseq=${{dseq}} hov=${{hov}} sstale=${{sstale}} sfresh=${{sfresh}} srestore=${{srestore}} plut=${{plut}} reg=${{reg}} refresh=${{refresh}} dpick=${{dpick}} hold=${{hold}} zoomout=${{zoomout}} broad=${{broadfallback}} dying=${{dying}} dback=${{dback}} dnorm=${{dnorm}} dnormDone=${{dnormDone}} stale=${{stale}} thrash=${{thrash}} qwire=${{qwire}} stream=${{stream}} tj=${{Math.round(maxJump*100)}} td=${{Math.round(reviveDip*100)}} malformed=${{malformed}} pixdet=${{pixdet}} splitbuf=${{splitbuf}} barBase=${{barBase}} histBase=${{histBase}} edgepad=${{edgepad}} mgrad=${{mgrad}} axisontop=${{axisontop}} mtipbase=${{mtipbase}} mcorner=${{mcorner}} mstroke=${{mstroke}} bgrad=${{bgrad}} bcorner=${{bcorner}} msmooth=${{msmooth}} bgocc=${{bgocc}} meancolor=${{meancolor}} dretire=${{dretire}}`; const baseWithStyle=`${{base}} vstyle=${{vstyle}}`; // Responsive: 100%-by-100% chart in a 400x300 container tracks its parent; // growing the container must fire the ResizeObserver and re-render bigger. @@ -1333,6 +1377,8 @@ def main() -> None: refresh = int(re.search(r"refresh=(\d+)", title).group(1)) dying = int(re.search(r"dying=(\d+)", title).group(1)) density_lit = int(re.search(r"densityLit=(\d+)", title).group(1)) + meancolor = int(re.search(r"meancolor=(\d+)", title).group(1)) + dretire = int(re.search(r"dretire=(\d+)", title).group(1)) dpick = int(re.search(r"dpick=(\d+)", title).group(1)) hold = int(re.search(r"hold=(\d+)", title).group(1)) zoomout = int(re.search(r"zoomout=(\d+)", title).group(1)) @@ -1509,6 +1555,16 @@ def main() -> None: ) if qwire != 1: raise SystemExit("log-u8 density decode failed (quantized wire)") + if meancolor != 1: + raise SystemExit( + "mean-color density failed (surface must wear the per-cell mean " + "point colors, count as alpha — LOD doc §2)" + ) + if dretire != 1: + raise SystemExit( + "settled drill kept its aggregate backdrop painted (T10: the " + "backdrop retires once the marks are exact for the window)" + ) if stream != 1: raise SystemExit( "streaming append failed (trace rebuild or follow policy: refit/hold/slide)" diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 5dce7618..6f38f018 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -182,8 +182,9 @@ benchmark tracks this as part of the core 2D payload budget. decision with hysteresis, drilled-subset versioning (`drill_seq`), window encoding, screen-derived grid shaping, entry/exit fades, the density-source cache, and eased normalization. An *aggregating* kind supplies its own - aggregate kernel (density uses `bin_2d`; a 1D histogram supplies 1D binning) - and reuses this framework around it. + aggregate kernel (density uses `bin_2d` for counts plus `bin_2d_mean_color` + for the per-cell mean point color, LOD doc §2; a 1D histogram supplies 1D + binning) and reuses this framework around it. - **Transport**: data-less JSON spec + one binary blob; no JSON numbers (§29). - **Ticks / axes / time axis / autorange**: keyed on axis kind, not mark kind. @@ -206,9 +207,13 @@ usually wrong). Each has an explicit trigger: - **Picking** (`_renderPick`, `_pickAt`): point-geometry only today (`pointPick`). *Trigger: first pickable non-point mark (bar/candle)* — add a `pick` step to `MARK_KINDS` and give the mark its own ID-pass geometry. -- **Legend** (`_buildLegend`): keyed on *channel modes* (density / categorical / +- **Legend** (`_buildLegend`): keyed on *channel modes* (categorical / continuous / named-series), not mark kinds — a colored bar inherits swatches - for free. *Trigger: a mark needing a swatch that isn't channel-shaped.* + for free. A density-tier surface gets no gradient swatch of its own: count is + encoded as alpha, not color (LOD doc §2), so a colormap gradient would read as + "color == density" and mislead; a named density trace falls through to the + plain named-series swatch, matching the static exporters. *Trigger: a mark + needing a swatch that isn't channel-shaped.* - **Decimation** (`interaction.decimate_view`): line and area-like marks use the shared M4 path on first payload; errorbar/stem segments reduce to a pixel-derived cap at emit time. Contour is NOT pixel-bounded: its segment diff --git a/spec/api/chart-roadmap.md b/spec/api/chart-roadmap.md index 6a2e80b4..25f5e51d 100644 --- a/spec/api/chart-roadmap.md +++ b/spec/api/chart-roadmap.md @@ -292,9 +292,12 @@ and drill updates emit the same wire shape. step, O(visible tiles) per frame after a one-pass build (~1.33× cost). - *Progressive refinement* (§17) — bin a 1-in-k sample first so a coarse density appears <100 ms, refine over subsequent frames. -- *Per-bin channel aggregation* (§5-F5) — mean/max color per density cell, so - a zoomed-out colored scatter keeps *some* channel signal instead of - `channels_dropped`. +- *Per-bin channel aggregation* (§5-F5) — **color shipped.** Density cells + carry the alpha-weighted mean of their points' resolved colors with count as + the alpha channel (LOD doc §2) — continuous, categorical, and direct-RGBA + channels alike, at every tier including the pyramid — recorded as + `color_agg: "mean"` instead of `channels_dropped`. Non-color aggregates + (mean/max of a value channel read as data, size) remain open. ## Cross-Cutting: Styling & Theming diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 96a42539..271cbeaa 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -48,8 +48,10 @@ Every claim in this dossier is **mode-scoped and testable** — no universal num **Outstanding work (F4–F12), the honest to-do list:** - **F4** — per-trace f32 offsets (a single viewport origin can't serve traces at wildly different coordinate magnitudes). *Augments §4/§16.* -- **F5** — aggregation algebra for color-by-category / mean / non-count reductions - (Tier-2 currently assumes a scalar density). *Augments §5.* +- **F5** — aggregation algebra for per-point color: **shipped as mean point color** + (Tier-2 surfaces wear the per-cell alpha-weighted mean of the resolved point + colors, count drives only the alpha — LOD doc §2). Non-color reductions + (mean/max of arbitrary value channels as *data*, size) remain unmodeled. *Augments §5.* - **F6** — per-*view* colormap normalization domain (else zoom flickers brightness). *Augments §5.* - **F7** — streaming into the pyramid: `+=`/`-=` across levels; min/max tiles need @@ -282,7 +284,10 @@ the visible x-range on zoom. Turns 100M points into ~a few thousand drawn vertic with no visible difference. **Tier 2 — multiresolution aggregation (datashader-style, but tiled):** for massive -scatter and heatmaps, don't draw points — draw a colormapped **density texture**. +scatter and heatmaps, don't draw points — draw a **density texture** that wears the +data's own colors: per cell, the alpha-weighted mean of the resolved point colors, +with the log-tone-mapped count as the alpha channel (LOD doc §2). Constant-color +traces reduce to a count texture tinted with the constant. The naive version ("bin all points into a screen-sized texture") is *not* "O(points) once": the bin grid depends on the viewport, so every pan/zoom would re-bin the whole @@ -317,8 +322,11 @@ served upsampled from the finest level — an O(N) rescan of a 100 GB+ mmap is n interactive, and past 2³²−1 rows the per-row index kernels would overflow u32 anyway. Those traces also get a finer finest level (adaptive `~sqrt(N/target)`, capped `PYRAMID_MAX_DIM` = 16384²) so the upsampled floor stays as sharp as memory allows. -Level and mode are recorded per update (`binning: "pyramid-L[-upsampled]"`). Only the -`count` plane exists; the per-channel `sum` / `argmax+purity` planes above are not built. +Level and mode are recorded per update (`binning: "pyramid-L[-upsampled]"`). +Channel-bearing traces build mean-color planes alongside the counts +(`xy_pyramid_build_color` / `_compose_color`, LOD doc §2/§4.1 — same level, same +`max_upsample`, both compose regimes; colored pyramids refuse `_append` and rebuild +lazily), so pyramid-served views wear the data's own colors at any zoom. *Windowed-exact tier for out-of-core scatter (`python/xy/_spatial.py`):* the pyramid's upsampled floor is blocky at metro/city zoom (its finest cell is kilometres wide over a @@ -342,6 +350,11 @@ tier keyed on the *actual* in-window count: and uploaded **nearest-neighbour** — no coarser grid stretched over the viewport, so no upscale blur and no pixelation. The upsampled pyramid keeps `linear` (smooth aggregate); the choice rides the wire as `density.filter`. +- The whole windowed-exact tier is gated to traces **without a color channel**: the + position-only index cannot bin the LOD doc §2 mean-color plane, and a count-only + surface the shader colormaps is exactly what §2 retired. A color-channelled trace + keeps its upsampled *colored* pyramid grid instead — blurry beats mis-colored (§28, + recorded per update by the `binning` value). The sorted columns are a derived **f32** cache (§27: canonical stays f64, every derived buffer is rebuildable). *Pending:* tiling proper (per-tile build, fetch, and pan-time reuse), so @@ -397,7 +410,7 @@ re-exports several of them as a historic import path and is not listed for those | `PROTOCOL_VERSION` | `3` | Wire-spec version stamped on every payload; the client refuses a mismatch loudly (§33). | `_payload.py` | | `DECIMATION_THRESHOLD` | `10_000` | Line/area traces with more points than this ship M4-decimated (Tier 1); at or below, raw columns go over the wire. Also gates re-decimation on the interaction path. | `_payload.py`, `interaction.py` | | `SCATTER_DENSITY_THRESHOLD` | `200_000` | Tier-0 → Tier-2 count budget for a scatter with **no** per-point channel (`Trace.use_density()`), and the visible-count budget for view-LOD planning and drill decisions. | `_trace.py`, `interaction.py`; mirrored client-side as `LOD_DIRECT_POINT_BUDGET` in `js/src/45_lod.ts` | -| `DIRECT_SOFT_CEILING` | `2_000_000` | Tier-0 → Tier-2 count budget for a scatter that **does** carry a per-point color or size channel; above it density is forced and the channels are warned about, never silently dropped (§5 F5). | `_trace.py`, `marks.py` | +| `DIRECT_SOFT_CEILING` | `2_000_000` | Tier-0 → Tier-2 count budget for a scatter that **does** carry a per-point color or size channel; above it density is forced and warned about — the color channel aggregates to the surface's per-cell mean point color (LOD doc §2), every other per-item channel is dropped and named, never silently (§5 F5). | `_trace.py`, `marks.py` | | `DENSITY_GRID` | `(512, 384)` | Default density-grid cell dimensions for the initial spec, before the client requests a viewport-matched size via `density_view`. | `_payload.py` | | `MAX_SCREEN_DIM` | `4096` | Upper clamp on any browser-supplied pixel dimension, so untrusted widget/comm input cannot inflate decimation buckets or density grids. | `lod.py`, `_native.py` | | `MAX_CONTOUR_WORK` | `4_000_000` | Ceiling on contour `cells × levels`; a request over it raises instead of allocating an unbounded segment buffer. | `marks.py`, `_native.py` | @@ -452,7 +465,9 @@ F3, still pending (above). - **WebGPU primary, WebGL2 fallback.** One ``, everything is GPU primitives. - **Instanced draws** for markers/bars/lines — one draw call for millions of marks. -- **Density textures** for aggregated tiers (§5, Tier 2). +- **Density textures** for aggregated tiers (§5, Tier 2) — mean point color per + cell, count as alpha (LOD doc §2); premultiplied RGBA8 upload for + channel-bearing traces, tinted R8 count texture for constant-color ones. - **DOM/SVG only for chrome** — axis tick labels, legend, title, tooltip. Little of it, and it stays crisp/accessible/selectable. - **Retained scene graph**, spec-diff → buffer-diff. Pan/zoom is a view-matrix uniform @@ -987,7 +1002,8 @@ means uniform in the axis's scale coordinates, not in raw data. Concretely: rule "cells are uniform in scale coordinates" (wire-protocol.md §3). Without this, clusters at x=1 and x=1000 share one cell on a symlog axis despite being a third of the screen apart. The drill handoff's per-point - `local_log_density` bins in the same space so its colors match the grid's. + `local_log_density` bins in the same space so its count-alpha matches the + grid's (hue already matches — the grid wears the mean point color, LOD doc §2). - **The raw-space tile pyramid** (Tier 3) cannot compose a scale-coordinate grid, so traces on a nonlinear axis skip pyramid build/compose and always take the exact scan (`binning: "exact"`). Cost: the O(visible) rescan the @@ -1693,7 +1709,7 @@ haven't measured. | F2 | No filtering / selection / linked-brushing model; a static pyramid is stale under any filter | **Critical** | confirmed | | F3 | GPU ceilings mis-modeled — fill-rate & the 1 GB single-allocation cap bound Tier 0, not point count | **Major** | confirmed | | F4 | A single viewport offset can't serve multiple traces at different coordinate magnitudes | **Major** | plausible | -| F5 | Tier-2 assumes a scalar density — color-by-category / mean / non-count aggregation unmodeled | **Major** | confirmed | +| F5 | Tier-2 assumed a scalar density — **resolved for color** (mean-point-color surfaces, LOD doc §2); non-color value aggregations (mean/min/max-as-data, size) remain unmodeled | **Major → residual Moderate** | confirmed, largely addressed | | F6 | Colormap normalization domain across pyramid levels is unspecified → brightness flicker on zoom | **Major** | plausible | | F7 | Streaming into the pyramid is under-reconciled with multi-level structure + eviction | Moderate | confirmed | | F8 | "One core, logically identical" collides with the per-backend implementation matrix | Moderate | confirmed | @@ -1776,14 +1792,23 @@ viewport origin; a single origin leaves the far trace with catastrophic f32 erro the shared view transform stays f64 on CPU and composes with each trace's offset at upload. Modest bookkeeping, but the doc doesn't have it and multi-trace is the norm. -### F5 — Tier-2 assumes a scalar density; categorical/aggregation algebra is missing. [Major] -**Failure scenario.** `scatter(x, y, color=category)` with 12 categories over 50M points. -One density texture can't carry per-category counts; you need N per-category planes -(imMens's ∏-bins problem) or a per-bin categorical mode/argmax. `color=mean(value)` needs -2-channel (sum,count) accumulation; std needs more. The doc's Tier-2 is single-scalar. -**Fix.** Specify the **aggregation algebra** — count / sum / mean(2-ch) / min / max / -**categorical-by (N capped planes + "other")** — mirroring datashader's `ds.by`/`ds.mean`, -with its memory cost (N× the tile, feeding F9) and a category cap that logs truncation. +### F5 — Tier-2 assumed a scalar density; the color algebra now ships. [Major → residual Moderate] +**Original failure scenario.** `scatter(x, y, color=category)` with 12 categories over +50M points: one count texture couldn't carry the colors, so the surface wore a count +colormap that matched neither the points nor the legend — a jarring recolor at every +density⇄points transition. +**Shipped fix (LOD doc §2).** The Tier-2 surface carries the **per-cell alpha-weighted +mean of the resolved point colors** (one law for continuous, categorical, and +direct-RGBA channels; linear-light integer pipeline, deterministic), with the +log-tone-mapped count as the alpha channel. The pyramid grew matching mean-color +planes (`build_color`/`compose_color`); the drill handoff became intensity-only +because hue is continuous by construction. `color_agg: "mean"` records the transform. +**Residual gap.** Aggregating *values as data* — per-cell mean/min/max of a channel +readable from hover/legend, per-category count planes for filtered queries +(imMens's ∏-bins), std, etc. — is still unmodeled; size channels still drop. Those +need the fuller algebra (count / sum / mean(2-ch) / min / max / categorical-by with +capped planes) mirroring datashader's `ds.by`/`ds.mean`, with its memory cost +(N× the tile, feeding F9) and a category cap that logs truncation. ### F6 — Colormap normalization across pyramid levels is unspecified → zoom flicker. [Major] **Failure scenario.** Color maps bin-count→hue. Coarse tiles have higher counts than fine diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 6031d75c..2e4d9d64 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -28,7 +28,7 @@ pixel-area or overdraw term exists in `python/xy/` or `js/src/` today. |---|---|---|---|---| | 0 | Direct | every visible mark, exact | O(visible) verts | shipped (all kinds) | | 1 | Shape-preserving reduction | per-pixel-column aggregate that IS the mark's meaning (M4 for lines; OHLC-bucket for candles when finance returns; max-bin for bars) | O(px) verts | shipped (line) | -| 2 | Density / aggregate surface | colormapped count (+channel) texture | O(screen) texels | shipped (scatter) | +| 2 | Density / aggregate surface | mean-point-color texture, count as alpha (§2) | O(screen) texels | shipped (scatter) | | 3 | Out-of-core tiles | Tier-2 pyramid where not all tiles are resident | O(visible tiles) | **this doc** | **Invariant L1 (recorded reduction):** every update carries `tier`, `mode`, @@ -44,7 +44,7 @@ real marks with real channels — never a sample, never an aggregate. | Kind | Tier 0 | Tier 1 | Tier 2 | zoom-in recovery | |---|---|---|---|---| -| scatter | points | — (unordered decimation lies, §28) | density grid (+ channel aggregates, §4 below) | drill-in ships exact visible points + channels (shipped) | +| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact visible points + channels (shipped) | | line/area | polyline | M4 per column (extrema-exact) | — (M4 already screen-bounded) | re-decimate window (shipped) | | heatmap | cell rects | — | mip-style tile reduction of the user grid (max/mean recorded) | finer pyramid level, then exact cells | | histogram/bar | rects | re-bin at viewport resolution | — (bins are already aggregates) | re-bin visible window (finer bins = more truth, never less) | @@ -93,38 +93,71 @@ metadata, or encoded-buffer assembly. ## 2. Zoomed-out truthfulness with channels (the "no visual lying" rules) Aggregating positions is easy; aggregating **color/size/category** without -lying is the hard part (§5-F5). Rules, per channel mode: - -1. **Count is always channel 0.** The density surface's luminance/alpha - encodes count (log tone-mapped, shipped). Any additional channel rides on - top of count, never instead of it — otherwise sparse-but-extreme cells - look as important as dense ones. -2. **Continuous channel → per-cell MEAN, displayed only with count ≥ floor.** - Ship a second grid `sum(channel)`; client computes mean = sum/count in the - shader. Cells under a count floor (default 1) render count-only — a mean - of one point is exact, a mean of zero is a lie. - *Rejected:* per-cell max (overweights outliers silently). Max is offered as - an explicit `agg="max"` the user must opt into, and the legend then says - "max per cell" (recorded, §28). -3. **Categorical channel → per-cell MAJORITY + purity.** Ship two grids: - `argmax(category)` (palette index) and `purity = max_count/count`. - The shader desaturates toward the count ramp as purity → 1/k, so a - 50/50 cell doesn't masquerade as solidly category A. This is the honest - version of datashader's `count_cat` composite. - Categories >256 already warn at ingest (LUT width); majority-of-256 is - fine because the palette itself caps there. +lying is the hard part (§5-F5). The governing principle, shipped: **the +density surface wears the data's own colors; count drives only the alpha.** +A cell's color is what the eye would see if every one of its points were +drawn sub-pixel — the physically downsampled image — so the aggregate view +and the point view are two magnifications of the same picture, not two +different charts. More points → deeper (more opaque) color; fewer points → +lighter; the color itself always comes from the represented points. + +1. **Count is always channel 0, and it is the ALPHA channel.** The count + grid ships log-tone-mapped (`buf` + `max`, log-u8) and drives opacity + exclusively. Color rides on top of count, never instead of it — + otherwise sparse-but-extreme cells look as important as dense ones. No + shipped path colormaps counts anymore (the client keeps a count→LUT + fallback solely for hand-built/legacy count-only specs). +2. **Per-point colors → per-cell alpha-weighted MEAN of the *resolved* + colors, averaged in linear light.** One law for every channel mode — + continuous (colormapped value), categorical (palette color), and + `direct_rgba` alike: each point contributes the exact sRGB color it draws + with, weighted by its straight alpha, summed in linear light (integer + pipeline: checked-in sRGB⇄linear-u16 tables, u64 sums — bitwise + deterministic across thread counts and platforms), then quantized back to + sRGB. Ships as a `w*h*4` straight-alpha RGBA8 plane (`rgba`, mean color + + mean point alpha) with `color_agg: "mean"` recorded; displayed alpha = + count tone-curve × mean point alpha. A cell of one point shows that + point's exact color; an all-invisible (alpha-0) cell never invents one. + Constant-color traces ship no plane — the mean of a constant IS the + constant, so the wire keeps the 1-byte count grid plus `color` and the + client tints (bit-equivalent, 4× cheaper). Cost, recorded: a + channel-bearing trace pays one extra O(visible) color pass per exact + re-bin (the count grid and sample selection stay fused as before; plain + scatters are byte-identical to the pre-color pipeline), +4 B/cell on the + wire, and the §4 pyramid's color planes at +8 B/cell. + *Why mean-of-colors and not colormap(mean value):* the mean color is + representation-agnostic (categories have no mean value), matches the + drilled points' downsample exactly (the anti-jarring contract, T3), and + never claims a data value that isn't there — a mixed red/blue cell reads + as the purple *blend* the points themselves produce, not as a fictitious + mid-scale value. The cost, recorded: a mixed cell's color can sit off the + palette/colormap gamut; the sample overlay and drill-in disambiguate. + *Rejected:* per-cell max (overweights outliers silently); majority+purity + (hid minority categories entirely and desaturated toward a count ramp + that no longer exists). Linear-light averaging is deliberate — sRGB-space + byte averaging darkens mixes (red+blue → 128-purple instead of the + physically correct 188-purple). +3. **Categorical channels beyond the palette fold first.** Codes wider than + the 256-entry LUT bin with `code % len(palette)` — exactly the repeat + rule the point shader applies — so every point bins with the color it + draws with, at any cardinality. 4. **Size channel → drop at Tier 2, always say so.** Size has no honest - per-cell aggregate that isn't just another continuous channel; we already - ship `channels_dropped: true`. Keep that; render the legend badge from it. -5. **The drill handoff already solves re-entry:** freshly drilled points wear - the density ramp by local log-density (`lod_blend`), easing to native - channels as the window shrinks — the same rules make the *outbound* - transition honest because both endpoints display the same statistics. + per-cell aggregate that isn't just another continuous channel; we ship + `channels_dropped: true` + `dropped_channels` naming it. Color is *not* + listed there when it aggregates — `color_agg: "mean"` records the + transform instead (aggregated ≠ dropped). +5. **The drill handoff is intensity-only now:** hue is continuous by + construction (both sides show the points' colors), so freshly drilled + points arrive in their native colors and only their opacity hands off — + entering at the cell's count-alpha (`density_val` through the same tone + curve as the texture) and easing to native opacity as `lod_blend` → 0. + The same rule runs outbound: exiting marks re-target the aggregate's + count-alpha and melt into a surface that already matches their hue. **Invariant L4 (badge):** any active reduction that drops or transforms a -channel renders a visible "aggregated: mean/majority/…" badge sourced from -the spec, not from client guesswork. (Client work item; spec already carries -the facts.) +channel renders a visible "aggregated: mean/…" badge sourced from the spec +(`color_agg`, `dropped_channels`), not from client guesswork. (Client work +item; spec already carries the facts.) --- @@ -166,16 +199,24 @@ pyramid replaces per-view scans with per-view *tile composition*. - **Data-space tiles**, power-of-two levels, 256×256 cells/tile. Level 0 covers the full x/y extent with 1 tile; level l has 4^l tiles. -- Each tile stores channel-aggregate grids per §2: `count`, and per channel - `sum` (continuous) or `argmax+purity` (categorical) — f32 planes. +- Each tile stores the channel aggregates per §2: `count` (u32), and for + channel-bearing traces a mean-color plane — per cell `[r, g, b, a]` as + linear-light u16 means plus mean straight alpha (u16 scale). Means, not + sums: 8 B/cell instead of 24+, at the cost of one re-rounding per level + (≤ 0.5 lsb of u16 per level — recorded, invisible). - **Build:** one pass over rows bins into the finest level L (L ≈ ceil(log4(N / target_points_per_cell / 256²))); levels L-1..0 are 4→1 - reductions (`count` sums; `sum` sums; majority re-argmaxes from summed - per-category counts — which requires keeping per-category counts at build - time for the top-k categories, k ≤ 8, "other" bucketed; recorded). + reductions (`count`: exact u64 sums saturating to u32; color: exact + weighted means, weight = child count × child mean alpha — the same + alpha-weighted average the flat kernel computes over raw points). Total cost ≈ 1.33 × one full pass; total size ≈ 1.33 × finest level. - **Rust owns this** (`tiles.rs`, see rust-engine doc): build_pyramid(), tile fetch by (level, tx, ty), append-aware rebuild of dirty tiles. + Colored pyramids refuse native appends — the batch's colors are unknown to + the count-only append path and an append can move a continuous channel's + domain, silently re-coloring every already-binned point — so the caller + invalidates and the next density view rebuilds lazily (recorded; count-only + pyramids keep O(rows·levels) increments). ### 4.2 Serving a viewport @@ -199,16 +240,22 @@ reply = compose(tiles) → one grid ≤ (screen px) — done kernel-side today, ### 4.3 Truthfulness across levels -Reductions are exact (sums of sums; majority from kept per-category counts), -so any pyramid level shows the same statistics the raw pass would — modulo -the "other" bucket for tail categories, which the spec records -(`categories_bucketed: k`). Level choice is recorded per update -(`pyramid_level`). No level ever extrapolates. +Count reductions are exact (sums of sums), so any pyramid level shows the +same counts the raw pass would. Color reductions are exact weighted means of +the stored child means, re-quantized to u16 once per level (≤ 0.5 lsb of +linear-u16 per level; over the 11 levels of the default base that stays +under one displayable sRGB step — recorded here, invisible in practice). +Level choice is recorded per update (`binning: "pyramid-L"`). No level +ever extrapolates. ### 4.4 Memory & residency - Finest level for 100M points at 16 pts/cell ≈ 6.25M cells ≈ 25 MB (count - f32) + 25 MB/channel. ×1.33 pyramid ≈ 33-66 MB kernel-side. Fine. + u32) + 50 MB mean-color plane ([u16; 4]/cell). ×1.33 pyramid ≈ 33-100 MB + kernel-side. Fine. (Shipped shape: 2048² default base ⇒ 22 MB counts + + 45 MB color per colored trace, `pyramid_report_bytes`; huge/out-of-core + traces build adaptively finer bases — Phase-3 item 7 — and the color plane + scales with them.) - 1B points: ~330-660 MB — still kernel-side RAM, but now Tier 3 applies: tiles are chunked to disk (Arrow/Parquet row groups per tile, dossier §32), LRU-resident under a byte budget, and *only* the ≤ ~12 visible tiles are @@ -264,16 +311,19 @@ invariants so future kinds don't regress them: the aggregate→marks transition only — restarting per refresh reads as flashing; exit fade with the "dying" state so buffers outlive the fade). - **T3 — color-continuous:** the two sides of a transition display the same - statistic at the boundary (lod_blend density-ramp handoff). The kernel's - blend weight (`visible/budget`) is only ≈1 when the swap happens at the - budget boundary — a fast zoom skips levels and lands marks with a - mostly-native weight, a visible recolor at the swap (live-drilldown field - capture). The BOUNDARY is therefore the transition itself, client-side: - fresh marks arrive with the shown blend seeded at 1 (wearing the - aggregate's colormap) and ease to the kernel's native weight, and - dying/exiting marks re-target blend 1 so they melt into the texture as - they fade (`lodApplyDrill`, `lodBeginDrillExitContinuous`; revives restore - the native weight via `lodEnterDrillContinuous`). + statistic at the boundary. Hue is continuous *by construction* — the + aggregate surface wears the mean point color (§2), so marks and texture + agree on color at every zoom — and intensity hands off via the lod_blend + count-alpha ramp. The kernel's blend weight (`visible/budget`) is only ≈1 + when the swap happens at the budget boundary — a fast zoom skips levels + and lands marks with a mostly-native weight, a visible intensity pop at + the swap (live-drilldown field capture). The BOUNDARY is therefore the + transition itself, client-side: fresh marks arrive with the shown blend + seeded at 1 (entering at the aggregate's local count-alpha) and ease to + the kernel's native weight, and dying/exiting marks re-target blend 1 so + they melt into the texture as they fade (`lodApplyDrill`, + `lodBeginDrillExitContinuous`; revives restore the native weight via + `lodEnterDrillContinuous`). - **T4 — normalization is eased, never stepped** (exposure-style normMax). - **T5 — stale replies die:** seq on view updates, drill_seq on subsets, pending-view hold for prefetched drills. @@ -334,19 +384,25 @@ invariants so future kinds don't regress them: latches. Overlays die with their evicted cache entry (except the home/init overlay, the standalone re-bin worker's CPU-side source), and the "sampled n of N" badge reports the overlay actually drawn. -- **T10 — the aggregate backdrop is continuous:** the density texture draws - under the marks in EVERY drill state — entering, settled inside, held, - exiting — never only until the entry fade completes. The background of a - drilled frame and a density frame is the same texture, so every drill - transition is a marks-layer fade over a stable context, not a full-frame - swap. (Previously marks "owned the frame" once their entry fade finished: - the backdrop flipped to the blank chart background, and interleaved - density/points replies during a continuous zoom flashed - green-texture ⇄ points-on-blank — the live-drilldown flicker. It also - kept the aggregate context visible while drilled, which is the §28 hybrid - intent.) `lodDrawDensityTier` routes every branch's backdrop through - `lodDrawDensityWithFade`, so cached-window crossfades stay continuous - while drilled too. +- **T10 — the aggregate backdrop is continuous through transitions, and + retires when the drill settles:** the density texture draws under the + marks in every TRANSITIONAL drill state — entering, held, dying, exiting — + so every representation change is a marks-layer fade over a stable + context, never a full-frame swap or a blank. (Previously marks "owned the + frame" once their entry fade finished: the backdrop flipped to the blank + chart background, and interleaved density/points replies during a + continuous zoom flashed green-texture ⇄ points-on-blank — the + live-drilldown flicker.) Once a drill is SETTLED inside its window — + entry fade landed, no exit/hold/death in flight — the backdrop eases out + (`lodDrillBackdropScale`): the marks are exact for that window, so the + aggregate adds no information the marks don't already carry, and leaving + it painted washes exact points with mean color that reads as data + (field-reported against the mean-color surface). It eases back FAST the + moment the view leaves the window, a refinement goes pending, or the + drill dies — so zoom-outs still never blank (T1) and per-reply refreshes + inside a settled drill never re-flash it. `lodDrawDensityTier` routes + every branch's backdrop through `lodDrawDensityWithFade`, so + cached-window crossfades stay continuous while drilled too. - **T11 — an exited drill is a bounded revive cache:** a drill whose entry completed and whose exit fade has finished is retained so a rapid zoom back into its window hands the exact marks back with no kernel round-trip @@ -369,14 +425,27 @@ contract entry before it lands. ## 6. Implementation plan -**Phase 1 — channel-truthful density (kernel+client, ~1 wk)** -1. `bin_2d_channels` kernel: count + sum(channel) [+ top-k category counts] - in one pass (native Rust core). -2. Wire mean/majority+purity grids through `density_view` and the initial - emit; extend DENSITY_FS to blend channel color over the count ramp with - the purity desaturation; count-floor uniform. -3. Legend/badge rendering from spec facts; smoke probes: mean-cell color - correctness, purity desaturation, badge presence. +**Phase 1 — channel-truthful density (kernel+client)** +1. **Done:** `bin_2d_mean_color` kernel — per-cell count + alpha-weighted + linear-light color sums in one pass, straight-alpha RGBA8 mean-color grid + out (native Rust core; integer tables + integer sums, deterministic for + any thread count and across platforms). Color source is either per-point + LUT indices + a ≤256-entry RGBA8 LUT (continuous quantizes to the + client's 256 texels; categorical passes codes, wide codes fold modulo the + palette) or per-point straight RGBA8 (`direct_rgba`) — + `channels.resolve_bin_colors` maps every channel mode onto one of the two. +2. **Done:** mean-color planes wired through the initial emit + (`_payload._density_trace_spec`), `density_view` (exact and pyramid + paths), the static SVG/PNG exporters, and the standalone re-bin worker; + DENSITY_FS gained the `u_meanColor` branch (premultiplied RGBA8 texture, + count tone-curve baked into alpha at upload so bilinear filtering weights + color by coverage); the drill handoff became intensity-only (§2 rule 5). + The colorbar for a continuous channel now stays on density traces — the + surface really shows the channel's colors. +3. Legend/badge rendering from spec facts (`color_agg`, `dropped_channels`) + remains a client work item; smoke probes for mean-cell color correctness + ship (`render_smoke_nonumpy.py` `meancolor`, `abi_smoke.py` mean-color + checks, `tests/test_density_mean_color.py` NumPy oracle). **Phase 2 — deterministic sampling utilities** 4. **Done:** `lod.sample_keep_mask(row_ids, level)` SplitMix64 sampler + @@ -395,16 +464,24 @@ contract entry before it lands. by the coverage fade when nothing covers the view. **Phase 3 — pyramid (build + serve shipped; client cache and bench gate open)** -6. **Done (count plane only):** `src/tiles.rs` builds a square count pyramid - over the trace's full data bounds — finest level is `PYRAMID_BASE_DIM`² - (2048², `python/xy/config.py`), each coarser level an exact 4→1 u64 sum - saturating to u32, so every level conserves total count. C ABI is - `xy_pyramid_build` / `xy_pyramid_append` / `xy_pyramid_count` / - `xy_pyramid_compose` / `xy_pyramid_free` (no per-tile fetch entry point; - composition happens kernel-side). Handles are slab indices behind a mutex, - cached per trace and built lazily by `interaction._ensure_pyramid` on the - first density view at ≥ `PYRAMID_MIN_POINTS` (2,000,000), released by a - weakref finalizer. Channel planes (§2, §4.1) are not built yet. +6. **Done (count + mean-color planes):** `src/tiles.rs` builds a square count + pyramid over the trace's full data bounds — finest level is + `PYRAMID_BASE_DIM`² (2048², `python/xy/config.py`), each coarser level an + exact 4→1 u64 sum saturating to u32, so every level conserves total count. + Channel-bearing traces build the §4.1 mean-color planes alongside + (`xy_pyramid_build_color`, one fused scan — fan-out gated by the + points-per-cell ratio and capped at 4 workers, ~170 MB transient + accumulator each; +8 B/cell stored, ~45 MB/colored trace at the default + base, reported by `pyramid_report_bytes`) and serve them with + `xy_pyramid_compose_color`, + whose count grid is bit-identical to `xy_pyramid_compose`. C ABI is + `xy_pyramid_build` / `xy_pyramid_build_color` / `xy_pyramid_append` / + `xy_pyramid_count` / `xy_pyramid_compose` / `xy_pyramid_compose_color` / + `xy_pyramid_free` (no per-tile fetch entry point; composition happens + kernel-side). Handles are slab indices behind a mutex, cached per trace + and built lazily by `interaction._ensure_pyramid` on the first density + view at ≥ `PYRAMID_MIN_POINTS` (2,000,000), released by a weakref + finalizer; colored pyramids refuse appends and rebuild lazily (§4.1). 7. **Done:** `density_view` estimates the window with `pyramid_count` and serves it with `pyramid_compose` when that estimate sits safely above the drill threshold; `compose` picks the coarsest level that still meets the @@ -434,6 +511,11 @@ contract entry before it lands. nearest-neighbour filtering (`binning: "spatial-exact"`, `filter: "nearest"`) — no interpolation blur. Gated by an offsets-only `window_count` upper bound (`SPATIAL_EXACT_MAX_POINTS`); wider windows keep the instant upsampled pyramid. + Color-channelled traces skip this tier entirely: the index is position-only + (§27), so it can neither ship channels with a points drill nor bin the §2 + mean-color plane — the upsampled *colored* pyramid grid stays (blurry but + truthful) rather than flipping to a count-only surface (recorded, dossier + §28 table). 8. Client: tile-keyed cache replaces window-keyed `densityCache` (same eviction, same crossfades). Still pending — `js/src/45_lod.ts` keys the cache by density window, and no client code reads the served level. diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md index 6c3fa426..7e65e36a 100644 --- a/spec/design/renderer-architecture.md +++ b/spec/design/renderer-architecture.md @@ -29,7 +29,7 @@ relative mass, not as a budget (see §3 on why a line count failed as a metric). | `30_ticks.ts` | 224 | CPU-side tick generation in f64 for linear, log, category and time axes, plus every axis/colorbar label formatter (automatic and `format=`-driven). Specified in §6. | | `40_gl.ts` | 829 | WebGL2 primitives: shader compile/link, `makeProgram` with its per-program uniform-location memo (R1), the fixed `ATTR_SLOTS` attribute-slot table bound at link time, and the shader inventory itself. The only module that is GPU-API-specific by design (§4). | | `45_lod.ts` | 567 | View-dependent level-of-detail orchestration, deliberately chart-agnostic: tier selection, drill enter/exit hysteresis (`LOD_DRILL_EXIT_FACTOR`), cross-tier fades, and the retained tier caches. Calls back into `view._draw*` rather than drawing itself, which is the seam tests intercept. | -| `46_worker.ts` | 59 | The standalone density re-bin worker: a worker source string carried inside the bundle and booted from a Blob URL. Re-bins the retained sample off the main thread so kernel-less (`to_html`) density charts refine on zoom instead of stretching the overview texture; absence of workers falls back to stretching. | +| `46_worker.ts` | 103 | The standalone density re-bin worker: a worker source string carried inside the bundle and booted from a Blob URL. Re-bins the retained sample off the main thread — counts plus, for channel-bearing traces, the per-cell mean point color (same linear-light law as the kernel, LOD doc §2) — so kernel-less (`to_html`) density charts refine on zoom instead of stretching the overview texture; absence of workers falls back to stretching. | | `50_chartview.ts` | 4175 | The `ChartView` class: the four drawing surfaces, scale/view state, chrome (background, grid, axes, legend, colorbar), GL buffer and VAO management (R2), and pick orchestration. Modules 51–54 extend this same class. | | `51_annotations.ts` | 591 | The 2D overlay canvas above the marks canvas: annotation markers, arrows, shape fills, and collision-nudged labels. Separates canvas shape style keys from label CSS so annotation styling never leaks into the DOM label. | | `52_tooltip.ts` | 321 | Hit → source row → tooltip DOM. Anchors the tooltip at the picked point's data coordinates and reprojects it every draw ([interaction.md](../api/interaction.md) §7). Renders the local f32-decoded row immediately, then replaces it with the kernel's exact f64 row when that reply arrives (sequence- and `drill_seq`-guarded); composes text nodes, never HTML. | diff --git a/spec/design/rust-engine.md b/spec/design/rust-engine.md index a8a0f394..b8a2e3b5 100644 --- a/spec/design/rust-engine.md +++ b/spec/design/rust-engine.md @@ -25,7 +25,7 @@ clear ImportError when it can't load, with no pure-Python fallback. | Concern | Today | Verdict | |---|---|---| -| zone maps, encode_f32, m4, bin_2d, min_max, histogram_uniform, normalize_f32, range/validity indices, local_log_density | Rust (ABI v36) | correct — new equal-length x/y columns use a paired zone-map call with bit-identical per-column reductions; full-domain density first paint fuses binning with uniform or counted-u8 overlay sampling while retaining exact standalone outputs; mesh/rectangle validity scans consume only columns not already proven finite by zone metadata | +| zone maps, encode_f32, m4, bin_2d, bin_2d_mean_color, min_max, histogram_uniform, normalize_f32, range/validity indices, local_log_density | Rust (ABI v40) | correct — new equal-length x/y columns use a paired zone-map call with bit-identical per-column reductions; full-domain density first paint fuses binning with uniform or counted-u8 overlay sampling while retaining exact standalone outputs; mean-color binning (LOD doc §2) is an integer-only pipeline (checked-in sRGB⇄linear-u16 tables, alpha-weighted u64 sums) so grids are bitwise deterministic across thread counts and platforms; mesh/rectangle validity scans consume only columns not already proven finite by zone metadata | | fixed-width string/bytes/bool factorization | Rust (ABI v36) | correct — compact palettes use a bounded L1-resident codebook with full-record collision checks and emit exact counts; U1 uses a direct Unicode-scalar table with endian support; ≥512k rows probe a prefix then encode disjoint chunks in parallel, merging late labels by canonical first-row order before any retry; Python sees only unique labels and retains display-label ordering policy | | static display-list raster, row-banded polyline/point/segment paint, batched fill+stroke triangle meshes, affine scatter projection plus typed color/size resolution, density/heatmap colormap and sampling | Rust (ABI v36) | correct — commands borrow f32/u8 payload or canonical spans synchronously; compact stratified sampling reuses factorization counts; batched/banded output is byte-identical | | signal processing: `xy_rfft`, `xy_welch_spectra`, `xy_spectrogram` | Rust (ABI v36) | correct — O(N) transforms over sample columns; window/segment policy stays in Python | @@ -101,8 +101,11 @@ src/ # 15,423 lines shipped, 8 modules # rest of SVG scene construction stays in Python. simd.rs (448) # AVX2 twins of eligible kernels, runtime-dispatched (§3.4). # The one module besides lib.rs allowed `unsafe`. - tiles.rs (481) # pyramid build/compose/incremental append. Owns tile memory; - # handles are opaque u64 ids passed over the ABI (§3.3). + tiles.rs (1050) # pyramid build/compose/incremental append, plus the + # mean-color planes for channel-bearing traces + # (build_color/compose_color, LOD doc §2/§4.1; colored + # pyramids refuse appends and rebuild lazily). Owns tile + # memory; handles are opaque u64 ids over the ABI (§3.3). stream.rs # (plan) Rust-owned canonical append buffers. stats.rs # (plan) quantiles/box/violin/factorize. ``` @@ -213,18 +216,35 @@ Arrays-in/arrays-out stops working when Rust must own long-lived state uint64_t xy_pyramid_build(const double* x, const double* y, size_t len, double x0, double x1, double y0, double y1, uint32_t base_dim); -/* append: 1 applied; 0 on stale/busy handle, bad args, or a point outside - the pyramid's original domain (never partially mutates) */ +/* build with mean-color planes (LOD doc §2). The color source is exactly + one of idx (per-point LUT index, with lut as 1..=256 RGBA8 rows) or + rgba (per-point straight-alpha RGBA8); the other pointer is NULL */ +uint64_t xy_pyramid_build_color(const double* x, const double* y, size_t len, + const uint8_t* idx, const uint8_t* rgba, + const uint8_t* lut, size_t lut_len, + double x0, double x1, double y0, double y1, + uint32_t base_dim); +/* append: 1 applied; 0 on stale/busy handle, bad args, a point outside + the pyramid's original domain, or a colored pyramid (its colors are + unknown to this entry point — caller invalidates and rebuilds lazily). + Never partially mutates. */ int32_t xy_pyramid_append(uint64_t handle, const double* x, const double* y, size_t len); /* count over a window: 1 ok, 0 on stale handle/bad args */ int32_t xy_pyramid_count(uint64_t handle, double lo_x, double hi_x, double lo_y, double hi_y, double* out_count); /* compose window into a w×h grid: level used (>=0), -1 stale/bad args, - -2 window outresolves the pyramid (caller re-bins exactly and discloses) */ + -2 window outresolves the pyramid past max_upsample (caller re-bins + exactly and discloses) */ int32_t xy_pyramid_compose(uint64_t handle, double lo_x, double hi_x, - double lo_y, double hi_y, - size_t w, size_t h, float* out); + double lo_y, double hi_y, size_t w, size_t h, + size_t max_upsample, float* out); +/* compose plus the mean-color plane: counts bit-identical to + xy_pyramid_compose; -2 also for a pyramid built without color planes */ +int32_t xy_pyramid_compose_color(uint64_t handle, double lo_x, double hi_x, + double lo_y, double hi_y, size_t w, size_t h, + size_t max_upsample, + float* out, uint8_t* out_rgba); /* free: 1 if it existed, 0 for stale/unknown */ int32_t xy_pyramid_free(uint64_t handle); ``` diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index b7171e7a..fd2a4c1e 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -121,28 +121,39 @@ message unless `msg.seq === this.seq`. states which representation this view resolved to: - `mode: "density"` — `{id, mode, tier, visible, reduction, binning, density}`. - `density` is `{buf, w, h, max, enc: "log-u8", x_range, y_range}` plus - optional `color` (a constant-channel color) and `sample` (the retained - point-sample overlay). `binning` is `"exact"` or `"pyramid-L"`. - `x_range`/`y_range` are raw data endpoints, but the grid's cells are - **uniform in the axis's scale coordinates** (identical to raw on a linear - axis): on a log/symlog axis the kernel bins transformed values so every - cell covers the same strip of screen, and renderers interpolate cell edges - between the *transformed* endpoints (dossier §28). The raw-space tile - pyramid cannot compose such a grid, so nonlinear-axis traces always report - `binning: "exact"`. + `density` is `{buf, w, h, max, enc: "log-u8", x_range, y_range}` plus, for + channel-bearing traces, `rgba` (a `w*h*4` straight-alpha RGBA8 plane: each + occupied cell's alpha-weighted **mean point color**, averaged in linear + light, plus the cell's mean point alpha) with `color_agg: "mean"` recording + the aggregation (LOD doc §2); constant-color traces ship `color` (the + constant) instead and no color plane — the mean of a constant IS the + constant, so the client tints the count texture. Count always rides `buf` + as log-u8 and drives only the drawn **alpha**; renderers must never + colormap counts when either color source is present. Optional `sample` is + the retained point-sample overlay. `binning` is `"exact"` or + `"pyramid-L"`. `x_range`/`y_range` are raw data endpoints, but the + grid's cells are **uniform in the axis's scale coordinates** (identical to + raw on a linear axis): on a log/symlog axis the kernel bins transformed + values so every cell covers the same strip of screen, and renderers + interpolate cell edges between the *transformed* endpoints (dossier §28). + The raw-space tile pyramid cannot compose such a grid, so nonlinear-axis + traces always report `binning: "exact"`. - `mode: "points"` — the deep-zoom drill: `{id, mode, tier: "direct", visible, reduction: "none", x_range, y_range, - x, y, color, size, density_val, lod_blend, density_colormap, drill_seq, - style}`. `x_range`/`y_range` are the window these points cover; the client - falls back to the density overview the moment the view leaves it. + x, y, color, size, density_val, lod_blend, drill_seq, style}`. + `x_range`/`y_range` are the window these points cover; the client falls + back to the density overview the moment the view leaves it. `density_val` + (per-point local log-density) and `lod_blend` drive the intensity-only + handoff: hue is continuous by construction because the aggregate surface + wears the mean point color, so no `density_colormap` field rides this + message (clients ignore one if present). Channel encodings on this (and the sampled-overlay) live wire: `x`/`y` are offset-encoded f32 (position precision is load-bearing, dossier §16), but - every unit-scalar channel — continuous `color`, continuous `size`, - `density_val` — ships as **u8 LUT coordinates** with a `dtype: "u8"` - marker (absent means f32; the client accepts both), and categorical color - ships u8 codes. The quantization is safe precisely because these values are + every unit-scalar channel — continuous `color` and `size` (LUT/ramp + coordinates), `density_val` (the handoff weight) — ships quantized to + **u8** with a `dtype: "u8"` marker (absent means f32; the client accepts + both), and categorical color ships u8 codes. The quantization is safe precisely because these values are never read back into displayed numbers on a live path: hover/pick answers come from the kernel's canonical columns (`pick_result` above). The *build* payload keeps continuous channels as unit f32 — the client retains those diff --git a/src/kernels.rs b/src/kernels.rs index bf7e5648..aeaabdb1 100644 --- a/src/kernels.rs +++ b/src/kernels.rs @@ -3314,6 +3314,267 @@ fn bin_2d_impl( }); } +/// sRGB byte → linear-light u16 (IEC 61966-2-1, scaled to 0..=65535, +/// round-half-even). A checked-in literal keeps the mean-color pipeline +/// below integer-only end to end (no runtime `powf`/libm dependency), so it +/// is bitwise deterministic for any thread count and across platforms. +/// Strictly increasing, so the inverse is an exact search. +pub(crate) const SRGB_TO_LINEAR_U16: [u16; 256] = [ + 0, 20, 40, 60, 80, 99, 119, 139, + 159, 179, 199, 219, 241, 264, 288, 313, + 340, 367, 396, 427, 458, 491, 526, 562, + 599, 637, 677, 718, 761, 805, 851, 898, + 947, 997, 1048, 1101, 1156, 1212, 1270, 1330, + 1391, 1453, 1517, 1583, 1651, 1720, 1790, 1863, + 1937, 2013, 2090, 2170, 2250, 2333, 2418, 2504, + 2592, 2681, 2773, 2866, 2961, 3058, 3157, 3258, + 3360, 3464, 3570, 3678, 3788, 3900, 4014, 4129, + 4247, 4366, 4488, 4611, 4736, 4864, 4993, 5124, + 5257, 5392, 5530, 5669, 5810, 5953, 6099, 6246, + 6395, 6547, 6700, 6856, 7014, 7174, 7335, 7500, + 7666, 7834, 8004, 8177, 8352, 8528, 8708, 8889, + 9072, 9258, 9445, 9635, 9828, 10022, 10219, 10417, + 10619, 10822, 11028, 11235, 11446, 11658, 11873, 12090, + 12309, 12530, 12754, 12980, 13209, 13440, 13673, 13909, + 14146, 14387, 14629, 14874, 15122, 15371, 15623, 15878, + 16135, 16394, 16656, 16920, 17187, 17456, 17727, 18001, + 18277, 18556, 18837, 19121, 19407, 19696, 19987, 20281, + 20577, 20876, 21177, 21481, 21787, 22096, 22407, 22721, + 23038, 23357, 23678, 24002, 24329, 24658, 24990, 25325, + 25662, 26001, 26344, 26688, 27036, 27386, 27739, 28094, + 28452, 28813, 29176, 29542, 29911, 30282, 30656, 31033, + 31412, 31794, 32179, 32567, 32957, 33350, 33745, 34143, + 34544, 34948, 35355, 35764, 36176, 36591, 37008, 37429, + 37852, 38278, 38706, 39138, 39572, 40009, 40449, 40891, + 41337, 41785, 42236, 42690, 43147, 43606, 44069, 44534, + 45002, 45473, 45947, 46423, 46903, 47385, 47871, 48359, + 48850, 49344, 49841, 50341, 50844, 51349, 51858, 52369, + 52884, 53401, 53921, 54445, 54971, 55500, 56032, 56567, + 57105, 57646, 58190, 58737, 59287, 59840, 60396, 60955, + 61517, 62082, 62650, 63221, 63795, 64372, 64952, 65535, +]; + +/// Nearest sRGB byte for a linear-light u16 — the exact inverse of the table +/// above (integer search over a strictly increasing sequence; ties round to +/// the darker byte). +pub(crate) fn linear_u16_to_srgb_u8(linear: u16) -> u8 { + let above = SRGB_TO_LINEAR_U16.partition_point(|&v| v <= linear); + if above == 0 { + return 0; // unreachable: table[0] == 0 ≤ every u16 + } + if above >= 256 { + return 255; + } + let below_value = SRGB_TO_LINEAR_U16[above - 1]; + let above_value = SRGB_TO_LINEAR_U16[above]; + if linear - below_value <= above_value - linear { + (above - 1) as u8 + } else { + above as u8 + } +} + +/// Per-point straight-alpha RGBA8 source for mean-color binning. +pub enum BinColorSource<'a> { + /// One LUT index per point plus an RGBA8 palette/colormap table + /// (1..=256 entries). Indices wrap modulo the table length — the + /// palette's own repeat rule, so out-of-palette categorical codes bin + /// with exactly the color they draw with. + Indexed { idx: &'a [u8], lut: &'a [[u8; 4]] }, + /// Straight-alpha RGBA8, 4 bytes per point (the `direct_rgba` channel). + Rgba(&'a [u8]), +} + +/// One cell of the mean-color accumulator: exact integer sums, so the +/// parallel merge is order-independent (bitwise deterministic for any thread +/// count, like `bin_2d`). Color sums are alpha-weighted linear-light u16, so +/// a translucent point contributes proportionally and the mean is the +/// physically downsampled color of the cell's points. +#[derive(Clone, Copy, Default)] +pub(crate) struct MeanColorCell { + pub(crate) count: u32, + alpha: u64, + red: u64, + green: u64, + blue: u64, +} + +impl MeanColorCell { + /// Straight-alpha RGBA8: sRGB mean color + mean point alpha (integer + /// rounding, half away from zero — deterministic). + fn rgba8(&self) -> [u8; 4] { + if self.count == 0 || self.alpha == 0 { + return [0, 0, 0, 0]; + } + let mean = |sum: u64| ((sum + self.alpha / 2) / self.alpha).min(u16::MAX as u64) as u16; + let alpha = + ((self.alpha + u64::from(self.count) / 2) / u64::from(self.count)).min(255) as u8; + [ + linear_u16_to_srgb_u8(mean(self.red)), + linear_u16_to_srgb_u8(mean(self.green)), + linear_u16_to_srgb_u8(mean(self.blue)), + alpha, + ] + } + + /// Storage form for pyramid color planes: linear-light u16 mean color + /// plus the mean straight alpha rescaled to 0..=65535. + pub(crate) fn mean_u16x4(&self) -> [u16; 4] { + if self.count == 0 || self.alpha == 0 { + return [0, 0, 0, 0]; + } + let mean = |sum: u64| ((sum + self.alpha / 2) / self.alpha).min(u16::MAX as u64) as u16; + let alpha = ((self.alpha * 257 + u64::from(self.count) / 2) / u64::from(self.count)) + .min(u16::MAX as u64) as u16; + [mean(self.red), mean(self.green), mean(self.blue), alpha] + } +} + +/// Fused count+color accumulation over a full grid: the shared core of +/// `bin_2d_mean_color` and the pyramid build's base pass +/// (`tiles::build_color`). Returns the raw accumulator cells so callers keep +/// exact counts alongside the color means. +/// +/// Fan-out is gated by the points-per-cell ratio (like `bin_2d`) and capped +/// at 4: each worker's private accumulator is ~10× a count grid (40 B/cell), +/// so the cap bounds the transient to ~4 grids while still cutting the +/// pyramid's 100M-row base scan to a quarter. Integer sums merge +/// order-independently — bitwise deterministic for any thread count. +#[allow(clippy::too_many_arguments)] +pub(crate) fn bin_2d_mean_color_cells( + x: &[f64], + y: &[f64], + colors: &BinColorSource<'_>, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, +) -> Vec { + let n = x.len(); + let cells = w * h; + let threads = bin_2d_threads(n, cells).min(4); + if threads <= 1 || n < threads { + let mut grid = vec![MeanColorCell::default(); cells]; + bin_2d_mean_color_accumulate(x, y, colors, 0, x0, x1, y0, y1, w, h, &mut grid); + return grid; + } + let chunk = n.div_ceil(threads); + let grids: Vec> = std::thread::scope(|s| { + let handles: Vec<_> = (0..threads) + .map(|t| { + let lo = (t * chunk).min(n); + let hi = ((t + 1) * chunk).min(n); + let (xs, ys) = (&x[lo..hi], &y[lo..hi]); + s.spawn(move || { + let mut grid = vec![MeanColorCell::default(); cells]; + bin_2d_mean_color_accumulate(xs, ys, colors, lo, x0, x1, y0, y1, w, h, &mut grid); + grid + }) + }) + .collect(); + handles + .into_iter() + .map(|hd| hd.join().expect("bin_2d_mean_color worker panicked")) + .collect() + }); + let mut grid = vec![MeanColorCell::default(); cells]; + for part in &grids { + for (acc, cell) in grid.iter_mut().zip(part) { + acc.count = acc.count.saturating_add(cell.count); + acc.alpha += cell.alpha; + acc.red += cell.red; + acc.green += cell.green; + acc.blue += cell.blue; + } + } + grid +} + +/// Mean-color companion to `bin_2d` (§5 Tier 2, LOD doc §2): average the +/// *resolved point colors* of each cell so the density surface wears the data +/// set's own colors while count drives only the alpha channel. `out` is +/// `w*h*4` straight-alpha RGBA8, row 0 = bottom (same orientation as +/// `bin_2d`): rgb = alpha-weighted mean point color (averaged in linear +/// light, quantized back to sRGB), a = plain mean of the points' straight +/// alpha. Empty cells are fully zeroed; a cell whose points are all alpha-0 +/// keeps rgb 0 too (its display alpha is 0, so no color is ever invented). +/// In-window/NaN predicates and cell indexing are bit-identical to +/// `bin_2d_count_scalar`, so occupied cells match the count grid exactly. +#[allow(clippy::too_many_arguments)] // window (x0,x1,y0,y1) + grid (w,h) + io is irreducible +pub fn bin_2d_mean_color( + x: &[f64], + y: &[f64], + colors: &BinColorSource<'_>, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, + out: &mut [u8], +) { + assert_eq!(x.len(), y.len()); + assert_eq!(out.len(), w * h * 4); + assert!(w > 0 && h > 0 && x1_gt_x0(x0, x1) && x1_gt_x0(y0, y1)); + match colors { + BinColorSource::Indexed { idx, lut } => { + assert_eq!(idx.len(), x.len()); + assert!(!lut.is_empty() && lut.len() <= 256); + } + BinColorSource::Rgba(rgba) => assert_eq!(rgba.len(), x.len() * 4), + } + let grid = bin_2d_mean_color_cells(x, y, colors, x0, x1, y0, y1, w, h); + for (cell, quad) in grid.iter().zip(out.chunks_exact_mut(4)) { + quad.copy_from_slice(&cell.rgba8()); + } +} + +/// Shared scan used by the serial and parallel paths (per-point behavior is +/// identical by construction). `base` offsets the row index into the color +/// source so parallel chunks read their own colors. +#[allow(clippy::too_many_arguments)] +fn bin_2d_mean_color_accumulate( + x: &[f64], + y: &[f64], + colors: &BinColorSource<'_>, + base: usize, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, + grid: &mut [MeanColorCell], +) { + let sx = w as f64 / (x1 - x0); + let sy = h as f64 / (y1 - y0); + for i in 0..x.len() { + let xv = x[i]; + let yv = y[i]; + if !xv.is_finite() || !yv.is_finite() || xv < x0 || xv >= x1 || yv < y0 || yv >= y1 { + continue; + } + let cx = (((xv - x0) * sx) as usize).min(w - 1); + let cy = (((yv - y0) * sy) as usize).min(h - 1); + let [r, g, b, a] = match colors { + BinColorSource::Indexed { idx, lut } => lut[idx[base + i] as usize % lut.len()], + BinColorSource::Rgba(rgba) => { + let at = (base + i) * 4; + [rgba[at], rgba[at + 1], rgba[at + 2], rgba[at + 3]] + } + }; + let cell = &mut grid[cy * w + cx]; + let weight = u64::from(a); + cell.count = cell.count.saturating_add(1); + cell.alpha += weight; + cell.red += weight * u64::from(SRGB_TO_LINEAR_U16[r as usize]); + cell.green += weight * u64::from(SRGB_TO_LINEAR_U16[g as usize]); + cell.blue += weight * u64::from(SRGB_TO_LINEAR_U16[b as usize]); + } +} + /// Fused density scan (§5 Tier 2): one pass over `x`/`y` producing BOTH the /// screen-bounded count grid and the ascending in-window row indices, instead /// of `bin_2d` + `range_indices` each re-reading the full columns. The two @@ -5987,6 +6248,174 @@ mod tests { assert_eq!(par_threads_for(PAR_THRESHOLD, 1), 1); } + #[test] + fn srgb_linear_table_roundtrips_every_byte() { + for (byte, &linear) in SRGB_TO_LINEAR_U16.iter().enumerate() { + assert_eq!( + linear_u16_to_srgb_u8(linear), + byte as u8, + "table entry {byte} must invert exactly" + ); + } + assert_eq!(linear_u16_to_srgb_u8(0), 0); + assert_eq!(linear_u16_to_srgb_u8(u16::MAX), 255); + assert!( + SRGB_TO_LINEAR_U16.windows(2).all(|p| p[1] > p[0]), + "strictly increasing — the inverse search depends on it" + ); + } + + const MC_RED_BLUE: [[u8; 4]; 2] = [[255, 0, 0, 255], [0, 0, 255, 255]]; + + #[test] + fn bin_2d_mean_color_places_pure_and_mixed_cells() { + // 2×2 grid over the unit square: bottom-left one red point; + // bottom-right one red + one blue; NaN and out-of-window skipped. + let x = [0.1, 0.6, 0.9, f64::NAN, 2.0]; + let y = [0.1, 0.1, 0.1, 0.5, 0.5]; + let idx = [0u8, 0, 1, 0, 1]; + let colors = BinColorSource::Indexed { + idx: &idx, + lut: &MC_RED_BLUE, + }; + let mut out = vec![0u8; 2 * 2 * 4]; + bin_2d_mean_color(&x, &y, &colors, 0.0, 1.0, 0.0, 1.0, 2, 2, &mut out); + assert_eq!(&out[0..4], &[255, 0, 0, 255], "pure cell keeps exact color"); + // Half red + half blue averaged in linear light: 65535/2 → 32768, + // whose nearest sRGB byte is 188 — brighter than the sRGB-space + // average (128), the physically downsampled mix. + assert_eq!(&out[4..8], &[188, 0, 188, 255]); + assert_eq!(&out[8..16], &[0u8; 8], "empty cells stay fully zero"); + } + + #[test] + fn bin_2d_mean_color_weights_by_alpha() { + // A faint red (alpha 51 = 20%) and an opaque blue share a cell: the + // mean must lean blue 5:1, and the cell alpha is the plain mean. + let x = [0.5, 0.5]; + let y = [0.5, 0.5]; + let lut = [[255, 0, 0, 51], [0, 0, 255, 255]]; + let idx = [0u8, 1]; + let colors = BinColorSource::Indexed { + idx: &idx, + lut: &lut, + }; + let mut out = vec![0u8; 4]; + bin_2d_mean_color(&x, &y, &colors, 0.0, 1.0, 0.0, 1.0, 1, 1, &mut out); + let expected_red = linear_u16_to_srgb_u8(((51u64 * 65535 + 153) / 306) as u16); + let expected_blue = linear_u16_to_srgb_u8(((255u64 * 65535 + 153) / 306) as u16); + assert_eq!(out, vec![expected_red, 0, expected_blue, 153]); + assert!(out[2] > out[0], "opaque blue outweighs faint red"); + + // All-invisible cell: count > 0 but zero weight — never invent color. + let ghost_lut = [[255, 0, 0, 0]]; + let ghost_idx = [0u8, 0]; + let ghost = BinColorSource::Indexed { + idx: &ghost_idx, + lut: &ghost_lut, + }; + bin_2d_mean_color(&x, &y, &ghost, 0.0, 1.0, 0.0, 1.0, 1, 1, &mut out); + assert_eq!(out, vec![0, 0, 0, 0]); + } + + #[test] + fn bin_2d_mean_color_rgba_source_and_lut_wrap() { + let x = [0.5, 0.5]; + let y = [0.5, 0.5]; + let rgba: Vec = vec![255, 0, 0, 255, 0, 0, 255, 255]; + let mut direct = vec![0u8; 4]; + bin_2d_mean_color( + &x, + &y, + &BinColorSource::Rgba(&rgba), + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + &mut direct, + ); + assert_eq!(direct, vec![188, 0, 188, 255]); + // Indices wrap modulo the LUT length (the palette repeat rule): code 2 + // over a 2-entry LUT wears entry 0. + let idx = [2u8, 1]; + let mut wrapped = vec![0u8; 4]; + bin_2d_mean_color( + &x, + &y, + &BinColorSource::Indexed { + idx: &idx, + lut: &MC_RED_BLUE, + }, + 0.0, + 1.0, + 0.0, + 1.0, + 1, + 1, + &mut wrapped, + ); + assert_eq!(wrapped, direct); + } + + #[test] + fn bin_2d_mean_color_parallel_matches_serial_oracle() { + // Enough rows to fan out (PAR_THRESHOLD gate) over a small grid; the + // serial cell accumulator is the oracle, so the threaded integer + // merge must reproduce it bit-for-bit. + let n = PAR_THRESHOLD + 4096; + let mut x = Vec::with_capacity(n); + let mut y = Vec::with_capacity(n); + let mut idx = Vec::with_capacity(n); + let mut seed = 0x00C0FFEE_u64; + for _ in 0..n { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + x.push((seed % 1000) as f64 / 10.0); + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + y.push((seed % 1000) as f64 / 10.0); + idx.push((seed % 4) as u8); + } + let lut = [ + [255, 0, 0, 255], + [0, 0, 255, 255], + [0, 200, 80, 128], + [240, 240, 240, 30], + ]; + let colors = BinColorSource::Indexed { + idx: &idx, + lut: &lut, + }; + let (w, h) = (32, 32); + let mut out = vec![0u8; w * h * 4]; + bin_2d_mean_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, w, h, &mut out); + // The oracle accumulates serially by construction (direct call, one + // chunk) — `bin_2d_mean_color_cells` itself fans out at this size. + let mut cells = vec![MeanColorCell::default(); w * h]; + bin_2d_mean_color_accumulate(&x, &y, &colors, 0, 0.0, 100.0, 0.0, 100.0, w, h, &mut cells); + for (cell_index, cell) in cells.iter().enumerate() { + assert_eq!( + &out[cell_index * 4..cell_index * 4 + 4], + &cell.rgba8(), + "cell {cell_index}" + ); + } + // Occupancy must match bin_2d exactly (same predicates, same cells). + let mut counts = vec![0.0f32; w * h]; + bin_2d(&x, &y, 0.0, 100.0, 0.0, 100.0, w, h, &mut counts); + for (cell_index, count) in counts.iter().enumerate() { + assert_eq!( + *count > 0.0, + out[cell_index * 4 + 3] > 0, + "cell {cell_index} occupancy" + ); + } + } + #[test] fn bin_2d_density_hotspot() { // A cluster in one cell should dominate grid_max. diff --git a/src/lib.rs b/src/lib.rs index 0a91e9ed..db6c4985 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,7 +80,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 39; +pub const ABI_VERSION: u32 = 40; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] @@ -1686,6 +1686,90 @@ pub unsafe extern "C" fn xy_bin_2d_f32( }) } +/// Marshal the C-side color source for mean-color binning: exactly one of +/// `idx` (one LUT index per point, with `lut`/`lut_len` as 1..=256 RGBA8 +/// entries) or `rgba` (straight-alpha RGBA8, 4 bytes per point) must be +/// non-null. Returns `None` for any other shape. +/// +/// # Safety +/// Non-null pointers must address the documented lengths for `len` points. +unsafe fn color_source_from_raw<'a>( + len: usize, + idx: *const u8, + rgba: *const u8, + lut: *const u8, + lut_len: usize, +) -> Option> { + match (idx.is_null(), rgba.is_null()) { + (false, true) => { + if lut.is_null() || lut_len == 0 || lut_len > 256 { + return None; + } + Some(kernels::BinColorSource::Indexed { + idx: std::slice::from_raw_parts(idx, len), + lut: std::slice::from_raw_parts(lut as *const [u8; 4], lut_len), + }) + } + (true, false) => Some(kernels::BinColorSource::Rgba(std::slice::from_raw_parts( + rgba, + len * 4, + ))), + _ => None, + } +} + +/// Mean-color companion grid to `xy_bin_2d` (§5 Tier 2, LOD doc §2): fill +/// `out` (`w*h*4` straight-alpha RGBA8, row 0 = bottom) with each cell's +/// alpha-weighted mean point color (linear-light average, sRGB bytes out) +/// and mean point alpha. Cell membership is bit-identical to `xy_bin_2d`. +/// Returns 1 on success, 0 on invalid arguments. +/// +/// # Safety +/// `x`/`y` must point to `len` readable f64s; the color source pointers must +/// satisfy `color_source_from_raw`; `out` must address `w*h*4` writable bytes. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn xy_bin_2d_mean_color( + x: *const f64, + y: *const f64, + len: usize, + idx: *const u8, + rgba: *const u8, + lut: *const u8, + lut_len: usize, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + w: usize, + h: usize, + out: *mut u8, +) -> i32 { + if w == 0 || h == 0 || !finite_gt(x0, x1) || !finite_gt(y0, y1) || out.is_null() { + return 0; + } + let Some(grid_len) = w.checked_mul(h).and_then(|n| n.checked_mul(4)) else { + return 0; + }; + let out = std::slice::from_raw_parts_mut(out, grid_len); + if len == 0 { + out.fill(0); + return 1; + } + if x.is_null() || y.is_null() { + return 0; + } + let Some(colors) = color_source_from_raw(len, idx, rgba, lut, lut_len) else { + return 0; + }; + let x = std::slice::from_raw_parts(x, len); + let y = std::slice::from_raw_parts(y, len); + ffi_guard(0, || { + kernels::bin_2d_mean_color(x, y, &colors, x0, x1, y0, y1, w, h, out); + 1 + }) +} + /// Native PNG rasterizer (dossier Phase 3). Paints a Python-built display list /// (`cmd[0..cmd_len]`, see `raster.rs`/`_raster.py`) into a caller-owned /// straight-alpha RGBA8 framebuffer `out` of `w*h*4` bytes. Returns 1 on @@ -2779,6 +2863,46 @@ pub unsafe extern "C" fn xy_pyramid_build( }) } +/// Build a pyramid with mean-color planes (LOD doc §2/§4.1) for a +/// channel-bearing trace. Same handle registry and geometry as +/// `xy_pyramid_build`; color source as in `xy_bin_2d_mean_color`. Returns the +/// handle, or 0 on invalid arguments. +/// +/// # Safety +/// `x`/`y` must point to `len` readable f64s; color source pointers must +/// satisfy `color_source_from_raw`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn xy_pyramid_build_color( + x: *const f64, + y: *const f64, + len: usize, + idx: *const u8, + rgba: *const u8, + lut: *const u8, + lut_len: usize, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + base_dim: u32, +) -> u64 { + if x.is_null() || y.is_null() || len == 0 { + return 0; + } + let Some(colors) = color_source_from_raw(len, idx, rgba, lut, lut_len) else { + return 0; + }; + let x = std::slice::from_raw_parts(x, len); + let y = std::slice::from_raw_parts(y, len); + ffi_guard(0, || { + match tiles::build_color(x, y, &colors, x0, x1, y0, y1, base_dim as usize) { + Some(p) => tiles::reg_insert(p), + None => 0, + } + }) +} + /// Increment a live pyramid from an appended point batch. Returns 1 when the /// update was applied, or 0 for a stale/busy handle, invalid pointers/lengths, /// or a finite point outside the pyramid's original domain. A rejected update @@ -2875,6 +2999,56 @@ pub unsafe extern "C" fn xy_pyramid_compose( }) } +/// `xy_pyramid_compose` plus the mean-color plane: fills `out` with the same +/// f32 counts (bit-identical) and `out_rgba` (`w*h*4`, straight-alpha RGBA8) +/// with the composed mean colors. Returns the level used (>= 0), -1 for bad +/// arguments/stale handle, or -2 when the window outresolves the pyramid OR +/// the pyramid carries no color planes — the caller re-bins exactly either way. +/// +/// # Safety +/// `out` must address `w*h` writable f32s and `out_rgba` `w*h*4` writable bytes. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn xy_pyramid_compose_color( + handle: u64, + lo_x: f64, + hi_x: f64, + lo_y: f64, + hi_y: f64, + w: usize, + h: usize, + max_upsample: usize, + out: *mut f32, + out_rgba: *mut u8, +) -> i32 { + if out.is_null() + || out_rgba.is_null() + || w == 0 + || h == 0 + || !finite_gt(lo_x, hi_x) + || !finite_gt(lo_y, hi_y) + { + return -1; + } + let Some(out_len) = w.checked_mul(h) else { + return -1; + }; + // Zero forgives a caller that forgot the knob; the count-only entry point + // applies the same floor. + let max_upsample = max_upsample.max(1); + let out = std::slice::from_raw_parts_mut(out, out_len); + let out_rgba = std::slice::from_raw_parts_mut(out_rgba, out_len * 4); + ffi_guard(-1, || { + match tiles::reg_with(handle, |p| { + tiles::compose_color(p, lo_x, hi_x, lo_y, hi_y, w, h, max_upsample, out, out_rgba) + }) { + Some(Some(level)) => level as i32, + Some(None) => -2, + None => -1, + } + }) +} + /// Release a pyramid. 1 if it existed, 0 for stale/unknown handles. /// # Safety /// No pointer arguments; safe for any handle value. diff --git a/src/tiles.rs b/src/tiles.rs index 2f4c7649..a0bc61b1 100644 --- a/src/tiles.rs +++ b/src/tiles.rs @@ -28,6 +28,13 @@ use crate::kernels; pub struct Pyramid { /// levels[0] = finest (dim²), levels[k] has dim >> k per side; last is 1². levels: Vec>, + /// Mean-color planes for channel-bearing traces (LOD doc §2): per cell + /// `[r, g, b, a]` — the alpha-weighted mean point color in linear-light + /// u16 plus the mean straight alpha scaled to 0..=65535. Cells store + /// means rather than sums to stay at 8 B/cell; each 4→1 reduction + /// re-rounds once, a ≤ 0.5-lsb-of-u16 error per level (recorded in the + /// LOD doc, §28). `None` for count-only pyramids. + color_levels: Option>>, dims: Vec, x0: f64, x1: f64, @@ -35,6 +42,12 @@ pub struct Pyramid { y1: f64, } +impl Pyramid { + pub fn has_color(&self) -> bool { + self.color_levels.is_some() + } +} + /// The production default upsample bound (callers pass their own via the ABI; /// see `xy_pyramid_compose`). Rendering source cells into an output grid finer /// than 2x reads as blur/blocks, so normal traces refuse past it and re-bin @@ -74,6 +87,79 @@ pub fn build( } Some(Pyramid { levels, + color_levels: None, + dims, + x0, + x1, + y0, + y1, + }) +} + +/// Build a pyramid that also carries mean-color planes, for channel-bearing +/// traces whose density surface wears the mean point color (LOD doc §2). +/// One fused scan accumulates counts and alpha-weighted linear-light color +/// sums together (exact integer sums merged order-independently, so the +/// result is bitwise identical for any fan-out); count levels are identical +/// to `build`'s. The scan fans out only when rows outnumber base cells +/// (points-per-cell gate, capped at 4 workers): each worker's accumulator is +/// 40 B/cell (~170 MB at the 2048² default), released before returning — +/// builds are one-time per trace and lazy. +#[allow(clippy::too_many_arguments)] +pub fn build_color( + x: &[f64], + y: &[f64], + colors: &kernels::BinColorSource<'_>, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + base_dim: usize, +) -> Option { + if x.len() != y.len() || base_dim < 2 || !base_dim.is_power_of_two() { + return None; + } + if !(x0.is_finite() && x1.is_finite() && y0.is_finite() && y1.is_finite() && x1 > x0 && y1 > y0) + { + return None; + } + match colors { + kernels::BinColorSource::Indexed { idx, lut } => { + if idx.len() != x.len() || lut.is_empty() || lut.len() > 256 { + return None; + } + } + kernels::BinColorSource::Rgba(rgba) => { + if rgba.len() != x.len() * 4 { + return None; + } + } + } + let base = kernels::bin_2d_mean_color_cells(x, y, colors, x0, x1, y0, y1, base_dim, base_dim); + let mut counts = Vec::with_capacity(base.len()); + let mut color = Vec::with_capacity(base.len()); + for cell in &base { + counts.push(cell.count); + color.push(cell.mean_u16x4()); + } + drop(base); + let mut levels = vec![counts]; + let mut color_levels = vec![color]; + let mut dims = vec![base_dim]; + let mut dim = base_dim; + while dim > 1 { + let prev = levels.last().expect("at least one level"); + let prev_color = color_levels.last().expect("at least one color level"); + let lvl = reduce_level(prev, dim); + let clvl = reduce_color_level(prev, prev_color, dim); + dim /= 2; + levels.push(lvl); + color_levels.push(clvl); + dims.push(dim); + } + Some(Pyramid { + levels, + color_levels: Some(color_levels), dims, x0, x1, @@ -92,6 +178,13 @@ pub fn append(p: &mut Pyramid, x: &[f64], y: &[f64]) -> bool { if x.len() != y.len() { return false; } + // A colored pyramid cannot be incremented without the batch's colors — + // and an append can also move a continuous channel's domain, silently + // re-coloring every already-binned point. Refuse; the caller invalidates + // and the next density view rebuilds lazily (LOD doc §4.1). + if p.color_levels.is_some() { + return false; + } for (&xv, &yv) in x.iter().zip(y) { if (xv.is_finite() && yv.is_finite()) && (xv < p.x0 || xv >= p.x1 || yv < p.y0 || yv >= p.y1) @@ -141,6 +234,50 @@ fn reduce_level(prev: &[u32], dim: usize) -> Vec { lvl } +/// 4→1 reduction of one color level. Each parent is the exact weighted mean +/// of its children — weight = child count × child mean alpha, the same +/// alpha-weighted average `bin_2d_mean_color` computes over raw points — then +/// re-rounded once to u16 (the ≤ 0.5-lsb-per-level error recorded on the +/// struct). u128 keeps the count×alpha×mean products exact even at saturated +/// child counts. +fn reduce_color_level(prev_counts: &[u32], prev_color: &[[u16; 4]], dim: usize) -> Vec<[u16; 4]> { + let next = dim / 2; + let mut lvl = vec![[0u16; 4]; next * next]; + for cy in 0..next { + for cx in 0..next { + let mut count: u64 = 0; + let mut weight: u128 = 0; + let mut sums = [0u128; 3]; + for (sy, sx) in [ + (2 * cy, 2 * cx), + (2 * cy, 2 * cx + 1), + (2 * cy + 1, 2 * cx), + (2 * cy + 1, 2 * cx + 1), + ] { + let c = u64::from(prev_counts[sy * dim + sx]); + if c == 0 { + continue; + } + let [r, g, b, a] = prev_color[sy * dim + sx]; + let w = u128::from(c) * u128::from(a); + count += c; + weight += w; + sums[0] += w * u128::from(r); + sums[1] += w * u128::from(g); + sums[2] += w * u128::from(b); + } + if count == 0 || weight == 0 { + continue; + } + let mean = |s: u128| ((s + weight / 2) / weight).min(u128::from(u16::MAX)) as u16; + let alpha = + ((weight + u128::from(count) / 2) / u128::from(count)).min(u128::from(u16::MAX)); + lvl[cy * next + cx] = [mean(sums[0]), mean(sums[1]), mean(sums[2]), alpha as u16]; + } + } + lvl +} + /// Cell-index range [lo, hi) of a level whose cell CENTERS fall inside the /// window along one axis. fn center_range(lo: f64, hi: f64, full_lo: f64, full_hi: f64, dim: usize) -> (usize, usize) { @@ -171,6 +308,45 @@ pub fn count(p: &Pyramid, lo_x: f64, hi_x: f64, lo_y: f64, hi_y: f64) -> f64 { total as f64 } +/// Pick the coarsest level that still meets the render resolution without +/// upsampling (fewest cells to walk, no blur). Only when even level 0 +/// cannot meet it, tolerate up to `max_upsample` before refusing — beyond +/// that the exact path must run. Callers over huge/out-of-core columns pass +/// a large `max_upsample` so the finest level is served (progressively +/// blurry) rather than triggering an O(N) rescan of the whole column (§28). +/// Shared by `compose` and `compose_color`, so the two grids of a colored +/// pyramid always come from the same level. +#[allow(clippy::too_many_arguments)] +fn choose_level( + p: &Pyramid, + lo_x: f64, + hi_x: f64, + lo_y: f64, + hi_y: f64, + w: usize, + h: usize, + max_upsample: usize, +) -> Option { + for level in (0..p.levels.len()).rev() { + let dim = p.dims[level]; + let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); + let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); + if cx1 - cx0 >= w && cy1 - cy0 >= h { + return Some(level); + } + } + let dim = p.dims[0]; + let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); + let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); + // Saturating multiply so a huge `max_upsample` can't overflow usize. + if (cx1 - cx0).saturating_mul(max_upsample) >= w + && (cy1 - cy0).saturating_mul(max_upsample) >= h + { + return Some(0); + } + None +} + /// Fill `out` (w×h, row-major, row 0 = bottom, same contract as bin_2d) for /// the window from the coarsest adequate level. Returns the level used, or /// None when even level 0 cannot meet the resolution (window too small). @@ -192,34 +368,7 @@ pub fn compose( if !(hi_x > lo_x && hi_y > lo_y) { return None; } - // Pick the coarsest level that still meets the render resolution without - // upsampling (fewest cells to walk, no blur). Only when even level 0 - // cannot meet it, tolerate up to `max_upsample` before refusing — beyond - // that the exact path must run. Callers over huge/out-of-core columns pass - // a large `max_upsample` so the finest level is served (progressively - // blurry) rather than triggering an O(N) rescan of the whole column (§28). - let mut chosen: Option = None; - for level in (0..p.levels.len()).rev() { - let dim = p.dims[level]; - let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); - let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); - if cx1 - cx0 >= w && cy1 - cy0 >= h { - chosen = Some(level); - break; - } - } - if chosen.is_none() { - let dim = p.dims[0]; - let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); - let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); - // Saturating multiply so a huge `max_upsample` can't overflow usize. - if (cx1 - cx0).saturating_mul(max_upsample) >= w - && (cy1 - cy0).saturating_mul(max_upsample) >= h - { - chosen = Some(0); - } - } - let level = chosen?; + let level = choose_level(p, lo_x, hi_x, lo_y, hi_y, w, h, max_upsample)?; let dim = p.dims[level]; let lvl = &p.levels[level]; for c in out.iter_mut() { @@ -355,6 +504,147 @@ fn axis_weights( v } +/// `compose` plus the mean-color plane: fills the identical f32 count grid +/// (delegates to `compose`, so counts are bit-identical to the count-only +/// entry point by construction) and a `w*h*4` straight-alpha RGBA8 grid whose +/// cells carry the weighted mean of the composed source cells' mean colors — +/// weight = overlap fraction × count × mean alpha, the same alpha-weighted +/// average `bin_2d_mean_color` computes over raw points. Both of `compose`'s +/// regimes apply: downsampling area-weights each source cell across the +/// output bins it overlaps (the color plane splits exactly like the counts, +/// matching the #153 area-weighted compose), and upsampling pulls the source +/// cell under each output pixel. f64 accumulation in a fixed traversal order +/// keeps the result deterministic. Returns `None` for windows the pyramid +/// cannot serve and for pyramids built without color planes; the caller +/// re-bins exactly either way. +#[allow(clippy::too_many_arguments)] +pub fn compose_color( + p: &Pyramid, + lo_x: f64, + hi_x: f64, + lo_y: f64, + hi_y: f64, + w: usize, + h: usize, + max_upsample: usize, + out: &mut [f32], + out_rgba: &mut [u8], +) -> Option { + let color_levels = p.color_levels.as_ref()?; + if out_rgba.len() != w.checked_mul(h)?.checked_mul(4)? { + return None; + } + let level = compose(p, lo_x, hi_x, lo_y, hi_y, w, h, max_upsample, out)?; + out_rgba.fill(0); + let dim = p.dims[level]; + let lvl = &p.levels[level]; + let clvl = &color_levels[level]; + let cell_x = (p.x1 - p.x0) / dim as f64; + let cell_y = (p.y1 - p.y0) / dim as f64; + let sx = w as f64 / (hi_x - lo_x); + let sy = h as f64 / (hi_y - lo_y); + let (cx0, cx1) = center_range(lo_x, hi_x, p.x0, p.x1, dim); + let (cy0, cy1) = center_range(lo_y, hi_y, p.y0, p.y1, dim); + let upsampling = (cx1 - cx0) < w || (cy1 - cy0) < h; + if upsampling { + // Pull, exactly like `compose`: each output pixel wears the mean + // color of the single source cell under it. + let inv_cell_x = 1.0 / cell_x; + let inv_cell_y = 1.0 / cell_y; + for (oy, quad_row) in out_rgba.chunks_exact_mut(w * 4).enumerate() { + let ydata = lo_y + (oy as f64 + 0.5) / sy; + let cy = ((ydata - p.y0) * inv_cell_y) as isize; + if cy < 0 || cy as usize >= dim { + continue; + } + let base = cy as usize * dim; + for (ox, quad) in quad_row.chunks_exact_mut(4).enumerate() { + let xdata = lo_x + (ox as f64 + 0.5) / sx; + let cx = ((xdata - p.x0) * inv_cell_x) as isize; + if cx < 0 || cx as usize >= dim { + continue; + } + let cell = base + cx as usize; + if lvl[cell] == 0 { + continue; + } + let [r, g, b, alpha] = clvl[cell]; + quad[0] = kernels::linear_u16_to_srgb_u8(r); + quad[1] = kernels::linear_u16_to_srgb_u8(g); + quad[2] = kernels::linear_u16_to_srgb_u8(b); + quad[3] = ((u32::from(alpha) + 128) / 257).min(255) as u8; + } + } + return Some(level); + } + if cx0 >= cx1 || cy0 >= cy1 { + return Some(level); + } + // Downsample / 1:1 — the same area-weighted splits as the count pass + // (`axis_weights`), accumulating per output bin: `weight_sum` carries + // fraction × count × mean-alpha (the color weights), `count_sum` carries + // fraction × count (the mean-alpha denominator). + let none = u32::MAX; + let xw = axis_weights(cx0, cx1, p.x0, cell_x, lo_x, sx, w); + let yw = axis_weights(cy0, cy1, p.y0, cell_y, lo_y, sy, h); + let mut count_sum = vec![0.0f64; w * h]; + let mut weight_sum = vec![0.0f64; w * h]; + let mut red = vec![0.0f64; w * h]; + let mut green = vec![0.0f64; w * h]; + let mut blue = vec![0.0f64; w * h]; + for (cy, &(by, wpy, nby, wny)) in (cy0..cy1).zip(yw.iter()) { + let row = &lvl[cy * dim + cx0..cy * dim + cx1]; + let crow = &clvl[cy * dim + cx0..cy * dim + cx1]; + let by = by as usize; + for ((&c, &[r, g, b, alpha]), &(bx, wpx, nbx, wnx)) in + row.iter().zip(crow.iter()).zip(xw.iter()) + { + if c == 0 { + continue; + } + let cf = f64::from(c); + let af = f64::from(alpha); + let (rf, gf, bf) = (f64::from(r), f64::from(g), f64::from(b)); + let bx = bx as usize; + let mut splat = |bin: usize, frac: f64| { + let effective = frac * cf; + let weight = effective * af; + count_sum[bin] += effective; + weight_sum[bin] += weight; + red[bin] += weight * rf; + green[bin] += weight * gf; + blue[bin] += weight * bf; + }; + splat(by * w + bx, f64::from(wpx) * f64::from(wpy)); + if nbx != none { + splat(by * w + nbx as usize, f64::from(wnx) * f64::from(wpy)); + } + if nby != none { + let nby = nby as usize; + splat(nby * w + bx, f64::from(wpx) * f64::from(wny)); + if nbx != none { + splat(nby * w + nbx as usize, f64::from(wnx) * f64::from(wny)); + } + } + } + } + for (bin, quad) in out_rgba.chunks_exact_mut(4).enumerate() { + let weight = weight_sum[bin]; + if !(count_sum[bin] > 0.0 && weight > 0.0) { + continue; + } + let mean = |s: f64| (s / weight).round().clamp(0.0, 65535.0) as u16; + // Stored alphas are u16-scaled (×257); the weighted mean over source + // cells comes back on the same scale, so /257 restores the byte. + let alpha_u16 = (weight / count_sum[bin]).round().clamp(0.0, 65535.0); + quad[0] = kernels::linear_u16_to_srgb_u8(mean(red[bin])); + quad[1] = kernels::linear_u16_to_srgb_u8(mean(green[bin])); + quad[2] = kernels::linear_u16_to_srgb_u8(mean(blue[bin])); + quad[3] = ((alpha_u16 as u32 + 128) / 257).min(255) as u8; + } + Some(level) +} + // -- handle registry (engine doc §3.3) --------------------------------------- // Pyramids are stored as `Arc` so lookups can clone the handle out and drop @@ -462,6 +752,114 @@ mod tests { ); } + /// Deterministic two-color source: points left of x=50 wear LUT entry 0, + /// the rest entry 1. + fn split_idx(x: &[f64]) -> Vec { + x.iter().map(|&v| u8::from(v >= 50.0)).collect() + } + + const RED_BLUE: [[u8; 4]; 2] = [[255, 0, 0, 255], [0, 0, 255, 255]]; + + #[test] + fn colored_build_keeps_count_levels_and_rejects_append() { + let (x, y) = cross(5000); + let idx = split_idx(&x); + let colors = kernels::BinColorSource::Indexed { + idx: &idx, + lut: &RED_BLUE, + }; + let plain = build(&x, &y, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + let mut colored = build_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + assert_eq!( + plain.levels, colored.levels, + "color planes must not perturb the count pyramid" + ); + assert!(colored.has_color() && !plain.has_color()); + let before = colored.levels.clone(); + assert!( + !append(&mut colored, &[50.0], &[50.0]), + "colored pyramids refuse increments (colors unknown, domain may shift)" + ); + assert_eq!(colored.levels, before); + } + + #[test] + fn compose_color_counts_match_compose_and_colors_match_kernel() { + let (x, y) = cross(6000); + let idx = split_idx(&x); + let colors = kernels::BinColorSource::Indexed { + idx: &idx, + lut: &RED_BLUE, + }; + let dim = 64; + let p = build_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, dim).unwrap(); + let mut counts = vec![0.0f32; dim * dim]; + let mut rgba = vec![0u8; dim * dim * 4]; + let level = + compose_color(&p, 0.0, 100.0, 0.0, 100.0, dim, dim, MAX_UPSAMPLE, &mut counts, &mut rgba) + .unwrap(); + assert_eq!(level, 0); + let mut plain = vec![0.0f32; dim * dim]; + assert_eq!( + compose(&p, 0.0, 100.0, 0.0, 100.0, dim, dim, MAX_UPSAMPLE, &mut plain), + Some(0) + ); + assert_eq!(counts, plain, "count grid is bit-identical to compose"); + let mut direct = vec![0u8; dim * dim * 4]; + kernels::bin_2d_mean_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, dim, dim, &mut direct); + assert_eq!( + rgba, direct, + "full-window level-0 compose reproduces the direct mean-color grid" + ); + } + + #[test] + fn compose_color_zoomed_out_levels_stay_pure_per_side() { + // All-red left half, all-blue right half: any pyramid level keeps + // each side's cells exactly pure, and mean alpha stays opaque. + let (x, y) = cross(8000); + let idx = split_idx(&x); + let colors = kernels::BinColorSource::Indexed { + idx: &idx, + lut: &RED_BLUE, + }; + let p = build_color(&x, &y, &colors, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + let (w, h) = (8, 8); + let mut counts = vec![0.0f32; w * h]; + let mut rgba = vec![0u8; w * h * 4]; + let level = + compose_color(&p, 0.0, 100.0, 0.0, 100.0, w, h, MAX_UPSAMPLE, &mut counts, &mut rgba) + .unwrap(); + assert!(level > 0, "an 8x8 render must come from a coarser level"); + for cy in 0..h { + for cx in 0..w { + let quad = &rgba[(cy * w + cx) * 4..(cy * w + cx) * 4 + 4]; + if counts[cy * w + cx] <= 0.0 { + assert_eq!(quad, [0, 0, 0, 0]); + continue; + } + let expect = if cx < w / 2 { RED_BLUE[0] } else { RED_BLUE[1] }; + assert_eq!( + quad, expect, + "pure-side cell at ({cx},{cy}) must keep its exact color" + ); + } + } + } + + #[test] + fn compose_color_without_planes_or_bad_shapes_refuses() { + let (x, y) = cross(4000); + let p = build(&x, &y, 0.0, 100.0, 0.0, 100.0, 64).unwrap(); + let mut counts = vec![0.0f32; 16]; + let mut rgba = vec![0u8; 64]; + assert_eq!( + compose_color(&p, 0.0, 100.0, 0.0, 100.0, 4, 4, MAX_UPSAMPLE, &mut counts, &mut rgba), + None, + "count-only pyramids refuse color composition" + ); + } + #[test] fn compose_full_window_matches_bin2d_exactly() { let (x, y) = cross(4000); diff --git a/tests/test_declarative_colorbar.py b/tests/test_declarative_colorbar.py index 03502f41..35f20c4c 100644 --- a/tests/test_declarative_colorbar.py +++ b/tests/test_declarative_colorbar.py @@ -102,7 +102,7 @@ def test_noncontinuous_scatter_does_not_invent_a_colorbar(color) -> None: assert "colorbar" not in spec -def test_density_scatter_does_not_label_the_dropped_per_row_color_channel() -> None: +def test_density_scatter_colorbar_labels_the_aggregated_color_channel() -> None: chart = xy.scatter_chart( xy.scatter( [0.0, 1.0, 2.0], @@ -116,9 +116,14 @@ def test_density_scatter_does_not_label_the_dropped_per_row_color_channel() -> N spec, _ = chart.figure().build_payload() + # The aggregated surface wears the channel's own colors — per-cell mean + # point color (LOD doc §2) — so the channel is not dropped and its + # domain⇄colormap legend stays truthful at every tier. assert spec["traces"][0]["tier"] == "density" - assert spec["traces"][0]["density"]["channels_dropped"] is True - assert "colorbar" not in spec + assert spec["traces"][0]["density"]["channels_dropped"] is False + assert spec["traces"][0]["density"]["color_agg"] == "mean" + assert spec["colorbar"]["domain"] == [10.0, 30.0] + assert spec["colorbar"]["colormap"] == "plasma" def test_hexbin_and_contour_colorbars_use_compiled_domains() -> None: diff --git a/tests/test_density_mean_color.py b/tests/test_density_mean_color.py new file mode 100644 index 00000000..75d813c3 --- /dev/null +++ b/tests/test_density_mean_color.py @@ -0,0 +1,326 @@ +"""Mean-color density surface (LOD doc §2): the aggregated view wears the +data's own colors — per-cell alpha-weighted mean of the resolved point +colors, averaged in linear light — while the binned count drives only the +alpha channel. These tests pin the kernel against a NumPy oracle (the LOD +doc's exit criterion), the wire shape across the initial emit / exact +density_view / pyramid paths, and the static exporters' color law. +""" + +from __future__ import annotations + +import numpy as np + +from xy import channels, kernels +from xy._figure import Figure +from xy.config import DEFAULT_PALETTE, PYRAMID_MIN_POINTS, SCATTER_DENSITY_THRESHOLD +from xy.interaction import _decode_log_u8 + +# sRGB <-> linear-light, float oracle (IEC 61966-2-1) — independent of the +# kernel's integer tables so the test checks the law, not the implementation. + + +def _srgb_to_linear(byte: np.ndarray) -> np.ndarray: + c = byte.astype(np.float64) / 255.0 + return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) + + +def _linear_to_srgb_u8(lin: np.ndarray) -> np.ndarray: + lin = np.clip(lin, 0.0, 1.0) + c = np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055) + return np.rint(c * 255.0).astype(np.uint8) + + +def _mean_color_oracle( + x: np.ndarray, + y: np.ndarray, + rgba: np.ndarray, + window: tuple[float, float, float, float], + w: int, + h: int, +) -> np.ndarray: + """NumPy reference for bin_2d_mean_color (straight-alpha RGBA8 out).""" + x0, x1, y0, y1 = window + out = np.zeros((h, w, 4), dtype=np.uint8) + keep = np.isfinite(x) & np.isfinite(y) & (x >= x0) & (x < x1) & (y >= y0) & (y < y1) + cx = np.minimum(((x[keep] - x0) * (w / (x1 - x0))).astype(np.int64), w - 1) + cy = np.minimum(((y[keep] - y0) * (h / (y1 - y0))).astype(np.int64), h - 1) + colors = rgba[keep] + lin = _srgb_to_linear(colors[:, :3]) + alpha = colors[:, 3].astype(np.float64) + for cell in np.unique(cy * w + cx): + rows = cy * w + cx == cell + weight = alpha[rows].sum() + count = int(rows.sum()) + if weight <= 0: + continue + mean_lin = (lin[rows] * alpha[rows, None]).sum(axis=0) / weight + out.reshape(-1, 4)[cell, :3] = _linear_to_srgb_u8(mean_lin) + # Round half up, like the kernel's integer (sum + count/2) / count — + # Python's round() is half-to-even and disagrees on exact halves. + out.reshape(-1, 4)[cell, 3] = min(255, int(np.floor(weight / count + 0.5))) + return out + + +def _decode_truecolor_png(data: bytes) -> tuple[int, int, np.ndarray]: + """Minimal decoder for xy's own truecolor PNGs (color type 6, + filter-0 scanlines, no interlace) — enough to assert exported pixels.""" + import struct + import zlib + + assert data[:8] == b"\x89PNG\r\n\x1a\n" + at = 8 + width = height = None + idat = b"" + while at < len(data): + (length,) = struct.unpack(">I", data[at : at + 4]) + kind = data[at + 4 : at + 8] + chunk = data[at + 8 : at + 8 + length] + at += 12 + length + if kind == b"IHDR": + width, height, depth, color_type, _c, _f, interlace = struct.unpack(">IIBBBBB", chunk) + assert (depth, color_type, interlace) == (8, 6, 0) + elif kind == b"IDAT": + idat += chunk + elif kind == b"IEND": + break + assert width and height + raw = zlib.decompress(idat) + stride = width * 4 + rows = [] + for row in range(height): + offset = row * (stride + 1) + assert raw[offset] == 0, "xy PNGs write filter-0 scanlines" + rows.append(np.frombuffer(raw, dtype=np.uint8, count=stride, offset=offset + 1)) + return width, height, np.stack(rows).reshape(height, width, 4) + + +def _payload_u8(spec, blob, ref) -> np.ndarray: + """Read a u8 column of a packed build_payload blob by column index.""" + meta = spec["columns"][ref] + return np.frombuffer(blob, dtype=np.uint8, count=meta["len"], offset=meta["byte_offset"]) + + +def test_mean_color_kernel_matches_numpy_oracle(): + rng = np.random.default_rng(11) + n = 20_000 + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + rgba = rng.integers(0, 256, size=(n, 4), dtype=np.uint8) + got = kernels.bin_2d_mean_color(x, y, 0.0, 100.0, 0.0, 100.0, 32, 24, rgba=rgba) + want = _mean_color_oracle(x, y, rgba, (0.0, 100.0, 0.0, 100.0), 32, 24) + # Independent float oracle vs the kernel's exact integer pipeline: alphas + # match to the byte; colors to 1 lsb (quantization at different stages). + assert np.array_equal(got[..., 3], want[..., 3]) + lit = want[..., 3] > 0 + diff = np.abs(got.astype(np.int16) - want.astype(np.int16))[lit] + assert diff.max() <= 1 + # Empty cells are fully zero — no invented color. + assert not got[~lit].any() + + +def test_payload_density_ships_mean_colors_for_categorical(): + # Two spatially separated categories: left red-ish cells must wear the + # first palette color exactly, right cells the second — the surface shows + # the data's colors, not a count colormap. + n = SCATTER_DENSITY_THRESHOLD + 10_000 + rng = np.random.default_rng(5) + x = np.concatenate([rng.uniform(0.0, 1.0, n // 2), rng.uniform(9.0, 10.0, n - n // 2)]) + y = rng.uniform(0.0, 1.0, n) + cats = np.where(x < 5.0, "left", "right") + # density=True: channel-bearing traces keep direct draw until the 2M + # ceiling, so force the aggregate view at test-friendly sizes. + fig = Figure().scatter(x, y, color=cats, density=True) + spec, blob = fig.build_payload() + tr = spec["traces"][0] + assert tr["tier"] == "density" + d = tr["density"] + assert d["color_agg"] == "mean" + assert d["channels_dropped"] is False and d["dropped_channels"] == [] + w, h = d["w"], d["h"] + rgba = _payload_u8(spec, blob, d["rgba"]).reshape(h, w, 4) + counts = _decode_log_u8(_payload_u8(spec, blob, d["buf"]).tobytes(), d["max"]).reshape(h, w) + palette = channels.palette_rgba8(DEFAULT_PALETTE, 2) + lit = rgba[..., 3] > 0 + assert lit.any() + assert np.array_equal(lit, counts > 0.5), "occupied cells match the count grid" + # "left" sorts before "right": palette rows 0 / 1. + left = rgba[:, : w // 2][lit[:, : w // 2]] + right = rgba[:, w // 2 :][lit[:, w // 2 :]] + assert (left == palette[0]).all() + assert (right == palette[1]).all() + + +def test_constant_color_density_keeps_count_only_wire(): + n = SCATTER_DENSITY_THRESHOLD + 10_000 + rng = np.random.default_rng(6) + fig = Figure().scatter(rng.uniform(0, 1, n), rng.uniform(0, 1, n), color="#ff0000") + spec, _ = fig.build_payload() + d = spec["traces"][0]["density"] + assert "rgba" not in d and "color_agg" not in d + assert d["color"] == "#ff0000" # client tints; mean of a constant IS the constant + + +def test_density_view_exact_path_ships_mean_colors(): + n = SCATTER_DENSITY_THRESHOLD * 3 + rng = np.random.default_rng(7) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + values = x.copy() # continuous channel correlated with position + fig = Figure().scatter(x, y, color=values, density=True) + upd, bufs = fig.density_view(0, 0.0, 100.0, 0.0, 100.0, 256, 192) + tr = upd["traces"][0] + assert tr["mode"] == "density" and tr["binning"] == "exact" + d = tr["density"] + assert d["color_agg"] == "mean" + rgba = np.frombuffer(bufs[d["rgba"]], dtype=np.uint8).reshape(d["h"], d["w"], 4) + lit = rgba[..., 3] > 0 + assert lit.any() + # Viridis runs dark-purple -> yellow: left columns must be bluer, right + # columns greener/yellower — the surface follows the channel, not count. + left_mean = rgba[:, : d["w"] // 4][lit[:, : d["w"] // 4]].mean(axis=0) + right_mean = rgba[:, 3 * d["w"] // 4 :][lit[:, 3 * d["w"] // 4 :]].mean(axis=0) + assert left_mean[2] > right_mean[2] # blue fades toward the right + assert right_mean[1] > left_mean[1] # green rises toward the right + + +def test_density_view_pyramid_path_ships_mean_colors(): + n = PYRAMID_MIN_POINTS + 50_000 + rng = np.random.default_rng(8) + x = np.concatenate([rng.uniform(0.0, 1.0, n // 2), rng.uniform(9.0, 10.0, n - n // 2)]) + y = rng.uniform(0.0, 1.0, n) + cats = np.where(x < 5.0, "left", "right") + fig = Figure().scatter(x, y, color=cats) + upd, bufs = fig.density_view(0, 0.0, 10.0, 0.0, 1.0, 128, 96) + tr = upd["traces"][0] + assert tr["binning"].startswith("pyramid-L"), "large trace must serve from the pyramid" + d = tr["density"] + assert d["color_agg"] == "mean" + rgba = np.frombuffer(bufs[d["rgba"]], dtype=np.uint8).reshape(d["h"], d["w"], 4) + palette = channels.palette_rgba8(DEFAULT_PALETTE, 2) + lit = rgba[..., 3] > 0 + assert lit.any() + left = rgba[:, : d["w"] // 2][lit[:, : d["w"] // 2]] + right = rgba[:, d["w"] // 2 :][lit[:, d["w"] // 2 :]] + assert (left == palette[0]).all() + assert (right == palette[1]).all() + # The area-weighted compose (#153) spreads a cluster-edge source cell + # across every output bin its extent overlaps, so boundary bins can carry + # a fractional sliver of count — lit in the color plane while the log-u8 + # count plane rounds the sliver to 0 or 1. The planes must still agree on + # where mass exists: any bin the count plane shows nonzero is lit, and an + # unlit bin never shows count. + enc = np.frombuffer(bufs[d["buf"]], dtype=np.uint8).reshape(d["h"], d["w"]) + assert lit[enc > 0].all() + assert (enc[~lit] == 0).all() + + +def test_colored_pyramid_append_invalidates_for_lazy_rebuild(): + n = PYRAMID_MIN_POINTS + 10_000 + rng = np.random.default_rng(9) + x = rng.uniform(0.0, 10.0, n) + y = rng.uniform(0.0, 1.0, n) + fig = Figure().scatter(x, y, color=x.copy()) + fig.density_view(0, 0.0, 10.0, 0.0, 1.0, 128, 96) # builds the colored pyramid + t = fig.traces[0] + assert getattr(t, "_pyr_handle", 0) + assert getattr(t, "_pyr_colored", False) is True + fig.append(0, [5.0], [0.5], color=[5.0]) + # The colored pyramid refuses native increments; the append must have + # invalidated it for a lazy rebuild rather than leaving stale colors. + assert getattr(t, "_pyr_handle", 0) in (None, 0) + + +def test_svg_export_density_uses_mean_colors(): + n = SCATTER_DENSITY_THRESHOLD + 10_000 + rng = np.random.default_rng(10) + x = np.concatenate([rng.uniform(0.0, 1.0, n // 2), rng.uniform(9.0, 10.0, n - n // 2)]) + y = rng.uniform(0.0, 1.0, n) + cats = np.where(x < 5.0, "left", "right") + fig = Figure().scatter(x, y, color=cats, density=True) + svg = fig.to_svg() + assert "data:image/png;base64," in svg + # The categorical palette must color the exported surface: decode the + # embedded density PNG (png_truecolor writes filter-0 scanlines) and + # check the two clusters' hues. + import base64 + import re + + payload = re.search(r"data:image/png;base64,([A-Za-z0-9+/=]+)", svg).group(1) + w, h, img = _decode_truecolor_png(base64.b64decode(payload)) + lit = img[..., 3] > 0 + palette = channels.palette_rgba8(DEFAULT_PALETTE, 2) + left = img[:, : w // 2][lit[:, : w // 2]] + right = img[:, w // 2 :][lit[:, w // 2 :]] + assert left.size and right.size + assert (left[:, :3] == palette[0, :3]).all() + assert (right[:, :3] == palette[1, :3]).all() + + +def test_resolve_bin_colors_modes(): + # constant -> None (count-only grid + client tint) + assert ( + channels.resolve_bin_colors( + channels.ColorChannel(mode="constant", constant="#123456"), None, DEFAULT_PALETTE + ) + is None + ) + # continuous -> 256-texel colormap LUT + quantized indices + cc = channels.resolve_color(np.array([0.0, 0.5, 1.0]), 3, default_constant="#000000") + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None and out["lut"].shape == (256, 4) + assert out["idx"].dtype == np.uint8 and list(out["idx"]) == [0, 128, 255] + # categorical -> palette rows, codes pass through + cc = channels.resolve_color(np.array(["a", "b", "a"]), 3, default_constant="#000000") + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None and out["lut"].shape[0] == 2 + assert list(out["idx"]) == [0, 1, 0] + # direct rgba -> packed straight-alpha bytes + cc = channels.resolve_color(np.array([[1.0, 0.0, 0.0, 0.5]] * 3), 3, default_constant="#000000") + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None and out["rgba"].shape == (3, 4) + assert list(out["rgba"][0]) == [255, 0, 0, 128] + + +def test_mean_color_weights_by_point_alpha(): + # A 20%-alpha red and an opaque blue in one cell: the mean must lean blue, + # and the cell's mean alpha rides the wire so display intensity follows. + x = np.array([0.5, 0.5]) + y = np.array([0.5, 0.5]) + rgba = np.array([[255, 0, 0, 51], [0, 0, 255, 255]], dtype=np.uint8) + grid = kernels.bin_2d_mean_color(x, y, 0.0, 1.0, 0.0, 1.0, 1, 1, rgba=rgba) + cell = grid[0, 0] + assert cell[2] > cell[0] > 0 + assert cell[3] == 153 # mean straight alpha: (51 + 255) / 2 + # An all-invisible cell must not invent color or intensity. + ghost = kernels.bin_2d_mean_color( + x, y, 0.0, 1.0, 0.0, 1.0, 1, 1, rgba=np.array([[255, 0, 0, 0]] * 2, dtype=np.uint8) + ) + assert list(ghost[0, 0]) == [0, 0, 0, 0] + + +def test_mean_color_mixed_cell_averages_in_linear_light(): + # Half red + half blue: linear-light averaging gives a brighter purple + # (188) than naive sRGB byte averaging (128) — the physically downsampled + # color of the cluster (LOD doc §2). + x = np.array([0.5, 0.5]) + y = np.array([0.5, 0.5]) + rgba = np.array([[255, 0, 0, 255], [0, 0, 255, 255]], dtype=np.uint8) + grid = kernels.bin_2d_mean_color(x, y, 0.0, 1.0, 0.0, 1.0, 1, 1, rgba=rgba) + assert list(grid[0, 0]) == [188, 0, 188, 255] + + +def test_wide_categorical_codes_fold_onto_palette(): + # >256 categories ship u32 codes; binned colors must still be exactly the + # palette color each point draws with (repeat rule, modulo palette). + n_cats = 300 + codes = np.arange(n_cats, dtype=np.uint32) + cc = channels.ColorChannel( + mode="categorical", + codes=codes, + categories=[f"c{i}" for i in range(n_cats)], + ) + out = channels.resolve_bin_colors(cc, None, DEFAULT_PALETTE) + assert out is not None + assert out["lut"].shape[0] == len(DEFAULT_PALETTE) + expected = (codes % len(DEFAULT_PALETTE)).astype(np.uint8) + assert np.array_equal(out["idx"], expected) diff --git a/tests/test_scatter.py b/tests/test_scatter.py index d25cb450..a004e7b3 100644 --- a/tests/test_scatter.py +++ b/tests/test_scatter.py @@ -740,15 +740,17 @@ def test_density_view_drills_to_points_when_window_fits(): row = fig.pick(0, 0) assert row is not None and 0.0 <= row["x"] <= 10.0 assert "color_value" in row - # Color-continuous handoff: per-point local log-density in [0,1], a blend - # weight = visible/budget, and the colormap the density surface uses — - # so freshly drilled points wear the density ramp (§5, never a palette jump). + # Intensity handoff (§5): per-point local log-density (u8, like every + # unit-scalar live channel) and a blend weight = visible/budget. Freshly + # drilled points enter at their cell's count-alpha and ease to native + # opacity; the surface wears the mean point color (LOD doc §2), so no + # density_colormap rides the points wire. assert tr["density_val"]["dtype"] == "u8" dbuf = np.frombuffer(bufs[tr["density_val"]["buf"]], dtype=np.uint8) assert len(dbuf) == inwin - assert dbuf.max() == 255 # the hottest cell hits the ramp top + assert dbuf.max() == 255 # the hottest cell reaches full handoff alpha assert tr["lod_blend"] == pytest.approx(inwin / SCATTER_DENSITY_THRESHOLD) - assert tr["density_colormap"] == "viridis" # continuous channel's colormap + assert "density_colormap" not in tr # Channels are normalized over the *global* domain after slicing (staff # review: slice-first must not change values — colors stay view-stable), # then quantized: every byte within half a step of the exact unit value. @@ -865,8 +867,9 @@ def test_drill_lod_blend_shrinks_as_zoom_deepens(): assert upd_wide["traces"][0]["mode"] == "points" assert upd_deep["traces"][0]["mode"] == "points" assert upd_deep["traces"][0]["lod_blend"] < upd_wide["traces"][0]["lod_blend"] - # constant-color scatter still gets the default density ramp for the handoff - assert upd_deep["traces"][0]["density_colormap"] == ch.DEFAULT_COLORMAP + # the handoff is intensity-only (hue is continuous by construction, LOD + # doc §2), so no colormap rides the points wire + assert "density_colormap" not in upd_deep["traces"][0] def test_density_view_returns_to_density_on_zoom_out(): @@ -905,17 +908,42 @@ def test_drill_hysteresis_holds_points_mode_near_boundary(): assert upd2["traces"][0]["mode"] == "density" # cold entry aggregates -def test_huge_scatter_with_channels_warns_and_drops(): +def test_huge_scatter_with_color_channel_warns_and_aggregates_mean_color(): from xy._figure import DIRECT_SOFT_CEILING n = DIRECT_SOFT_CEILING + 1 x = np.zeros(n) color = np.arange(n, dtype=np.float64) - with pytest.warns(RuntimeWarning, match="dropped"): + # Color survives aggregation as the surface's per-cell mean point color + # (LOD doc §2), and the warning says so. + with pytest.warns(RuntimeWarning, match="mean point color"): fig = Figure().scatter(x, x, color=color) spec, _ = fig.build_payload() - assert spec["traces"][0]["tier"] == "density" - assert spec["traces"][0]["density"]["channels_dropped"] is True + tr = spec["traces"][0] + assert tr["tier"] == "density" + assert tr["density"]["channels_dropped"] is False + assert tr["density"]["dropped_channels"] == [] + assert tr["density"]["color_agg"] == "mean" + assert "rgba" in tr["density"] + + +def test_huge_scatter_with_size_channel_warns_and_drops(): + from xy._figure import DIRECT_SOFT_CEILING + + n = DIRECT_SOFT_CEILING + 1 + x = np.zeros(n) + size = np.ones(n, dtype=np.float64) + size[0] = 2.0 + # Size has no honest per-cell aggregate (LOD doc §2 rule 4): dropped, + # recorded in `dropped_channels`, and warned about. + with pytest.warns(RuntimeWarning, match="dropped channels: size"): + fig = Figure().scatter(x, x, size=size) + spec, _ = fig.build_payload() + tr = spec["traces"][0] + assert tr["tier"] == "density" + assert tr["density"]["channels_dropped"] is True + assert tr["density"]["dropped_channels"] == ["size"] + assert "rgba" not in tr["density"] # -- pick / hover drill ------------------------------------------------------ From a6237479526c45ba67b63fc08c18f5697f2e1b76 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:34 +0000 Subject: [PATCH 02/11] Slim the fastapi live drilldown demo to a thin XYBF transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 8 + examples/fastapi/README.md | 6 +- examples/fastapi/live_drilldown.py | 705 ++++---------------------- tests/test_drilldown_pan_alignment.py | 144 ------ tests/test_example_apps.py | 11 +- 5 files changed, 117 insertions(+), 757 deletions(-) delete mode 100644 tests/test_drilldown_pan_alignment.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d41b9a4b..fad60130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,14 @@ in the README). channel is no longer listed in `dropped_channels` at Tier 2, and a continuous-channel density scatter renders its colorbar again. C ABI v40 (`xy_bin_2d_mean_color` + pyramid color entry points). +- **The FastAPI live-drilldown example is a thin transport over the engine.** + Every window is served through `Figure.density_view` (mean-color pyramid + warmed at startup); the demo's pre-pyramid density machinery — an + integral-image overview server-side and ~350 lines of page JS (local + re-bins, request parking, per-client staleness maps), all count-only — is + gone, and the page JS is a POST transport plus a status badge. Round-trip + replies ship as `XYBF` binary frames (wire-protocol §7) instead of + base64-in-JSON. ### Added - **Export format parity and a unified export API (ENG-10447).** diff --git a/examples/fastapi/README.md b/examples/fastapi/README.md index 5dcbe7e0..e9023568 100644 --- a/examples/fastapi/README.md +++ b/examples/fastapi/README.md @@ -14,7 +14,11 @@ Two integration surfaces: - **Server drilldown tier** — `GET /drilldown` serves a 100M-point scatter whose density surface refines into exact points on zoom, using `POST /api/xy/drilldown` (a Starlette endpoint in - [`live_drilldown.py`](live_drilldown.py)) for the view round-trips. + [`live_drilldown.py`](live_drilldown.py)) for the view round-trips. Each + reply is an `XYBF` binary frame — compact JSON metadata plus raw f32/u8 + buffers — decoded in the browser with the bundled `xy.decodeFrame`, so the + density grids and point buffers that dominate a drill never pay a base64 + encode/decode or its ~33% inflation. ## Run diff --git a/examples/fastapi/live_drilldown.py b/examples/fastapi/live_drilldown.py index 3e9922c9..79860bbd 100644 --- a/examples/fastapi/live_drilldown.py +++ b/examples/fastapi/live_drilldown.py @@ -1,3 +1,26 @@ +"""FastAPI live drilldown: a 100M-point scatter served by the engine's own LOD. + +The pattern this example demonstrates is deliberately small: + +- build one figure (`live_figure`), serve its payload plus the bundled render + client as a single HTML page; +- wire `ChartView` to a custom transport — a `comm` object whose `send` POSTs + the client's messages to one endpoint and feeds each reply — an `XYBF` + binary frame (`spec/design/wire-protocol.md` §7): small JSON metadata plus + raw f32/u8 buffers, decoded in the browser with `xy.decodeFrame` — back + through `onMessage`, with no base64 on either side; +- the endpoint dispatches `density_view` / `pick` straight to the figure, + under one lock. + +The LOD ladder itself lives in the engine: `Figure.density_view` picks the +tier per window — mean-color pyramid composition for wide views, exact +re-bins near the drill budget, exact points inside it (LOD doc §2/§4) — and +the render client debounces its requests, drops stale replies by `seq`, and +keeps the best cached density texture drawn until a fresh one lands (§17 +stale-while-revalidate), so the host app carries no aggregation or request +bookkeeping of its own. +""" + from __future__ import annotations import base64 @@ -5,22 +28,24 @@ import os import threading import warnings -from dataclasses import dataclass from functools import lru_cache from typing import Any, Union import numpy as np from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, Response import xy -# The live drilldown server probes the engine directly (traces, density_view, -# drill bookkeeping), so it works on the internal figure compiled from the -# public composition API via `Chart.figure()`. +# The live drilldown server probes the engine directly (density_view, pick), +# so it works on the internal figure compiled from the public composition API +# via `Chart.figure()`. from xy._figure import Figure -from xy.config import DRILL_EXIT_FACTOR, SCATTER_DENSITY_THRESHOLD -from xy.lod import grid_shape + +# `encode_frame` builds the XYBF binary transport frame (wire-protocol.md §7) +# the browser decodes with the bundled `xy.decodeFrame`; it is re-exported from +# the transport-neutral channel module, the same seam the Reflex adapter uses. +from xy.channel import encode_frame from xy.widget import bundled_js _DEFAULT_LIVE_POINTS = 100_000_000 @@ -52,12 +77,9 @@ def _live_points() -> int: # Point count for the drilldown demo; override with XY_LIVE_POINTS. LIVE_SCATTER_POINTS = _live_points() LIVE_DRILLDOWN_ROUTE = "/api/xy/drilldown" -DENSITY_OVERVIEW_BINS = 6144 -DENSITY_OVERVIEW_CHUNK = 1_000_000 -OVERVIEW_EXACT_FACTOR = 4.0 +# One figure serves every request; density_view mutates its drill bookkeeping, +# so requests (and payload builds) serialize. _FIGURE_LOCK = threading.Lock() -_DENSITY_SEQ_LOCK = threading.Lock() -_LATEST_DENSITY_SEQ: dict[str, int] = {} def _point_label(n: int) -> str: @@ -123,236 +145,55 @@ def colored_scatter_figure( @lru_cache(maxsize=1) def live_figure() -> Figure: - return live_store().figure - - -@dataclass(frozen=True) -class DensityOverview: - integral: np.ndarray - x_range: tuple[float, float] - y_range: tuple[float, float] - width: int - height: int - - @classmethod - def build(cls, fig: Figure, trace_id: int = 0) -> "DensityOverview": - t = fig.traces[trace_id] - x0, x1 = fig.x_range() - y0, y1 = fig.y_range() - w = h = DENSITY_OVERVIEW_BINS - grid = np.zeros((h, w), dtype=np.uint32) - flat_grid = grid.reshape(-1) - x_scale = w / (x1 - x0) - y_scale = h / (y1 - y0) - xv = t.x.values - yv = t.y.values - - for start in range(0, t.n_points, DENSITY_OVERVIEW_CHUNK): - end = min(start + DENSITY_OVERVIEW_CHUNK, t.n_points) - xs = xv[start:end] - ys = yv[start:end] - valid = np.isfinite(xs) & np.isfinite(ys) - valid &= (xs >= x0) & (xs < x1) & (ys >= y0) & (ys < y1) - if not np.any(valid): - continue - ix = ((xs[valid] - x0) * x_scale).astype(np.int64) - iy = ((ys[valid] - y0) * y_scale).astype(np.int64) - np.clip(ix, 0, w - 1, out=ix) - np.clip(iy, 0, h - 1, out=iy) - counts = np.bincount(iy * w + ix) - flat_grid[: len(counts)] += counts.astype(np.uint32, copy=False) - - summed = np.cumsum(grid, axis=0, dtype=np.uint32) - summed = np.cumsum(summed, axis=1, dtype=np.uint32) - integral = np.zeros((h + 1, w + 1), dtype=np.uint32) - integral[1:, 1:] = summed - return cls(integral=integral, x_range=(x0, x1), y_range=(y0, y1), width=w, height=h) - - def _edges( - self, lo: float, hi: float, domain: tuple[float, float], cells: int, bins: int - ) -> np.ndarray: - d0, d1 = domain - span = d1 - d0 - edges = (np.linspace(lo, hi, cells + 1) - d0) * (bins / span) - return np.clip(np.floor(edges).astype(np.int64), 0, bins) - - def _view_bounds(self, x0: float, x1: float, y0: float, y1: float) -> tuple[int, int, int, int]: - bx0, bx1 = self._edges(x0, x1, self.x_range, 1, self.width) - by0, by1 = self._edges(y0, y1, self.y_range, 1, self.height) - return int(bx0), int(bx1), int(by0), int(by1) - - def count(self, x0: float, x1: float, y0: float, y1: float) -> int: - bx0, bx1, by0, by1 = self._view_bounds(x0, x1, y0, y1) - if bx1 <= bx0 or by1 <= by0: - return 0 - ii = self.integral - return int(ii[by1, bx1]) - int(ii[by0, bx1]) - int(ii[by1, bx0]) + int(ii[by0, bx0]) - - def density( - self, - x0: float, - x1: float, - y0: float, - y1: float, - w: int, - h: int, - visible: int, - ) -> np.ndarray | None: - bx0, bx1, by0, by1 = self._view_bounds(x0, x1, y0, y1) - source_w = bx1 - bx0 - source_h = by1 - by0 - if source_w < 16 or source_h < 16: - return None - w, h = grid_shape(w, h, visible) - w = max(16, min(w, source_w)) - h = max(16, min(h, source_h)) - x_edges = self._edges(x0, x1, self.x_range, w, self.width) - y_edges = self._edges(y0, y1, self.y_range, h, self.height) - ii = self.integral - x_lo = x_edges[:-1] - x_hi = x_edges[1:] - y_lo = y_edges[:-1] - y_hi = y_edges[1:] - grid = ( - ii[y_hi[:, None], x_hi[None, :]].astype(np.int64) - - ii[y_lo[:, None], x_hi[None, :]].astype(np.int64) - - ii[y_hi[:, None], x_lo[None, :]].astype(np.int64) - + ii[y_lo[:, None], x_lo[None, :]].astype(np.int64) - ) - return grid.astype(np.float32, copy=False) - - -@dataclass(frozen=True) -class LiveStore: - figure: Figure - overview: DensityOverview - - -@lru_cache(maxsize=1) -def live_store() -> LiveStore: fig = colored_scatter_figure() - return LiveStore(figure=fig, overview=DensityOverview.build(fig)) + # Warm the kernel's mean-color pyramid (LOD doc §4) at startup so the + # first interactive zoom skips the one-time build; the reply itself is + # discarded. + x0, x1 = fig.x_range() + y0, y1 = fig.y_range() + fig.density_view(0, x0, x1, y0, y1, 512, 384) + return fig def _b64(buf: bytes) -> str: return base64.b64encode(buf).decode("ascii") -def _seq_value(raw: Any) -> int | None: - try: - return int(raw) - except (TypeError, ValueError): - return None - - -def _density_client_id(content: dict[str, Any]) -> str: - return str(content.get("client_id") or "default") - - -def _mark_latest_density(content: dict[str, Any]) -> tuple[str, int | None]: - client_id = _density_client_id(content) - seq = _seq_value(content.get("seq")) - if seq is None: - return client_id, None - with _DENSITY_SEQ_LOCK: - latest = _LATEST_DENSITY_SEQ.get(client_id, -1) - if seq > latest: - _LATEST_DENSITY_SEQ[client_id] = seq - return client_id, seq - - -def _density_is_stale(client_id: str, seq: int | None) -> bool: - if seq is None: - return False - with _DENSITY_SEQ_LOCK: - return seq < _LATEST_DENSITY_SEQ.get(client_id, -1) +# Round-trip replies travel as XYBF binary frames (wire-protocol.md §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. First paint still embeds its blob as +# base64 in the page below, because inline HTML has no binary channel +# (wire-protocol.md §6). +_FRAME_MEDIA_TYPE = "application/octet-stream" -def _response(message: dict[str, Any], buffers: list[bytes] | None = None) -> JSONResponse: - return JSONResponse( - { - "message": message, - "buffers": [_b64(buffer) for buffer in (buffers or [])], - } - ) +def _frame_response(message: dict[str, Any], buffers: list[bytes] | None = None) -> Response: + return Response(encode_frame(message, buffers or []), media_type=_FRAME_MEDIA_TYPE) -def _live_density_view( - store: LiveStore, trace_id: int, x0: float, x1: float, y0: float, y1: float, w: int, h: int -) -> tuple[dict[str, Any], list[bytes]]: - fig = store.figure - if trace_id != 0: - return fig.density_view(trace_id, x0, x1, y0, y1, w, h) - t = fig.traces[trace_id] - if not t.use_density(): - return {"traces": []}, [] - - lo_x, hi_x = min(x0, x1), max(x0, x1) - lo_y, hi_y = min(y0, y1), max(y0, y1) - budget = SCATTER_DENSITY_THRESHOLD * (DRILL_EXIT_FACTOR if t.drill_mode else 1.0) - visible = store.overview.count(lo_x, hi_x, lo_y, hi_y) - if visible <= budget * OVERVIEW_EXACT_FACTOR: - return fig.density_view(trace_id, lo_x, hi_x, lo_y, hi_y, w, h) - - grid = store.overview.density(lo_x, hi_x, lo_y, hi_y, w, h, visible) - if grid is None: - return fig.density_view(trace_id, lo_x, hi_x, lo_y, hi_y, w, h) - - if t.drill_mode: - t.drill_seq += 1 - t.drill_mode = False - t.shipped_sel = None - return ( - { - "traces": [ - { - "id": trace_id, - "mode": "density", - "visible": visible, - "density": { - "buf": 0, - "w": int(grid.shape[1]), - "h": int(grid.shape[0]), - "max": float(grid.max()) if grid.size else 0.0, - "x_range": [lo_x, hi_x], - "y_range": [lo_y, hi_y], - }, - } - ] - }, - [grid.reshape(-1).astype(np.float32, copy=False).tobytes()], - ) +async def drilldown_endpoint(request: Request) -> Response: + """Answer the render client's messages with the engine's own replies. - -async def drilldown_endpoint(request: Request) -> JSONResponse: + `Figure.density_view` owns the whole ladder (mean-color pyramid for wide + windows, exact re-bins near the budget, drill-in to real points, + hysteresis, recorded reductions), and the render client discards stale + replies by `seq`, so this endpoint is one lock and one dispatch. Each + reply ships as an XYBF binary frame (`_frame_response`); malformed + requests return a plain JSON error the client rejects on `res.ok` before + decoding. + """ try: content = await request.json() except json.JSONDecodeError: return JSONResponse({"error": "invalid json"}, status_code=400) kind = content.get("type") - density_client_id: str | None = None - density_seq: int | None = None - if kind == "density_view": - density_client_id, density_seq = _mark_latest_density(content) - with _FIGURE_LOCK: - store = live_store() - fig = store.figure + fig = live_figure() if kind == "density_view": try: - if _density_is_stale(density_client_id or "default", density_seq): - return _response( - { - "type": "density_update", - "seq": content.get("seq"), - "trace": content.get("trace"), - "stale": True, - "traces": [], - } - ) - update, buffers = _live_density_view( - store, + update, buffers = fig.density_view( int(content["trace"]), float(content["x0"]), float(content["x1"]), @@ -363,7 +204,7 @@ async def drilldown_endpoint(request: Request) -> JSONResponse: ) except (KeyError, ValueError, IndexError): return JSONResponse({"error": "bad density_view request"}, status_code=400) - return _response( + return _frame_response( { "type": "density_update", "seq": content.get("seq"), @@ -380,17 +221,18 @@ async def drilldown_endpoint(request: Request) -> JSONResponse: int(content.get("index", -1)), None if dseq is None else int(dseq), ) - return _response({"type": "pick_result", "seq": content.get("seq"), "row": row}) + return _frame_response({"type": "pick_result", "seq": content.get("seq"), "row": row}) if kind in {"select", "select_clear"}: - return _response({"type": "selection", "traces": [], "total": 0}) + return _frame_response({"type": "selection", "traces": [], "total": 0}) return JSONResponse({"error": f"unsupported message type {kind!r}"}, status_code=400) def live_drilldown_html() -> str: - fig = colored_scatter_figure() - spec, blob = fig.build_payload() + fig = live_figure() + with _FIGURE_LOCK: + spec, blob = fig.build_payload() spec_js = json.dumps(spec).replace(" str: diff --git a/tests/test_drilldown_pan_alignment.py b/tests/test_drilldown_pan_alignment.py deleted file mode 100644 index ecb0dbc3..00000000 --- a/tests/test_drilldown_pan_alignment.py +++ /dev/null @@ -1,144 +0,0 @@ -"""FastAPI drilldown: a pan past the data must not offset the density surface. - -The drilldown export re-bins its density overview *in the browser* on pan/zoom -(``localDensityUpdate`` in ``examples/fastapi/live_drilldown.py``) from a -fixed-extent integral image. A requested window can reach past the data domain; -the source-bin lookups clamp there, so the grid covers only the on-domain part -of the window. If the update still reports the *requested* window as the grid's -data range, the fixed-extent texture is stretched across the wider window and -the density slides off the point cloud (drilled points and the retained §28 -sample draw at true data coordinates). This drives the real client in headless -Chromium, pans well past the +x/+y data edge, and checks the reported density -range stays within the data domain and its mass stays aligned with the data. -""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path - -import pytest - -from conftest import run_browser_probe -from xy.export import find_chromium - -FASTAPI_DIR = Path(__file__).resolve().parents[1] / "examples" / "fastapi" - -_ANCHOR = "window.xyLiveDrilldown = view;" - -_PROBE = r""" -window.xyLiveDrilldown = view; -(async () => { - try { - view._drawNow(); view._raf = null; - const g = view.gpuTraces.find((t) => t.tier === "density"); - const ov = spec.traces.find((t) => t.kind === "scatter" && t.density).density; - const domX = ov.x_range, domY = ov.y_range; - const v0 = view.view0 || view.view; - const homeRange = { x: g.density.xRange.slice(), y: g.density.yRange.slice() }; - - // Density mass centroid in DATA coordinates, using the reported grid range. - const centroid = (d) => { - const { grid, w, h, xRange, yRange } = d; - let sx = 0, sy = 0, sw = 0; - for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { - const v = grid[y * w + x] || 0; if (v <= 0) continue; - sx += (xRange[0] + (x + 0.5) / w * (xRange[1] - xRange[0])) * v; - sy += (yRange[0] + (y + 0.5) / h * (yRange[1] - yRange[0])) * v; - sw += v; - } - return sw > 0 ? { x: sx / sw, y: sy / sw } : null; - }; - - // Pan well past the +x / +y data edge (span unchanged), so the requested - // window reaches beyond the domain and the source bins clamp. - const sx = v0.x1 - v0.x0, sy = v0.y1 - v0.y0; - const panned = { x0: v0.x0 + sx * 0.4, x1: v0.x1 + sx * 0.4, - y0: v0.y0 + sy * 0.4, y1: v0.y1 + sy * 0.4 }; - view.view = view._viewFrom(panned); - view._scheduleViewRequest(view.view, { delay: 0, seq: ++view.seq }); - await new Promise((r) => setTimeout(r, 60)); - view._drawNow(); view._raf = null; - - const c = centroid(g.density); - document.body.setAttribute("data-drill-pan", JSON.stringify({ - hasDensity: !!g, - domX, domY, - reqHiX: panned.x1, reqHiY: panned.y1, - homeRange, - pannedRange: { x: g.density.xRange.slice(), y: g.density.yRange.slice() }, - centroid: c, - })); - } catch (err) { - document.body.setAttribute("data-drill-pan-error", String((err && err.stack) || err)); - } -})(); -""" - - -def _drilldown_html() -> str: - # Enough points that a panned window still clears the browser-local re-bin - # budget (DIRECT_POINT_BUDGET * 4 = 800k visible); below it the pan falls to - # the server round-trip, which is unavailable under file:// and untested here. - os.environ["XY_LIVE_POINTS"] = "2000000" - sys.path.insert(0, str(FASTAPI_DIR)) - import importlib - - import live_drilldown - - importlib.reload(live_drilldown) - html = live_drilldown.live_drilldown_html() - assert _ANCHOR in html - return html - - -def test_drilldown_pan_past_data_keeps_density_aligned(tmp_path: Path) -> None: - chromium = find_chromium() - if chromium is None: - pytest.skip("Chromium unavailable") - # examples/fastapi/live_drilldown.py imports starlette at module load (the - # callback endpoint), but it is not a core test dependency. Skip cleanly - # where the fastapi example extra is not installed — same convention as - # tests/test_example_apps.py's importorskip for the fastapi app test. - pytest.importorskip("starlette") - - document = _drilldown_html().replace(_ANCHOR, _PROBE, 1) - result = run_browser_probe( - chromium, - document, - tmp_path / "drilldown_pan.html", - "data-drill-pan", - label="drilldown pan alignment probe", - ) - - assert result["hasDensity"] is True - dom_x = result["domX"] - dom_y = result["domY"] - px = result["pannedRange"]["x"] - py = result["pannedRange"]["y"] - - # The browser-local re-bin must have run (range changed from home), proving - # this exercised localDensityUpdate rather than silently no-op'ing. - assert px != result["homeRange"]["x"], "local density re-bin did not run on pan" - - # The reported grid range must stay within the data domain: the overview - # integral only holds data inside the domain, so a range reaching past it - # (toward the requested window edge) is exactly the stretch bug. - span_x = dom_x[1] - dom_x[0] - span_y = dom_y[1] - dom_y[0] - eps_x = span_x * 1e-6 - eps_y = span_y * 1e-6 - assert px[1] <= dom_x[1] + eps_x, (px, dom_x) - assert py[1] <= dom_y[1] + eps_y, (py, dom_y) - # And it was genuinely clamped to the domain, not left at the request. - assert px[1] < result["reqHiX"] - eps_x, (px, result["reqHiX"]) - assert py[1] < result["reqHiY"] - eps_y, (py, result["reqHiY"]) - - # The density mass stays aligned with the data (centered near the origin, - # not dragged toward the requested-but-empty window edge). The pre-fix - # stretch put the centroid past ~1.1 in x; the data centroid here is ~0.2. - c = result["centroid"] - assert c is not None - assert -1.0 < c["x"] < 1.0, c - assert -1.0 < c["y"] < 1.0, c diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index bb14d06e..09108b79 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -127,7 +127,16 @@ def test_fastapi_app_serves_live_charts_and_code() -> None: }, ) assert drill.status_code == 200 - assert "density_update" in drill.text + # The round-trip reply is an XYBF binary frame (no base64), decoded by the + # same seam the browser's xy.decodeFrame uses; density grids ride as raw + # buffers beside the compact JSON metadata. + assert drill.headers["content-type"] == "application/octet-stream" + from xy.channel import decode_frame + + frame = decode_frame(drill.content) + assert frame.message["type"] == "density_update" + assert frame.message["seq"] == 1 + assert frame.buffers # the density grid rides raw, not base64 in JSON # --- Reflex app structure (source text, no reflex import) ------------------- From b2e31c1caf9482ac184fa7b897f3014b06b892bd Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:39 +0000 Subject: [PATCH 03/11] Add the 100M drilldown to examples/reflex, adapter-native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/reflex/README.md | 18 ++++ .../reflex/xy_reflex_demo/xy_reflex_demo.py | 96 ++++++++++++++++++- scripts/reflex_ws_smoke.py | 34 +++++-- spec/design/reflex-integration.md | 8 +- spec/process/production-readiness.md | 8 +- tests/test_example_apps.py | 12 ++- 6 files changed, 161 insertions(+), 15 deletions(-) diff --git a/examples/reflex/README.md b/examples/reflex/README.md index f68bfc0b..c63a511b 100644 --- a/examples/reflex/README.md +++ b/examples/reflex/README.md @@ -23,6 +23,14 @@ binary columns; Reflex state holds only a token string per chart. 5. **Fixed data, two ways** — a `xy.Chart` passed straight to `reflex_xy.chart` (static payload tier) and a `reflex_xy.inline` token (fixed data served through the kernel). +6. **The 100M drilldown, adapter-native** — the live drilldown scatter + from [`examples/fastapi`](../fastapi) (identical seed-11 data and mark + config, a density surface that drills into exact points on zoom) as a + single `reflex_xy.inline` token. The FastAPI app hand-rolls its transport + for this chart (a Starlette endpoint plus an HTTP comm bridge); here the + adapter's websocket namespace and the kernel's density tiers do all of it, + so behavioral differences between the two apps isolate what that custom + code adds. ## Run @@ -36,6 +44,16 @@ reflex-xy) into a local environment. Open the URL Reflex prints (usually ). Zoom into the cloud to drill density into exact points; box-select to cross-filter the histogram; press **go live** to stream. +`XY_LIVE_POINTS` sets §6's point count — the same override the FastAPI app +honors, so both apps build the identical dataset at any size. Unlike the +FastAPI app (lazy, on first use) the columns are built at import, because +`inline()` registers at module scope; the default 100M costs a few gigabytes +of RAM and some startup seconds, so dial it down on small machines: + +```bash +XY_LIVE_POINTS=1000000 uv run reflex run +``` + The adapter is wired in one line — `plugins=[reflex_xy.XYPlugin()]` in [`rxconfig.py`](rxconfig.py). diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 91124a26..4913659d 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -1,6 +1,6 @@ """reflex-xy showcase: ways to link chart data into a Reflex app. -One page of five sections; each has a "Code" accordion showing its own source +One page of six sections; each has a "Code" accordion showing its own source via `inspect.getsource`. 1. **Live figure var + events.** A 1M-point drillable scatter from an @@ -17,6 +17,10 @@ 5. **Fixed data, two ways.** A ``xy.Chart`` passed straight to ``reflex_xy.chart`` (static payload tier) and a ``reflex_xy.inline`` token (fixed data served through the kernel). +6. **The drilldown, adapter-native.** The 100M-point live drilldown + scatter from ``examples/fastapi`` — identical data and mark config — as one + ``reflex_xy.inline`` token with zero transport code, for A/B-ing the two + hosts. ``XY_LIVE_POINTS`` resizes it (both apps honor the same override). Run from ``examples/reflex``:: @@ -27,6 +31,8 @@ import asyncio import inspect +import os +import warnings from functools import lru_cache from typing import Any @@ -112,6 +118,79 @@ def orbits_chart() -> xy.Chart: ORBITS_TOKEN = reflex_xy.inline(orbits_chart()) +# --- the live drilldown, adapter-native (§6) -------------------------- + + +def _drilldown_points() -> int: + """Point count for the §6 drilldown chart, from ``XY_LIVE_POINTS`` — the + same override ``examples/fastapi`` honors, so both apps build the identical + dataset at any size.""" + raw = os.environ.get("XY_LIVE_POINTS") + if raw is None: + return 100_000_000 + try: + points = int(raw) + except ValueError: + points = 0 + if points < 1: + warnings.warn( + f"XY_LIVE_POINTS={raw!r} is not a positive integer; using 100,000,000", + RuntimeWarning, + stacklevel=2, + ) + return 100_000_000 + return points + + +DRILLDOWN_POINTS = _drilldown_points() + + +def _point_label(n: int) -> str: + if n % 1_000_000 == 0: + return f"{n // 1_000_000}M" + if n % 1_000 == 0: + return f"{n // 1_000}k" + return f"{n:,}" + + +def drilldown_chart(n: int = DRILLDOWN_POINTS) -> xy.Chart: + """The ``examples/fastapi`` live-drilldown scatter: same seed, same chunked + generation, same mark config. That app wires the chart through its own + HTTP transport (a Starlette endpoint plus a comm bridge); here the + kernel's density tiers answer every pan/zoom over the app websocket.""" + rng = np.random.default_rng(11) + x = np.empty(n, dtype=np.float64) + y = np.empty(n, dtype=np.float64) + color = np.empty(n, dtype=np.float64) + size = np.empty(n, dtype=np.float64) + chunk = 1_000_000 + for start in range(0, n, chunk): + end = min(start + chunk, n) + xs = rng.normal(0, 1.0, end - start) + ys = rng.normal(0, 0.55, end - start) + ys += xs * 0.55 + ss = rng.normal(6, 2.5, end - start) + np.abs(ss, out=ss) + np.clip(ss, 2, 16, out=ss) + x[start:end] = xs + y[start:end] = ys + np.hypot(xs, ys, out=color[start:end]) + size[start:end] = ss + return xy.scatter_chart( + xy.scatter(x, y, color=color, size=size, colormap="viridis", opacity=0.72, density=True), + xy.x_axis(label="feature A"), + xy.y_axis(label="feature B"), + title=f"{_point_label(n)} live drilldown scatter", + width="100%", + height=430, + ) + + +# One shared kernel-backed figure for every viewer, expressed as a single +# inline() token; the registry keeps it process-global. +DRILLDOWN_TOKEN = reflex_xy.inline(drilldown_chart()) + + # --- state ------------------------------------------------------------------ @@ -435,6 +514,11 @@ def fixed_view() -> rx.Component: ) +# §6 wiring — the whole drilldown integration is this one line +def drilldown_view() -> rx.Component: + return reflex_xy.chart(DRILLDOWN_TOKEN, height="430px", id="drilldown") + + def index() -> rx.Component: return rx.container( rx.vstack( @@ -524,6 +608,16 @@ def index() -> rx.Component: fixed_view(), code_accordion(sparkline_chart, orbits_chart, fixed_view), ), + section( + f"6 · The {_point_label(DRILLDOWN_POINTS)} drilldown, adapter-native", + "The live drilldown scatter from examples/fastapi — same data, " + "same mark config — with the adapter replacing that app's custom " + "HTTP transport (Starlette endpoint plus comm bridge). Zoom until " + "the density surface drills into exact points; XY_LIVE_POINTS " + "resizes both apps for side-by-side comparison.", + drilldown_view(), + code_accordion(drilldown_chart, drilldown_view), + ), spacing="5", width="100%", ), diff --git a/scripts/reflex_ws_smoke.py b/scripts/reflex_ws_smoke.py index 2948d0d8..0db393df 100644 --- a/scripts/reflex_ws_smoke.py +++ b/scripts/reflex_ws_smoke.py @@ -6,8 +6,9 @@ 1. ONE physical websocket to the backend carries both the app plane and the chart data plane (socket.io namespace multiplexing) — counted via CDP. -2. All three charts paint real pixels from binary socket payloads (screenshot - evidence; there are no HTTP data endpoints to fall back on). +2. The charts paint real pixels from binary socket payloads (screenshot + evidence; there are no HTTP data endpoints to fall back on) — including + the §6 fastapi-parity drilldown chart's density surface. 3. Deep zoom drills the 1M-point density scatter to exact points (density_view round-trips over the socket, §16), and hovering a drilled point closes the semantic loop: kernel pick -> reflex event -> state @@ -241,9 +242,9 @@ def main() -> None: with ChromiumSession(chromium, gl="software", sandbox=False) as session: probe = Probe(session, args.frontend) - # 1) every chart mounts a view (six live figure vars + one static Chart) + # 1) every chart mounts a view (seven live sources + one static Chart) probe.wait_for( - "window.__xy_views && window.__xy_views.size >= 6", + "window.__xy_views && window.__xy_views.size >= 7", timeout_s=120.0, label="mounted chart views", ) @@ -257,17 +258,32 @@ def main() -> None: failures.append(f"expected exactly 1 backend websocket (shared transport), got {ws}") # 2b) the direct-Chart mount is truly static: it never subscribes. The - # six live sources (five figure vars + one inline() token) each sub. + # seven live sources (five figure vars + two inline() tokens: the + # orbits and the §6 drilldown) each sub. subs = probe.sent_ws_frames('"sub"') print(f"sub frames sent: {len(subs)}") - if len(subs) < 6: - failures.append(f"expected >= 6 sub frames (live sources), got {len(subs)}") + if len(subs) < 7: + failures.append(f"expected >= 7 sub frames (live sources), got {len(subs)}") # 3) pixels: every chart paints ink inside its rect (full-page shot, - # rects in page coordinates so below-the-fold charts count too) + # rects in page coordinates so below-the-fold charts count too). + # The §6 drilldown's first payload bins its full source (100M at + # the default XY_LIVE_POINTS), so wait for its trace explicitly. + probe.wait_for( + "(() => { const v = window.__xy_views.get('drilldown');" + " return !!(v && v.gpuTraces && v.gpuTraces[0]); })()", + timeout_s=120.0, + label="drilldown density payload", + ) time.sleep(1.0) png = probe.screenshot() - checks = (("cloud", 0.02), ("hist", 0.02), ("live", 0.005), ("inline", 0.005)) + checks = ( + ("cloud", 0.02), + ("hist", 0.02), + ("live", 0.005), + ("inline", 0.005), + ("drilldown", 0.02), + ) for chart_id, min_ink in checks: frac = ink_fraction(png, probe.rect(chart_id, page_coords=True), 1.0) print(f"{chart_id}: ink fraction {frac:.2%}") diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index e25a8a29..37e1b4e6 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -574,8 +574,12 @@ python/reflex-xy/ examples/reflex/ (repo root) reflex-xy showcase: figure-var drilldown with hover/click/select events, a slider-driven + cross-filtered histogram, a streaming line, an - on_view_change-computed detail chart, and both - fixed-data tiers (direct Chart + inline() token) + on_view_change-computed detail chart, both + fixed-data tiers (direct Chart + inline() token), + and the fastapi live drilldown served adapter- + natively from an inline() token (same data and + XY_LIVE_POINTS override, zero transport code — + the cross-host A/B for that chart) examples/fastapi/ (repo root) the same charts + a live 100M drilldown served from a plain FastAPI app (no committed HTML) tests/reflex_adapter/ 69 tests: token/registry/var/bridge/payload-asset diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index b9f1605b..ab058709 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -445,8 +445,12 @@ Keep pushing these in low-conflict increments: - Keep the two example apps focused: `examples/reflex` on the reflex-xy integration surfaces (figure vars, events, state-driven and streaming updates, `on_view_change`), and `examples/fastapi` on the framework-neutral - gallery plus the live 100M drilldown. Neither commits static chart HTML, and - both surface their own source via `inspect.getsource`. + gallery plus the live 100M drilldown. The one deliberate overlap is that + drilldown chart itself: `examples/reflex` §6 serves the identical dataset + adapter-natively (an `inline()` token, no transport code) so cross-host + behavior can be A/B'd against fastapi's hand-rolled transport; both honor + `XY_LIVE_POINTS`. Neither commits static chart HTML, and both surface their + own source via `inspect.getsource`. - Add first-class docs for the supported-platform matrix and the clear-error behavior when the native core is unavailable. - Move advisory type checking to a hard gate once the checker and codebase agree diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index 09108b79..df1b7635 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -150,6 +150,11 @@ def test_reflex_app_shows_every_linking_method_and_event() -> None: "reflex_xy.append(", # streaming "reflex_xy.inline(", # inline() token tier "sparkline_chart()", # static Chart tier passed directly + # the FastAPI 100M drilldown, served adapter-natively (§6); both apps + # honor the same point-count override for side-by-side comparison. + "def drilldown_chart", + "reflex_xy.inline(drilldown_chart())", + "XY_LIVE_POINTS", "on_point_hover=", "on_point_click=", "on_select_end=", @@ -178,6 +183,9 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None pytest.importorskip("reflex_xy") # A static chart compiles a payload asset into cwd/assets/xy; keep it in tmp. monkeypatch.chdir(tmp_path) + # The §6 drilldown builds its columns at import; keep the test-time build + # cheap (same override the fastapi app test uses). + monkeypatch.setenv("XY_LIVE_POINTS", "50000") sys.path.insert(0, str(REFLEX_DIR)) module = _load(REFLEX_APP, "xy_reflex_demo_under_test") @@ -186,8 +194,10 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None assert "@reflex_xy.figure" in module._source(module.Demo.cloud) assert "def cloud" in module._source(module.Demo.cloud) assert "def on_view" in module._source(module.Demo.on_view) - # The page composes without error and mints an inline() token at import. + # The page composes without error and mints inline() tokens at import. assert module.ORBITS_TOKEN.startswith("xyin-") + assert module.DRILLDOWN_TOKEN.startswith("xyin-") + assert module.DRILLDOWN_POINTS == 50000 assert module.index() is not None From 427ac4f8a5e9ec9085db13a6748b65790b179606 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 02:32:45 +0000 Subject: [PATCH 04/11] Elide density_view requests for zooms inside an exact drill window (T12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 + js/src/45_lod.ts | 34 +++++ js/src/54_kernel.ts | 38 ++++- spec/design-dossier.md | 11 +- spec/design/lod-architecture.md | 29 +++- spec/design/wire-protocol.md | 8 +- tests/test_drill_zoomin_elides_request.py | 178 ++++++++++++++++++++++ 7 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 tests/test_drill_zoomin_elides_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fad60130..4f75630f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,12 @@ in the README). gone, and the page JS is a POST transport plus a status badge. Round-trip replies ship as `XYBF` binary frames (wire-protocol §7) instead of base64-in-JSON. +- **Zooms inside an exact drill window skip the kernel round-trip (T12).** + Once a points reply has shipped its window exactly (`reduction: "none"`), + the client answers any contained view from the marks it already holds and + sends no `density_view` request, until the view leaves the window, the + drill dies, or the zoom is deep enough (1/256 of the window span) to need + a §16 re-centered f32 encoding. ### Added - **Export format parity and a unified export API (ENG-10447).** diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index f2475fd2..caf2d350 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -316,6 +316,35 @@ function lodHoldPendingDrill(view, g, d) { return estimatedVisible <= LOD_DIRECT_POINT_BUDGET * LOD_DRILL_EXIT_FACTOR; } +// Zoom-in request elision (T12): a drill that shipped its window EXACTLY +// (reduction "none" — the subset IS every point in the window) already holds +// every point of any view contained in that window, so drilling deeper needs +// no kernel round-trip. What a deeper zoom eventually needs is precision, not +// data: the shipped geometry is f32, offset-encoded around the window midpoint +// (§16), so once the view span drops below LOD_DRILL_REENCODE_SPAN of the +// window span on either axis one request goes out purely to re-center the +// encoding (at 2^-8 of the window the ~2^-24 encode quantum is still ≲0.1 px +// on a 4k-wide plot). A dying drill never elides — the kernel chose a +// different representation and the reply flow owns that transition. +const LOD_DRILL_REENCODE_SPAN = 1 / 256; + +export function lodDrillServesView(g, x0, x1, y0, y1) { + const d = g && g.drill; + if (!d || !d.exact || !d.win || g._drillDying) return false; + const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); + const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); + const wx0 = Math.min(d.win.x0, d.win.x1), wx1 = Math.max(d.win.x0, d.win.x1); + const wy0 = Math.min(d.win.y0, d.win.y1), wy1 = Math.max(d.win.y0, d.win.y1); + // Same edge tolerance as _viewInside: f32 round-trip slop at the window + // boundary must not force a request right after drilling in. + const ex = (vx1 - vx0) * 1e-4, ey = (vy1 - vy0) * 1e-4; + if (vx0 < wx0 - ex || vx1 > wx1 + ex || vy0 < wy0 - ey || vy1 > wy1 + ey) return false; + return ( + vx1 - vx0 >= (wx1 - wx0) * LOD_DRILL_REENCODE_SPAN && + vy1 - vy0 >= (wy1 - wy0) * LOD_DRILL_REENCODE_SPAN + ); +} + // Geometry-only retirement (T11): an entered-then-exited drill is kept as a // revive cache — a rapid zoom back into its window hands the exact marks // back with no kernel round-trip — but only while a nearby view could still @@ -405,6 +434,11 @@ export function lodApplyDrill(view, g, upd, buffers) { d.win = { x0: upd.x_range[0], x1: upd.x_range[1], y0: upd.y_range[0], y1: upd.y_range[1] }; d.n = Math.min(upd.x.len, upd.y.len); d.visible = upd.visible ?? d.n; + // The kernel's exactness claim (§28 Invariant L2): reduction "none" means + // the subset IS every point in the window — the fact that arms T12's + // zoom-in request elision. Anything else (or a reply that doesn't say) + // keeps the request path live. + d.exact = upd.reduction === "none"; d.seq = upd.drill_seq; // subset version — echoed with picks, gates selections d.selActive = false; // drilled subset changed; old mask indices are stale // §34 selection continuity: the swapped subset invalidates the old mask diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 12b3d8db..9511a6f6 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -1,7 +1,9 @@ import { payloadBuffers } from "./00_header"; import { buildLutData } from "./10_colormaps"; import { parseColor } from "./20_theme"; -import { lodApplyDensityUpdate, lodApplyDrill, lodDropDrill, lodRememberDensity } from "./45_lod"; +import { + lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, lodDropDrill, lodRememberDensity, +} from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -30,6 +32,18 @@ Object.assign(ChartView.prototype, { const now = this._now(); for (const g of this.gpuTraces) { if (g.tier !== "density") continue; + // Zoom-in request elision (T12): a view contained in an exact drill's + // window is already answered by the marks on the GPU — the smaller + // window's points are a subset of the shipped ones — so this trace + // goes neither pending nor on the wire. The seq bump above stands, so + // an in-flight reply for an older, wider view dies stale instead of + // yanking the exact marks out from under the view it can't improve. + if (this._drillServesView(g, view)) { + g._lodPendingView = null; + g._lodPendingSeq = null; + g._lodPendingAt = null; + continue; + } g._lodPendingView = view; g._lodPendingSeq = seq; g._lodPendingAt = now; @@ -60,6 +74,17 @@ Object.assign(ChartView.prototype, { if (needsDensity) { for (const g of this.gpuTraces) { if (g.tier !== "density") continue; + // T12 re-check at actual send time: a drill that landed during the + // debounce elides the request it made unnecessary; one that died + // during it re-arms the request the schedule-time check skipped. + if (this._drillServesView(g, view)) { + if (g._lodPendingSeq === seq) { + g._lodPendingView = null; + g._lodPendingSeq = null; + g._lodPendingAt = null; + } + continue; + } const [x0, x1] = this._axisRange(g.xAxis, view); const [y0, y1] = this._axisRange(g.yAxis, view); this.comm.send({ @@ -513,6 +538,17 @@ Object.assign(ChartView.prototype, { lodDropDrill(this, g); }, + // Can `view` be answered locally from this trace's live drill, with no + // kernel round-trip (T12)? True only for an exact (reduction "none"), + // non-dying drill whose window contains the view's per-axis ranges, and + // only until the zoom outgrows the §16 f32 encode precision. + _drillServesView(g, view) { + if (!g.drill) return false; + const [x0, x1] = this._axisRange(g.xAxis, view); + const [y0, y1] = this._axisRange(g.yAxis, view); + return lodDrillServesView(g, x0, x1, y0, y1); + }, + // Is the current view fully covered by a drilled window? A tiny epsilon // absorbs f32 round-trip slop so we don't flip to the overview at the exact // window edge right after drilling in. diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 271cbeaa..f423d6f0 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -677,6 +677,15 @@ detail inside a decade of millisecond timestamps). zoom depth spans a sliver of a decade (invisible on any log-family display) and is the same class of limit matplotlib accepts; recorded here per §28's no-silent-decisions rule. +- **Zooming inside an exact scatter drill is request-free until re-centering is + due.** A view contained in a drill window that shipped exactly (`reduction: + "none"`) needs no new data — the shipped subset already holds every point of + it — so the client elides the `density_view` round-trip entirely (LOD doc §5, + T12). It re-requests only once the view span falls below 1/256 of the drilled + window's span on either axis, an f32-safe margin (at 2⁻⁸ of the window the + ~2⁻²⁴ encode quantum is still ≲0.1 px on a 4k-wide plot); that request exists + to re-center the offset encoding per this section, and the re-centered reply + re-arms the elision around its own window. - **Time is i64 end-to-end** (Arrow timestamp columns), with calendar-aware tick generation (months are not 30×86400s). Plotly gets this right; matching it is table stakes and it must not be routed through any float path. @@ -981,7 +990,7 @@ recomputes on zoom.* | Trace kind | Canonical requirement | Tier ladder | Hover/select | On zoom | |---|---|---|---|---| | **Line / area / time series** | x sorted (or engine sorts once at ingest, stated) | direct → min-max per-px-column decimation → zone-map-pruned chunk streaming | exact point (binary search on x in canonical) at every tier | recompute decimation for visible x-range only; zone maps prune chunks | -| **Scatter** | none for Tiers 0–1; spatial bucketing pass at ingest for Tiers 2–3 | direct instanced → *no Tier 1 (decimating unordered points misleads)* → density pyramid → out-of-core tiles | Tier 0: GPU pick, exact row. Tiers 2–3: bin summary + async drill to top-k rows | pan = tile reuse; zoom = adjacent pyramid level; below pyramid floor = re-bin visible via tile index | +| **Scatter** | none for Tiers 0–1; spatial bucketing pass at ingest for Tiers 2–3 | direct instanced → *no Tier 1 (decimating unordered points misleads)* → density pyramid → out-of-core tiles | Tier 0: GPU pick, exact row. Tiers 2–3: bin summary + async drill to top-k rows | pan = tile reuse; zoom = adjacent pyramid level; below pyramid floor = re-bin visible via tile index; inside an exact drill window = nothing recomputes and no request is sent — the client already holds every point (§16, LOD doc T12) | | **Heatmap / image** | gridded input | direct texture → mip pyramid (same machinery, degenerate case) | cell value (exact, from canonical grid) | mip level selection; nothing recomputes | | **Bar / histogram** | histogram: raw column; bar: categories | bars are visually bounded (≤ ~10⁴ on screen) → direct; histogram re-bins from canonical on range change (cheap: 1-D, visible range only, zone-map-pruned) | exact bar/bin | 1-D re-bin, worker, stale-while-revalidate | | **Streaming (any kind)** | ring capacity declared up front | ring buffer + incremental decimation (Tier-1 buckets updated, not rebuilt); pyramid tiles updated incrementally for touched cells | same as base kind, within retained window | append is O(appended); eviction from ring updates affected buckets only | diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 2e4d9d64..fff965c1 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -44,7 +44,7 @@ real marks with real channels — never a sample, never an aggregate. | Kind | Tier 0 | Tier 1 | Tier 2 | zoom-in recovery | |---|---|---|---|---| -| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact visible points + channels (shipped) | +| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact visible points + channels (shipped); deeper zooms inside the exact window are answered locally, no request (T12) | | line/area | polyline | M4 per column (extrema-exact) | — (M4 already screen-bounded) | re-decimate window (shipped) | | heatmap | cell rects | — | mip-style tile reduction of the user grid (max/mean recorded) | finer pyramid level, then exact cells | | histogram/bar | rects | re-bin at viewport resolution | — (bins are already aggregates) | re-bin visible window (finer bins = more truth, never less) | @@ -417,8 +417,31 @@ invariants so future kinds don't regress them: window, and the view being far from it is their normal transient state. A dying drill (kernel chose density) still frees via the exit fade as in T2, independent of geometry. - -Any new tiered kind must state how it satisfies T1–T11 in its chart-kind +- **T12 — a zoom inside an exact drill elides the request:** once a points + reply has shipped its window EXACTLY (`reduction: "none"` — Invariant L2's + subset IS every point in the window), any requested 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, so `_scheduleViewRequest` sends no + `density_view` for that trace and clears its pending markers + (`lodDrillServesView` in `js/src/45_lod.ts`, re-checked at debounced send + time so a drill landing or dying mid-debounce flips the decision). The seq + still bumps, 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. + Two things re-arm the request: leaving the window (any edge, same epsilon + as `_viewInside`), and depth — the shipped geometry is f32, offset-encoded + around the window midpoint (dossier §16), so once the view span drops below + `LOD_DRILL_REENCODE_SPAN` (1/256) of the window span on either axis one + request goes out purely to re-center the encoding (at 2⁻⁸ of the window the + ~2⁻²⁴ encode quantum is still ≲0.1 px on a 4k-wide plot; the reply's + re-centered window then re-arms the elision around itself). A dying drill + never elides — the kernel chose a different representation and the reply + flow owns that transition. Data changes cannot serve stale marks through + the elision: streaming append and full payload updates rebuild the GPU + trace, which drops the drill and with it the elision. Non-exact replies + (anything but `reduction: "none"`, including replies that don't say) never + arm it. + +Any new tiered kind must state how it satisfies T1–T12 in its chart-kind contract entry before it lands. --- diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index fd2a4c1e..57304179 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -61,7 +61,13 @@ produces no traces, there is no reply at all (silence, not an empty message). **`density_view`** — one request per density-tier trace, each naming its `trace` id. `w` defaults to `512`, `h` to `384`; the client sends the rounded plot width and height. A trace that is not in density mode yields -`{"traces": []}`, which is dropped rather than sent. +`{"traces": []}`, which is dropped rather than sent. Not every view change +produces a request: a view fully contained in a live drill's window is elided +client-side when that drill shipped its window exactly (`reduction: "none"` — +the subset already holds every point of any contained view; LOD doc §5 T12). +The elision ends, and one request goes out purely to re-center the §16 f32 +offset encoding, once the view span drops below 1/256 of the drilled window's +span on either axis. **`pick`** — `trace` and `index` pass through `_integer_id`. `index` is a *shipped-vertex* index, translated kernel-side to a canonical row when the diff --git a/tests/test_drill_zoomin_elides_request.py b/tests/test_drill_zoomin_elides_request.py new file mode 100644 index 00000000..b7f02025 --- /dev/null +++ b/tests/test_drill_zoomin_elides_request.py @@ -0,0 +1,178 @@ +"""Zooming inside an exact drill window must not re-request points (T12). + +Once a drill 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 must therefore elide the +`density_view` round-trip entirely (LOD doc §5 T12) — no pending markers, no +wire message — while still bumping `seq` so an in-flight reply for an older, +wider view dies stale instead of yanking exact marks out from under the view. + +The elision has exactly three re-arm conditions, each asserted here: +- the view leaves the drill window (any edge); +- the zoom outgrows the §16 f32 offset encoding — view span below 1/256 of + the window span re-requests purely to re-center the encoding; +- the drill stops being an exact live subset (dying, or a reply that did not + claim `reduction: "none"`). + +This drives the real client in headless Chromium with a captured comm, applies +a drill for a known window, and inspects exactly which view requests reach the +wire. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import xy +from conftest import run_browser_probe +from xy.export import find_chromium + +_RENDER_CALL = 'xy.renderStandalone(document.getElementById("chart"), spec, buf);' + +_PROBE = """ + const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); + try { + view._drawNow(); + view._raf = null; + view._sampleRebinDisabled = true; + const g = view.gpuTraces.find((t) => t.tier === "density"); + + const v0 = view.view0; + const cx = (v0.x0 + v0.x1) / 2, cy = (v0.y0 + v0.y1) / 2; + const sx = v0.x1 - v0.x0, sy = v0.y1 - v0.y0; + // Drill window: the central 10% of home, shipped exactly. + const wx0 = cx - sx * 0.05, wx1 = cx + sx * 0.05; + const wy0 = cy - sy * 0.05, wy1 = cy + sy * 0.05; + const N = 800; + const xs = new Float32Array(N), ys = new Float32Array(N); + for (let i = 0; i < N; i++) { + xs[i] = wx0 + (wx1 - wx0) * ((i * 0.6180339887) % 1); + ys[i] = wy0 + (wy1 - wy0) * ((i * 0.3141592653) % 1); + } + view._applyDrill(g, { + id: g.trace.id, mode: "points", visible: N, reduction: "none", drill_seq: 1, + x: { buf: 0, offset: 0, scale: 1, len: N }, + y: { buf: 1, offset: 0, scale: 1, len: N }, + x_range: [wx0, wx1], y_range: [wy0, wy1], + }, [xs.buffer, ys.buffer]); + const drillMarkedExact = g.drill.exact === true; + + // Kernel-connected from here on: capture what would go on the wire. + const sent = []; + view.comm = { send: (m) => sent.push(m) }; + const densitySent = () => sent.filter((m) => m.type === "density_view").length; + const request = (x0, x1, y0, y1) => { + const before = densitySent(); + view._scheduleViewRequest(view._viewFrom({ x0, x1, y0, y1 }), { delay: 0 }); + return densitySent() - before; + }; + + // 1) Zooming IN inside the exact window: every point of the smaller view + // is already on the GPU — no request, pending cleared, seq still bumped + // (a stale in-flight reply must die rather than replace exact marks). + const seqBefore = view.seq; + g._lodPendingView = { x0: wx0, x1: wx1, y0: wy0, y1: wy1 }; + const zoomInElided = request( + cx - sx * 0.02, cx + sx * 0.02, cy - sy * 0.02, cy + sy * 0.02) === 0; + const zoomInClearedPending = g._lodPendingView === null; + const zoomInBumpedSeq = view.seq === seqBefore + 1; + + // 1b) The window itself (containment up to the edge epsilon) also elides. + const fullWindowElided = request(wx0, wx1, wy0, wy1) === 0; + + // 2) Leaving the window (pan past an edge) re-arms the request. + const outsideRequested = request( + wx0 + sx * 0.03, wx1 + sx * 0.03, wy0, wy1) === 1; + + // 3) Deep zoom past the §16 re-encode bound (view span < window/256): + // the request goes out purely to re-center the f32 offset encoding. + const deep = (wx1 - wx0) / 1024; + const deepZoomRequested = request( + cx - deep / 2, cx + deep / 2, cy - deep / 2, cy + deep / 2) === 1; + + // 4) A dying drill never elides — the kernel chose a different + // representation and the reply flow owns that transition. + g._drillDying = true; + const dyingRequested = request( + cx - sx * 0.02, cx + sx * 0.02, cy - sy * 0.02, cy + sy * 0.02) === 1; + g._drillDying = false; + + // 5) A subset that did not claim reduction "none" never arms the elision. + g.drill.exact = false; + const nonExactRequested = request( + cx - sx * 0.02, cx + sx * 0.02, cy - sy * 0.02, cy + sy * 0.02) === 1; + + document.body.setAttribute("data-xy-elide-probe", JSON.stringify({ + hasDensity: !!g, + drillMarkedExact, + zoomInElided, + zoomInClearedPending, + zoomInBumpedSeq, + fullWindowElided, + outsideRequested, + deepZoomRequested, + dyingRequested, + nonExactRequested, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-elide-probe-error", + String((err && err.stack) || err), + ); + } +""" + + +def _density_html() -> str: + rng = np.random.default_rng(0) + n = 60_000 + x = rng.normal(0.0, 1.0, n) + y = rng.normal(0.0, 1.0, n) + # density=True forces the density tier (the drill is a sibling of it), + # regardless of point count, so the export exercises the request path + # deterministically and cheaply. + chart = xy.scatter_chart( + xy.scatter(x, y, density=True), + xy.x_axis(), + xy.y_axis(), + width=480, + height=360, + ) + html = chart.to_html() + assert _RENDER_CALL in html + return html + + +def test_zoom_in_within_exact_drill_sends_no_request(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + document = _density_html().replace(_RENDER_CALL, _PROBE) + result = run_browser_probe( + chromium, + document, + tmp_path / "drill_zoomin_elide.html", + "data-xy-elide-probe", + label="drill zoom-in request-elision probe", + ) + + assert result["hasDensity"] is True + # The reply's reduction "none" claim is what arms the elision. + assert result["drillMarkedExact"] is True + # Zooming in within the exact window: no wire request, no pending marker, + # but seq still advances so stale in-flight replies are dropped. + assert result["zoomInElided"] is True + assert result["zoomInClearedPending"] is True + assert result["zoomInBumpedSeq"] is True + assert result["fullWindowElided"] is True + # Each re-arm condition still requests: leaving the window, outzooming the + # f32 encoding (§16 re-centering), a dying drill, a non-exact subset. + assert result["outsideRequested"] is True + assert result["deepZoomRequested"] is True + assert result["dyingRequested"] is True + assert result["nonExactRequested"] is True From aead7d5cfe38e185cf28f4798a395666ef74df41 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 07:12:43 +0000 Subject: [PATCH 05/11] Gate sampled points to resolvable views; cache aligned full-point windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #225: zooming out from a drilled window brought fixed-rate "sampled points" back over the density surface — individual marks at a zoom where real points are sub-pixel, misrepresenting the dataset. Now: - Interactive density_view replies ship no point sample at all. The mean-color density surface (LOD doc §2) stands alone above the drill budget; real points still arrive the moment a window fits it. The retained first-payload sample (the standalone re-bin worker's CPU source) draws only below a resolvable-count gate: estimated in-view count <= LOD_DIRECT_POINT_BUDGET (lodOverlayResolvable, T9). Datasets under the budget keep the hybrid look everywhere. Point caching (LOD doc T13) so pan/zoom stops re-shipping the same points: - Kernel: a points-tier reply ships the largest ALIGNED window around the view whose exact count fits the budget (lod.aligned_window: bounds snap outward to a power-of-two grid over the trace's extent, per dimension; DRILL_PAD_TARGETS ladder, pyramid-count-gated, exact-verified, DRILL_PAD_SPAN_CAP guards the §16 re-encode ladder). Consecutive pans resolve to the SAME window; lod_blend stays keyed on the view's own count. Nonlinear axes keep the exact view window. - Client: a reply for a new window retires the previous exact drill into a bounded per-trace cache (lodRetireDrill/LOD_POINT_CACHE_WINDOWS) instead of overwriting its buffers — so does a drill dying outside its window — and any later view covered by a cached window promotes it back alpha-continuously with zero round-trips (lodPromoteCachedDrill). Outgrown windows free geometry-only (T11 rule, §27). - Kernel keeps a bounded drill-subset history (Trace.drill_history, DRILL_HISTORY_KEEP) so picks against a promoted retired window still translate exactly; expired seqs drop the pick, data changes clear it. - Client suppresses identical density_view requests (same window, screen, unchanged data) and accepts the suppressed duplicate's in-flight reply per-trace instead of killing it with the bumped seq — ending the re-request loop at gesture end. Spec: lod-architecture T9 rewritten + new T13, wire-protocol density_view/points/pick contracts, dossier constants table, CHANGELOG, README representation labels, docs ladder table. Tests: new test_drill_window_padding (alignment, budget, span cap, nonlinear skip, history clear), test_density_sample_resolvability_gate and test_drill_point_window_cache (headless-Chromium client probes), elision test extended for duplicate suppression; drill tests updated to the padded-window contract. --- CHANGELOG.md | 20 ++ README.md | 6 +- .../large-data-and-performance.md | 4 +- js/src/45_lod.ts | 245 +++++++++++++++--- js/src/50_chartview.ts | 7 +- js/src/54_kernel.ts | 100 ++++++- python/xy/_trace.py | 8 + python/xy/config.py | 33 ++- python/xy/interaction.py | 171 ++++++------ python/xy/lod.py | 68 ++++- spec/design-dossier.md | 10 +- spec/design/lod-architecture.md | 115 +++++--- spec/design/wire-protocol.md | 47 ++-- .../test_density_sample_resolvability_gate.py | 199 ++++++++++++++ tests/test_drill_point_window_cache.py | 216 +++++++++++++++ tests/test_drill_window_padding.py | 158 +++++++++++ tests/test_drill_zoomin_elides_request.py | 38 ++- tests/test_lod.py | 4 +- tests/test_scatter.py | 95 +++++-- 19 files changed, 1333 insertions(+), 211 deletions(-) create mode 100644 tests/test_density_sample_resolvability_gate.py create mode 100644 tests/test_drill_point_window_cache.py create mode 100644 tests/test_drill_window_padding.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f75630f..31f3a096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,26 @@ in the README). to the internal engine object. ### Changed +- **No sampled points above the resolution of the graph (#225).** Interactive + `density_view` replies no longer ship a point-sample overlay: a fixed-size + sample above the drill budget reads as individual data points at a zoom + where real points are sub-pixel, misrepresenting the dataset — the + mean-color density surface stands alone there. The retained first-payload + sample (also the standalone re-bin worker's CPU source) now draws only when + the view's estimated in-view count fits the direct point budget, i.e. when + individual points are actually resolvable; real points still ship the + moment a window fits the budget, so drilldown behavior is unchanged. +- **Full-point windows are padded, aligned, cached, and never re-requested + (LOD doc T13).** A points-tier reply now ships the largest aligned window + around the view whose exact count still fits the budget (bounds snapped to + a power-of-two grid over the trace's extent, per dimension), so consecutive + pans resolve to the same window; the client retires replaced exact windows + into a bounded per-trace cache and promotes them back — pan ping-pong and + zoom-out/zoom-in render entirely from the GPU with zero round-trips. Picks + against a promoted (older) window still resolve exactly through a bounded + kernel-side subset history. Identical `density_view` requests (same window, + same screen, unchanged data) are suppressed client-side, and a suppressed + duplicate's in-flight reply is accepted instead of dying to the seq race. - **Density surfaces now wear the data's own colors (LOD doc §2).** A Tier-2 scatter's aggregated view colors each cell with the alpha-weighted **mean of its binned points' resolved colors** (continuous colormap, categorical diff --git a/README.md b/README.md index 68abc23d..bbd8a583 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ an Apple M5 Pro with 64 GB RAM. Times are mean ± sample standard deviation. |---:|---:|---:|---:|---| | 10k | 0.0085 ± 0.0002 s | 0.1533 ± 0.0079 s | 0.9580 ± 0.0103 s | direct | | 100k | 0.0108 ± 0.0004 s | 0.1742 ± 0.0029 s | 0.9752 ± 0.0048 s | direct | -| 1M | 0.0114 ± 0.0013 s | 0.1688 ± 0.0081 s | 0.9678 ± 0.0039 s | density; density + sample interactive | -| 10M | 0.0232 ± 0.0023 s | 0.1797 ± 0.0007 s | 0.9920 ± 0.0078 s | density; density + sample interactive | -| 1B | 1.1452 ± 0.0389 s | 1.2530 ± 0.0018 s | 2.0877 ± 0.0063 s | density; density + sample interactive | +| 1M | 0.0114 ± 0.0013 s | 0.1688 ± 0.0081 s | 0.9678 ± 0.0039 s | density; exact-point drill-in interactive | +| 10M | 0.0232 ± 0.0023 s | 0.1797 ± 0.0007 s | 0.9920 ± 0.0078 s | density; exact-point drill-in interactive | +| 1B | 1.1452 ± 0.0389 s | 1.2530 ± 0.0018 s | 2.0877 ± 0.0063 s | density; exact-point drill-in interactive | At 10M points, the same recorded run measured: diff --git a/docs/core-concepts/large-data-and-performance.md b/docs/core-concepts/large-data-and-performance.md index e6c4d8c1..ccb12764 100644 --- a/docs/core-concepts/large-data-and-performance.md +++ b/docs/core-concepts/large-data-and-performance.md @@ -16,8 +16,8 @@ readout and refinement. | --- | --- | --- | | Direct | Small visible traces | One rendered point or segment per retained row | | M4-decimated | Long ordered lines and areas | A bounded sequence preserving per-bucket extrema | -| Density + sample | Dense scatter overviews | A fixed-resolution count grid plus a deterministic point sample | -| Refined view | A narrower pan/zoom window | A new viewport-specific aggregate or exact visible points when they fit | +| Density | Dense scatter overviews | A fixed-resolution count grid wearing the data's own mean point colors; a deterministic point sample overlays it only when the view's estimated count would fit the direct budget (individual points are resolvable) | +| Refined view | A narrower pan/zoom window | A new viewport-specific aggregate, or exact visible points for a padded aligned window when they fit — nearby pans and zooms then render from the cached window with no further requests | The current defaults begin M4 line decimation above 10,000 rows and automatic scatter density above 200,000 points. Density grids default to 512×384 cells, diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index caf2d350..c3a40ac4 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -4,6 +4,10 @@ import { parseColor } from "./20_theme"; const LOD_DIRECT_POINT_BUDGET = 200000; const LOD_DRILL_EXIT_FACTOR = 1.15; +// Retired exact point windows kept per trace (T13), beyond the live drill. +// Each holds ≤ budget points of GPU buffers, so the cap bounds VRAM; the +// outgrown sweep (T11 rule) frees entries geometry can no longer revive. +const LOD_POINT_CACHE_WINDOWS = 3; // Retained-sample zoom-out fade band (T9): full alpha while the sample's home // window still covers ≥ HI of the view area, gone below LO (log-eased between, // applied as composited opacity — see lodSampleViewAlpha). @@ -250,6 +254,28 @@ function lodSampleViewAlpha(view, s) { return k > 1 ? 1 - Math.pow(1 - band, 1 / k) : band; } +// #225 resolvability gate: a sample overlay draws only when the view it +// would describe could plausibly be points-tier — the estimated in-view +// count (the overlay's recorded window count, scaled by the view's share of +// its window) fits the direct budget. Above that, a fixed-size sample reads +// as individual data points at a zoom where real points are sub-pixel: +// sampling above the resolution of the graph misrepresents the dataset, and +// the aggregate surface — which wears the data's own colors (LOD doc §2) — +// is the truthful representation. In kernel mode the gate makes the hybrid +// look transient at most (real points ship the moment a window fits the +// budget); standalone exports keep the overlay as their only point +// representation once a zoom resolves it. Overlays without recorded counts +// (hand-built/legacy specs) keep drawing — the gate needs facts, not guesses. +function lodOverlayResolvable(view, o) { + const meta = o.sample; + if (!meta || !Number.isFinite(meta.visible)) return true; + const v = view.view; + const viewArea = Math.abs((v.x1 - v.x0) * (v.y1 - v.y0)); + const winArea = lodWindowArea(o.win); + const share = winArea > 0 && viewArea > 0 ? Math.min(1, viewArea / winArea) : 1; + return meta.visible * share <= LOD_DIRECT_POINT_BUDGET; +} + // §28 hybrid overlay, window pairing (T9): every sample rides the density // window it was computed for, so the points on screen always describe the // window being displayed. Selection mirrors lodDensityForView: the smallest @@ -258,6 +284,8 @@ function lodSampleViewAlpha(view, s) { // of a stale drilled cluster). Only when NO cached window covers the view // (pan off-cache, zoom-out past home) does the best partial overlay draw, // bounded by the T9 coverage fade so it can never read as a false cluster. +// Every candidate passes the #225 resolvability gate first: above the direct +// budget the aggregate stands alone. export function lodSampleForView(view, g) { const cache = g.densityCache || (g.density ? [g.density] : []); let contained = null; @@ -268,6 +296,7 @@ export function lodSampleForView(view, g) { const o = d && d.overlay; if (!o || !o.n || !o.win || seen.has(o)) continue; seen.add(o); + if (!lodOverlayResolvable(view, o)) continue; if (view._viewInside(o.win)) { if (!contained || lodWindowArea(o.win) < lodWindowArea(contained.win)) contained = o; } else if (view._viewOverlaps(o.win)) { @@ -328,13 +357,14 @@ function lodHoldPendingDrill(view, g, d) { // different representation and the reply flow owns that transition. const LOD_DRILL_REENCODE_SPAN = 1 / 256; -export function lodDrillServesView(g, x0, x1, y0, y1) { - const d = g && g.drill; - if (!d || !d.exact || !d.win || g._drillDying) return false; +// Containment + §16 re-encode depth bound, shared by the live drill and the +// retired point-window cache (T13): an exact window answers any view it +// contains, until the zoom outgrows the f32 offset encoding. +function lodWindowServesView(win, x0, x1, y0, y1) { const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); - const wx0 = Math.min(d.win.x0, d.win.x1), wx1 = Math.max(d.win.x0, d.win.x1); - const wy0 = Math.min(d.win.y0, d.win.y1), wy1 = Math.max(d.win.y0, d.win.y1); + const wx0 = Math.min(win.x0, win.x1), wx1 = Math.max(win.x0, win.x1); + const wy0 = Math.min(win.y0, win.y1), wy1 = Math.max(win.y0, win.y1); // Same edge tolerance as _viewInside: f32 round-trip slop at the window // boundary must not force a request right after drilling in. const ex = (vx1 - vx0) * 1e-4, ey = (vy1 - vy0) * 1e-4; @@ -345,6 +375,12 @@ export function lodDrillServesView(g, x0, x1, y0, y1) { ); } +export function lodDrillServesView(g, x0, x1, y0, y1) { + const d = g && g.drill; + if (!d || !d.exact || !d.win || g._drillDying) return false; + return lodWindowServesView(d.win, x0, x1, y0, y1); +} + // Geometry-only retirement (T11): an entered-then-exited drill is kept as a // revive cache — a rapid zoom back into its window hands the exact marks // back with no kernel round-trip — but only while a nearby view could still @@ -408,6 +444,116 @@ export function lodRememberDensity(view, g, d) { } } +// -- point-window cache (T13) ------------------------------------------------- +// +// Retired exact drills, LRU per trace. "Once we get points, we can render +// anything inside there without further requests": a view covered by any +// cached full-point window promotes it back to the live drill with no wire +// round-trip. The cache pairs with the kernel's padded ALIGNED windows — +// consecutive pans resolve to the same aligned bounds, so windows crossed +// once are held, and ping-pong pans/zooms across a boundary re-render from +// the cache instead of re-shipping ~all the same points. + +function lodSameWindow(a, b) { + if (!a || !b) return false; + const eps = (Math.abs(a.x1 - a.x0) + Math.abs(a.y1 - a.y0)) * 1e-9 + 1e-300; + return Math.abs(a.x0 - b.x0) <= eps && Math.abs(a.x1 - b.x1) <= eps && + Math.abs(a.y0 - b.y0) <= eps && Math.abs(a.y1 - b.y1) <= eps; +} + +function lodFreeDrillBuffers(view, d) { + const gl = view.gl; + view._deleteVaos(d); // each drill object carries its own VAOs + for (const b of [d.xBuf, d.yBuf, d.cBuf, d.rgbaBuf, d.sBuf, d.styleBuf, + d.strokeBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); +} + +// Reset the trace's drill lifecycle state without touching the (moved or +// freed) drill object itself — shared by drop, retire, and promote. +function lodClearDrillState(view, g) { + g.drill = null; + g._drillFadeStart = null; + g._drillExitFadeStart = null; + g._drillWasInside = false; + g._drillEverInside = false; + g._drillShownAlpha = null; + g._drillDying = false; + g._drillDiedInsideWin = false; + g._drillBackdropShown = 1; // next drill enters over a full backdrop (T10) + g._drillBackdropTick = 0; + view._hoverId = -1; // drilled indices are dead; don't reuse a cached row + view._lastRow = null; + // The freed drill may have been the only pickable geometry (a density-only + // chart): retract the modebar Select trigger with the capability. + view._updatePickable(); +} + +// Move the live drill into the retired-window cache. Only an exact subset can +// serve future views (Invariant L2), so anything else frees instead. +function lodRetireDrill(view, g) { + const d = g.drill; + if (!d) return; + if (!d.exact || !d.win) { + lodDropDrill(view, g); + return; + } + if (!g.drillCache) g.drillCache = []; + // A re-shipped window replaces its stale cached twin instead of duplicating. + for (let i = g.drillCache.length - 1; i >= 0; i--) { + if (lodSameWindow(g.drillCache[i].win, d.win)) { + lodFreeDrillBuffers(view, g.drillCache.splice(i, 1)[0]); + } + } + g.drillCache.push(d); + while (g.drillCache.length > LOD_POINT_CACHE_WINDOWS) { + lodFreeDrillBuffers(view, g.drillCache.shift()); + } + lodClearDrillState(view, g); +} + +export function lodDropPointCache(view, g) { + for (const d of g.drillCache || []) lodFreeDrillBuffers(view, d); + g.drillCache = null; +} + +// A retired cached window that covers the view swaps back in as the live +// drill — the pan-back / zoom-ping-pong "render anything inside there" path +// (T13). Alpha-continuous like a revive: the swap hands marks over at the +// alpha currently on screen, and any active brush mask re-derives locally +// exactly as a fresh reply would (§34 continuity). +export function lodPromoteCachedDrill(view, g, x0, x1, y0, y1) { + const cache = g.drillCache; + if (!cache || !cache.length) return false; + let pick = -1; + for (let i = 0; i < cache.length; i++) { + const e = cache[i]; + if (!e || !e.exact || !e.win || !lodWindowServesView(e.win, x0, x1, y0, y1)) continue; + // Smallest covering window wins (mirrors lodDensityForView): tightest + // offset encoding and the most local blend/density metadata. + if (pick < 0 || lodWindowArea(e.win) < lodWindowArea(cache[pick].win)) pick = i; + } + if (pick < 0) return false; + const e = cache.splice(pick, 1)[0]; + const shownAlpha = g.drill ? lodDrillShownAlpha(view, g) : 0; + if (g.drill) { + if (g._drillDying || !g.drill.exact) lodDropDrill(view, g); + else lodRetireDrill(view, g); + } else { + lodClearDrillState(view, g); + } + g.drill = e; + // Continuity across the swap: the promoted window's marks pick up at the + // alpha the retired ones showed (the retired window's points are a subset/ + // superset over the same region — a restart-from-zero reads as a blink). + g._drillShownAlpha = shownAlpha; + lodEnterDrillContinuous(view, g); + if (view._lastBrush && e._cpuX && e._cpuY) lodRestoreBrushMask(view, e, e._cpuX, e._cpuY); + else e.selActive = false; + view._updatePickable(); + view.draw(); + return true; +} + // -- drill lifecycle ---------------------------------------------------------- // The kernel decided this view fits the direct budget and shipped real marks @@ -415,7 +561,26 @@ export function lodRememberDensity(view, g, d) { // trace; the tier draw uses it until the kernel switches back. export function lodApplyDrill(view, g, upd, buffers) { const gl = view.gl; - const fresh = !g.drill; // transition INTO drill vs refresh of a live drill + const win = { + x0: upd.x_range[0], x1: upd.x_range[1], + y0: upd.y_range[0], y1: upd.y_range[1], + }; + // A reply for a NEW window retires the old one into the point-window cache + // (T13) instead of overwriting its buffers: the old window's points remain + // exact for views inside it, so a pan back promotes them with no kernel + // round-trip. The handoff is alpha-continuous below — a window-to-window + // swap must not restart the entry fade (reads as flashing mid-pan). + let handoff = null; + if (g.drill && !lodSameWindow(g.drill.win, win)) { + handoff = { + shownAlpha: lodDrillShownAlpha(view, g), + wasInside: g._drillWasInside, + everInside: g._drillEverInside, + }; + if (g._drillDying || !g.drill.exact) lodDropDrill(view, g); + else lodRetireDrill(view, g); + } + const fresh = !g.drill && !handoff; // true aggregate→marks transition let d = g.drill; if (!d) { d = g.drill = { trace: g.trace, xBuf: gl.createBuffer(), yBuf: gl.createBuffer() }; @@ -431,9 +596,14 @@ export function lodApplyDrill(view, g, upd, buffers) { gl.bufferData(gl.ARRAY_BUFFER, ys, gl.STATIC_DRAW); d.xMeta = { offset: upd.x.offset, scale: upd.x.scale }; d.yMeta = { offset: upd.y.offset, scale: upd.y.scale }; - d.win = { x0: upd.x_range[0], x1: upd.x_range[1], y0: upd.y_range[0], y1: upd.y_range[1] }; + d.win = win; d.n = Math.min(upd.x.len, upd.y.len); d.visible = upd.visible ?? d.n; + // Encoded coordinates retained CPU-side (views over the reply frame, no + // copy): a promoted cached window re-derives the brush mask from these, + // exactly as this fresh reply does below (§34 continuity across T13 swaps). + d._cpuX = xs; + d._cpuY = ys; // The kernel's exactness claim (§28 Invariant L2): reduction "none" means // the subset IS every point in the window — the fact that arms T12's // zoom-in request elision. Anything else (or a reply that doesn't say) @@ -562,6 +732,18 @@ export function lodApplyDrill(view, g, upd, buffers) { g._drillDiedInsideWin = false; return; } + // Window-to-window swap (the old window retired into the point cache): + // marks continue from the alpha the retired window showed — the two windows + // agree on every shared point, so a restart-from-zero reads as a blink. + if (handoff) { + g._drillWasInside = handoff.wasInside; + g._drillEverInside = handoff.everInside; + g._drillShownAlpha = handoff.shownAlpha; + g._drillDying = false; + g._drillDiedInsideWin = false; + lodEnterDrillContinuous(view, g); + return; + } // A live points reply revives a dying/exiting drill (hysteresis flip or a // fast zoom back in): hand the marks back at their CURRENT alpha — neither // fighting the exit fade nor snapping to full. @@ -607,25 +789,8 @@ function lodRestoreBrushMask(view, d, xs, ys) { export function lodDropDrill(view, g) { const d = g.drill; if (!d) return; - const gl = view.gl; - view._deleteVaos(d); // the drill sibling carries its own VAOs - for (const b of [d.xBuf, d.yBuf, d.cBuf, d.rgbaBuf, d.sBuf, d.styleBuf, - d.strokeBuf, d.selBuf, d.dBuf]) if (b) gl.deleteBuffer(b); - g.drill = null; - g._drillFadeStart = null; - g._drillExitFadeStart = null; - g._drillWasInside = false; - g._drillEverInside = false; - g._drillShownAlpha = null; - g._drillDying = false; - g._drillDiedInsideWin = false; - g._drillBackdropShown = 1; // next drill enters over a full backdrop (T10) - g._drillBackdropTick = 0; - view._hoverId = -1; // drilled indices are dead; don't reuse a cached row - view._lastRow = null; - // The freed drill may have been the only pickable geometry (a density-only - // chart): retract the modebar Select trigger with the capability. - view._updatePickable(); + lodFreeDrillBuffers(view, d); + lodClearDrillState(view, g); } // A density update arrived while drilled: don't drop the marks instantly @@ -824,6 +989,18 @@ function lodDrillBackdropScale(view, g, target) { // goes pending. export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { lodStepNorm(view, g); + // Retired point windows obey the same geometry-only discipline as the live + // drill (T11, via T13): once the view outgrows a cached window past the + // drill budget, no nearby view could be served by it, so its GPU buffers + // free on this frame — §27's rebuildable-cache rule, no kernel reply needed. + if (g.drillCache) { + for (let i = g.drillCache.length - 1; i >= 0; i--) { + if (lodDrillOutgrown(view, g, g.drillCache[i])) { + lodFreeDrillBuffers(view, g.drillCache.splice(i, 1)[0]); + } + } + if (!g.drillCache.length) g.drillCache = null; + } const d = g.drill; // Rapid zoom out→in revive: a dying drill whose window still covers the // view is exact for it (the subset IS every point in that window). Cancel @@ -906,13 +1083,23 @@ export function lodDrawDensityTier(view, g, x0, x1, y0, y1) { drawMarks(1 - exitFade); view.draw(); } else { - if (g._drillDying) lodDropDrill(view, g); // fade done: free the buffers - else if (exitingDrill) g._drillWasInside = false; + if (g._drillDying) { + // Fade done. A subset that died OUTSIDE its window is still exact for + // it — the kernel chose density for a view the window doesn't cover — + // so it retires into the point cache (T13) and a zoom back in + // promotes it with no round-trip. Dying INSIDE means the kernel chose + // density FOR this window's own views (forced density / data change): + // its points-tier claim is void, free the buffers. + if (d.exact && !g._drillDiedInsideWin) lodRetireDrill(view, g); + else lodDropDrill(view, g); + } else if (exitingDrill) { + g._drillWasInside = false; + } // Geometry-only retirement (T11): an entered drill whose exit has // completed frees its buffers once the view outgrows its window past // the drill budget — no kernel reply required, and it must run on the // completion frame itself (a settled view schedules no further frames). - // A dying drill was just dropped above; a never-entered prefetch is + // A dying drill was just handled above; a never-entered prefetch is // exempt (its window is simply ahead of the view). if (g.drill && g._drillEverInside && lodDrillOutgrown(view, g, d)) { lodDropDrill(view, g); diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index eeac5ca3..87d2988c 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -3,7 +3,7 @@ import { buildLutData, colormapStops } from "./10_colormaps"; import { cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; -import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; +import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; // --------------------------------------------------------------------------- @@ -5085,6 +5085,7 @@ export class ChartView { _destroyTraceResources(g, texSeen) { if (!g) return; this._destroyDensitySample(g); + lodDropPointCache(this, g); // retired point windows die with the trace (T13) this._deleteVaos(g); this._deleteVaos(g.drill); this._deleteBuffers(g, [ @@ -5094,7 +5095,9 @@ export class ChartView { "_transitionPrevXBuf", "_transitionPrevYBuf", "_transitionPrevPosBuf", "_transitionPrevValue1Buf", "_transitionPrevValue0Buf", ]); - this._deleteBuffers(g.drill, ["xBuf", "yBuf", "cBuf", "sBuf", "selBuf", "dBuf"]); + this._deleteBuffers(g.drill, [ + "xBuf", "yBuf", "cBuf", "rgbaBuf", "sBuf", "styleBuf", "strokeBuf", "selBuf", "dBuf", + ]); const textures = []; if (g.heatmap) textures.push(g.heatmap.tex); for (const d of g.densityCache || []) textures.push(d && d.tex); diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 9511a6f6..5bce4d8a 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -2,7 +2,8 @@ import { payloadBuffers } from "./00_header"; import { buildLutData } from "./10_colormaps"; import { parseColor } from "./20_theme"; import { - lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, lodDropDrill, lodRememberDensity, + lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, lodDropDrill, + lodPromoteCachedDrill, lodRememberDensity, } from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -32,9 +33,9 @@ Object.assign(ChartView.prototype, { const now = this._now(); for (const g of this.gpuTraces) { if (g.tier !== "density") continue; - // Zoom-in request elision (T12): a view contained in an exact drill's - // window is already answered by the marks on the GPU — the smaller - // window's points are a subset of the shipped ones — so this trace + // Zoom-in request elision (T12/T13): a view contained in an exact + // window already on the GPU — the live drill, or a retired cached + // point window promoted back — is answered locally, so this trace // goes neither pending nor on the wire. The seq bump above stands, so // an in-flight reply for an older, wider view dies stale instead of // yanking the exact marks out from under the view it can't improve. @@ -44,6 +45,17 @@ Object.assign(ChartView.prototype, { g._lodPendingAt = null; continue; } + // Duplicate of the request already in flight (identical window and + // screen): keep waiting on ITS seq instead of arming a new one that + // would kill the incoming reply just to resend the same window — + // the "constantly re-requesting the same points" loop (#225 notes). + const dup = this._densityRequestDup(g, view, plotW, plotH, now); + if (dup) { + g._lodPendingView = view; + g._lodPendingSeq = dup.seq; + g._lodPendingAt = dup.sentAt; + continue; + } g._lodPendingView = view; g._lodPendingSeq = seq; g._lodPendingAt = now; @@ -72,6 +84,7 @@ Object.assign(ChartView.prototype, { }); } if (needsDensity) { + const sendNow = this._now(); for (const g of this.gpuTraces) { if (g.tier !== "density") continue; // T12 re-check at actual send time: a drill that landed during the @@ -85,6 +98,20 @@ Object.assign(ChartView.prototype, { } continue; } + // Identical-request suppression (T13): the same window at the same + // screen size is either already answered (nothing to refresh — the + // reply is deterministic for unchanged data; a data change rebuilds + // this GPU record and clears the memo) or still in flight (its + // reply was adopted as this trace's pending marker above). + const dup = this._densityRequestDup(g, view, plotW, plotH, sendNow); + if (dup) { + if (dup.answered && g._lodPendingSeq === seq) { + g._lodPendingView = null; + g._lodPendingSeq = null; + g._lodPendingAt = null; + } + continue; + } const [x0, x1] = this._axisRange(g.xAxis, view); const [y0, y1] = this._axisRange(g.yAxis, view); this.comm.send({ @@ -93,6 +120,10 @@ Object.assign(ChartView.prototype, { y0: Math.min(y0, y1), y1: Math.max(y0, y1), w: plotW, h: plotH, }); + g._lastDensityReq = { + key: this._densityRequestKey(g, view, plotW, plotH), + seq, sentAt: sendNow, answered: false, + }; } } }; @@ -104,6 +135,45 @@ Object.assign(ChartView.prototype, { return seq; }, + _densityRequestKey(g, view, plotW, plotH) { + const [x0, x1] = this._axisRange(g.xAxis, view); + const [y0, y1] = this._axisRange(g.yAxis, view); + return `${Math.min(x0, x1)},${Math.max(x0, x1)},` + + `${Math.min(y0, y1)},${Math.max(y0, y1)},${plotW},${plotH}`; + }, + + // The last density_view actually sent for this GPU record, when a new + // request would be its byte-identical twin: either already answered (the + // reply is deterministic for unchanged data, so there is nothing to + // refresh) or still in flight (bounded by the same 1200ms window as the T8 + // pending hold, so a lost reply can never suppress refresh forever). The + // memo lives on the GPU record — a rebuild (append, payload update, context + // restore) starts it fresh, so data changes are never suppressed. + _densityRequestDup(g, view, plotW, plotH, now) { + const last = g._lastDensityReq; + if (!last || last.key !== this._densityRequestKey(g, view, plotW, plotH)) return null; + if (last.answered) return last; + return now - last.sentAt < 1200 ? last : null; + }, + + // A reply whose seq lost the global race can still be current in substance: + // duplicate-suppressed requests leave their traces waiting on the ORIGINAL + // request's seq (T13 — identical window and screen). Accept it only when + // every trace it updates is still waiting on exactly this seq AND that seq + // names the trace's last actually-SENT request — a pending marker can + // outlive a debounce-cancelled send, and a reply matching such a phantom + // request is stale, not suppressed-current (T5). + _densityReplyCurrent(msg) { + const ids = (msg.traces || []).map((u) => Number(u.id)); + if (!ids.length && msg.trace !== undefined) ids.push(Number(msg.trace)); + if (!ids.length) return false; + return ids.every((id) => { + const g = this.gpuTraces.find((t) => t.trace.id === id && t.tier === "density"); + return g && g._lodPendingSeq === msg.seq && + g._lastDensityReq && g._lastDensityReq.seq === msg.seq; + }); + }, + // Standalone (kernel-less) density refinement. Debounced like the kernel // request path, then the retained §28 sample re-bins in the bundled worker — // off the main thread — and applies like a density_update. @@ -385,7 +455,7 @@ Object.assign(ChartView.prototype, { } this.draw(); } else if (msg.type === "density_update") { - if (msg.seq !== undefined && msg.seq !== this.seq) return; + if (msg.seq !== undefined && msg.seq !== this.seq && !this._densityReplyCurrent(msg)) return; const densityTraces = msg.traces || []; const pendingTraceIds = new Set(densityTraces.map((upd) => Number(upd.id))); if (pendingTraceIds.size === 0 && msg.trace !== undefined) { @@ -394,6 +464,11 @@ Object.assign(ChartView.prototype, { const clearAllPending = pendingTraceIds.size === 0 && msg.stale; const clearPending = (g) => { if (msg.seq !== undefined && g._lodPendingSeq !== msg.seq) return; + // The identical-request memo (T13): this window is now answered, so a + // later request for the same window and screen sends nothing. + if (g._lastDensityReq && g._lastDensityReq.seq === msg.seq) { + g._lastDensityReq.answered = true; + } g._lodPendingView = null; g._lodPendingSeq = null; g._lodPendingAt = null; @@ -538,15 +613,18 @@ Object.assign(ChartView.prototype, { lodDropDrill(this, g); }, - // Can `view` be answered locally from this trace's live drill, with no - // kernel round-trip (T12)? True only for an exact (reduction "none"), - // non-dying drill whose window contains the view's per-axis ranges, and - // only until the zoom outgrows the §16 f32 encode precision. + // Can `view` be answered locally with no kernel round-trip? True for an + // exact (reduction "none"), non-dying live drill whose window contains the + // view's per-axis ranges (T12), and — failing that — for any retired cached + // point window that does, which is promoted back to the live drill on the + // spot (T13). Both only until the zoom outgrows the §16 f32 encode + // precision. _drillServesView(g, view) { - if (!g.drill) return false; + if (!g) return false; const [x0, x1] = this._axisRange(g.xAxis, view); const [y0, y1] = this._axisRange(g.yAxis, view); - return lodDrillServesView(g, x0, x1, y0, y1); + if (lodDrillServesView(g, x0, x1, y0, y1)) return true; + return lodPromoteCachedDrill(this, g, x0, x1, y0, y1); }, // Is the current view fully covered by a drilled window? A tiny epsilon diff --git a/python/xy/_trace.py b/python/xy/_trace.py index 73629a7d..b3b82c33 100644 --- a/python/xy/_trace.py +++ b/python/xy/_trace.py @@ -71,6 +71,14 @@ class Trace: # subset is dropped instead of translating indices in the wrong space # (§16/§17: exact readout beats stale availability). drill_seq: int = 0 + # Recent shipped subsets, {drill_seq: sel}, bounded to DRILL_HISTORY_KEEP + # (LOD doc T13): the client may pick against a retired cached point window + # whose seq is no longer current — translating through the remembered + # subset keeps that hover exact instead of dead. Cleared on data changes + # (an old subset's indices would then name different rows — §16). + drill_history: dict[int, Any] = field( + default_factory=dict, init=False, repr=False, compare=False + ) # Count-pyramid cache (§5 Tier 3), managed by `interaction.py`: None = # never tried, 0 = tried and not applicable, otherwise the native handle. # The finalizer frees the native side when the trace is collected. diff --git a/python/xy/config.py b/python/xy/config.py index 60dd4310..93c319ae 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -64,12 +64,39 @@ # static and re-ship large; a few points per cell keeps drill-out continuous. DENSITY_TARGET_POINTS_PER_CELL = 16.0 -# Hybrid density overlay (§5): when scatter is aggregated, ship a small, -# deterministic sample of real points over the density texture. This keeps -# zoomed-out views from becoming pure heatmaps while staying payload-bounded. +# Deterministic point sample retained with the FIRST density payload only +# (§28/#225): the client draws it solely when the view it would describe could +# be points-tier (estimated in-view count within the drill budget) — a +# fixed-size sample above that resolution reads as individual data points at a +# zoom where real points are sub-pixel, misrepresenting the dataset. Its other +# job is unchanged: it is the standalone (kernel-less) re-bin worker's CPU +# source. Interactive density_view replies ship no samples at all — the +# density surface already wears the data's own colors (LOD doc §2), and real +# points arrive the moment a window fits the budget. DENSITY_SAMPLE_TARGET = 8_192 DENSITY_SAMPLE_SEED = 0 +# Padded drill windows (LOD doc T13): a points-tier reply ships the largest +# ALIGNED window around the view whose exact count still fits the budget, so +# the client's window cache answers nearby pans/zooms with zero round-trips. +# Ladder of padded-span targets (× the view span), coarsest first; bounds snap +# outward to a power-of-two grid over the trace's extent (lod.aligned_window), +# making consecutive pans resolve to the SAME window. +DRILL_PAD_TARGETS = (8.0, 4.0, 2.0) +# Hard per-axis cap on the padded span (× the view span). Must stay well under +# the client's §16 re-encode bound (1/256 of the window span): the shipped +# offset encoding centers on the padded window, and a window unboundedly wider +# than the view would let deep zooms outrun f32 precision before the re-encode +# request re-arms. +DRILL_PAD_SPAN_CAP = 64.0 + +# Recent drilled subsets kept resolvable for picks (LOD doc T13): the client +# may serve a view from a retired cached point window whose drill_seq is no +# longer current; translating through the remembered subset keeps hover exact +# instead of dead. Bounded — anything older resolves to None, never to a +# wrong row (§16 exact-or-nothing). +DRILL_HISTORY_KEEP = 8 + # CVD-safe default categorical palette (§20/§36 default theme). Eight slots in # a fixed order; charts render on unknown host surfaces, so every step sits in # the OKLCH lightness band both light and dark modes share (L 0.48–0.67) and is diff --git a/python/xy/interaction.py b/python/xy/interaction.py index 114d2616..d47a35c5 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -24,10 +24,10 @@ from .config import ( DECIMATION_THRESHOLD, DEFAULT_PALETTE, - DENSITY_SAMPLE_SEED, - DENSITY_SAMPLE_TARGET, DENSITY_TARGET_POINTS_PER_CELL, # noqa: F401 (historic import path) DRILL_EXIT_FACTOR, + DRILL_PAD_SPAN_CAP, + DRILL_PAD_TARGETS, PYRAMID_BASE_DIM, PYRAMID_MAX_DIM, PYRAMID_MIN_POINTS, @@ -95,8 +95,16 @@ def pick( except ValueError: return None if dseq is not None and dseq != t.drill_seq: - return None - shipped_sel = _point_shipped_sel(t) + # The client may pick against a RETIRED cached point window (LOD doc + # T13) whose subset version is no longer the current drill. Recent + # subsets stay resolvable through the bounded history; anything older + # (or bumped by exits/data changes) drops the pick rather than reading + # the index in the wrong subset space (§16 exact-or-nothing). + shipped_sel = lod.drill_history(t, dseq) + if shipped_sel is None: + return None + else: + shipped_sel = _point_shipped_sel(t) if shipped_sel is not None: if idx < 0 or idx >= len(shipped_sel): return None @@ -484,74 +492,66 @@ def _decode_log_u8(buf: bytes, gmax: float) -> np.ndarray: return np.expm1((v / 255.0) * np.log1p(gmax)) -def _density_sample_update( +# Interactive density replies deliberately ship NO point sample (#225): a +# fixed-size sample above the drill budget reads as individual data points at +# a zoom where real points are sub-pixel — misrepresenting the dataset — and +# the density surface already wears the data's own colors (LOD doc §2). Real +# points arrive the moment a window fits the budget. The only retained sample +# is the first-payload one (`_payload._density_sample_spec`), which the client +# draws solely below the resolvable-count gate and the standalone re-bin +# worker keeps as its CPU source. + + +def _padded_drill_window( fig: "Figure", t: Any, - sel: np.ndarray, - visible: int, + pyr: int | None, lo_x: float, hi_x: float, lo_y: float, hi_y: float, - writer: lod.BufferWriter, -) -> Optional[dict[str, Any]]: - if visible <= 0: +) -> Optional[tuple[float, float, float, float, np.ndarray]]: + """The widest aligned window around the view that still drills (T13). + + The client elides any request whose view an exact shipped window already + contains (T12) and caches retired windows, so every extra span this window + can afford converts future pans and zooms into zero-round-trip renders. + Bounds snap outward to the power-of-two grid over the trace's extent + (`lod.aligned_window`), making consecutive pans resolve to the SAME + window; the coarsest ladder rung whose exact in-window count fits the + budget wins. Returns None (drill the raw view window) when padding buys + nothing: nonlinear axes (raw-space alignment mis-sizes log windows near + zero), non-finite extents, or every rung over budget. + """ + if fig._axis_scale(t.x_axis) != "linear" or fig._axis_scale(t.y_axis) != "linear": return None - categories = None - if t.color_ch and t.color_ch.mode == "categorical" and t.color_ch.codes is not None: - categories = t.color_ch.codes[sel] - sample_sel = lod.sample_rows_for_target( - sel, - DENSITY_SAMPLE_TARGET, - categories=categories, - seed=DENSITY_SAMPLE_SEED, - ) - if len(sample_sel) == 0: + ex0, ex1, ey0, ey1 = t.x.min, t.x.max, t.y.min, t.y.max + if not all(np.isfinite(v) for v in (ex0, ex1, ey0, ey1)): return None - xs, ys = t.x.values[sample_sel], t.y.values[sample_sel] - x_ref, y_ref = lod.add_window_xy( - writer, - xs, - ys, - lo_x, - hi_x, - lo_y, - hi_y, - fig._axis_scale(t.x_axis), - fig._axis_scale(t.y_axis), - ) - color_spec, size_spec = fig._ship_channels( - t, sample_sel, writer.add_f32, writer.add_u8, quantize_continuous=True - ) - style = dict(t.style) - try: - style["opacity"] = min(float(style.get("opacity", 0.8)), 0.55) - except (TypeError, ValueError): - style["opacity"] = 0.55 - sample = { - "mode": "sampled", - "n": int(len(sample_sel)), - "visible": int(visible), - "target": DENSITY_SAMPLE_TARGET, - "level": 0, - "seed": DENSITY_SAMPLE_SEED, - "x": x_ref, - "y": y_ref, - "x_range": [lo_x, hi_x], - "y_range": [lo_y, hi_y], - "color": color_spec, - "size": size_spec, - "style": style, - } - if t.stroke_ch is not None: - sample["stroke"] = channels.ship_color_channel( - t.stroke_ch, sample_sel, writer.add_f32, writer.add_u8, DEFAULT_PALETTE - ) - if t.style_channels: - sample["channels"] = channels.ship_style_channels( - t.style_channels, sample_sel, writer.add_f32, writer.add_u8 - ) - return sample + span_x, span_y = hi_x - lo_x, hi_y - lo_y + budget = SCATTER_DENSITY_THRESHOLD + for pad in DRILL_PAD_TARGETS: + px0, px1 = lod.aligned_window(lo_x, hi_x, ex0, ex1, pad) + py0, py1 = lod.aligned_window(lo_y, hi_y, ey0, ey1, pad) + if px0 == lo_x and px1 == hi_x and py0 == lo_y and py1 == hi_y: + continue + # §16 precision guard: the shipped offset encoding centers on THIS + # window, and the client re-requests precision only below 1/256 of the + # window span — a window unboundedly wider than the view (tiny view + # over a small dataset) would let deep zooms outrun f32 first. + if px1 - px0 > span_x * DRILL_PAD_SPAN_CAP or py1 - py0 > span_y * DRILL_PAD_SPAN_CAP: + continue + if pyr is not None: + # Cheap center-in-cell estimate gates the exact O(N) verify scan; + # the margin keeps a boundary-straddling estimate from wasting a + # scan on a window the exact count then rejects. + est = kernels.pyramid_count(pyr, px0, px1, py0, py1) + if est is not None and est > budget * 0.85: + continue + sel = kernels.range_indices(t.x.values, t.y.values, px0, px1, py0, py1) + if len(sel) <= budget: + return px0, px1, py0, py1, sel + return None def _quantize_dval(dval: np.ndarray) -> np.ndarray: @@ -790,7 +790,17 @@ def density_view( plan = lod.plan_view_lod(request, len(sel), SCATTER_DENSITY_THRESHOLD, t.drill_mode) visible = plan.visible if plan.exact: - return _drill_points(fig, t, sel, visible, lo_x, hi_x, lo_y, hi_y, w, h) + # Ship the widest aligned window that still fits the budget (T13) + # so the client's point-window cache answers nearby pans/zooms + # locally; the raw view window is the floor. `visible` describes + # the SHIPPED window; the view's own count keeps driving the + # density→points intensity handoff. + padded = _padded_drill_window(fig, t, pyr, lo_x, hi_x, lo_y, hi_y) + if padded is not None: + lo_x, hi_x, lo_y, hi_y, sel = padded + return _drill_points( + fig, t, sel, len(sel), lo_x, hi_x, lo_y, hi_y, w, h, blend_visible=visible + ) lod.exit_drill(t) w, h = plan.grid_w, plan.grid_h @@ -812,11 +822,6 @@ def density_view( writer = lod.BufferWriter() density_wire, gmax = _encode_log_u8(grid) density_buf = writer.add_raw(density_wire) - sample = ( - _density_sample_update(fig, t, sel, visible, lo_x, hi_x, lo_y, hi_y, writer) - if binning == "exact" - else None - ) density = { "buf": density_buf, "w": w, @@ -835,8 +840,6 @@ def density_view( density["color_agg"] = "mean" if t.color_ch and t.color_ch.mode == "constant" and t.color_ch.constant is not None: density["color"] = t.color_ch.constant - if sample is not None: - density["sample"] = sample return ( { "traces": [ @@ -866,6 +869,7 @@ def _drill_points( hi_y: float, w: int, h: int, + blend_visible: int | None = None, ) -> tuple[dict[str, Any], list[bytes]]: """Ship the visible subset of a Tier-2 scatter as real points (§5 drill-in). @@ -873,10 +877,14 @@ def _drill_points( ship in the direct-scatter wire shape, normalized over their *global* domain so colors/sizes stay stable across views; offsets re-center on the window midpoint (§16); each point carries its local log-density plus a - `lod_blend` weight (visible/budget) so the density→points handoff stays - continuous (§5). The surface wears the mean point color (LOD doc §2), so - the handoff is intensity-only: fresh marks enter at their cell's - count-alpha and ease to native opacity, with hue continuous throughout.""" + `lod_blend` weight so the density→points handoff stays continuous (§5). + The window may be a padded aligned superset of the requested view (T13), + so `visible` counts the SHIPPED window while `blend_visible` — the + requested view's own count — drives the handoff weight: the intensity a + user sees at the swap belongs to the view, not to padding they can't see. + The surface wears the mean point color (LOD doc §2), so the handoff is + intensity-only: fresh marks enter at their cell's count-alpha and ease to + native opacity, with hue continuous throughout.""" xs, ys = t.x.values[sel], t.y.values[sel] writer = lod.BufferWriter() x_ref, y_ref = lod.add_window_xy( @@ -909,8 +917,11 @@ def _drill_points( # 1.0 right at the boundary → points enter at the density surface's # local count-alpha; →0 as zoom deepens. The surface wears the mean point # color (LOD doc §2), so marks arrive in their native colors and only - # intensity eases. - lod_blend = float(min(1.0, visible / SCATTER_DENSITY_THRESHOLD)) + # intensity eases. Keyed on the VIEW's count when the window is padded — + # padding widens what ships, not what the user is looking at. + lod_blend = float( + min(1.0, (visible if blend_visible is None else blend_visible) / SCATTER_DENSITY_THRESHOLD) + ) trace_update = { "id": t.id, "mode": "points", @@ -1146,6 +1157,10 @@ def _style_tail(name: str, values: Any) -> Optional[np.ndarray]: # "not applicable" result; let the next wide view build it. t._pyr_handle = None lod.exit_drill(t) + # Remembered subsets were computed against the pre-append canonical state; + # the client rebuilds its GPU traces (and drops its cached point windows) + # on the refresh anyway, so stale-seq picks must die rather than translate. + lod.clear_drill_history(t) # Split layout, same as first paint (§29): per-column borrowed views, no # join copy. The spec itself names the append — `append.seq` is the apply diff --git a/python/xy/lod.py b/python/xy/lod.py index 5a8e6675..35d0f981 100644 --- a/python/xy/lod.py +++ b/python/xy/lod.py @@ -27,7 +27,12 @@ import numpy as np from . import kernels -from .config import DENSITY_TARGET_POINTS_PER_CELL, DRILL_EXIT_FACTOR, MAX_SCREEN_DIM +from .config import ( + DENSITY_TARGET_POINTS_PER_CELL, + DRILL_EXIT_FACTOR, + DRILL_HISTORY_KEEP, + MAX_SCREEN_DIM, +) _SPLITMIX_INCREMENT = np.uint64(0x9E3779B97F4A7C15) _SPLITMIX_MUL_1 = np.uint64(0xBF58476D1CE4E5B9) @@ -720,19 +725,78 @@ def enter_drill(trace: Any, sel: np.ndarray) -> int: trace.drill_mode = True trace.shipped_sel = sel trace.drill_seq += 1 + # Remember recent subsets (T13): the client can serve a view from a + # RETIRED cached point window whose drill_seq is no longer current, and a + # pick against it should still translate exactly. Bounded FIFO — an + # expired seq resolves to None (a dropped pick), never to a wrong row. + history = trace.drill_history + history[trace.drill_seq] = sel + while len(history) > DRILL_HISTORY_KEEP: + del history[next(iter(history))] return trace.drill_seq def exit_drill(trace: Any) -> None: """Back to the aggregate: no per-point marks, no pick mapping. Bumps the version when leaving an actual drill so a drilled-index pick arriving late - is rejected instead of being read as a *canonical* index.""" + is rejected instead of being read as a *canonical* index. Remembered + subsets survive the exit — client-side cached point windows outlive the + kernel's current-tier choice (T13) — until a data change clears them.""" if trace.drill_mode: trace.drill_seq += 1 trace.drill_mode = False trace.shipped_sel = None +def drill_history(trace: Any, seq: int) -> np.ndarray | None: + """The shipped subset a recent `drill_seq` named, or None when expired. + + None must stay None at the call site (drop the pick): translating through + the wrong subset would read an arbitrary canonical row (§16).""" + return trace.drill_history.get(seq) + + +def clear_drill_history(trace: Any) -> None: + """Forget remembered subsets after a data change: the retained indices + were computed against the previous canonical state, and a client window + built on them is rebuilt anyway (append/update rebuilds GPU traces).""" + trace.drill_history.clear() + + +def aligned_window( + lo: float, hi: float, extent_lo: float, extent_hi: float, pad: float +) -> tuple[float, float]: + """Snap a 1-D window outward to the power-of-two grid over its extent. + + The grid level is a pure function of the extent and the window's span + bucket: one block spans ``extent / 2**level``, the coarsest level with a + block no wider than ``pad × span``, and the window's bounds snap outward + to block edges. Every request whose span falls in the same power-of-two + bucket therefore resolves to the SAME aligned bounds regardless of pan + position — full-point buffers aligned by dimension, so client caches can + key, dedupe, and reuse them (LOD doc T13). The result always CONTAINS the + input window (client request elision needs containment, and a view panned + past the data extent must stay contained even though nothing lives there); + the snapped span stays within ``(1 + 2·pad) × span`` of the input window. + """ + span = hi - lo + extent = extent_hi - extent_lo + if not ( + np.isfinite(span) and np.isfinite(extent) and span > 0.0 and extent > 0.0 and pad >= 1.0 + ): + return lo, hi + if pad * span >= extent: + return min(extent_lo, lo), max(extent_hi, hi) + level = max(0, math.ceil(math.log2(extent / (pad * span)))) + block = extent / (1 << level) + b0 = math.floor((lo - extent_lo) / block) + b1 = math.ceil((hi - extent_lo) / block) + # The min/max guards absorb the one-ulp case where a bound lands exactly + # on a grid line and float rounding would nudge the snapped edge inside + # the window — containment is the contract, alignment the optimization. + return (min(lo, extent_lo + b0 * block), max(hi, extent_lo + b1 * block)) + + class BufferWriter: """Accumulates a view-update's binary buffers (typed scalars, never JSON numbers). The update spec references entries by index — the same shape diff --git a/spec/design-dossier.md b/spec/design-dossier.md index f423d6f0..4d5a5ee7 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -416,9 +416,13 @@ re-exports several of them as a historic import path and is not listed for those | `MAX_CONTOUR_WORK` | `4_000_000` | Ceiling on contour `cells × levels`; a request over it raises instead of allocating an unbounded segment buffer. | `marks.py`, `_native.py` | | `DRILL_EXIT_FACTOR` | `1.15` | Hysteresis multiplier on the drill boundary: a trace already drilled to real points stays drilled until the visible count exceeds `budget × 1.15`. | `lod.py` (`drill_decision`, `plan_view_lod`), `interaction.py`; mirrored as `LOD_DRILL_EXIT_FACTOR` in `js/src/45_lod.ts` | | `DENSITY_TARGET_POINTS_PER_CELL` | `16.0` | Target points per cell when sizing an aggregation grid, so a barely-over-budget view does not get a one-point-per-pixel grid that looks like static and re-ships large. | `lod.py` | -| `DENSITY_SAMPLE_TARGET` | `8_192` | Size of the deterministic real-point sample shipped over an aggregated scatter's density texture (hybrid overlay). | `_payload.py`, `interaction.py` | -| `DENSITY_SAMPLE_SEED` | `0` | Seed for that sample; a fixed seed makes the overlay identical across re-ships of the same view. | `_payload.py`, `interaction.py` | -| `LOD_SAMPLE_FADE_COVER_HI` / `LOD_SAMPLE_FADE_COVER_LO` | `1/4` / `1/32` | Fallback bound on the hybrid-overlay sample (T9): overlays ride their density windows and the drawn one is the best cached window covering the view (full alpha), so the band only governs a view NO cached window covers — the best partial draws at full alpha while its window covers ≥ 1/4 of the view area, hidden below 1/32, log-eased between. The band value is a *composited* opacity target (per-point alpha solved against the expected overplot, `1−(1−band)^(1/k)`), so a partial overlay can never overplot into a false cluster. | `js/src/45_lod.js` (`lodSampleViewAlpha`, `lodSampleForView`) | +| `DENSITY_SAMPLE_TARGET` | `8_192` | Size of the deterministic real-point sample retained with the FIRST density payload only (#225: interactive density replies ship no samples; the client draws the retained overlay solely below the T9 resolvable-count gate, and the standalone re-bin worker keeps it as its CPU source). | `_payload.py` | +| `DENSITY_SAMPLE_SEED` | `0` | Seed for that sample; a fixed seed makes the overlay identical across re-ships of the same view. | `_payload.py` | +| `LOD_SAMPLE_FADE_COVER_HI` / `LOD_SAMPLE_FADE_COVER_LO` | `1/4` / `1/32` | Fallback bound on the retained sample overlay (T9): overlays ride their density windows and the drawn one is the best cached window covering the view (full alpha), so the band only governs a view NO cached window covers — the best partial draws at full alpha while its window covers ≥ 1/4 of the view area, hidden below 1/32, log-eased between. The band value is a *composited* opacity target (per-point alpha solved against the expected overplot, `1−(1−band)^(1/k)`), so a partial overlay can never overplot into a false cluster. Every candidate first passes the #225 resolvability gate (estimated in-view count ≤ the direct budget). | `js/src/45_lod.ts` (`lodOverlayResolvable`, `lodSampleViewAlpha`, `lodSampleForView`) | +| `DRILL_PAD_TARGETS` | `(8, 4, 2)` | Ladder of padded-span targets (× the view span) for points-tier replies: the coarsest ALIGNED window around the view whose exact count fits the budget ships, so the client's point-window cache answers nearby pans/zooms with zero round-trips (LOD doc T13). | `interaction.py` (`_padded_drill_window`), `lod.py` (`aligned_window`) | +| `DRILL_PAD_SPAN_CAP` | `64.0` | Hard per-axis cap on a padded drill window's span (× the view span), kept well under the client's §16 re-encode bound (1/256 of window span) so deep zooms can always re-tighten the f32 offset encoding. | `interaction.py` | +| `DRILL_HISTORY_KEEP` | `8` | Recent drilled subsets kept resolvable per trace, so picks against a retired cached point window (T13) still translate exactly; older seqs drop the pick, data changes clear the history. | `lod.py` (`enter_drill`, `drill_history`), `interaction.py` (`pick`) | +| `LOD_POINT_CACHE_WINDOWS` | `3` | Retired exact point windows kept per trace client-side beyond the live drill; LRU-bounded VRAM, swept by the T11 outgrown rule. | `js/src/45_lod.ts` (`lodRetireDrill`, `lodPromoteCachedDrill`) | | `DEFAULT_PALETTE` | 10 CVD-safe hex entries | Per-trace default color cycle and the fallback categorical palette when a channel supplies none (§20/§36). | `marks.py`, `_payload.py`, `_svg.py`, `_raster.py` | | `PYRAMID_MIN_POINTS` | `2_000_000` | Trace size at/above which a Tier-3 tile pyramid is built lazily; smaller traces never pay for one. | `interaction.py` | | `PYRAMID_BASE_DIM` | `2048` | Edge of the pyramid's base level in cells (`dim²` u32 counts, ~1/3 overhead for the coarser levels); sets resident pyramid bytes. | `interaction.py` | diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index fff965c1..477fa0ff 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -44,7 +44,7 @@ real marks with real channels — never a sample, never an aggregate. | Kind | Tier 0 | Tier 1 | Tier 2 | zoom-in recovery | |---|---|---|---|---| -| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact visible points + channels (shipped); deeper zooms inside the exact window are answered locally, no request (T12) | +| scatter | points | — (unordered decimation lies, §28) | mean-color density grid (§2; count planes + color planes, §4 below) | drill-in ships exact points + channels for a padded ALIGNED window (T13); views inside any shipped window — live or cached — are answered locally, no request (T12/T13) | | line/area | polyline | M4 per column (extrema-exact) | — (M4 already screen-bounded) | re-decimate window (shipped) | | heatmap | cell rects | — | mip-style tile reduction of the user grid (max/mean recorded) | finer pyramid level, then exact cells | | histogram/bar | rects | re-bin at viewport resolution | — (bins are already aggregates) | re-bin visible window (finer bins = more truth, never less) | @@ -353,22 +353,31 @@ invariants so future kinds don't regress them: drive it out of the hold, so the drilled subset stayed painted and the full point cloud never returned. (Complements T7, which fixes the same visible symptom from the texture-lifetime side.) -- **T9 — the drawn sample describes the displayed window:** the deterministic - point sample shipped with an exact-scan density reply represents *its* - window only, so every sample overlay rides the density cache entry it was - computed for (`density.overlay`; replies that omit a sample — pyramid and - integral-image zoom-out paths, Phase 2 item 5 — simply add no overlay to - their entry). Each frame draws the overlay of the best cached window for - the current view (`lodSampleForView`, mirroring `lodDensityForView`): the - smallest window covering the view wins at full alpha, so a deep zoom-out - falls back through the cache to the HOME sample and the full point cloud - returns — points on screen always describe the window being displayed. - (The pre-pairing client retained one global sample and drew it whenever - the view merely overlapped its window: a drilled window's sample lingered - over every later zoom-out as an opaque "stuck point blob", pinned by a - live-drilldown wire capture — the blob's extent matched the last - sample-bearing reply's window exactly while the density surface kept - updating under it.) Only a view that NO cached window covers draws a +- **T9 — the drawn sample describes the displayed window, and only a + RESOLVABLE window (#225):** a sample overlay draws only when the view it + would describe could plausibly be points-tier — the overlay's recorded + window count, scaled by the view's share of its window, fits + `LOD_DIRECT_POINT_BUDGET` (`lodOverlayResolvable`). Above that, a + fixed-size sample reads as individual data points at a zoom where real + points are sub-pixel — sampling above the resolution of the graph + misrepresents the dataset (the #225 field capture: zooming out from a + drilled 100M-point cloud brought "individual points" back over the + aggregate) — and the density surface, which wears the data's own colors + (§2), stands alone. Interactive `density_view` replies therefore ship NO + sample at all: the only retained overlay is the first-payload one, which + doubles as the standalone re-bin worker's CPU source and draws only below + the gate (kernel mode ships real points before that, so the hybrid look is + transient at most; standalone deep zooms keep it as their only point + representation; datasets under the budget keep it everywhere). For the + overlays that DO draw, window pairing holds: every sample rides the + density cache entry it was computed for (`density.overlay`), and each + frame draws the overlay of the best cached window for the current view + (`lodSampleForView`, mirroring `lodDensityForView`) — the smallest window + covering the view wins at full alpha, so points on screen always describe + the window being displayed. (The pre-pairing client retained one global + sample and drew it whenever the view merely overlapped its window: a + drilled window's sample lingered over every later zoom-out as an opaque + "stuck point blob".) Only a view that NO cached window covers draws a partial overlay, faded by the window's share of the view area — full alpha at ≥ `LOD_SAMPLE_FADE_COVER_HI` (1/4), hidden at ≤ `LOD_SAMPLE_FADE_COVER_LO` (1/32), log-eased between @@ -379,11 +388,12 @@ invariants so future kinds don't regress them: near-opaque slab (a "fading" sample that never looked faded). The per-point alpha is solved as a = 1−(1−band)^(1/k), with k estimated from the drawn count, mean point footprint, and the window's on-screen area; - k ≤ 1 degenerates to the band value exactly. Selection and alpha are pure - functions of (view, cache): every zoom frame re-derives them, nothing - latches. Overlays die with their evicted cache entry (except the home/init - overlay, the standalone re-bin worker's CPU-side source), and the - "sampled n of N" badge reports the overlay actually drawn. + k ≤ 1 degenerates to the band value exactly. Selection, alpha, and the + resolvability gate are pure functions of (view, cache): every zoom frame + re-derives them, nothing latches. Overlays die with their evicted cache + entry (except the home/init overlay, the standalone re-bin worker's + CPU-side source), and the "sampled n of N" badge reports the overlay + actually drawn. - **T10 — the aggregate backdrop is continuous through transitions, and retires when the drill settles:** the density texture draws under the marks in every TRANSITIONAL drill state — entering, held, dying, exiting — @@ -440,8 +450,45 @@ invariants so future kinds don't regress them: trace, which drops the drill and with it the elision. Non-exact replies (anything but `reduction: "none"`, including replies that don't say) never arm it. - -Any new tiered kind must state how it satisfies T1–T12 in its chart-kind +- **T13 — full-point windows are padded, aligned, cached, and never + re-requested:** T12's elision is only as good as the window it can elide + against, so both ends widen it. *Kernel side*, a points-tier reply ships + the largest ALIGNED window around the view whose exact count still fits + the budget (`interaction._padded_drill_window`): bounds snap outward to a + power-of-two grid over the trace's extent, per dimension + (`lod.aligned_window`), from a ladder of span targets + (`DRILL_PAD_TARGETS`, coarsest first, pyramid-count-gated then + exact-verified), floored at the raw view window. Alignment makes + consecutive pans resolve to the SAME window — dedupable, cacheable — and + neighboring windows tile; the padded span is hard-capped per axis + (`DRILL_PAD_SPAN_CAP`, well under the 1/256 re-encode bound) so the §16 + offset encoding centered on the padded window can always be re-tightened + by a deeper zoom's re-encode request. Nonlinear-axis traces skip padding + (raw-space alignment mis-sizes log windows near zero) and keep the exact + view window. `visible` counts the SHIPPED window; `lod_blend` stays keyed + on the VIEW's own count — padding widens what ships, not what the user + sees. *Client side*, a points reply for a new window RETIRES the previous + exact drill into a bounded per-trace LRU (`g.drillCache`, + `LOD_POINT_CACHE_WINDOWS`) instead of overwriting its buffers, and so does + a drill that dies outside its window; any later view covered by a cached + window promotes it back (`lodPromoteCachedDrill` — alpha-continuous, brush + mask re-derived locally) with no wire message, so pan ping-pong and + zoom-out/zoom-in sequences render entirely from the GPU. Cached windows + obey the live drill's geometry-only memory discipline (T11): outgrown ⇒ + freed on the frame, no kernel reply required (§27). Because promoted + windows carry retired `drill_seq`s, the kernel keeps a bounded subset + history (`Trace.drill_history`, `DRILL_HISTORY_KEEP`) so picks against a + recent retired window still translate exactly; expired seqs drop the pick + (§16 exact-or-nothing), and data changes clear the history. Finally, an + identical request never rides the wire twice: a `density_view` whose + window and screen size match the trace's last sent request is suppressed — + already answered ⇒ nothing to refresh (the reply is deterministic for + unchanged data; rebuilds reset the memo), still in flight ⇒ the trace + keeps waiting on the ORIGINAL request's seq and that reply is accepted + per-trace instead of dying to the global seq race (bounded by the same + 1200ms window as the T8 hold, so a lost reply can't suppress forever). + +Any new tiered kind must state how it satisfies T1–T13 in its chart-kind contract entry before it lands. --- @@ -477,14 +524,18 @@ contract entry before it lands. row-order independence, rare-category floors, validation, dtype-preserving saturation, and subset-monotonicity across levels and viewport narrowing. -5. **Done for exact density views:** hybrid scatter mode renders density plus - deterministic sampled exact points, with a visible "sampled n of N" badge. - Pyramid-served density views still need tile-aware sample overlays so the - same anti-shimmer contract holds without rescanning raw rows. Until they - exist, a sample-less reply's window carries no overlay of its own and the - T9 window pairing serves the best cached window's sample instead (the - home overlay above all, so zoom-outs keep representative points), bounded - by the coverage fade when nothing covers the view. +5. **Revised by #225 (resolvability gate, T9):** interactive density replies + no longer ship samples at all — a fixed-size sample above the drill budget + reads as individual data points at a zoom where real points are sub-pixel, + and the mean-color surface (§2) is the truthful stand-alone + representation. The retained first-payload sample remains (the standalone + re-bin worker's CPU source) and draws — with its "sampled n of N" badge — + only when the estimated in-view count fits the direct budget: kernel mode + ships real points before that, so the hybrid look is transient at most; + standalone exports surface it once a zoom resolves it; datasets under the + budget keep it everywhere. The deterministic-sampling utilities (item 4) + stay: the first-payload overlay and any future resolvable-tier subset are + built from them. **Phase 3 — pyramid (build + serve shipped; client cache and bench gate open)** 6. **Done (count + mean-color planes):** `src/tiles.rs` builds a square count diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 57304179..4d5e3a99 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -62,18 +62,26 @@ produces no traces, there is no reply at all (silence, not an empty message). `trace` id. `w` defaults to `512`, `h` to `384`; the client sends the rounded plot width and height. A trace that is not in density mode yields `{"traces": []}`, which is dropped rather than sent. Not every view change -produces a request: a view fully contained in a live drill's window is elided -client-side when that drill shipped its window exactly (`reduction: "none"` — -the subset already holds every point of any contained view; LOD doc §5 T12). -The elision ends, and one request goes out purely to re-center the §16 f32 -offset encoding, once the view span drops below 1/256 of the drilled window's -span on either axis. +produces a request: a view fully contained in an exact shipped window — the +live drill's, or a retired cached point window the client promotes back — is +elided client-side (`reduction: "none"` means the subset already holds every +point of any contained view; LOD doc §5 T12/T13). The elision ends, and one +request goes out purely to re-center the §16 f32 offset encoding, once the +view span drops below 1/256 of the drilled window's span on either axis. +A request whose window and screen size match the trace's last sent request is +also suppressed (T13): if that request was already answered there is nothing +to refresh (replies are deterministic for unchanged data; data changes rebuild +the GPU trace and reset the memo), and if it is still in flight the trace +keeps waiting on the original request's `seq`, whose reply is then accepted +per-trace instead of dying to the global seq race. **`pick`** — `trace` and `index` pass through `_integer_id`. `index` is a *shipped-vertex* index, translated kernel-side to a canonical row when the shipped copy dropped non-finite rows. `drill_seq`, when present, is the drill -subset version the client picked against; a mismatch resolves to `row: null` -rather than to a row in a dead index space. +subset version the client picked against; a non-current seq translates +through the kernel's bounded subset history when it is still remembered (the +client may be drawing a retired cached point window, LOD doc T13) and +resolves to `row: null` otherwise — never to a row in a dead index space. **`click`** — same fields and same `fig.pick` resolution as `pick`, minus `seq`; it fires `on_click` and returns nothing. @@ -135,8 +143,11 @@ states which representation this view resolved to: constant) instead and no color plane — the mean of a constant IS the constant, so the client tints the count texture. Count always rides `buf` as log-u8 and drives only the drawn **alpha**; renderers must never - colormap counts when either color source is present. Optional `sample` is - the retained point-sample overlay. `binning` is `"exact"` or + colormap counts when either color source is present. Interactive replies + ship **no `sample`** (#225): the only retained point-sample overlay is the + first-payload one, and the client draws it solely below the T9 + resolvable-count gate (a client still accepts a legacy reply-borne `sample` + and gates it the same way). `binning` is `"exact"` or `"pyramid-L"`. `x_range`/`y_range` are raw data endpoints, but the grid's cells are **uniform in the axis's scale coordinates** (identical to raw on a linear axis): on a log/symlog axis the kernel bins transformed @@ -147,12 +158,16 @@ states which representation this view resolved to: - `mode: "points"` — the deep-zoom drill: `{id, mode, tier: "direct", visible, reduction: "none", x_range, y_range, x, y, color, size, density_val, lod_blend, drill_seq, style}`. - `x_range`/`y_range` are the window these points cover; the client falls - back to the density overview the moment the view leaves it. `density_val` - (per-point local log-density) and `lod_blend` drive the intensity-only - handoff: hue is continuous by construction because the aggregate surface - wears the mean point color, so no `density_colormap` field rides this - message (clients ignore one if present). + `x_range`/`y_range` are the window these points cover — usually a padded + ALIGNED superset of the requested view (LOD doc T13; the raw view window on + nonlinear axes or when no padding fits the budget) — and the client falls + back to the density overview the moment the view leaves it. `visible` + counts the points of the SHIPPED window; `lod_blend` stays keyed on the + requested view's own count. `density_val` (per-point local log-density) and + `lod_blend` drive the intensity-only handoff: hue is continuous by + construction because the aggregate surface wears the mean point color, so + no `density_colormap` field rides this message (clients ignore one if + present). Channel encodings on this (and the sampled-overlay) live wire: `x`/`y` are offset-encoded f32 (position precision is load-bearing, dossier §16), but diff --git a/tests/test_density_sample_resolvability_gate.py b/tests/test_density_sample_resolvability_gate.py new file mode 100644 index 00000000..b811248f --- /dev/null +++ b/tests/test_density_sample_resolvability_gate.py @@ -0,0 +1,199 @@ +"""Sample overlays draw only below the resolvable-count gate (#225). + +Above the drill budget, a fixed-size sample reads as individual data points at +a zoom where real points are sub-pixel — sampling above the resolution of the +graph misrepresents the dataset (the issue's field capture: zooming out from a +drilled window brought "individual points" back over a 100M-point cloud). The +client therefore gates every overlay candidate on the estimated in-view count: +the overlay's recorded window count scaled by the view's share of its window, +drawn only when that fits LOD_DIRECT_POINT_BUDGET. In kernel mode the gate +makes the hybrid look transient at most (interactive density replies ship no +samples anymore; real points arrive when a window fits the budget); standalone +exports keep the overlay as their only point representation once a zoom +resolves it. + +This drives the real client in headless Chromium: an overlay whose window +holds far more points than the budget must NOT draw even when its window +covers the view exactly (badge off — density-only), while zooming deep enough +into the same window that the estimated in-view count fits the budget brings +the overlay (and badge) back. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import xy +from conftest import run_browser_probe +from xy.export import find_chromium + +_RENDER_CALL = 'xy.renderStandalone(document.getElementById("chart"), spec, buf);' + +_PROBE = """ + const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); + try { + view._drawNow(); + view._raf = null; + view._sampleRebinDisabled = true; + const g = view.gpuTraces.find((t) => t.tier === "density"); + let clk = 100000; + view._now = () => clk; + g._densityNormAnim = null; + + const v0 = view.view0; + const cx = (v0.x0 + v0.x1) / 2, cy = (v0.y0 + v0.y1) / 2; + const sx = v0.x1 - v0.x0, sy = v0.y1 - v0.y0; + // Window W = central 20% of home; its sample CLAIMS 10M points in-window + // (a 100M-point-cloud zoom level) — far past the 200k direct budget. + const wx0 = cx - sx * 0.1, wx1 = cx + sx * 0.1; + const wy0 = cy - sy * 0.1, wy1 = cy + sy * 0.1; + const N = 400; + const xs = new Float32Array(N), ys = new Float32Array(N); + for (let i = 0; i < N; i++) { + xs[i] = wx0 + (wx1 - wx0) * ((i * 0.6180339887) % 1); + ys[i] = wy0 + (wy1 - wy0) * ((i * 0.3141592653) % 1); + } + const gw = 4, gh = 4; + const grid = new Float32Array(gw * gh).fill(3); + view._onKernelMsg({ + type: "density_update", seq: view.seq, trace: g.trace.id, + traces: [{ + id: g.trace.id, mode: "density", visible: 10000000, + density: { + buf: 0, w: gw, h: gh, max: 3, + x_range: [wx0, wx1], y_range: [wy0, wy1], + sample: { + n: N, visible: 10000000, + x: { buf: 1, offset: 0, scale: 1, len: N }, + y: { buf: 2, offset: 0, scale: 1, len: N }, + x_range: [wx0, wx1], y_range: [wy0, wy1], + }, + }, + }], + }, [grid.buffer, xs.buffer, ys.buffer]); + + const sampleDrawn = () => { + let drawn = null; + const real = view._drawPoints; + view._drawPoints = function (d, xm, ym, alpha) { + if (d && d.tier === "sampled") drawn = { n: d.n, alpha: alpha === undefined ? 1 : alpha }; + return real.apply(this, arguments); + }; + view._drawNow(); + view._drawPoints = real; + return drawn; + }; + const badgeShowsSample = () => + view._reductionBadgeItems().some((i) => i.indexOf("sampled") === 0); + const zoomTo = (f) => { + view.view = { + x0: cx - (wx1 - wx0) * f / 2, x1: cx + (wx1 - wx0) * f / 2, + y0: cy - (wy1 - wy0) * f / 2, y1: cy + (wy1 - wy0) * f / 2, + }; + }; + + // Model the 100M-cloud scenario: the HOME overlay too must claim a count + // far past the budget (the real chart under this probe holds only 60k + // points, whose overlay would honestly — and correctly — keep drawing). + const homeOverlay = g.sampleOverlay; + const homeVisible = homeOverlay.sample.visible; + homeOverlay.sample.visible = 100000000; + + // At W itself: 10M estimated in view — W's overlay must NOT draw even + // though its window covers the view exactly, and the home overlay (1e8) + // is gated too. Density-only, badge off. + zoomTo(1); + const atWindow = sampleDrawn(); + const atWindowBadge = badgeShowsSample(); + + // At home: nothing resolvable anywhere — the #225 field capture's fix: + // no individual points over a 100M-point aggregate. + view.view = { ...v0 }; + const atBigHome = sampleDrawn(); + + // 1/16 of W linearly (1/256 of the area): W's estimated in-view count is + // ~39k <= budget — the overlay (and badge) return: points are resolvable. + zoomTo(1 / 16); + const deepIn = sampleDrawn(); + const deepInBadge = badgeShowsSample(); + + // Just under the gate boundary from above: 1/4 linear = 1/16 area → + // 625k estimated, still over budget — still density-only. + zoomTo(1 / 4); + const midway = sampleDrawn(); + + // Restore the honest 60k home count: a dataset under the budget is + // resolvable everywhere and keeps the hybrid look, home included. + homeOverlay.sample.visible = homeVisible; + view.view = { ...v0 }; + const atHome = sampleDrawn(); + const atHomeBadge = badgeShowsSample(); + + document.body.setAttribute("data-xy-gate-probe", JSON.stringify({ + hasOverlays: !!(g.densityCache && g.densityCache.some((d) => d && d.overlay)), + windowN: N, + atWindow, atWindowBadge, atBigHome, deepIn, deepInBadge, midway, + atHome, atHomeBadge, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-gate-probe-error", + String((err && err.stack) || err), + ); + } +""" + + +def _density_html() -> str: + rng = np.random.default_rng(0) + n = 60_000 + x = rng.normal(0.0, 1.0, n) + y = rng.normal(0.0, 1.0, n) + chart = xy.scatter_chart( + xy.scatter(x, y, density=True), + xy.x_axis(), + xy.y_axis(), + width=480, + height=360, + ) + html = chart.to_html() + assert _RENDER_CALL in html + return html + + +def test_sample_overlay_gated_by_resolvable_count(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + document = _density_html().replace(_RENDER_CALL, _PROBE) + result = run_browser_probe( + chromium, + document, + tmp_path / "sample_resolvability_gate.html", + "data-xy-gate-probe", + label="sample resolvability-gate probe", + ) + + assert result["hasOverlays"] is True + # 10M points estimated in view: no overlay, no badge — density-only. This + # is the #225 contract: no sampled points above the graph's resolution. + assert result["atWindow"] is None + assert result["atWindowBadge"] is False + # Home over a (claimed) 100M cloud: density-only there too. + assert result["atBigHome"] is None + # Still over budget at 1/16 of the window's area: still density-only. + assert result["midway"] is None + # Deep enough that the estimated in-view count fits the budget: the + # overlay returns at full alpha, badge on. + assert result["deepIn"] is not None + assert result["deepIn"]["n"] == result["windowN"] + assert result["deepIn"]["alpha"] == 1 + assert result["deepInBadge"] is True + # A dataset under the budget is resolvable everywhere: its home overlay + # keeps drawing (nothing changes for small charts). + assert result["atHome"] is not None + assert result["atHomeBadge"] is True diff --git a/tests/test_drill_point_window_cache.py b/tests/test_drill_point_window_cache.py new file mode 100644 index 00000000..5e391505 --- /dev/null +++ b/tests/test_drill_point_window_cache.py @@ -0,0 +1,216 @@ +"""Retired exact point windows are cached and promoted, never re-requested +(LOD doc T13). + +"Once we get points, we can render anything inside there without further +requests": a points reply for a NEW window retires the previous exact window +into a per-trace LRU cache instead of overwriting its buffers; any later view +covered by a cached window promotes it back to the live drill with no kernel +round-trip — pan ping-pong across a window boundary and zoom-out/zoom-in +sequences render entirely from the GPU. A drill dying OUTSIDE its window +(zoom-out to density) retires the same way, so diving back into the region it +covered is also free. Cached windows obey the live drill's geometry-only +memory discipline (T11): once the view outgrows a window past the drill +budget, its buffers free with no kernel reply required (§27). + +This drives the real client in headless Chromium with a captured comm. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import xy +from conftest import run_browser_probe +from xy.export import find_chromium + +_RENDER_CALL = 'xy.renderStandalone(document.getElementById("chart"), spec, buf);' + +_PROBE = """ + const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); + try { + view._drawNow(); + view._raf = null; + view._sampleRebinDisabled = true; + const g = view.gpuTraces.find((t) => t.tier === "density"); + let clk = 100000; + view._now = () => clk; + g._densityNormAnim = null; + + const v0 = view.view0; + const sx = v0.x1 - v0.x0, sy = v0.y1 - v0.y0; + const sent = []; + view.comm = { send: (m) => sent.push(m) }; + const densitySent = () => sent.filter((m) => m.type === "density_view").length; + + // A points reply for a window spanning [fx0, fx1] x [fy0, fy1] of home + // (fractions), claiming `visible` in-window points. + const N = 600; + let nextSeq = 1; + const applyPoints = (fx0, fx1, fy0, fy1, visible) => { + const wx0 = v0.x0 + sx * fx0, wx1 = v0.x0 + sx * fx1; + const wy0 = v0.y0 + sy * fy0, wy1 = v0.y0 + sy * fy1; + const xs = new Float32Array(N), ys = new Float32Array(N); + for (let i = 0; i < N; i++) { + xs[i] = wx0 + (wx1 - wx0) * ((i * 0.6180339887) % 1); + ys[i] = wy0 + (wy1 - wy0) * ((i * 0.3141592653) % 1); + } + view._applyDrill(g, { + id: g.trace.id, mode: "points", visible, reduction: "none", + drill_seq: nextSeq++, + x: { buf: 0, offset: 0, scale: 1, len: N }, + y: { buf: 1, offset: 0, scale: 1, len: N }, + x_range: [wx0, wx1], y_range: [wy0, wy1], + }, [xs.buffer, ys.buffer]); + return { x0: wx0, x1: wx1, y0: wy0, y1: wy1 }; + }; + const viewIn = (w, m) => ({ + x0: w.x0 + (w.x1 - w.x0) * m, x1: w.x1 - (w.x1 - w.x0) * m, + y0: w.y0 + (w.y1 - w.y0) * m, y1: w.y1 - (w.y1 - w.y0) * m, + }); + const sameWin = (a, b) => a && b && + Math.abs(a.x0 - b.x0) + Math.abs(a.x1 - b.x1) + + Math.abs(a.y0 - b.y0) + Math.abs(a.y1 - b.y1) < (sx + sy) * 1e-9; + const request = (v) => { + const before = densitySent(); + view._scheduleViewRequest(view._viewFrom(v), { delay: 0 }); + return densitySent() - before; + }; + + // Drill into window A, then a points reply for a DISJOINT window B lands + // (a pan): A retires into the cache instead of being overwritten. + const winA = applyPoints(0.10, 0.20, 0.10, 0.20, 600); + const seqA = 1; + const winB = applyPoints(0.30, 0.40, 0.30, 0.40, 600); + const retiredA = !!(g.drillCache && g.drillCache.length === 1 && + sameWin(g.drillCache[0].win, winA)); + const liveIsB = sameWin(g.drill.win, winB) && g.drill.seq === 2; + + // Pan back to a view inside A: promoted from the cache, no wire request, + // and picks keep speaking A's subset version (the kernel's history + // resolves it exactly). + const promoteElided = request(viewIn(winA, 0.25)) === 0; + const promotedIsA = sameWin(g.drill.win, winA) && g.drill.seq === seqA; + const swappedBToCache = !!(g.drillCache && g.drillCache.length === 1 && + sameWin(g.drillCache[0].win, winB)); + + // A view no cached window covers still requests. + const elsewhereRequested = request({ + x0: v0.x0 + sx * 0.6, x1: v0.x0 + sx * 0.7, + y0: v0.y0 + sy * 0.6, y1: v0.y0 + sy * 0.7 }) === 1; + + // LRU bound: windows C, D, E push the cache past its cap; the oldest + // retired windows fall off, newest survive. + applyPoints(0.50, 0.60, 0.50, 0.60, 600); // C (A -> cache) + applyPoints(0.62, 0.72, 0.62, 0.72, 600); // D (C -> cache) + applyPoints(0.74, 0.84, 0.74, 0.84, 600); // E (D -> cache) + const cacheBounded = g.drillCache.length <= 3; + + // Zoom-out to density: the drill dies; once its exit fade completes + // OUTSIDE its window, it retires into the cache (still exact for its + // window) instead of freeing. + view.view = { ...v0 }; + const gw = 4, gh = 4; + const grid = new Float32Array(gw * gh).fill(3); + view._onKernelMsg({ + type: "density_update", seq: view.seq, trace: g.trace.id, + traces: [{ + id: g.trace.id, mode: "density", visible: 60000, + density: { buf: 0, w: gw, h: gh, max: 3, + x_range: [v0.x0, v0.x1], y_range: [v0.y0, v0.y1] }, + }], + }, [grid.buffer]); + const dyingAfterDensity = g._drillDying === true; + view._drawNow(); // exit fade starts + clk += 500; // past LOD_EXIT_FADE_MS + view._drawNow(); // fade complete -> retire + const winE = { x0: v0.x0 + sx * 0.74, x1: v0.x0 + sx * 0.84, + y0: v0.y0 + sy * 0.74, y1: v0.y0 + sy * 0.84 }; + const retiredOnDeath = !g.drill && + g.drillCache.some((e) => sameWin(e.win, winE)); + + // Dive back into E's region: promoted, zero requests — the #225 workflow + // (drill, zoom out, drill the same spot) with no re-shipping. + const reviveElided = request(viewIn(winE, 0.25)) === 0; + const revivedIsE = !!(g.drill && sameWin(g.drill.win, winE)); + + // Geometry-only retirement for cached windows (T11 via T13): claim a + // budget-scale count on every cached window, zoom far out, and the sweep + // frees them on that frame — no kernel reply needed. + for (const e of g.drillCache) e.visible = 150000; + if (g.drill) g.drill.visible = 150000; + view.view = { ...v0 }; + view._drawNow(); + const sweptWhenOutgrown = !g.drillCache || g.drillCache.length === 0; + + document.body.setAttribute("data-xy-cache-probe", JSON.stringify({ + hasDensity: !!g, + retiredA, liveIsB, promoteElided, promotedIsA, swappedBToCache, + elsewhereRequested, cacheBounded, dyingAfterDensity, retiredOnDeath, + reviveElided, revivedIsE, sweptWhenOutgrown, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-cache-probe-error", + String((err && err.stack) || err), + ); + } +""" + + +def _density_html() -> str: + rng = np.random.default_rng(0) + n = 60_000 + x = rng.normal(0.0, 1.0, n) + y = rng.normal(0.0, 1.0, n) + chart = xy.scatter_chart( + xy.scatter(x, y, density=True), + xy.x_axis(), + xy.y_axis(), + width=480, + height=360, + ) + html = chart.to_html() + assert _RENDER_CALL in html + return html + + +def test_point_windows_cache_promote_and_free(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + document = _density_html().replace(_RENDER_CALL, _PROBE) + result = run_browser_probe( + chromium, + document, + tmp_path / "drill_point_window_cache.html", + "data-xy-cache-probe", + label="point-window cache probe", + ) + + assert result["hasDensity"] is True + # A new window's reply retires the old exact window instead of + # overwriting its buffers. + assert result["retiredA"] is True + assert result["liveIsB"] is True + # Pan back inside A: promoted from the cache with no wire request, and + # the promoted drill keeps A's subset version for picks. + assert result["promoteElided"] is True + assert result["promotedIsA"] is True + assert result["swappedBToCache"] is True + # Views nothing covers still go to the kernel. + assert result["elsewhereRequested"] is True + # The cache is bounded (LRU). + assert result["cacheBounded"] is True + # Zoom-out to density retires the dying drill (died outside its window) + # into the cache once the exit fade completes... + assert result["dyingAfterDensity"] is True + assert result["retiredOnDeath"] is True + # ...so diving back into that region is answered locally, zero requests. + assert result["reviveElided"] is True + assert result["revivedIsE"] is True + # Outgrown cached windows free on the frame, no kernel reply required. + assert result["sweptWhenOutgrown"] is True diff --git a/tests/test_drill_window_padding.py b/tests/test_drill_window_padding.py new file mode 100644 index 00000000..25b4522e --- /dev/null +++ b/tests/test_drill_window_padding.py @@ -0,0 +1,158 @@ +"""Padded aligned drill windows + drill-subset history (LOD doc T13, #225). + +A points-tier reply ships the largest ALIGNED window around the view whose +exact count still fits the budget: bounds snap outward to a power-of-two grid +over the trace's extent (`lod.aligned_window`), so consecutive pans resolve to +the SAME window and the client's point-window cache can key, dedupe, and +reuse full-point buffers by dimension. The raw view window is the floor — +never a subset, never over budget — and the §16 offset encoding re-centers on +the shipped window, so its span is hard-capped relative to the view +(DRILL_PAD_SPAN_CAP) to keep deep zooms inside the client's re-encode ladder. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from xy import lod +from xy._figure import Figure +from xy.config import DRILL_PAD_SPAN_CAP, SCATTER_DENSITY_THRESHOLD + + +def _uniform_fig(n: int, seed: int = 3) -> tuple[Figure, np.ndarray, np.ndarray]: + rng = np.random.default_rng(seed) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + return Figure().scatter(x, y, density=True), x, y + + +def test_aligned_window_contains_input_and_is_pan_stable() -> None: + # Containment is the contract (client request elision needs it), even for + # windows panned past the data extent where nothing lives. + lo, hi = lod.aligned_window(-3.0, 5.0, 0.0, 100.0, 2.0) + assert lo <= -3.0 and hi >= 5.0 + # Same span bucket, different pan positions inside one block: identical + # aligned bounds — this is what makes cached windows reusable/dedupable. + a = lod.aligned_window(10.0, 14.0, 0.0, 128.0, 2.0) + b = lod.aligned_window(10.5, 14.5, 0.0, 128.0, 2.0) + assert a == b + # Bounds land on the extent's power-of-two grid. + span = 14.0 - 10.0 + level = int(np.ceil(np.log2(128.0 / (2.0 * span)))) + block = 128.0 / (1 << level) + assert a[0] / block == pytest.approx(round(a[0] / block)) + assert a[1] / block == pytest.approx(round(a[1] / block)) + + +def test_aligned_window_degenerate_inputs_pass_through() -> None: + assert lod.aligned_window(3.0, 3.0, 0.0, 10.0, 2.0) == (3.0, 3.0) # zero span + assert lod.aligned_window(1.0, 2.0, 5.0, 5.0, 2.0) == (1.0, 2.0) # zero extent + assert lod.aligned_window(1.0, 2.0, 0.0, np.inf, 2.0) == (1.0, 2.0) + # pad*span >= extent: the whole extent (unioned with the window so a view + # sticking past the data stays contained). + assert lod.aligned_window(1.0, 9.0, 0.0, 10.0, 4.0) == (0.0, 10.0) + assert lod.aligned_window(-2.0, 9.0, 0.0, 10.0, 4.0) == (-2.0, 10.0) + + +def test_drill_ships_padded_aligned_window_within_budget() -> None: + fig, x, y = _uniform_fig(SCATTER_DENSITY_THRESHOLD + 300_000) + upd, _ = fig.density_view(0, 40.0, 44.0, 40.0, 44.0, 512, 384) + tr = upd["traces"][0] + assert tr["mode"] == "points" and tr["reduction"] == "none" + (wx0, wx1), (wy0, wy1) = tr["x_range"], tr["y_range"] + # Contains the view, wider than it, still under budget. + assert wx0 <= 40.0 and wx1 >= 44.0 and wy0 <= 40.0 and wy1 >= 44.0 + assert (wx1 - wx0) > 4.0 and (wy1 - wy0) > 4.0 + assert tr["visible"] <= SCATTER_DENSITY_THRESHOLD + # visible counts the SHIPPED window exactly. + inwin = int(np.sum((x >= wx0) & (x <= wx1) & (y >= wy0) & (y <= wy1))) + assert tr["visible"] == inwin + # §16: offsets re-center on the SHIPPED window's midpoint, and the span + # cap keeps the padded window inside the client's re-encode ladder. + assert tr["x"]["offset"] == pytest.approx((wx0 + wx1) / 2.0) + assert (wx1 - wx0) <= 4.0 * DRILL_PAD_SPAN_CAP + assert (wy1 - wy0) <= 4.0 * DRILL_PAD_SPAN_CAP + + +def test_padded_windows_are_identical_across_pans() -> None: + fig, _, _ = _uniform_fig(SCATTER_DENSITY_THRESHOLD + 300_000) + upd_a, _ = fig.density_view(0, 40.0, 44.0, 40.0, 44.0, 512, 384) + upd_b, _ = fig.density_view(0, 40.7, 44.7, 39.5, 43.5, 512, 384) + tr_a, tr_b = upd_a["traces"][0], upd_b["traces"][0] + assert tr_a["mode"] == tr_b["mode"] == "points" + # A small pan inside the same aligned blocks resolves to the SAME shipped + # window — the client-side cache dedupes it, the wire carries it once. + assert tr_a["x_range"] == tr_b["x_range"] + assert tr_a["y_range"] == tr_b["y_range"] + + +def test_padding_falls_back_to_view_window_when_over_budget() -> None: + # A view centered on the extent midpoint straddles a grid line at every + # candidate level, so ANY aligned superset roughly doubles the count; with + # the view's own count near the budget, every ladder rung is over budget + # and the raw view window ships, exactly as before padding existed. + n = 1_000_000 + rng = np.random.default_rng(5) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + fig = Figure().scatter(x, y, density=True) + # view count ~= n * (a/100)^2 targeted at ~0.95 * budget, centered at 50. + a = 100.0 * float(np.sqrt(0.95 * SCATTER_DENSITY_THRESHOLD / n)) + upd, _ = fig.density_view(0, 50.0 - a / 2, 50.0 + a / 2, 50.0 - a / 2, 50.0 + a / 2, 512, 384) + tr = upd["traces"][0] + assert tr["mode"] == "points" + assert tr["x_range"] == [50.0 - a / 2, 50.0 + a / 2] + assert tr["y_range"] == [50.0 - a / 2, 50.0 + a / 2] + assert tr["visible"] <= SCATTER_DENSITY_THRESHOLD + + +def test_nonlinear_axis_skips_padding() -> None: + # Raw-space alignment mis-sizes log windows near zero; nonlinear-axis + # traces keep today's exact-view drill (recorded contract, LOD doc T13). + n = SCATTER_DENSITY_THRESHOLD + 100_000 + rng = np.random.default_rng(9) + x = rng.uniform(1.0, 1000.0, n) + y = rng.uniform(0.0, 100.0, n) + fig = Figure().scatter(x, y, density=True) + fig.set_axis("x", type_="log") + upd, _ = fig.density_view(0, 10.0, 20.0, 40.0, 60.0, 512, 384) + tr = upd["traces"][0] + assert tr["mode"] == "points" + assert tr["x_range"] == [10.0, 20.0] + assert tr["y_range"] == [40.0, 60.0] + + +def test_padded_span_cap_guards_f32_reencode_ladder() -> None: + # A tiny dataset fits any window under the budget, so only the span cap + # stops a microscopic view from shipping the whole extent — whose midpoint + # offset would leave the client's §16 re-encode requests unable to ever + # improve precision. + n = 50_000 + rng = np.random.default_rng(13) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + fig = Figure().scatter(x, y, density=True) + upd, _ = fig.density_view(0, 50.0, 50.001, 50.0, 50.001, 512, 384) + tr = upd["traces"][0] + assert tr["mode"] == "points" + (wx0, wx1), (wy0, wy1) = tr["x_range"], tr["y_range"] + assert wx0 <= 50.0 and wx1 >= 50.001 + assert (wx1 - wx0) <= 0.001 * DRILL_PAD_SPAN_CAP + assert (wy1 - wy0) <= 0.001 * DRILL_PAD_SPAN_CAP + + +def test_append_clears_drill_history() -> None: + n = SCATTER_DENSITY_THRESHOLD + 50_000 + rng = np.random.default_rng(17) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + fig = Figure().scatter(x, y, density=True) + upd, _ = fig.density_view(0, 10.0, 16.0, 10.0, 16.0, 512, 384) + seq = upd["traces"][0]["drill_seq"] + assert fig.pick(0, 0, drill_seq=seq) is not None + fig.append(0, np.asarray([1.0]), np.asarray([1.0])) + # Remembered subsets were computed against the pre-append canonical state; + # a stale-seq pick must die rather than translate (§16 exact-or-nothing). + assert fig.traces[0].drill_history == {} + assert fig.pick(0, 0, drill_seq=seq) is None diff --git a/tests/test_drill_zoomin_elides_request.py b/tests/test_drill_zoomin_elides_request.py index b7f02025..302dc65e 100644 --- a/tests/test_drill_zoomin_elides_request.py +++ b/tests/test_drill_zoomin_elides_request.py @@ -15,6 +15,11 @@ - the drill stops being an exact live subset (dying, or a reply that did not claim `reduction: "none"`). +A re-armed request is still subject to the T13 identical-request suppression: +the same window at the same screen size never goes on the wire twice while +the first request is in flight — the trace keeps waiting on the original seq, +whose reply is then accepted instead of dying stale. + This drives the real client in headless Chromium with a captured comm, applies a drill for a known window, and inspects exactly which view requests reach the wire. @@ -102,9 +107,28 @@ g._drillDying = false; // 5) A subset that did not claim reduction "none" never arms the elision. + // Distinct bounds from case 4: an identical re-request would be elided by + // the T13 duplicate suppression asserted separately below. g.drill.exact = false; const nonExactRequested = request( - cx - sx * 0.02, cx + sx * 0.02, cy - sy * 0.02, cy + sy * 0.02) === 1; + cx - sx * 0.025, cx + sx * 0.025, cy - sy * 0.025, cy + sy * 0.025) === 1; + + // 6) Identical-request suppression (T13): re-requesting the SAME window + // and screen while the first request is still in flight sends nothing, + // and the trace keeps waiting on the ORIGINAL request's seq so that + // reply is accepted instead of dying stale — the fix for the + // "constantly re-requesting the same points" loop (#225 notes). + const densityMsgs = sent.filter((m) => m.type === "density_view"); + const firstReq = densityMsgs[densityMsgs.length - 1]; + const dupElided = request( + cx - sx * 0.025, cx + sx * 0.025, cy - sy * 0.025, cy + sy * 0.025) === 0; + const dupKeptPendingSeq = g._lodPendingSeq === firstReq.seq; + // The in-flight reply (old seq) is still accepted for this trace… + const acceptedOldSeq = view._densityReplyCurrent({ + seq: firstReq.seq, traces: [{ id: g.trace.id }] }); + // …but a reply for a seq nobody is waiting on stays stale (T5). + const rejectedUnknownSeq = view._densityReplyCurrent({ + seq: firstReq.seq + 1000, traces: [{ id: g.trace.id }] }); document.body.setAttribute("data-xy-elide-probe", JSON.stringify({ hasDensity: !!g, @@ -117,6 +141,10 @@ deepZoomRequested, dyingRequested, nonExactRequested, + dupElided, + dupKeptPendingSeq, + acceptedOldSeq, + rejectedUnknownSeq, })); } catch (err) { document.body.setAttribute( @@ -176,3 +204,11 @@ def test_zoom_in_within_exact_drill_sends_no_request(tmp_path: Path) -> None: assert result["deepZoomRequested"] is True assert result["dyingRequested"] is True assert result["nonExactRequested"] is True + # Identical-request suppression (T13): the same window+screen sends + # nothing while the original request is in flight; the trace keeps + # waiting on the original seq, whose reply is accepted — any other + # losing seq stays stale (T5). + assert result["dupElided"] is True + assert result["dupKeptPendingSeq"] is True + assert result["acceptedOldSeq"] is True + assert result["rejectedUnknownSeq"] is False diff --git a/tests/test_lod.py b/tests/test_lod.py index e8b641c4..297af624 100644 --- a/tests/test_lod.py +++ b/tests/test_lod.py @@ -136,7 +136,9 @@ def wrapped_exit(trace): assert len(calls["request"]) == 3 assert any(plan.exact is False for plan in calls["plan"]) assert any(plan.exact is True for plan in calls["plan"]) - assert len(calls["encode"]) >= 2 + # One geometry encode: the drill's window-centered x/y pair. Density + # replies ship no sample overlay (#225), so nothing else encodes points. + assert len(calls["encode"]) == 1 assert calls["enter"] == [drilled["traces"][0]["visible"]] assert True in calls["exit"] diff --git a/tests/test_scatter.py b/tests/test_scatter.py index a004e7b3..eab4091c 100644 --- a/tests/test_scatter.py +++ b/tests/test_scatter.py @@ -617,7 +617,11 @@ def test_continuous_drill_update_ships_u8_but_build_payload_stays_f32() -> None: assert spec["dtype"] == "u8" assert len(buffers[spec["buf"]]) == tr["visible"] # 1 byte/point sbuf = np.frombuffer(buffers[tr["size"]["buf"]], dtype=np.uint8) - vis = (x >= 0) & (x <= 5) & (y >= 0) & (y <= 5) + # The reply ships a padded aligned window around the view (LOD doc T13); + # the subset is every point of the SHIPPED window, in canonical row order. + (wx0, wx1), (wy0, wy1) = tr["x_range"], tr["y_range"] + assert wx0 <= 0.0 and wx1 >= 5.0 and wy0 <= 0.0 and wy1 >= 5.0 + vis = (x >= wx0) & (x <= wx1) & (y >= wy0) & (y <= wy1) expected = (s[vis] - s.min()) / (s.max() - s.min()) np.testing.assert_allclose(sbuf / 255.0, expected, atol=0.5 / 255.0 + 1e-6) @@ -679,14 +683,12 @@ def test_density_view_rebins(): inwin = np.sum((x >= 10) & (x < 90) & (y >= 20) & (y < 80)) assert inwin > SCATTER_DENSITY_THRESHOLD # really over budget assert grid.sum() == pytest.approx(inwin, rel=0.05) - sample = d["sample"] - assert sample["mode"] == "sampled" - assert sample["visible"] == update["traces"][0]["visible"] == inwin - assert 0 < sample["n"] <= int(DENSITY_SAMPLE_TARGET * 1.2) - xs = np.frombuffer(buffers[sample["x"]["buf"]], dtype=np.float32) - ys = np.frombuffer(buffers[sample["y"]["buf"]], dtype=np.float32) - assert len(xs) == sample["n"] - assert len(ys) == sample["n"] + # Interactive density replies ship NO point sample (#225): above the drill + # budget a fixed-size sample reads as individual data points at a zoom + # where real points are sub-pixel. The only retained sample is the + # first-payload one, drawn below the resolvable-count gate client-side. + assert "sample" not in d + assert update["traces"][0]["visible"] == inwin def test_density_view_coarsens_sparse_screen_grid(): @@ -723,12 +725,21 @@ def test_density_view_drills_to_points_when_window_fits(): assert tr["mode"] == "points" assert tr["tier"] == "direct" assert tr["reduction"] == "none" - inwin = int(np.sum((x >= 0) & (x <= 10) & (y >= 0) & (y <= 10))) - assert 0 < inwin <= SCATTER_DENSITY_THRESHOLD + view_inwin = int(np.sum((x >= 0) & (x <= 10) & (y >= 0) & (y <= 10))) + assert 0 < view_inwin <= SCATTER_DENSITY_THRESHOLD + # The shipped window is a padded ALIGNED superset of the view (LOD doc + # T13) so nearby pans/zooms render from the client's point-window cache; + # the subset is every point of that window, still within the budget. + (wx0, wx1), (wy0, wy1) = tr["x_range"], tr["y_range"] + assert wx0 <= 0.0 and wx1 >= 10.0 and wy0 <= 0.0 and wy1 >= 10.0 + vis = (x >= wx0) & (x <= wx1) & (y >= wy0) & (y <= wy1) + inwin = int(vis.sum()) + assert view_inwin <= inwin <= SCATTER_DENSITY_THRESHOLD assert tr["visible"] == inwin and tr["x"]["len"] == inwin xs = np.frombuffer(bufs[tr["x"]["buf"]], dtype=np.float32) assert len(xs) == inwin - assert tr["x"]["offset"] == pytest.approx(5.0) # §16 window-centered + # §16 window-centered offset — the SHIPPED window's midpoint. + assert tr["x"]["offset"] == pytest.approx((wx0 + wx1) / 2.0) # Continuous channels on the drill wire are u8 LUT coordinates (§29: # live-interaction readbacks resolve server-side, so quantization is # invisible; the ramp/colormap only has ~256 useful levels anyway). @@ -738,23 +749,23 @@ def test_density_view_drills_to_points_when_window_fits(): assert fig.traces[0].drill_mode is True # pick speaks drilled indices: shipped 0 -> a canonical row in the window row = fig.pick(0, 0) - assert row is not None and 0.0 <= row["x"] <= 10.0 + assert row is not None and wx0 <= row["x"] <= wx1 assert "color_value" in row # Intensity handoff (§5): per-point local log-density (u8, like every - # unit-scalar live channel) and a blend weight = visible/budget. Freshly - # drilled points enter at their cell's count-alpha and ease to native - # opacity; the surface wears the mean point color (LOD doc §2), so no - # density_colormap rides the points wire. + # unit-scalar live channel) and a blend weight keyed on the VIEW's own + # count — padding widens what ships, not what the user is looking at. + # Freshly drilled points enter at their cell's count-alpha and ease to + # native opacity; the surface wears the mean point color (LOD doc §2), so + # no density_colormap rides the points wire. assert tr["density_val"]["dtype"] == "u8" dbuf = np.frombuffer(bufs[tr["density_val"]["buf"]], dtype=np.uint8) assert len(dbuf) == inwin assert dbuf.max() == 255 # the hottest cell reaches full handoff alpha - assert tr["lod_blend"] == pytest.approx(inwin / SCATTER_DENSITY_THRESHOLD) + assert tr["lod_blend"] == pytest.approx(view_inwin / SCATTER_DENSITY_THRESHOLD) assert "density_colormap" not in tr # Channels are normalized over the *global* domain after slicing (staff # review: slice-first must not change values — colors stay view-stable), # then quantized: every byte within half a step of the exact unit value. - vis = (x >= 0) & (x <= 10) & (y >= 0) & (y <= 10) expected = (c[vis] - c.min()) / (c.max() - c.min()) np.testing.assert_allclose(cbuf / 255.0, expected, atol=0.5 / 255.0 + 1e-6) @@ -823,9 +834,11 @@ def fake_bin_2d(_x, _y, _lo_x, _hi_x, _lo_y, _hi_y, w, h): def test_drill_seq_guards_stale_picks(): - # A pick that raced a drill update must return None — never a row read - # through the wrong index space (§16: exact or nothing). - # n chosen so the full window (n visible) clears the 1.15x hysteresis exit. + # A pick against another subset version must translate through THAT + # subset when it is still remembered (LOD doc T13: the client can serve a + # view from a retired cached point window) and return None once it is not + # — never a row read through the wrong index space (§16: exact or + # nothing). n chosen so the full window clears the 1.15x hysteresis exit. n = SCATTER_DENSITY_THRESHOLD + 50_000 rng = np.random.default_rng(11) x = rng.uniform(0, 100, n) @@ -833,22 +846,48 @@ def test_drill_seq_guards_stale_picks(): fig = Figure().scatter(x, y, density=True) upd1, _ = fig.density_view(0, 0.0, 10.0, 0.0, 10.0, 512, 384) seq1 = upd1["traces"][0]["drill_seq"] + sel1 = fig.traces[0].shipped_sel.copy() assert seq1 == 1 - assert fig.pick(0, 0, drill_seq=seq1) is not None # matching subset: exact row + row1 = fig.pick(0, 0, drill_seq=seq1) # matching subset: exact row + assert row1 is not None and row1["index"] == int(sel1[0]) assert fig.pick(0, 0) is not None # legacy caller without seq still works assert fig.pick(0, 0, drill_seq=True) is None # bool must not alias seq 1 upd2, _ = fig.density_view(0, 20.0, 30.0, 20.0, 30.0, 512, 384) seq2 = upd2["traces"][0]["drill_seq"] assert seq2 == seq1 + 1 - assert fig.pick(0, 0, drill_seq=seq1) is None # stale subset: dropped + # The previous subset stays remembered (bounded history): a pick against + # it translates through THAT subset — the exact row it named, not the new + # subset's row 0 — because the client may still be drawing that window. + row_old = fig.pick(0, 0, drill_seq=seq1) + assert row_old is not None and row_old["index"] == int(sel1[0]) assert fig.pick(0, 0, drill_seq=seq2) is not None - - # Drill-out bumps the version too: a drilled-index pick arriving after the - # subset died must not be read as a *canonical* index. + # An index past the remembered subset's length is dropped, and a seq the + # history never held (bumped by exits, or simply unknown) is dropped too. + assert fig.pick(0, len(sel1), drill_seq=seq1) is None + assert fig.pick(0, 0, drill_seq=seq2 + 50) is None + + # Drill-out keeps remembered subsets alive for retired client windows, + # but the exit-bumped seq itself never shipped a subset: a pick against + # it must not be read as a *canonical* index. upd3, _ = fig.density_view(0, 0.0, 100.0, 0.0, 100.0, 512, 384) assert upd3["traces"][0]["mode"] == "density" - assert fig.pick(0, 0, drill_seq=seq2) is None + exit_seq = fig.traces[0].drill_seq + assert exit_seq == seq2 + 1 + assert fig.pick(0, 0, drill_seq=exit_seq) is None + row_after_exit = fig.pick(0, 0, drill_seq=seq2) + (wx0, wx1) = upd2["traces"][0]["x_range"] # seq2 shipped its PADDED window + assert row_after_exit is not None and wx0 <= row_after_exit["x"] <= wx1 + + # The history is bounded: churning past DRILL_HISTORY_KEEP subsets expires + # the oldest, whose picks then drop instead of translating. + from xy.config import DRILL_HISTORY_KEEP + + for i in range(DRILL_HISTORY_KEEP + 1): + lo = 5.0 * (i % 8) + upd_i, _ = fig.density_view(0, lo, lo + 6.0, lo, lo + 6.0, 512, 384) + assert upd_i["traces"][0]["mode"] == "points" + assert fig.pick(0, 0, drill_seq=seq1) is None # out-of-range trace ids are rejected, not wrapped pythonically assert fig.pick(-1, 0) is None assert fig.pick(99, 0) is None From c097a530888a93a9f4a9f2720206c85858c1c0a1 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 17:42:41 +0000 Subject: [PATCH 06/11] Bound density wire traffic: source-clamped grids, request elision, layered draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field HAR from the 100M live drilldown (PR #227 review): every pan/zoom step at 200-450% zoom re-requested a ~2.7 MB full-screen density grid — including back-to-back windows differing by sub-pixel amounts — and the frame dropped to the blurriest cached texture whenever a pan left the freshest window. Three fixes, all recorded under LOD doc T13: - Kernel: pyramid-served grids are clamped to the source cells the finest level resolves under the window (interaction._pyramid_source_shape, ceil(base*frac)+1 per axis) — composing to full screen resolution just upsamples blocky cells the client's texture filtering reproduces from the source-resolution grid at a fraction of the bytes. The attainable per-axis cell size rides the reply as density.min_cell; exact and spatial grids (true full-detail bins) omit it. The HAR's 2.7 MB replies drop to ~0.5-1.4 MB in its zoom range and ~200 KB deeper. - Client request elision (lodDensityCacheServes): a cached texture that contains the view and is as detailed as anything the kernel could return — one texel per screen pixel, or already at min_cell (zooming into a source-limited texture cannot sharpen it) — answers with no round-trip. Guarded so the kernel stays in charge where it can do better: estimated in-view count must exceed budget x LOD_DENSITY_ELIDE_EST_FACTOR, and the cached window may be at most LOD_DENSITY_ELIDE_MAX_AREA_RATIO x the view area (uniform-density estimates overshoot in sparse corners). Entries without recorded counts never elide; lodRememberDensity now supersedes an unpinned same-window twin so elision always reads current facts. - Sub-texel request dedup: the identical-request memo compares windows numerically within half an output texel per edge (gesture-end and settle emit sub-pixel twins; a grid shifted below half a texel is the same picture) instead of exact string equality. - Finer-detail layering (lodDensityDetailForView): when no fine cached window contains the view, the smallest cached texture overlapping >= 5% of it draws on top of the broad backdrop after every crossfade layer, so the already-fetched region stays sharp while the request for the rest is in flight — a resolution seam beats uniform blur. Spec: lod-architecture T13 extended, wire-protocol density_view/density contracts (min_cell, clamped grids, elision), dossier constants table, CHANGELOG. Tests: test_density_wire_economy (source-shape math, min_cell presence/ absence, 20M-point clamp integration), test_density_request_economy (headless-Chromium probe: source-resolution elision + guards, sub-texel dedup, fine-over-broad layering). --- CHANGELOG.md | 12 ++ js/src/45_lod.ts | 127 +++++++++++++++- js/src/54_kernel.ts | 61 +++++--- python/xy/interaction.py | 57 +++++++- spec/design-dossier.md | 2 + spec/design/lod-architecture.md | 49 ++++++- spec/design/wire-protocol.md | 26 +++- tests/test_density_request_economy.py | 199 ++++++++++++++++++++++++++ tests/test_density_wire_economy.py | 109 ++++++++++++++ 9 files changed, 602 insertions(+), 40 deletions(-) create mode 100644 tests/test_density_request_economy.py create mode 100644 tests/test_density_wire_economy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f3a096..6efc9435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,18 @@ in the README). the view's estimated in-view count fits the direct point budget, i.e. when individual points are actually resolvable; real points still ship the moment a window fits the budget, so drilldown behavior is unchanged. +- **Density traffic is source-bounded, elided, and layered (LOD doc T13).** + Pyramid-served density replies are clamped to the source resolution the + window actually has — no more full-screen grids of upsampled base cells + (a 100M-scatter field capture shipped ~2.7 MB per pan/zoom step at 200-450% + zoom; the same views now cost a fraction of that) — and record `min_cell`, + the finest attainable cell size. The client elides `density_view` requests + a cached texture already answers (contained view, texture at screen or + attainable resolution, guarded so exact re-bins and drill-in stay + reachable), suppresses re-requests within half an output texel of the last + one (gesture-end/settle twins), and draws the finest overlapping cached + texture on top of the broad backdrop during pans/zooms instead of dropping + the frame to the blurriest texture it holds. - **Full-point windows are padded, aligned, cached, and never re-requested (LOD doc T13).** A points-tier reply now ships the largest aligned window around the view whose exact count still fits the budget (bounds snapped to diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index c3a40ac4..e9095fca 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -322,6 +322,91 @@ function lodDensityForView(view, g) { return best || broadest || g.density; } +// Finer-detail layer over the chosen backdrop (T13): when the view is not +// contained in any fine cached window, lodDensityForView falls back to the +// broadest texture — but a finer cached window usually still covers most of +// the view mid-pan/zoom, and drawing only the broad texture renders the +// frame at its blurriest exactly when the user is moving. Pick the +// smallest-area cached texture that overlaps the view meaningfully and is +// finer than the primary; the draw path paints it on top (GPU clips it to +// its window), so the region already fetched stays sharp while the request +// for the rest is in flight. Standard tile-pyramid behavior: a resolution +// seam at the window edge beats uniform blur. +const LOD_DENSITY_DETAIL_MIN_COVER = 0.05; // of the view area + +function lodDensityDetailForView(view, g, primary) { + if (!primary) return null; + const cache = g.densityCache || []; + const v = view.view; + const vx0 = Math.min(v.x0, v.x1), vx1 = Math.max(v.x0, v.x1); + const vy0 = Math.min(v.y0, v.y1), vy1 = Math.max(v.y0, v.y1); + const viewArea = (vx1 - vx0) * (vy1 - vy0); + if (!(viewArea > 0)) return null; + const primaryArea = lodDensityArea(primary); + let best = null; + for (const d of cache) { + if (!d || !d.tex || d === primary || d === g._densitySwitchPrev) continue; + if (!(lodDensityArea(d) < primaryArea)) continue; + const wx0 = Math.min(d.xRange[0], d.xRange[1]), wx1 = Math.max(d.xRange[0], d.xRange[1]); + const wy0 = Math.min(d.yRange[0], d.yRange[1]), wy1 = Math.max(d.yRange[0], d.yRange[1]); + const ox = Math.min(vx1, wx1) - Math.max(vx0, wx0); + const oy = Math.min(vy1, wy1) - Math.max(vy0, wy0); + if (ox <= 0 || oy <= 0 || (ox * oy) / viewArea < LOD_DENSITY_DETAIL_MIN_COVER) continue; + if (!best || lodDensityArea(d) < lodDensityArea(best)) best = d; + } + return best; +} + +// Density-side request elision (T13): skip the density_view round-trip when +// a cached texture is already as detailed as anything the kernel could +// return for this view. Two ways to qualify, both requiring the cached +// window to CONTAIN the view: +// - screen-resolution adequacy: the texture resolves at least one texel per +// screen pixel of the current view (zoom-outs and pan-returns inside an +// exact-scan grid); +// - source-resolution adequacy: the texture already sits at the trace's +// finest attainable aggregate cell size (`min_cell`, pyramid-served +// replies) — zooming further in cannot sharpen it, so re-requesting the +// same blur is pure wire waste (the HAR behind this: repeated ~2.7 MB +// grids per pan/zoom step at 200-450% on a 100M scatter). +// Guards keep the exact/drill regimes reachable: the estimated in-view count +// must sit clearly above the budget (the kernel would still answer with the +// pyramid, not an exact re-bin or points), and the view must not have dived +// more than MAX_AREA_RATIO below the cached window (the area estimate +// overshoots in sparse corners; a deep dive re-requests so the kernel can +// re-decide with real counts). +const LOD_DENSITY_ELIDE_EST_FACTOR = 2; // × LOD_DIRECT_POINT_BUDGET +const LOD_DENSITY_ELIDE_MAX_AREA_RATIO = 8; // cached-window area ÷ view area + +export function lodDensityCacheServes(view, g, x0, x1, y0, y1, plotW, plotH) { + const cache = g.densityCache || (g.density ? [g.density] : []); + const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); + const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); + const vSpanX = vx1 - vx0, vSpanY = vy1 - vy0; + if (!(vSpanX > 0 && vSpanY > 0 && plotW > 0 && plotH > 0)) return false; + const needX = vSpanX / plotW, needY = vSpanY / plotH; + const ex = vSpanX * 1e-4, ey = vSpanY * 1e-4; + for (const d of cache) { + if (!d || !d.tex || !d.xRange || !d.yRange || !(d.w > 0) || !(d.h > 0)) continue; + const wx0 = Math.min(d.xRange[0], d.xRange[1]), wx1 = Math.max(d.xRange[0], d.xRange[1]); + const wy0 = Math.min(d.yRange[0], d.yRange[1]), wy1 = Math.max(d.yRange[0], d.yRange[1]); + if (vx0 < wx0 - ex || vx1 > wx1 + ex || vy0 < wy0 - ey || vy1 > wy1 + ey) continue; + const winArea = (wx1 - wx0) * (wy1 - wy0); + if (winArea > vSpanX * vSpanY * LOD_DENSITY_ELIDE_MAX_AREA_RATIO) continue; + const cellX = (wx1 - wx0) / d.w, cellY = (wy1 - wy0) / d.h; + const mc = d.minCell; + if (cellX > Math.max(needX, mc ? mc[0] : 0) * 1.001) continue; + if (cellY > Math.max(needY, mc ? mc[1] : 0) * 1.001) continue; + // Unknown counts never elide: the kernel must stay free to choose a + // sharper representation (exact re-bin, points) the moment it can. + if (!Number.isFinite(d.visible) || !(d.visible > 0)) continue; + const est = d.visible * Math.min(1, (vSpanX * vSpanY) / winArea); + if (est <= LOD_DIRECT_POINT_BUDGET * LOD_DENSITY_ELIDE_EST_FACTOR) continue; + return true; + } + return false; +} + // The hold's density estimate, reused by retirement (T11): scale the drill // window's known count by the target window's area to predict whether that // window could still be answered with direct points. @@ -411,11 +496,35 @@ function lodDensityPinned(g, d) { d === g._shownDensity || d === g._homeDensity; } +function lodSameDensityWindow(a, b) { + if (!a || !b || !a.xRange || !b.xRange || !a.yRange || !b.yRange) return false; + const eps = + (Math.abs(a.xRange[1] - a.xRange[0]) + Math.abs(a.yRange[1] - a.yRange[0])) * 1e-9 + 1e-300; + return Math.abs(a.xRange[0] - b.xRange[0]) <= eps && Math.abs(a.xRange[1] - b.xRange[1]) <= eps && + Math.abs(a.yRange[0] - b.yRange[0]) <= eps && Math.abs(a.yRange[1] - b.yRange[1]) <= eps; +} + export function lodRememberDensity(view, g, d) { if (!d || !d.tex) return; d._stamp = ++view._densityStamp; if (!g.densityCache) g.densityCache = []; - if (!g.densityCache.includes(d)) g.densityCache.push(d); + if (!g.densityCache.includes(d)) { + // A fresh grid for a window already cached supersedes its twin: same + // coverage, newer facts (count, resolution, min_cell) — and request + // elision reads those facts, so a stale twin must not shadow them. + // Pinned twins (mid-crossfade, home) stay; eviction sweeps them later. + for (let i = g.densityCache.length - 1; i >= 0; i--) { + const old = g.densityCache[i]; + if (old === d || lodDensityPinned(g, old) || !lodSameDensityWindow(old, d)) continue; + g.densityCache.splice(i, 1); + view.gl.deleteTexture(old.tex); + if (old.overlay && old.overlay !== g.sampleOverlay) { + view._destroySampleOverlay(old.overlay); + old.overlay = null; + } + } + g.densityCache.push(d); + } const maxCached = 8; while (g.densityCache.length > maxCached) { let drop = -1; @@ -904,6 +1013,12 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap, color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color, xRange: d.x_range, yRange: d.y_range, + // Request-elision facts (T13): the window's point count, and — for + // pyramid-served replies — the finest cell size this trace's aggregate + // tier can attain, so lodDensityCacheServes knows when this texture is + // already as sharp as a re-request could be. + visible: upd.visible, + minCell: Array.isArray(d.min_cell) ? d.min_cell : null, grid, rgba, filter, @@ -941,6 +1056,7 @@ function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { if (fade < 1) { view._drawDensity(g, prev, (1 - fade) * opacityScale); view._drawDensity(g, density, fade * opacityScale); + lodDrawDensityDetail(view, g, density, opacityScale); view.draw(); return; } @@ -951,6 +1067,15 @@ function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { if (density === g.density) g._densityFadeStart = null; } view._drawDensity(g, density, opacityScale); + lodDrawDensityDetail(view, g, density, opacityScale); +} + +// The finer-detail layer rides every backdrop draw (T13): painted after the +// primary (and after both crossfade layers) so the sharpest fetched region +// stays on top while the rest of the view shows the broad context. +function lodDrawDensityDetail(view, g, density, opacityScale) { + const detail = lodDensityDetailForView(view, g, density); + if (detail && detail.tex) view._drawDensity(g, detail, opacityScale); } // The drilled frame's backdrop opacity, eased continuously (T10). The diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 5bce4d8a..ee7c276c 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -2,8 +2,8 @@ import { payloadBuffers } from "./00_header"; import { buildLutData } from "./10_colormaps"; import { parseColor } from "./20_theme"; import { - lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, lodDropDrill, - lodPromoteCachedDrill, lodRememberDensity, + lodApplyDensityUpdate, lodApplyDrill, lodDensityCacheServes, lodDrillServesView, + lodDropDrill, lodPromoteCachedDrill, lodRememberDensity, } from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -39,7 +39,8 @@ Object.assign(ChartView.prototype, { // goes neither pending nor on the wire. The seq bump above stands, so // an in-flight reply for an older, wider view dies stale instead of // yanking the exact marks out from under the view it can't improve. - if (this._drillServesView(g, view)) { + if (this._drillServesView(g, view) || + this._densityCacheServesView(g, view, plotW, plotH)) { g._lodPendingView = null; g._lodPendingSeq = null; g._lodPendingAt = null; @@ -87,10 +88,12 @@ Object.assign(ChartView.prototype, { const sendNow = this._now(); for (const g of this.gpuTraces) { if (g.tier !== "density") continue; - // T12 re-check at actual send time: a drill that landed during the - // debounce elides the request it made unnecessary; one that died - // during it re-arms the request the schedule-time check skipped. - if (this._drillServesView(g, view)) { + // T12/T13 re-check at actual send time: a drill or an adequate + // cached texture that landed during the debounce elides the + // request it made unnecessary; one that died during it re-arms + // the request the schedule-time check skipped. + if (this._drillServesView(g, view) || + this._densityCacheServesView(g, view, plotW, plotH)) { if (g._lodPendingSeq === seq) { g._lodPendingView = null; g._lodPendingSeq = null; @@ -112,18 +115,13 @@ Object.assign(ChartView.prototype, { } continue; } - const [x0, x1] = this._axisRange(g.xAxis, view); - const [y0, y1] = this._axisRange(g.yAxis, view); + const win = this._densityRequestWindow(g, view); this.comm.send({ type: "density_view", seq, trace: g.trace.id, - x0: Math.min(x0, x1), x1: Math.max(x0, x1), - y0: Math.min(y0, y1), y1: Math.max(y0, y1), + x0: win[0], x1: win[1], y0: win[2], y1: win[3], w: plotW, h: plotH, }); - g._lastDensityReq = { - key: this._densityRequestKey(g, view, plotW, plotH), - seq, sentAt: sendNow, answered: false, - }; + g._lastDensityReq = { win, w: plotW, h: plotH, seq, sentAt: sendNow, answered: false }; } } }; @@ -135,15 +133,26 @@ Object.assign(ChartView.prototype, { return seq; }, - _densityRequestKey(g, view, plotW, plotH) { + _densityRequestWindow(g, view) { const [x0, x1] = this._axisRange(g.xAxis, view); const [y0, y1] = this._axisRange(g.yAxis, view); - return `${Math.min(x0, x1)},${Math.max(x0, x1)},` + - `${Math.min(y0, y1)},${Math.max(y0, y1)},${plotW},${plotH}`; + return [Math.min(x0, x1), Math.max(x0, x1), Math.min(y0, y1), Math.max(y0, y1)]; + }, + + // Same request within half an output texel per edge: gesture-end and + // settle produce windows differing by sub-pixel amounts (field HAR: 0.03% + // shifts back-to-back), and a grid shifted below half a texel is visually + // identical — re-shipping it is pure wire waste. + _densityRequestSame(last, win, plotW, plotH) { + if (!last || last.w !== plotW || last.h !== plotH) return false; + const tx = (win[1] - win[0]) / plotW / 2; + const ty = (win[3] - win[2]) / plotH / 2; + return Math.abs(last.win[0] - win[0]) <= tx && Math.abs(last.win[1] - win[1]) <= tx && + Math.abs(last.win[2] - win[2]) <= ty && Math.abs(last.win[3] - win[3]) <= ty; }, // The last density_view actually sent for this GPU record, when a new - // request would be its byte-identical twin: either already answered (the + // request would be its (sub-texel) twin: either already answered (the // reply is deterministic for unchanged data, so there is nothing to // refresh) or still in flight (bounded by the same 1200ms window as the T8 // pending hold, so a lost reply can never suppress refresh forever). The @@ -151,11 +160,23 @@ Object.assign(ChartView.prototype, { // restore) starts it fresh, so data changes are never suppressed. _densityRequestDup(g, view, plotW, plotH, now) { const last = g._lastDensityReq; - if (!last || last.key !== this._densityRequestKey(g, view, plotW, plotH)) return null; + if (!last || !this._densityRequestSame(last, this._densityRequestWindow(g, view), plotW, plotH)) { + return null; + } if (last.answered) return last; return now - last.sentAt < 1200 ? last : null; }, + // Density-side elision (T13): a cached texture that is as detailed as + // anything the kernel could return for this view (lodDensityCacheServes) + // makes the round-trip moot — the pyramid cannot sharpen past its source + // cells, and zoom-outs inside an exact grid already have their texels. + _densityCacheServesView(g, view, plotW, plotH) { + const [x0, x1] = this._axisRange(g.xAxis, view); + const [y0, y1] = this._axisRange(g.yAxis, view); + return lodDensityCacheServes(this, g, x0, x1, y0, y1, plotW, plotH); + }, + // A reply whose seq lost the global race can still be current in substance: // duplicate-suppressed requests leave their traces waiting on the ORIGINAL // request's seq (T13 — identical window and screen). Accept it only when diff --git a/python/xy/interaction.py b/python/xy/interaction.py index d47a35c5..e544077f 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -502,6 +502,34 @@ def _decode_log_u8(buf: bytes, gmax: float) -> np.ndarray: # worker keeps as its CPU source. +def _pyramid_source_shape( + t: Any, lo_x: float, hi_x: float, lo_y: float, hi_y: float +) -> tuple[int, int, float, float] | None: + """The pyramid's finest-level cell budget under a window, plus cell size. + + Wire economy (§29 / #225 follow-up): composing a pyramid-served window to + full screen resolution upsamples blocky base cells into a grid several + times larger than the information it carries — a ~2.7 MB reply whose + content is a few hundred KB of source cells. Clamping the composed grid + to `(cells under the window at the finest level) + 1` per axis ships the + same detail (the client's own texture filtering reproduces the upscale) + at a fraction of the bytes. The returned per-axis cell sizes ride the + reply as `min_cell`: the finest resolution this trace's aggregate tier + can attain, which lets the client skip requests a cached texture already + answers (LOD doc T13). + """ + base = int(getattr(t, "_pyr_base_dim", 0) or PYRAMID_BASE_DIM) + ex0, ex1, ey0, ey1 = t.x.min, t.x.max, t.y.min, t.y.max + span_x, span_y = ex1 - ex0, ey1 - ey0 + if not all(np.isfinite(v) for v in (ex0, ex1, ey0, ey1)) or span_x <= 0 or span_y <= 0: + return None + frac_x = min(1.0, max(0.0, (hi_x - lo_x) / span_x)) + frac_y = min(1.0, max(0.0, (hi_y - lo_y) / span_y)) + cells_x = max(1, math.ceil(base * frac_x) + 1) + cells_y = max(1, math.ceil(base * frac_y) + 1) + return cells_x, cells_y, span_x / base, span_y / base + + def _padded_drill_window( fig: "Figure", t: Any, @@ -664,6 +692,10 @@ def density_view( # budget we fall through to the exact scan that drilling needs anyway. binning = "exact" grid = None + # Finest attainable per-axis cell size for pyramid-served replies (data + # units); rides the reply as `min_cell` so the client knows when a cached + # texture is already as sharp as this tier can get (LOD doc T13). + min_cell: list[float] | None = None # Mean point color plane (LOD doc §2): channel-bearing traces ship it # alongside the counts wherever a grid is produced below. rgba_grid: np.ndarray | None = None @@ -699,24 +731,35 @@ def density_view( False, aggregate_reduction="pyramid-count", ) + # Wire economy: never compose more grid cells than the finest + # level resolves under this window — a full-screen grid of + # upsampled base cells is the same picture at several times the + # bytes (the client's texture filtering does the upscale). The + # attainable cell size rides the reply as `min_cell` so the + # client can elide requests a cached texture already answers. + gw, gh = plan.grid_w, plan.grid_h + source = _pyramid_source_shape(t, lo_x, hi_x, lo_y, hi_y) + if source is not None: + gw = max(16, min(gw, source[0])) + gh = max(16, min(gh, source[1])) # Colored pyramids compose the mean-color plane with the counts # (same level, same max_upsample); both refusals (outresolved # window, missing planes) fall through to the paths below. if getattr(t, "_pyr_colored", False): res_color = kernels.pyramid_compose_color( - pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample + pyr, lo_x, hi_x, lo_y, hi_y, gw, gh, max_upsample ) res = (res_color[0], res_color[2]) if res_color is not None else None rgba_grid = res_color[1] if res_color is not None else None else: - res = kernels.pyramid_compose( - pyr, lo_x, hi_x, lo_y, hi_y, plan.grid_w, plan.grid_h, max_upsample - ) + res = kernels.pyramid_compose(pyr, lo_x, hi_x, lo_y, hi_y, gw, gh, max_upsample) if res is not None: grid, level = res visible = plan.visible - w, h = plan.grid_w, plan.grid_h + w, h = gw, gh binning = f"pyramid-L{level}{'-upsampled' if no_rescan and level == 0 else ''}" + if source is not None: + min_cell = [source[2], source[3]] lod.exit_drill(t) # Tier-3 spatial index: when the pyramid can only serve this window blurry # (upsampled), re-bin it *exactly* from just its in-window points — as long @@ -760,6 +803,7 @@ def density_view( visible = in_window binning = "spatial-exact" density_filter = "nearest" + min_cell = None # exact full-resolution bins replace the pyramid grid lod.exit_drill(t) if grid is None and no_rescan: # No pyramid (below PYRAMID_MIN_POINTS is impossible here) or compose @@ -786,6 +830,7 @@ def density_view( lod.exit_drill(t) if grid is None: rgba_grid = None + min_cell = None sel = kernels.range_indices(xv, yv, lo_x, hi_x, lo_y, hi_y) plan = lod.plan_view_lod(request, len(sel), SCATTER_DENSITY_THRESHOLD, t.drill_mode) visible = plan.visible @@ -835,6 +880,8 @@ def density_view( "x_range": [lo_x, hi_x], "y_range": [lo_y, hi_y], } + if min_cell is not None: + density["min_cell"] = min_cell if rgba_grid is not None: density["rgba"] = writer.add_u8(np.ascontiguousarray(rgba_grid).reshape(-1)) density["color_agg"] = "mean" diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 4d5a5ee7..761faf4b 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -423,6 +423,8 @@ re-exports several of them as a historic import path and is not listed for those | `DRILL_PAD_SPAN_CAP` | `64.0` | Hard per-axis cap on a padded drill window's span (× the view span), kept well under the client's §16 re-encode bound (1/256 of window span) so deep zooms can always re-tighten the f32 offset encoding. | `interaction.py` | | `DRILL_HISTORY_KEEP` | `8` | Recent drilled subsets kept resolvable per trace, so picks against a retired cached point window (T13) still translate exactly; older seqs drop the pick, data changes clear the history. | `lod.py` (`enter_drill`, `drill_history`), `interaction.py` (`pick`) | | `LOD_POINT_CACHE_WINDOWS` | `3` | Retired exact point windows kept per trace client-side beyond the live drill; LRU-bounded VRAM, swept by the T11 outgrown rule. | `js/src/45_lod.ts` (`lodRetireDrill`, `lodPromoteCachedDrill`) | +| `LOD_DENSITY_ELIDE_EST_FACTOR` / `LOD_DENSITY_ELIDE_MAX_AREA_RATIO` | `2` / `8` | Guards on density-side request elision (T13): a cached texture at attainable resolution answers a contained view only while the estimated in-view count exceeds `budget × 2` (the exact-rebin/points regimes stay reachable) and the cached window is at most 8× the view's area (the uniform-density estimate overshoots in sparse corners; deeper dives re-request). | `js/src/45_lod.ts` (`lodDensityCacheServes`) | +| `LOD_DENSITY_DETAIL_MIN_COVER` | `0.05` | Minimum share of the view a finer cached texture must overlap to draw as the detail layer over the broad backdrop — a sliver seam is worse than blur. | `js/src/45_lod.ts` (`lodDensityDetailForView`) | | `DEFAULT_PALETTE` | 10 CVD-safe hex entries | Per-trace default color cycle and the fallback categorical palette when a channel supplies none (§20/§36). | `marks.py`, `_payload.py`, `_svg.py`, `_raster.py` | | `PYRAMID_MIN_POINTS` | `2_000_000` | Trace size at/above which a Tier-3 tile pyramid is built lazily; smaller traces never pay for one. | `interaction.py` | | `PYRAMID_BASE_DIM` | `2048` | Edge of the pyramid's base level in cells (`dim²` u32 counts, ~1/3 overhead for the coarser levels); sets resident pyramid bytes. | `interaction.py` | diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 477fa0ff..3293c20f 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -480,13 +480,48 @@ invariants so future kinds don't regress them: history (`Trace.drill_history`, `DRILL_HISTORY_KEEP`) so picks against a recent retired window still translate exactly; expired seqs drop the pick (§16 exact-or-nothing), and data changes clear the history. Finally, an - identical request never rides the wire twice: a `density_view` whose - window and screen size match the trace's last sent request is suppressed — - already answered ⇒ nothing to refresh (the reply is deterministic for - unchanged data; rebuilds reset the memo), still in flight ⇒ the trace - keeps waiting on the ORIGINAL request's seq and that reply is accepted - per-trace instead of dying to the global seq race (bounded by the same - 1200ms window as the T8 hold, so a lost reply can't suppress forever). + identical request never rides the wire twice: a `density_view` within half + an output texel per edge of the trace's last sent request (gesture-end and + settle emit sub-pixel twins; a grid shifted below half a texel is the same + picture) is suppressed — already answered ⇒ nothing to refresh (the reply + is deterministic for unchanged data; rebuilds reset the memo), still in + flight ⇒ the trace keeps waiting on the ORIGINAL request's seq and that + reply is accepted per-trace instead of dying to the global seq race + (bounded by the same 1200ms window as the T8 hold, so a lost reply can't + suppress forever). + + The same economy governs the AGGREGATE tier's own traffic (the 100M field + HAR: ~2.7 MB full-screen grids re-shipped on every pan/zoom step at + 200–450%, drawn over a home-texture fallback that blurred the frame the + moment a pan left the freshest window): + - **Source-clamped grids:** a pyramid-served reply never composes more + cells than the finest level resolves under the window + (`interaction._pyramid_source_shape` — `ceil(base·frac)+1` per axis); + a full-screen grid of upsampled base cells is the same picture at + several times the bytes, and the client's texture filtering reproduces + the upscale. The attainable per-axis cell size rides the reply as + `density.min_cell`; exact/spatial grids (true full-detail bins) omit it. + - **Density-side request elision** (`lodDensityCacheServes`): a cached + texture that CONTAINS the view and is as detailed as anything the kernel + could return — one texel per screen pixel, or already at `min_cell` + (zooming into a source-limited texture cannot sharpen it) — answers the + view with no round-trip. Guards keep the kernel in charge where it can + do better: the estimated in-view count (window count × area share) must + exceed `LOD_DIRECT_POINT_BUDGET × LOD_DENSITY_ELIDE_EST_FACTOR` (the + exact-rebin/points regimes stay reachable), and the cached window may be + at most `LOD_DENSITY_ELIDE_MAX_AREA_RATIO` × the view's area (the + uniform-density estimate overshoots in sparse corners; a deep dive + re-requests so real counts decide). Entries without recorded counts + never elide. + - **Finer-detail layering** (`lodDensityDetailForView`): when no fine + cached window contains the view, the smallest cached texture overlapping + ≥ `LOD_DENSITY_DETAIL_MIN_COVER` of it draws ON TOP of the broad + backdrop (after every crossfade layer), so the already-fetched region + stays sharp while the request for the rest is in flight — a resolution + seam at the window edge beats uniform blur (standard tile-pyramid + behavior). A fresh grid for an already-cached window supersedes its + unpinned twin in the cache (`lodRememberDensity` dedupe), so elision + always reads current facts. Any new tiered kind must state how it satisfies T1–T13 in its chart-kind contract entry before it lands. diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4d5e3a99..d90c1e17 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -68,12 +68,17 @@ elided client-side (`reduction: "none"` means the subset already holds every point of any contained view; LOD doc §5 T12/T13). The elision ends, and one request goes out purely to re-center the §16 f32 offset encoding, once the view span drops below 1/256 of the drilled window's span on either axis. -A request whose window and screen size match the trace's last sent request is -also suppressed (T13): if that request was already answered there is nothing -to refresh (replies are deterministic for unchanged data; data changes rebuild -the GPU trace and reset the memo), and if it is still in flight the trace -keeps waiting on the original request's `seq`, whose reply is then accepted -per-trace instead of dying to the global seq race. +A request within half an output texel per edge of the trace's last sent +request (same screen size) is also suppressed (T13): if that request was +already answered there is nothing to refresh (replies are deterministic for +unchanged data; data changes rebuild the GPU trace and reset the memo), and +if it is still in flight the trace keeps waiting on the original request's +`seq`, whose reply is then accepted per-trace instead of dying to the global +seq race. Independently, a cached density texture that contains the view and +is as detailed as anything the kernel could return — one texel per screen +pixel, or already at the trace's attainable `min_cell` resolution — elides +the request entirely, guarded so the exact-rebin and points regimes stay +reachable (LOD doc T13). **`pick`** — `trace` and `index` pass through `_integer_id`. `index` is a *shipped-vertex* index, translated kernel-side to a canonical row when the @@ -148,7 +153,14 @@ states which representation this view resolved to: first-payload one, and the client draws it solely below the T9 resolvable-count gate (a client still accepts a legacy reply-borne `sample` and gates it the same way). `binning` is `"exact"` or - `"pyramid-L"`. `x_range`/`y_range` are raw data endpoints, but the + `"pyramid-L"`. Pyramid-served grids are **clamped to source + resolution** — never more cells per axis than the finest level resolves + under the window (a full-screen grid of upsampled cells is the same + picture at several times the bytes; the client's texture filtering does + the upscale) — and carry `min_cell: [cx, cy]`, the finest attainable + per-axis cell size in data units, which the client's request elision reads + (LOD doc T13). Exact and spatial grids are true full-detail bins and omit + `min_cell`. `x_range`/`y_range` are raw data endpoints, but the grid's cells are **uniform in the axis's scale coordinates** (identical to raw on a linear axis): on a log/symlog axis the kernel bins transformed values so every cell covers the same strip of screen, and renderers diff --git a/tests/test_density_request_economy.py b/tests/test_density_request_economy.py new file mode 100644 index 00000000..e4e0100d --- /dev/null +++ b/tests/test_density_request_economy.py @@ -0,0 +1,199 @@ +"""Density requests are elided, deduped, and drawn sharp (LOD doc T13). + +The field HAR behind this (100M live drilldown, 200-450% zoom): every pan and +zoom step re-requested a ~2.7 MB full-screen density grid — including +back-to-back windows differing by sub-pixel amounts — while the draw path fell +back to the blurriest cached texture the moment a pan left the freshest +window. Three client rules fix it: + +- **source-resolution elision**: a cached texture that already sits at the + kernel's finest attainable aggregate cell size (`min_cell`, pyramid-served + replies) answers any contained view — zooming further in cannot sharpen it — + guarded so the exact/drill regimes stay reachable (estimated in-view count + above the budget band, bounded window/view area ratio); +- **sub-texel request dedup**: a request within half an output texel of the + trace's last sent request is suppressed (answered → nothing to refresh; + in flight → keep waiting on the original seq); +- **finer-detail layering**: when no fine window contains the view, the + smallest cached texture overlapping it draws on top of the broad backdrop + instead of the frame dropping to uniform blur. + +This drives the real client in headless Chromium with a captured comm. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import xy +from conftest import run_browser_probe +from xy.export import find_chromium + +_RENDER_CALL = 'xy.renderStandalone(document.getElementById("chart"), spec, buf);' + +_PROBE = """ + const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); + try { + view._drawNow(); + view._raf = null; + view._sampleRebinDisabled = true; + const g = view.gpuTraces.find((t) => t.tier === "density"); + let clk = 100000; + view._now = () => clk; + g._densityNormAnim = null; + + const v0 = view.view0; + const cx = (v0.x0 + v0.x1) / 2, cy = (v0.y0 + v0.y1) / 2; + const sx = v0.x1 - v0.x0, sy = v0.y1 - v0.y0; + const sent = []; + view.comm = { send: (m) => sent.push(m) }; + const densitySent = () => sent.filter((m) => m.type === "density_view").length; + const request = (v) => { + const before = densitySent(); + view._scheduleViewRequest(view._viewFrom(v), { delay: 0 }); + return densitySent() - before; + }; + const viewAt = (fcx, fcy, fspanx, fspany) => ({ + x0: v0.x0 + sx * fcx - sx * fspanx / 2, x1: v0.x0 + sx * fcx + sx * fspanx / 2, + y0: v0.y0 + sy * fcy - sy * fspany / 2, y1: v0.y0 + sy * fcy + sy * fspany / 2, + }); + + // A pyramid-served reply: grid at the trace's SOURCE resolution + // (cell == min_cell), `visible` points in-window. + const applyDensity = (x0d, x1d, y0d, y1d, gw, gh, visible) => { + const grid = new Float32Array(gw * gh).fill(3); + view._onKernelMsg({ + type: "density_update", seq: view.seq, trace: g.trace.id, + traces: [{ + id: g.trace.id, mode: "density", visible, + binning: "pyramid-L0", + density: { + buf: 0, w: gw, h: gh, max: 3, + x_range: [x0d, x1d], y_range: [y0d, y1d], + min_cell: [(x1d - x0d) / gw, (y1d - y0d) / gh], + }, + }], + }, [grid.buffer]); + }; + // Window W = central 50% of home, 10M points in-window. + const wx0 = cx - sx * 0.25, wx1 = cx + sx * 0.25; + const wy0 = cy - sy * 0.25, wy1 = cy + sy * 0.25; + applyDensity(wx0, wx1, wy0, wy1, 200, 150, 10000000); + const cached = g.density; + const storedFacts = Number.isFinite(cached.visible) && !!cached.minCell; + + // Zooming INSIDE W (half its span, area ratio 4): the kernel could not + // return anything sharper than the cached source-resolution texture, and + // ~2.5M estimated points are nowhere near drill — no request. + const insideElided = request(viewAt(0.5, 0.5, 0.25, 0.25)) === 0; + const elisionClearedPending = g._lodPendingView === null; + + // A deep dive (area ratio 16 > 8) re-requests: the uniform-density + // estimate overshoots in sparse corners, so let the kernel re-decide. + const deepDiveRequested = request(viewAt(0.5, 0.5, 0.12, 0.12)) === 1; + + // Near the exact/drill regime the kernel CAN do better: a separate + // window with only 300k in-window — a half-span view inside it + // estimates ~75k, points territory, so the request must go out even + // though the cached texture is source-limited. + const w2x0 = v0.x0 + sx * 0.10, w2x1 = v0.x0 + sx * 0.40; + const w2y0 = v0.y0 + sy * 0.10, w2y1 = v0.y0 + sy * 0.40; + applyDensity(w2x0, w2x1, w2y0, w2y1, 100, 75, 300000); + const nearDrillRequested = request(viewAt(0.25, 0.25, 0.15, 0.15)) === 1; + + // Sub-texel dedup: a settle re-request shifted by 0.2 output texels is + // the same picture — suppressed while the first is in flight; a shift of + // 3 texels is a genuinely different window — sent. + const plotW = Math.round(view.plot.w); + const base = viewAt(0.75, 0.75, 0.2, 0.2); + const sentBase = request(base) === 1; + const texel = (base.x1 - base.x0) / plotW; + const nudged = { ...base, x0: base.x0 + texel * 0.2, x1: base.x1 + texel * 0.2 }; + const subTexelSuppressed = request(nudged) === 0; + const shifted = { ...base, x0: base.x0 + texel * 3, x1: base.x1 + texel * 3 }; + const texelsRequested = request(shifted) === 1; + + // Finer-detail layering: pan half-out of W (not contained, ~50% overlap). + // The home texture is the containing backdrop, but W's fine texture must + // draw ON TOP of it instead of the frame dropping to home's blur. + view.view = viewAt(0.75, 0.5, 0.5, 0.5); + view._drawNow(); + clk += 1000; // land the density crossfade so the detail layer engages + view._drawNow(); + const drawnRanges = []; + const real = view._drawDensity; + view._drawDensity = function (gg, dd, op) { + if (gg === g && dd && dd.xRange) drawnRanges.push([dd.xRange[0], dd.xRange[1], op]); + return real.apply(this, arguments); + }; + view._drawNow(); + view._drawDensity = real; + const fineIdx = drawnRanges.findIndex((r) => Math.abs(r[0] - wx0) < sx * 1e-9); + const broadIdx = drawnRanges.findIndex((r) => r[0] < wx0 - sx * 0.1); + const detailDrawn = fineIdx >= 0 && broadIdx >= 0 && fineIdx > broadIdx; + + document.body.setAttribute("data-xy-economy-probe", JSON.stringify({ + hasDensity: !!g, storedFacts, + insideElided, elisionClearedPending, deepDiveRequested, nearDrillRequested, + sentBase, subTexelSuppressed, texelsRequested, + drawnCount: drawnRanges.length, detailDrawn, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-economy-probe-error", + String((err && err.stack) || err), + ); + } +""" + + +def _density_html() -> str: + rng = np.random.default_rng(0) + n = 60_000 + x = rng.normal(0.0, 1.0, n) + y = rng.normal(0.0, 1.0, n) + chart = xy.scatter_chart( + xy.scatter(x, y, density=True), + xy.x_axis(), + xy.y_axis(), + width=480, + height=360, + ) + html = chart.to_html() + assert _RENDER_CALL in html + return html + + +def test_density_requests_elided_deduped_and_layered(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + document = _density_html().replace(_RENDER_CALL, _PROBE) + result = run_browser_probe( + chromium, + document, + tmp_path / "density_request_economy.html", + "data-xy-economy-probe", + label="density request-economy probe", + ) + + assert result["hasDensity"] is True + assert result["storedFacts"] is True + # Source-resolution elision: a zoom inside a source-limited cached window + # sends nothing (the kernel cannot sharpen it) and clears its pending + # marker like every other elision. + assert result["insideElided"] is True + assert result["elisionClearedPending"] is True + # The guards keep the kernel in charge where it can do better. + assert result["deepDiveRequested"] is True + assert result["nearDrillRequested"] is True + # Sub-texel dedup: same picture, no second request; a real shift sends. + assert result["sentBase"] is True + assert result["subTexelSuppressed"] is True + assert result["texelsRequested"] is True + # The finest overlapping cached texture draws over the broad backdrop. + assert result["detailDrawn"] is True diff --git a/tests/test_density_wire_economy.py b/tests/test_density_wire_economy.py new file mode 100644 index 00000000..40855112 --- /dev/null +++ b/tests/test_density_wire_economy.py @@ -0,0 +1,109 @@ +"""Pyramid-served density replies never ship more grid than their source +resolves (§29 wire economy; LOD doc T13; #225 follow-up). + +The field HAR behind this: a 100M-point drilldown at 200-450% zoom shipped a +~2.7 MB full-screen grid per pan/zoom step — count plane plus RGBA8 mean-color +plane at one cell per screen pixel — while the pyramid's finest level only +resolves a few hundred cells under such a window. Composing past the source +just upsamples blocky cells the client's own texture filtering reproduces +from the source-resolution grid at a fraction of the bytes. The reply also +records `min_cell`, the finest attainable per-axis cell size, so the client +can elide requests a cached texture already answers. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from xy import interaction +from xy._figure import Figure +from xy.config import PYRAMID_BASE_DIM + + +def test_pyramid_source_shape_math() -> None: + class _Col: + def __init__(self, lo, hi): + self.min, self.max = lo, hi + + class _T: + _pyr_base_dim = 1024 + + def __init__(self): + self.x = _Col(0.0, 100.0) + self.y = _Col(0.0, 50.0) + + t = _T() + # A quarter-extent window resolves ~256 (+1 straddle) source cells. + cells_x, cells_y, cell_x, cell_y = interaction._pyramid_source_shape(t, 10.0, 35.0, 5.0, 17.5) + assert cells_x == math.ceil(1024 * 0.25) + 1 + assert cells_y == math.ceil(1024 * 0.25) + 1 + assert cell_x == pytest.approx(100.0 / 1024) + assert cell_y == pytest.approx(50.0 / 1024) + # Windows wider than the extent clamp to the full base. + cells_x, _, _, _ = interaction._pyramid_source_shape(t, -50.0, 250.0, 0.0, 50.0) + assert cells_x == 1024 + 1 + # Degenerate extents refuse rather than divide by zero. + t.y = _Col(5.0, 5.0) + assert interaction._pyramid_source_shape(t, 0.0, 1.0, 0.0, 1.0) is None + + +def test_pyramid_reply_records_min_cell_and_exact_reply_does_not() -> None: + n = 2_500_000 + rng = np.random.default_rng(3) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + fig = Figure().scatter(x, y, density=True) + t = fig.traces[0] + + # Half-extent window: comfortably pyramid territory (~625k in-window). + upd, _ = fig.density_view(0, 25.0, 75.0, 25.0, 75.0, 512, 384) + tr = upd["traces"][0] + assert tr["mode"] == "density" + assert tr["binning"].startswith("pyramid") + d = tr["density"] + base = getattr(t, "_pyr_base_dim", 0) or PYRAMID_BASE_DIM + assert d["min_cell"][0] == pytest.approx((t.x.max - t.x.min) / base) + assert d["min_cell"][1] == pytest.approx((t.y.max - t.y.min) / base) + # The grid never exceeds the source cells under the window (+1 straddle). + assert d["w"] <= math.ceil(base * 0.5) + 1 + 1 + assert d["h"] <= math.ceil(base * 0.5) + 1 + 1 + + # A window near the drill budget (~260k in-window: over the budget, under + # the pyramid-serve margin) takes the exact path: true full-detail bins, + # so no min_cell rides the reply (nothing finer exists to attain). + upd2, _ = fig.density_view(0, 0.0, 35.0, 0.0, 30.0, 512, 384) + tr2 = upd2["traces"][0] + assert tr2["mode"] == "density" + assert tr2["binning"] == "exact" + assert "min_cell" not in tr2["density"] + + +def test_pyramid_grid_clamped_to_source_resolution() -> None: + # Large enough that the per-cell target no longer coarsens the grid below + # the screen, so the source clamp is the binding constraint on x — the + # regime the 100M field HAR exercised. + n = 20_000_000 + rng = np.random.default_rng(7) + x = rng.uniform(0.0, 100.0, n) + y = rng.uniform(0.0, 100.0, n) + fig = Figure().scatter(x, y, density=True) + t = fig.traces[0] + + frac = 0.6 + upd, bufs = fig.density_view(0, 20.0, 20.0 + 100.0 * frac, 20.0, 80.0, 2000, 348) + tr = upd["traces"][0] + assert tr["mode"] == "density" + assert tr["binning"].startswith("pyramid") + d = tr["density"] + base = getattr(t, "_pyr_base_dim", 0) or PYRAMID_BASE_DIM + source_x = math.ceil(base * frac) + 1 + # x is clamped to source cells; y stays screen/plan-bounded — the grid's + # aspect visibly departs from the requested screen aspect. + assert d["w"] <= source_x + 1 + assert d["h"] >= 200 + assert d["w"] / d["h"] < (2000 / 348) * 0.9 + # One byte per cell on the count plane (§29 log-u8 wire). + assert len(bufs[d["buf"]]) == d["w"] * d["h"] From 2b8d80b85aa7c5545a62721b66c86239eef29cdd Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 18:18:05 +0000 Subject: [PATCH 07/11] The aggregate never refines: density requests only probe the points band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field feedback on PR #227 (100M live drilldown), superseding the previous commit's adequacy-elision approach with a simpler rule: the density texture already covering the view STANDS — however blurry — until the view could plausibly resolve into real points. Intermediate-zoom blur is an accepted, recorded cost; the kernel's sharper answers (exact re-bins, drill-in) stay reachable because the points band still requests. - Client gate (lodAggregateStands): a density_view goes out only when the estimated in-view count sits within LOD_POINTS_REQUEST_BAND (4x) of the direct budget. Two estimators, LOWER wins so an over-estimate can never strand a view in blur: the smallest cached density window containing the view (recorded count, area-scaled; seeded by the home texture's first-payload count) and the retained deterministic sample counted in-view and re-weighted (lodSampleViewCount) — the sample follows the data's actual distribution, so sparse tails reach their points where uniform-density area-scaling over-estimates by orders of magnitude. Traces with no recorded counts always request. - Kernel-attached clients never draw the retained sample at ANY zoom (field capture: 8k arbitrary sample rows at full alpha over a view the kernel serves real points for): resolvable views get exact points, and the sample's kernel-mode job is now purely CPU-side estimation. The standalone client keeps drawing it below the resolvability gate — its only point representation. - Reverted the fine-over-broad detail layer from the previous commit (field capture: a stale darker rectangle) — density textures alpha-composite, so the overlap double-counts opacity, and per-window normalization makes the seam a brightness step. Recorded in T13; the tier draws one texture per frame. Dropped the density.min_cell reply field with it (no reader remains); the source-resolution grid clamp and the sub-texel request dedup stay. Spec: lod-architecture T9/T13 revised (never-refines rule, sample-based estimator, recorded reversal), wire-protocol, dossier constants (LOD_POINTS_REQUEST_BAND replaces the elision guards), config comment, CHANGELOG, docs ladder table. Tests: request-economy probe rewritten to the stands-rule (band requests, sub-texel dedup, single-texture frames), resolvability-gate probe gains kernel-mode assertions, wire-economy tests drop min_cell. --- CHANGELOG.md | 33 ++-- .../large-data-and-performance.md | 4 +- js/src/45_lod.ts | 176 +++++++++--------- js/src/50_chartview.ts | 4 + js/src/54_kernel.ts | 28 ++- python/xy/config.py | 16 +- python/xy/interaction.py | 27 +-- spec/design-dossier.md | 3 +- spec/design/lod-architecture.md | 87 +++++---- spec/design/wire-protocol.md | 20 +- tests/test_density_request_economy.py | 112 ++++++----- .../test_density_sample_resolvability_gate.py | 25 ++- tests/test_density_wire_economy.py | 18 +- 13 files changed, 293 insertions(+), 260 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efc9435..2288444c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,18 +44,27 @@ in the README). the view's estimated in-view count fits the direct point budget, i.e. when individual points are actually resolvable; real points still ship the moment a window fits the budget, so drilldown behavior is unchanged. -- **Density traffic is source-bounded, elided, and layered (LOD doc T13).** - Pyramid-served density replies are clamped to the source resolution the - window actually has — no more full-screen grids of upsampled base cells - (a 100M-scatter field capture shipped ~2.7 MB per pan/zoom step at 200-450% - zoom; the same views now cost a fraction of that) — and record `min_cell`, - the finest attainable cell size. The client elides `density_view` requests - a cached texture already answers (contained view, texture at screen or - attainable resolution, guarded so exact re-bins and drill-in stay - reachable), suppresses re-requests within half an output texel of the last - one (gesture-end/settle twins), and draws the finest overlapping cached - texture on top of the broad backdrop during pans/zooms instead of dropping - the frame to the blurriest texture it holds. +- **The aggregate tier no longer refines; density requests only probe the + points band (LOD doc T13).** Whatever density texture already covers the + view stands — however blurry — until the estimated in-view count comes + within `LOD_POINTS_REQUEST_BAND ×` the direct budget; only then does a + `density_view` go out, and the kernel answers with exact points once the + count fits. The estimate takes the lower of an area-scaled cached-window + count and the retained first-payload sample counted in-view — the sample + follows the data's actual distribution, so sparse regions reach their + points without being stranded in blur by uniform-density assumptions. A 100M-scatter field capture had shipped a ~2.7 MB full-screen + grid on every pan/zoom step (including sub-pixel window twins, now deduped + within half an output texel) for what was the same aggregate with + marginally different blur; intermediate-zoom blur is the accepted, + recorded cost. Transition-band replies that do go out are clamped to the + pyramid's source resolution — never more cells than the finest level + resolves under the window. Kernel-attached clients also no longer draw the + retained first-payload sample at any zoom (resolvable views get real + points, not retained sample rows); it remains the standalone client's + fallback, gated by resolvability. A tried fine-over-broad texture layering + was reverted (recorded): density textures alpha-composite, so overlaps + double-count opacity, and per-window normalization makes the seam a + brightness step. - **Full-point windows are padded, aligned, cached, and never re-requested (LOD doc T13).** A points-tier reply now ships the largest aligned window around the view whose exact count still fits the budget (bounds snapped to diff --git a/docs/core-concepts/large-data-and-performance.md b/docs/core-concepts/large-data-and-performance.md index ccb12764..119ca434 100644 --- a/docs/core-concepts/large-data-and-performance.md +++ b/docs/core-concepts/large-data-and-performance.md @@ -16,8 +16,8 @@ readout and refinement. | --- | --- | --- | | Direct | Small visible traces | One rendered point or segment per retained row | | M4-decimated | Long ordered lines and areas | A bounded sequence preserving per-bucket extrema | -| Density | Dense scatter overviews | A fixed-resolution count grid wearing the data's own mean point colors; a deterministic point sample overlays it only when the view's estimated count would fit the direct budget (individual points are resolvable) | -| Refined view | A narrower pan/zoom window | A new viewport-specific aggregate, or exact visible points for a padded aligned window when they fit — nearby pans and zooms then render from the cached window with no further requests | +| Density | Dense scatter overviews | A count grid wearing the data's own mean point colors. It stands across pans and zooms — softening as you go deeper — rather than re-aggregating per viewport; in standalone (kernel-less) charts a deterministic point sample overlays it once the view's estimated count would fit the direct budget | +| Refined view | A window near or below the direct budget | Exact visible points for a padded aligned window — nearby pans and zooms then render from the cached window with no further requests | The current defaults begin M4 line decimation above 10,000 rows and automatic scatter density above 200,000 points. Density grids default to 512×384 cells, diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index e9095fca..dbea7c1d 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -287,6 +287,14 @@ function lodOverlayResolvable(view, o) { // Every candidate passes the #225 resolvability gate first: above the direct // budget the aggregate stands alone. export function lodSampleForView(view, g) { + // Kernel-attached clients never draw retained samples (#225 field + // follow-up): whenever a view is resolvable the kernel answers with REAL + // points — the retained sample is a handful of arbitrary rows drawn at + // full alpha exactly where exact marks are one request away (or already + // on screen), which reads as data that isn't there. The overlay exists + // for the standalone (kernel-less) client, whose re-binned sample is the + // only point representation it will ever have. + if (view.comm) return null; const cache = g.densityCache || (g.density ? [g.density] : []); let contained = null; let fallback = null; @@ -322,89 +330,101 @@ function lodDensityForView(view, g) { return best || broadest || g.density; } -// Finer-detail layer over the chosen backdrop (T13): when the view is not -// contained in any fine cached window, lodDensityForView falls back to the -// broadest texture — but a finer cached window usually still covers most of -// the view mid-pan/zoom, and drawing only the broad texture renders the -// frame at its blurriest exactly when the user is moving. Pick the -// smallest-area cached texture that overlaps the view meaningfully and is -// finer than the primary; the draw path paints it on top (GPU clips it to -// its window), so the region already fetched stays sharp while the request -// for the rest is in flight. Standard tile-pyramid behavior: a resolution -// seam at the window edge beats uniform blur. -const LOD_DENSITY_DETAIL_MIN_COVER = 0.05; // of the view area +// NOTE (recorded reversal, T13): a "finer-detail layer" — drawing the +// smallest cached texture overlapping the view on top of the broad backdrop +// during pans — was tried here and reverted after a field capture showed it +// as a stale darker rectangle: density textures alpha-composite, so the +// overlap region double-counts opacity, and each window's texture is baked +// against its own eased normMax, so the seam is also a brightness step. +// Doing it right needs the backdrop scissored out of the detail region plus +// a shared normalization across cached textures (or kernel-side padded +// aligned density windows, so the primary texture simply keeps containing +// the view). Until then the tier draws ONE texture per frame. -function lodDensityDetailForView(view, g, primary) { - if (!primary) return null; - const cache = g.densityCache || []; - const v = view.view; - const vx0 = Math.min(v.x0, v.x1), vx1 = Math.max(v.x0, v.x1); - const vy0 = Math.min(v.y0, v.y1), vy1 = Math.max(v.y0, v.y1); - const viewArea = (vx1 - vx0) * (vy1 - vy0); - if (!(viewArea > 0)) return null; - const primaryArea = lodDensityArea(primary); - let best = null; - for (const d of cache) { - if (!d || !d.tex || d === primary || d === g._densitySwitchPrev) continue; - if (!(lodDensityArea(d) < primaryArea)) continue; - const wx0 = Math.min(d.xRange[0], d.xRange[1]), wx1 = Math.max(d.xRange[0], d.xRange[1]); - const wy0 = Math.min(d.yRange[0], d.yRange[1]), wy1 = Math.max(d.yRange[0], d.yRange[1]); - const ox = Math.min(vx1, wx1) - Math.max(vx0, wx0); - const oy = Math.min(vy1, wy1) - Math.max(vy0, wy0); - if (ox <= 0 || oy <= 0 || (ox * oy) / viewArea < LOD_DENSITY_DETAIL_MIN_COVER) continue; - if (!best || lodDensityArea(d) < lodDensityArea(best)) best = d; +// T13 (revised on #225 field feedback): the aggregate tier does NOT refine. +// Whatever density texture already covers the view stands — however blurry — +// until the view could plausibly resolve into REAL points; only then is a +// round-trip worth anything, and the kernel answers it with exact points +// once the window's count fits the budget. Blur at intermediate zooms is an +// accepted cost: a refreshed aggregate at 300% zoom is the same picture with +// marginally softer edges, and the field capture behind the old refinement +// loop paid megabytes per pan/zoom step for it. +// +// The estimate comes from the smallest cached density window CONTAINING the +// view — its recorded count is exact for its window, area-scaled down to the +// view (home's total count is the fallback everything starts from). The +// band factor absorbs area-scaling error against non-uniform data: dense +// cores under-estimate (a wasted-but-small transition request whose density +// reply recalibrates the local count), sparse tails over-estimate (points +// arrive a few zoom steps later than ideal — more of the accepted blur). A +// trace with no recorded counts anywhere always requests: silence must +// never be the default when the kernel might hold a sharper representation. +const LOD_POINTS_REQUEST_BAND = 4; // × LOD_DIRECT_POINT_BUDGET + +// Unbiased local count estimate from the retained deterministic sample: the +// first-payload sample is a fixed-rate thinning of the whole trace, so +// (in-view sample rows) × (total ÷ sample size) estimates the in-view count +// following the data's ACTUAL distribution — with ~65 expected sample rows +// right at the gating band, ±12% noise. Area-scaling alone cannot do this: +// it assumes uniform density, over-estimating sparse tails by orders of +// magnitude, which under the stands-rule would strand them in blur far past +// the zoom where real points are available. Kernel-attached clients never +// DRAW this sample (T9); estimating from it CPU-side is exactly the kind of +// use it is retained for (like the standalone re-bin worker). NaN when no +// sample is retained (overlay omitted past u32 rows, hand-built specs). +function lodSampleViewCount(view, g, x0, x1, y0, y1) { + const o = g.sampleOverlay; + const cpu = o && o._cpu; + const meta = o && o.sample; + if (!cpu || !cpu.x || !cpu.y || !meta || !Number.isFinite(meta.visible) || !(meta.n > 0)) { + return NaN; + } + // Containment tested in the sample's own encoded space (§16): enc = + // (raw − offset) × scale, with a swap guard for negative scales. + const sx = cpu.xMeta.scale || 1, ox = cpu.xMeta.offset || 0; + const sy = cpu.yMeta.scale || 1, oy = cpu.yMeta.offset || 0; + let ex0 = (Math.min(x0, x1) - ox) * sx, ex1 = (Math.max(x0, x1) - ox) * sx; + if (ex0 > ex1) { const t = ex0; ex0 = ex1; ex1 = t; } + let ey0 = (Math.min(y0, y1) - oy) * sy, ey1 = (Math.max(y0, y1) - oy) * sy; + if (ey0 > ey1) { const t = ey0; ey0 = ey1; ey1 = t; } + const n = Math.min(cpu.x.length, cpu.y.length); + let k = 0; + for (let i = 0; i < n; i++) { + const xv = cpu.x[i], yv = cpu.y[i]; + if (xv >= ex0 && xv <= ex1 && yv >= ey0 && yv <= ey1) k++; } - return best; + return k * (meta.visible / meta.n); } -// Density-side request elision (T13): skip the density_view round-trip when -// a cached texture is already as detailed as anything the kernel could -// return for this view. Two ways to qualify, both requiring the cached -// window to CONTAIN the view: -// - screen-resolution adequacy: the texture resolves at least one texel per -// screen pixel of the current view (zoom-outs and pan-returns inside an -// exact-scan grid); -// - source-resolution adequacy: the texture already sits at the trace's -// finest attainable aggregate cell size (`min_cell`, pyramid-served -// replies) — zooming further in cannot sharpen it, so re-requesting the -// same blur is pure wire waste (the HAR behind this: repeated ~2.7 MB -// grids per pan/zoom step at 200-450% on a 100M scatter). -// Guards keep the exact/drill regimes reachable: the estimated in-view count -// must sit clearly above the budget (the kernel would still answer with the -// pyramid, not an exact re-bin or points), and the view must not have dived -// more than MAX_AREA_RATIO below the cached window (the area estimate -// overshoots in sparse corners; a deep dive re-requests so the kernel can -// re-decide with real counts). -const LOD_DENSITY_ELIDE_EST_FACTOR = 2; // × LOD_DIRECT_POINT_BUDGET -const LOD_DENSITY_ELIDE_MAX_AREA_RATIO = 8; // cached-window area ÷ view area - -export function lodDensityCacheServes(view, g, x0, x1, y0, y1, plotW, plotH) { +export function lodAggregateStands(view, g, x0, x1, y0, y1) { const cache = g.densityCache || (g.density ? [g.density] : []); const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); const vSpanX = vx1 - vx0, vSpanY = vy1 - vy0; - if (!(vSpanX > 0 && vSpanY > 0 && plotW > 0 && plotH > 0)) return false; - const needX = vSpanX / plotW, needY = vSpanY / plotH; + if (!(vSpanX > 0 && vSpanY > 0)) return false; const ex = vSpanX * 1e-4, ey = vSpanY * 1e-4; + let winArea = 0; + let est = NaN; for (const d of cache) { - if (!d || !d.tex || !d.xRange || !d.yRange || !(d.w > 0) || !(d.h > 0)) continue; + if (!d || !d.xRange || !d.yRange || !Number.isFinite(d.visible)) continue; const wx0 = Math.min(d.xRange[0], d.xRange[1]), wx1 = Math.max(d.xRange[0], d.xRange[1]); const wy0 = Math.min(d.yRange[0], d.yRange[1]), wy1 = Math.max(d.yRange[0], d.yRange[1]); if (vx0 < wx0 - ex || vx1 > wx1 + ex || vy0 < wy0 - ey || vy1 > wy1 + ey) continue; - const winArea = (wx1 - wx0) * (wy1 - wy0); - if (winArea > vSpanX * vSpanY * LOD_DENSITY_ELIDE_MAX_AREA_RATIO) continue; - const cellX = (wx1 - wx0) / d.w, cellY = (wy1 - wy0) / d.h; - const mc = d.minCell; - if (cellX > Math.max(needX, mc ? mc[0] : 0) * 1.001) continue; - if (cellY > Math.max(needY, mc ? mc[1] : 0) * 1.001) continue; - // Unknown counts never elide: the kernel must stay free to choose a - // sharper representation (exact re-bin, points) the moment it can. - if (!Number.isFinite(d.visible) || !(d.visible > 0)) continue; - const est = d.visible * Math.min(1, (vSpanX * vSpanY) / winArea); - if (est <= LOD_DIRECT_POINT_BUDGET * LOD_DENSITY_ELIDE_EST_FACTOR) continue; - return true; + const a = (wx1 - wx0) * (wy1 - wy0); + if (!(a > 0)) continue; + if (!(winArea > 0) || a < winArea) { + winArea = a; + est = d.visible * Math.min(1, (vSpanX * vSpanY) / a); + } } - return false; + // Two independent estimators, keep the LOWER: never let an over-estimate + // hold a view in blur when either signal says points could be close. The + // area estimate under-shoots dense cores (a small early transition request + // whose reply recalibrates); the sample estimate is distribution-true. + const sampleEst = lodSampleViewCount(view, g, x0, x1, y0, y1); + if (Number.isFinite(sampleEst)) est = Number.isFinite(est) ? Math.min(est, sampleEst) : sampleEst; + if (!Number.isFinite(est)) return false; + return est > LOD_DIRECT_POINT_BUDGET * LOD_POINTS_REQUEST_BAND; } // The hold's density estimate, reused by retirement (T11): scale the drill @@ -1013,12 +1033,10 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { w: d.w, h: d.h, max: d.max, normMax, colormap: d.colormap || g.density.colormap, color: d.color ? parseColor(view.root, d.color, [0.3, 0.47, 0.66, 1]) : g.density.color, xRange: d.x_range, yRange: d.y_range, - // Request-elision facts (T13): the window's point count, and — for - // pyramid-served replies — the finest cell size this trace's aggregate - // tier can attain, so lodDensityCacheServes knows when this texture is - // already as sharp as a re-request could be. + // Request-gating fact (T13): the window's exact point count — the local + // estimate lodAggregateStands scales to decide whether a view is close + // enough to points territory to be worth a round-trip at all. visible: upd.visible, - minCell: Array.isArray(d.min_cell) ? d.min_cell : null, grid, rgba, filter, @@ -1056,7 +1074,6 @@ function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { if (fade < 1) { view._drawDensity(g, prev, (1 - fade) * opacityScale); view._drawDensity(g, density, fade * opacityScale); - lodDrawDensityDetail(view, g, density, opacityScale); view.draw(); return; } @@ -1067,15 +1084,6 @@ function lodDrawDensityWithFade(view, g, density, opacityScale = 1) { if (density === g.density) g._densityFadeStart = null; } view._drawDensity(g, density, opacityScale); - lodDrawDensityDetail(view, g, density, opacityScale); -} - -// The finer-detail layer rides every backdrop draw (T13): painted after the -// primary (and after both crossfade layers) so the sharpest fetched region -// stays on top while the rest of the view shows the broad context. -function lodDrawDensityDetail(view, g, density, opacityScale) { - const detail = lodDensityDetailForView(view, g, density); - if (detail && detail.tex) view._drawDensity(g, detail, opacityScale); } // The drilled frame's backdrop opacity, eased continuously (T10). The diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 87d2988c..3512a5f1 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2110,6 +2110,10 @@ export class ChartView { w: d.w, h: d.h, max: d.max, normMax: d.max, colormap: d.colormap, color: d.color ? parseColor(this.root, d.color, [0.3, 0.47, 0.66, 1]) : null, xRange: d.x_range, yRange: d.y_range, + // The home window's count seeds lodAggregateStands (T13): every + // zoom's points-band estimate starts from this until a closer + // window's reply recalibrates it. + visible: t.visible, grid: lodCopyGrid(grid), rgba, filter, diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index ee7c276c..345d6da4 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -2,7 +2,7 @@ import { payloadBuffers } from "./00_header"; import { buildLutData } from "./10_colormaps"; import { parseColor } from "./20_theme"; import { - lodApplyDensityUpdate, lodApplyDrill, lodDensityCacheServes, lodDrillServesView, + lodAggregateStands, lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, lodDropDrill, lodPromoteCachedDrill, lodRememberDensity, } from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; @@ -39,8 +39,7 @@ Object.assign(ChartView.prototype, { // goes neither pending nor on the wire. The seq bump above stands, so // an in-flight reply for an older, wider view dies stale instead of // yanking the exact marks out from under the view it can't improve. - if (this._drillServesView(g, view) || - this._densityCacheServesView(g, view, plotW, plotH)) { + if (this._drillServesView(g, view) || this._aggregateStandsForView(g, view)) { g._lodPendingView = null; g._lodPendingSeq = null; g._lodPendingAt = null; @@ -88,12 +87,12 @@ Object.assign(ChartView.prototype, { const sendNow = this._now(); for (const g of this.gpuTraces) { if (g.tier !== "density") continue; - // T12/T13 re-check at actual send time: a drill or an adequate - // cached texture that landed during the debounce elides the - // request it made unnecessary; one that died during it re-arms - // the request the schedule-time check skipped. - if (this._drillServesView(g, view) || - this._densityCacheServesView(g, view, plotW, plotH)) { + // T12/T13 re-check at actual send time: a drill that landed during + // the debounce — or a reply that moved the view back out of the + // points band — elides the request it made unnecessary; a drill + // that died during it re-arms the request the schedule-time check + // skipped. + if (this._drillServesView(g, view) || this._aggregateStandsForView(g, view)) { if (g._lodPendingSeq === seq) { g._lodPendingView = null; g._lodPendingSeq = null; @@ -167,14 +166,13 @@ Object.assign(ChartView.prototype, { return now - last.sentAt < 1200 ? last : null; }, - // Density-side elision (T13): a cached texture that is as detailed as - // anything the kernel could return for this view (lodDensityCacheServes) - // makes the round-trip moot — the pyramid cannot sharpen past its source - // cells, and zoom-outs inside an exact grid already have their texels. - _densityCacheServesView(g, view, plotW, plotH) { + // The aggregate never refines (T13, revised): the covering texture stands + // until the view could plausibly resolve into real points, so a density + // request goes out only inside that band (lodAggregateStands). + _aggregateStandsForView(g, view) { const [x0, x1] = this._axisRange(g.xAxis, view); const [y0, y1] = this._axisRange(g.yAxis, view); - return lodDensityCacheServes(this, g, x0, x1, y0, y1, plotW, plotH); + return lodAggregateStands(this, g, x0, x1, y0, y1); }, // A reply whose seq lost the global race can still be current in substance: diff --git a/python/xy/config.py b/python/xy/config.py index 93c319ae..947097a8 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -65,14 +65,16 @@ DENSITY_TARGET_POINTS_PER_CELL = 16.0 # Deterministic point sample retained with the FIRST density payload only -# (§28/#225): the client draws it solely when the view it would describe could -# be points-tier (estimated in-view count within the drill budget) — a -# fixed-size sample above that resolution reads as individual data points at a -# zoom where real points are sub-pixel, misrepresenting the dataset. Its other -# job is unchanged: it is the standalone (kernel-less) re-bin worker's CPU -# source. Interactive density_view replies ship no samples at all — the +# (§28/#225): interactive density_view replies ship no samples at all — the # density surface already wears the data's own colors (LOD doc §2), and real -# points arrive the moment a window fits the budget. +# points arrive the moment a window fits the budget. Kernel-attached clients +# never DRAW the retained sample either (a fixed-size sample reads as +# individual data points at zooms where real points are sub-pixel or one +# request away); they use it CPU-side as the distribution-true estimator for +# the points-band request gate (LOD doc T13, `lodSampleViewCount`). The +# standalone (kernel-less) client keeps it as its re-bin worker's CPU source +# and draws it below the resolvable-count gate, where it is the only point +# representation that build will ever have. DENSITY_SAMPLE_TARGET = 8_192 DENSITY_SAMPLE_SEED = 0 diff --git a/python/xy/interaction.py b/python/xy/interaction.py index e544077f..feb3b140 100644 --- a/python/xy/interaction.py +++ b/python/xy/interaction.py @@ -504,8 +504,8 @@ def _decode_log_u8(buf: bytes, gmax: float) -> np.ndarray: def _pyramid_source_shape( t: Any, lo_x: float, hi_x: float, lo_y: float, hi_y: float -) -> tuple[int, int, float, float] | None: - """The pyramid's finest-level cell budget under a window, plus cell size. +) -> tuple[int, int] | None: + """The pyramid's finest-level cell budget under a window, per axis. Wire economy (§29 / #225 follow-up): composing a pyramid-served window to full screen resolution upsamples blocky base cells into a grid several @@ -513,10 +513,7 @@ def _pyramid_source_shape( content is a few hundred KB of source cells. Clamping the composed grid to `(cells under the window at the finest level) + 1` per axis ships the same detail (the client's own texture filtering reproduces the upscale) - at a fraction of the bytes. The returned per-axis cell sizes ride the - reply as `min_cell`: the finest resolution this trace's aggregate tier - can attain, which lets the client skip requests a cached texture already - answers (LOD doc T13). + at a fraction of the bytes. """ base = int(getattr(t, "_pyr_base_dim", 0) or PYRAMID_BASE_DIM) ex0, ex1, ey0, ey1 = t.x.min, t.x.max, t.y.min, t.y.max @@ -525,9 +522,7 @@ def _pyramid_source_shape( return None frac_x = min(1.0, max(0.0, (hi_x - lo_x) / span_x)) frac_y = min(1.0, max(0.0, (hi_y - lo_y) / span_y)) - cells_x = max(1, math.ceil(base * frac_x) + 1) - cells_y = max(1, math.ceil(base * frac_y) + 1) - return cells_x, cells_y, span_x / base, span_y / base + return max(1, math.ceil(base * frac_x) + 1), max(1, math.ceil(base * frac_y) + 1) def _padded_drill_window( @@ -692,10 +687,6 @@ def density_view( # budget we fall through to the exact scan that drilling needs anyway. binning = "exact" grid = None - # Finest attainable per-axis cell size for pyramid-served replies (data - # units); rides the reply as `min_cell` so the client knows when a cached - # texture is already as sharp as this tier can get (LOD doc T13). - min_cell: list[float] | None = None # Mean point color plane (LOD doc §2): channel-bearing traces ship it # alongside the counts wherever a grid is produced below. rgba_grid: np.ndarray | None = None @@ -734,9 +725,7 @@ def density_view( # Wire economy: never compose more grid cells than the finest # level resolves under this window — a full-screen grid of # upsampled base cells is the same picture at several times the - # bytes (the client's texture filtering does the upscale). The - # attainable cell size rides the reply as `min_cell` so the - # client can elide requests a cached texture already answers. + # bytes (the client's texture filtering does the upscale). gw, gh = plan.grid_w, plan.grid_h source = _pyramid_source_shape(t, lo_x, hi_x, lo_y, hi_y) if source is not None: @@ -758,8 +747,6 @@ def density_view( visible = plan.visible w, h = gw, gh binning = f"pyramid-L{level}{'-upsampled' if no_rescan and level == 0 else ''}" - if source is not None: - min_cell = [source[2], source[3]] lod.exit_drill(t) # Tier-3 spatial index: when the pyramid can only serve this window blurry # (upsampled), re-bin it *exactly* from just its in-window points — as long @@ -803,7 +790,6 @@ def density_view( visible = in_window binning = "spatial-exact" density_filter = "nearest" - min_cell = None # exact full-resolution bins replace the pyramid grid lod.exit_drill(t) if grid is None and no_rescan: # No pyramid (below PYRAMID_MIN_POINTS is impossible here) or compose @@ -830,7 +816,6 @@ def density_view( lod.exit_drill(t) if grid is None: rgba_grid = None - min_cell = None sel = kernels.range_indices(xv, yv, lo_x, hi_x, lo_y, hi_y) plan = lod.plan_view_lod(request, len(sel), SCATTER_DENSITY_THRESHOLD, t.drill_mode) visible = plan.visible @@ -880,8 +865,6 @@ def density_view( "x_range": [lo_x, hi_x], "y_range": [lo_y, hi_y], } - if min_cell is not None: - density["min_cell"] = min_cell if rgba_grid is not None: density["rgba"] = writer.add_u8(np.ascontiguousarray(rgba_grid).reshape(-1)) density["color_agg"] = "mean" diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 761faf4b..5e55d07c 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -423,8 +423,7 @@ re-exports several of them as a historic import path and is not listed for those | `DRILL_PAD_SPAN_CAP` | `64.0` | Hard per-axis cap on a padded drill window's span (× the view span), kept well under the client's §16 re-encode bound (1/256 of window span) so deep zooms can always re-tighten the f32 offset encoding. | `interaction.py` | | `DRILL_HISTORY_KEEP` | `8` | Recent drilled subsets kept resolvable per trace, so picks against a retired cached point window (T13) still translate exactly; older seqs drop the pick, data changes clear the history. | `lod.py` (`enter_drill`, `drill_history`), `interaction.py` (`pick`) | | `LOD_POINT_CACHE_WINDOWS` | `3` | Retired exact point windows kept per trace client-side beyond the live drill; LRU-bounded VRAM, swept by the T11 outgrown rule. | `js/src/45_lod.ts` (`lodRetireDrill`, `lodPromoteCachedDrill`) | -| `LOD_DENSITY_ELIDE_EST_FACTOR` / `LOD_DENSITY_ELIDE_MAX_AREA_RATIO` | `2` / `8` | Guards on density-side request elision (T13): a cached texture at attainable resolution answers a contained view only while the estimated in-view count exceeds `budget × 2` (the exact-rebin/points regimes stay reachable) and the cached window is at most 8× the view's area (the uniform-density estimate overshoots in sparse corners; deeper dives re-request). | `js/src/45_lod.ts` (`lodDensityCacheServes`) | -| `LOD_DENSITY_DETAIL_MIN_COVER` | `0.05` | Minimum share of the view a finer cached texture must overlap to draw as the detail layer over the broad backdrop — a sliver seam is worse than blur. | `js/src/45_lod.ts` (`lodDensityDetailForView`) | +| `LOD_POINTS_REQUEST_BAND` | `4` | The aggregate tier never refines (T13, revised): a `density_view` goes out only when the estimated in-view count sits within `budget × 4` of points territory — the LOWER of an area-scaled cached-window count and the retained sample counted in-view (`lodSampleViewCount`, distribution-true where area-scaling over-estimates sparse tails). Outside the band the covering texture stands, however blurry — an accepted, recorded cost. | `js/src/45_lod.ts` (`lodAggregateStands`) | | `DEFAULT_PALETTE` | 10 CVD-safe hex entries | Per-trace default color cycle and the fallback categorical palette when a channel supplies none (§20/§36). | `marks.py`, `_payload.py`, `_svg.py`, `_raster.py` | | `PYRAMID_MIN_POINTS` | `2_000_000` | Trace size at/above which a Tier-3 tile pyramid is built lazily; smaller traces never pay for one. | `interaction.py` | | `PYRAMID_BASE_DIM` | `2048` | Edge of the pyramid's base level in cells (`dim²` u32 counts, ~1/3 overhead for the coarser levels); sets resident pyramid bytes. | `interaction.py` | diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 3293c20f..8cff70ca 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -365,10 +365,13 @@ invariants so future kinds don't regress them: aggregate) — and the density surface, which wears the data's own colors (§2), stands alone. Interactive `density_view` replies therefore ship NO sample at all: the only retained overlay is the first-payload one, which - doubles as the standalone re-bin worker's CPU source and draws only below - the gate (kernel mode ships real points before that, so the hybrid look is - transient at most; standalone deep zooms keep it as their only point - representation; datasets under the budget keep it everywhere). For the + doubles as the standalone re-bin worker's CPU source. A KERNEL-ATTACHED + client never draws it at any zoom (field follow-up): wherever a view is + resolvable the kernel ships REAL points, and a handful of retained sample + rows at full alpha there reads as data that isn't. The overlay is the + standalone (kernel-less) client's fallback — drawn only below the + resolvability gate, where it is the only point representation that build + will ever have. For the overlays that DO draw, window pairing holds: every sample rides the density cache entry it was computed for (`density.overlay`), and each frame draws the overlay of the best cached window for the current view @@ -490,38 +493,50 @@ invariants so future kinds don't regress them: (bounded by the same 1200ms window as the T8 hold, so a lost reply can't suppress forever). - The same economy governs the AGGREGATE tier's own traffic (the 100M field - HAR: ~2.7 MB full-screen grids re-shipped on every pan/zoom step at - 200–450%, drawn over a home-texture fallback that blurred the frame the - moment a pan left the freshest window): - - **Source-clamped grids:** a pyramid-served reply never composes more - cells than the finest level resolves under the window - (`interaction._pyramid_source_shape` — `ceil(base·frac)+1` per axis); - a full-screen grid of upsampled base cells is the same picture at - several times the bytes, and the client's texture filtering reproduces - the upscale. The attainable per-axis cell size rides the reply as - `density.min_cell`; exact/spatial grids (true full-detail bins) omit it. - - **Density-side request elision** (`lodDensityCacheServes`): a cached - texture that CONTAINS the view and is as detailed as anything the kernel - could return — one texel per screen pixel, or already at `min_cell` - (zooming into a source-limited texture cannot sharpen it) — answers the - view with no round-trip. Guards keep the kernel in charge where it can - do better: the estimated in-view count (window count × area share) must - exceed `LOD_DIRECT_POINT_BUDGET × LOD_DENSITY_ELIDE_EST_FACTOR` (the - exact-rebin/points regimes stay reachable), and the cached window may be - at most `LOD_DENSITY_ELIDE_MAX_AREA_RATIO` × the view's area (the - uniform-density estimate overshoots in sparse corners; a deep dive - re-requests so real counts decide). Entries without recorded counts - never elide. - - **Finer-detail layering** (`lodDensityDetailForView`): when no fine - cached window contains the view, the smallest cached texture overlapping - ≥ `LOD_DENSITY_DETAIL_MIN_COVER` of it draws ON TOP of the broad - backdrop (after every crossfade layer), so the already-fetched region - stays sharp while the request for the rest is in flight — a resolution - seam at the window edge beats uniform blur (standard tile-pyramid - behavior). A fresh grid for an already-cached window supersedes its - unpinned twin in the cache (`lodRememberDensity` dedupe), so elision - always reads current facts. + The AGGREGATE tier's own traffic is governed by a stronger rule, adopted + from field feedback on the 100M drilldown (a HAR showed ~2.7 MB + full-screen grids re-shipped on every pan/zoom step at 200–450% zoom for + what was the same aggregate with marginally different blur): **the + aggregate never refines.** Whatever density texture already covers the + view stands — however blurry — until the view could plausibly resolve + into REAL points; only then is a round-trip worth anything, and the + kernel answers it with exact points once the window's count fits the + budget. Blur at intermediate zooms is an accepted, recorded cost. + - **The points-band gate** (`lodAggregateStands`): a `density_view` goes + out only when the estimated in-view count sits within + `LOD_DIRECT_POINT_BUDGET × LOD_POINTS_REQUEST_BAND`. Two independent + estimators, LOWER wins (an over-estimate must never hold a view in blur + when either signal says points could be close): the smallest cached + density window CONTAINING the view (recorded `visible`, exact for its + window, area-scaled — seeded by the home texture's first-payload + count), and the retained deterministic sample counted in-view and + re-weighted by `visible/n` (`lodSampleViewCount`) — a fixed-rate + thinning of the whole trace, so it follows the data's ACTUAL + distribution where area-scaling assumes uniformity and over-estimates + sparse tails by orders of magnitude (~65 expected sample rows right at + the band ⇒ ±12% noise; kernel-attached clients never DRAW the sample, + T9 — estimating from it CPU-side is what it is retained for). A trace + with no recorded counts anywhere always requests. A fresh grid for an + already-cached window supersedes its unpinned twin + (`lodRememberDensity` dedupe), so the gate always reads current facts. + - **Source-clamped grids:** the transition-band replies that do go out + stay cheap — a pyramid-served reply never composes more cells than the + finest level resolves under the window + (`interaction._pyramid_source_shape`, `ceil(base·frac)+1` per axis); a + full-screen grid of upsampled base cells is the same picture at several + times the bytes, and the client's texture filtering reproduces the + upscale. Exact/spatial grids (true full-detail bins) keep screen + resolution. + - **One texture per frame (recorded reversal):** a fine-over-broad detail + layer — drawing the smallest cached texture overlapping the view on top + of the broad backdrop during pans — was tried and REVERTED: density + textures alpha-composite, so the overlap double-counts opacity, and + each window's texture is baked against its own eased normMax, so the + seam is also a brightness step (field capture: a stale darker + rectangle). Doing it right requires the backdrop scissored out of the + detail region plus a shared normalization across cached textures; + under the never-refines rule the case barely arises, so the tier draws + one texture per frame. Any new tiered kind must state how it satisfies T1–T13 in its chart-kind contract entry before it lands. diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index d90c1e17..0f94dde9 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -74,11 +74,13 @@ already answered there is nothing to refresh (replies are deterministic for unchanged data; data changes rebuild the GPU trace and reset the memo), and if it is still in flight the trace keeps waiting on the original request's `seq`, whose reply is then accepted per-trace instead of dying to the global -seq race. Independently, a cached density texture that contains the view and -is as detailed as anything the kernel could return — one texel per screen -pixel, or already at the trace's attainable `min_cell` resolution — elides -the request entirely, guarded so the exact-rebin and points regimes stay -reachable (LOD doc T13). +seq race. More fundamentally, the aggregate tier never refines (LOD doc T13, +revised): a `density_view` goes out only when the estimated in-view count — +the smallest cached window containing the view, area-scaled, seeded by the +first payload's recorded count — sits within the points band +(`LOD_DIRECT_POINT_BUDGET × LOD_POINTS_REQUEST_BAND`). Outside that band the +covering texture stands, however blurry; traces with no recorded counts +always request. **`pick`** — `trace` and `index` pass through `_integer_id`. `index` is a *shipped-vertex* index, translated kernel-side to a canonical row when the @@ -157,10 +159,10 @@ states which representation this view resolved to: resolution** — never more cells per axis than the finest level resolves under the window (a full-screen grid of upsampled cells is the same picture at several times the bytes; the client's texture filtering does - the upscale) — and carry `min_cell: [cx, cy]`, the finest attainable - per-axis cell size in data units, which the client's request elision reads - (LOD doc T13). Exact and spatial grids are true full-detail bins and omit - `min_cell`. `x_range`/`y_range` are raw data endpoints, but the + the upscale). Exact and spatial grids are true full-detail bins at screen + resolution. `visible` — the window's point count — is the fact the + client's points-band gate scales to decide whether any later view is + worth a request at all (LOD doc T13). `x_range`/`y_range` are raw data endpoints, but the grid's cells are **uniform in the axis's scale coordinates** (identical to raw on a linear axis): on a log/symlog axis the kernel bins transformed values so every cell covers the same strip of screen, and renderers diff --git a/tests/test_density_request_economy.py b/tests/test_density_request_economy.py index e4e0100d..86e820b8 100644 --- a/tests/test_density_request_economy.py +++ b/tests/test_density_request_economy.py @@ -1,22 +1,19 @@ -"""Density requests are elided, deduped, and drawn sharp (LOD doc T13). - -The field HAR behind this (100M live drilldown, 200-450% zoom): every pan and -zoom step re-requested a ~2.7 MB full-screen density grid — including -back-to-back windows differing by sub-pixel amounts — while the draw path fell -back to the blurriest cached texture the moment a pan left the freshest -window. Three client rules fix it: - -- **source-resolution elision**: a cached texture that already sits at the - kernel's finest attainable aggregate cell size (`min_cell`, pyramid-served - replies) answers any contained view — zooming further in cannot sharpen it — - guarded so the exact/drill regimes stay reachable (estimated in-view count - above the budget band, bounded window/view area ratio); -- **sub-texel request dedup**: a request within half an output texel of the - trace's last sent request is suppressed (answered → nothing to refresh; - in flight → keep waiting on the original seq); -- **finer-detail layering**: when no fine window contains the view, the - smallest cached texture overlapping it draws on top of the broad backdrop - instead of the frame dropping to uniform blur. +"""The aggregate tier never refines; requests only probe the points band +(LOD doc T13, revised on #225 field feedback). + +The field HAR behind this (100M live drilldown): every pan and zoom step +re-requested a multi-MB density grid — including back-to-back windows +differing by sub-pixel amounts — for a picture that was the same aggregate +with marginally different blur. The revised contract: whatever density +texture already covers the view STANDS, however blurry, until the view could +plausibly resolve into REAL points (estimated in-view count within +LOD_POINTS_REQUEST_BAND × the direct budget); only then does a density_view +go out, and the kernel answers it with exact points once the count fits. +Sub-texel twins of the last sent request are suppressed outright, and the +frame always draws ONE aggregate texture (a tried fine-over-broad detail +layer was reverted: density textures alpha-composite, so overlaps +double-count opacity, and per-window normalization makes the seam a +brightness step — the field capture's "stale rectangle"). This drives the real client in headless Chromium with a captured comm. """ @@ -61,8 +58,16 @@ y0: v0.y0 + sy * fcy - sy * fspany / 2, y1: v0.y0 + sy * fcy + sy * fspany / 2, }); - // A pyramid-served reply: grid at the trace's SOURCE resolution - // (cell == min_cell), `visible` points in-window. + // The home texture carries the payload's recorded count (60k here). + const homeHasCount = Number.isFinite(g.density.visible); + // Model a huge cloud: this probe chart holds 60k points (points-band + // everywhere, so nothing would ever stand). Re-weight the retained + // sample's claim so each sample row represents ~1.2M points — the + // distribution-true estimator then reads like a 10B-point trace, and + // the window-count fixtures below drive the area estimator. + g.sampleOverlay.sample.visible = 10000000000; + + // A density reply for window W = central 50% of home, 10M in-window. const applyDensity = (x0d, x1d, y0d, y1d, gw, gh, visible) => { const grid = new Float32Array(gw * gh).fill(3); view._onKernelMsg({ @@ -73,32 +78,26 @@ density: { buf: 0, w: gw, h: gh, max: 3, x_range: [x0d, x1d], y_range: [y0d, y1d], - min_cell: [(x1d - x0d) / gw, (y1d - y0d) / gh], }, }], }, [grid.buffer]); }; - // Window W = central 50% of home, 10M points in-window. const wx0 = cx - sx * 0.25, wx1 = cx + sx * 0.25; const wy0 = cy - sy * 0.25, wy1 = cy + sy * 0.25; applyDensity(wx0, wx1, wy0, wy1, 200, 150, 10000000); - const cached = g.density; - const storedFacts = Number.isFinite(cached.visible) && !!cached.minCell; - // Zooming INSIDE W (half its span, area ratio 4): the kernel could not - // return anything sharper than the cached source-resolution texture, and - // ~2.5M estimated points are nowhere near drill — no request. + // Zooming inside W at half its span: ~2.5M estimated in view — nowhere + // near points territory, so the texture stands and NOTHING is requested, + // pending markers included. const insideElided = request(viewAt(0.5, 0.5, 0.25, 0.25)) === 0; const elisionClearedPending = g._lodPendingView === null; - // A deep dive (area ratio 16 > 8) re-requests: the uniform-density - // estimate overshoots in sparse corners, so let the kernel re-decide. - const deepDiveRequested = request(viewAt(0.5, 0.5, 0.12, 0.12)) === 1; + // Deeper inside W (1/16 of its area): ~625k estimated — inside the + // points band (budget x 4), so the request goes out and the kernel + // decides with real counts. + const bandRequested = request(viewAt(0.5, 0.5, 0.12, 0.12)) === 1; - // Near the exact/drill regime the kernel CAN do better: a separate - // window with only 300k in-window — a half-span view inside it - // estimates ~75k, points territory, so the request must go out even - // though the cached texture is source-limited. + // A window whose count is already points-scale requests immediately. const w2x0 = v0.x0 + sx * 0.10, w2x1 = v0.x0 + sx * 0.40; const w2y0 = v0.y0 + sy * 0.10, w2y1 = v0.y0 + sy * 0.40; applyDensity(w2x0, w2x1, w2y0, w2y1, 100, 75, 300000); @@ -106,7 +105,9 @@ // Sub-texel dedup: a settle re-request shifted by 0.2 output texels is // the same picture — suppressed while the first is in flight; a shift of - // 3 texels is a genuinely different window — sent. + // 3 texels is a genuinely different window — sent. (These views sit + // inside the 60k home count, i.e. always in the points band, so only + // the dedup memo gates them.) const plotW = Math.round(view.plot.w); const base = viewAt(0.75, 0.75, 0.2, 0.2); const sentBase = request(base) === 1; @@ -116,30 +117,27 @@ const shifted = { ...base, x0: base.x0 + texel * 3, x1: base.x1 + texel * 3 }; const texelsRequested = request(shifted) === 1; - // Finer-detail layering: pan half-out of W (not contained, ~50% overlap). - // The home texture is the containing backdrop, but W's fine texture must - // draw ON TOP of it instead of the frame dropping to home's blur. + // One aggregate texture per frame (the reverted detail layer must stay + // reverted): straddle W's edge — home contains the view and draws alone + // once the crossfade settles; W's finer texture must NOT stack on top. view.view = viewAt(0.75, 0.5, 0.5, 0.5); view._drawNow(); - clk += 1000; // land the density crossfade so the detail layer engages + clk += 1000; // land the density crossfade view._drawNow(); - const drawnRanges = []; + let densityDraws = 0; const real = view._drawDensity; view._drawDensity = function (gg, dd, op) { - if (gg === g && dd && dd.xRange) drawnRanges.push([dd.xRange[0], dd.xRange[1], op]); + if (gg === g) densityDraws++; return real.apply(this, arguments); }; view._drawNow(); view._drawDensity = real; - const fineIdx = drawnRanges.findIndex((r) => Math.abs(r[0] - wx0) < sx * 1e-9); - const broadIdx = drawnRanges.findIndex((r) => r[0] < wx0 - sx * 0.1); - const detailDrawn = fineIdx >= 0 && broadIdx >= 0 && fineIdx > broadIdx; document.body.setAttribute("data-xy-economy-probe", JSON.stringify({ - hasDensity: !!g, storedFacts, - insideElided, elisionClearedPending, deepDiveRequested, nearDrillRequested, + hasDensity: !!g, homeHasCount, + insideElided, elisionClearedPending, bandRequested, nearDrillRequested, sentBase, subTexelSuppressed, texelsRequested, - drawnCount: drawnRanges.length, detailDrawn, + densityDraws, })); } catch (err) { document.body.setAttribute( @@ -167,7 +165,7 @@ def _density_html() -> str: return html -def test_density_requests_elided_deduped_and_layered(tmp_path: Path) -> None: +def test_aggregate_stands_until_points_band(tmp_path: Path) -> None: chromium = find_chromium() if chromium is None: pytest.skip("Chromium unavailable") @@ -182,18 +180,18 @@ def test_density_requests_elided_deduped_and_layered(tmp_path: Path) -> None: ) assert result["hasDensity"] is True - assert result["storedFacts"] is True - # Source-resolution elision: a zoom inside a source-limited cached window - # sends nothing (the kernel cannot sharpen it) and clears its pending - # marker like every other elision. + assert result["homeHasCount"] is True + # The aggregate stands: no refinement requests while the estimated + # in-view count sits clearly above points territory. assert result["insideElided"] is True assert result["elisionClearedPending"] is True - # The guards keep the kernel in charge where it can do better. - assert result["deepDiveRequested"] is True + # The points band and points-scale windows still request — the kernel + # keeps deciding with real counts everywhere it could answer sharper. + assert result["bandRequested"] is True assert result["nearDrillRequested"] is True # Sub-texel dedup: same picture, no second request; a real shift sends. assert result["sentBase"] is True assert result["subTexelSuppressed"] is True assert result["texelsRequested"] is True - # The finest overlapping cached texture draws over the broad backdrop. - assert result["detailDrawn"] is True + # Exactly one aggregate texture per settled frame — no detail stacking. + assert result["densityDraws"] == 1 diff --git a/tests/test_density_sample_resolvability_gate.py b/tests/test_density_sample_resolvability_gate.py index b811248f..14dbc2fc 100644 --- a/tests/test_density_sample_resolvability_gate.py +++ b/tests/test_density_sample_resolvability_gate.py @@ -16,7 +16,9 @@ holds far more points than the budget must NOT draw even when its window covers the view exactly (badge off — density-only), while zooming deep enough into the same window that the estimated in-view count fits the budget brings -the overlay (and badge) back. +the overlay (and badge) back. With a kernel attached the overlay never draws +at all — resolvable views get REAL points from the kernel, so retained +sample rows there read as data that isn't (#225 field follow-up). """ from __future__ import annotations @@ -132,11 +134,23 @@ const atHome = sampleDrawn(); const atHomeBadge = badgeShowsSample(); + // Kernel-attached clients NEVER draw retained samples (#225 field + // follow-up): wherever a view is resolvable, the kernel ships REAL + // points — a handful of arbitrary sample rows at full alpha there reads + // as data that isn't. The overlay is the standalone client's fallback. + view.comm = { send: () => {} }; + const kernelAtHome = sampleDrawn(); + zoomTo(1 / 16); + const kernelDeepIn = sampleDrawn(); + const kernelBadge = badgeShowsSample(); + view.comm = null; + view.view = { ...v0 }; + document.body.setAttribute("data-xy-gate-probe", JSON.stringify({ hasOverlays: !!(g.densityCache && g.densityCache.some((d) => d && d.overlay)), windowN: N, atWindow, atWindowBadge, atBigHome, deepIn, deepInBadge, midway, - atHome, atHomeBadge, + atHome, atHomeBadge, kernelAtHome, kernelDeepIn, kernelBadge, })); } catch (err) { document.body.setAttribute( @@ -194,6 +208,11 @@ def test_sample_overlay_gated_by_resolvable_count(tmp_path: Path) -> None: assert result["deepIn"]["alpha"] == 1 assert result["deepInBadge"] is True # A dataset under the budget is resolvable everywhere: its home overlay - # keeps drawing (nothing changes for small charts). + # keeps drawing (nothing changes for small charts) — in STANDALONE mode. assert result["atHome"] is not None assert result["atHomeBadge"] is True + # With a kernel attached the overlay never draws at any zoom: resolvable + # views get real points from the kernel, not retained sample rows. + assert result["kernelAtHome"] is None + assert result["kernelDeepIn"] is None + assert result["kernelBadge"] is False diff --git a/tests/test_density_wire_economy.py b/tests/test_density_wire_economy.py index 40855112..5b2011b4 100644 --- a/tests/test_density_wire_economy.py +++ b/tests/test_density_wire_economy.py @@ -16,7 +16,6 @@ import math import numpy as np -import pytest from xy import interaction from xy._figure import Figure @@ -37,20 +36,18 @@ def __init__(self): t = _T() # A quarter-extent window resolves ~256 (+1 straddle) source cells. - cells_x, cells_y, cell_x, cell_y = interaction._pyramid_source_shape(t, 10.0, 35.0, 5.0, 17.5) + cells_x, cells_y = interaction._pyramid_source_shape(t, 10.0, 35.0, 5.0, 17.5) assert cells_x == math.ceil(1024 * 0.25) + 1 assert cells_y == math.ceil(1024 * 0.25) + 1 - assert cell_x == pytest.approx(100.0 / 1024) - assert cell_y == pytest.approx(50.0 / 1024) # Windows wider than the extent clamp to the full base. - cells_x, _, _, _ = interaction._pyramid_source_shape(t, -50.0, 250.0, 0.0, 50.0) + cells_x, _ = interaction._pyramid_source_shape(t, -50.0, 250.0, 0.0, 50.0) assert cells_x == 1024 + 1 # Degenerate extents refuse rather than divide by zero. t.y = _Col(5.0, 5.0) assert interaction._pyramid_source_shape(t, 0.0, 1.0, 0.0, 1.0) is None -def test_pyramid_reply_records_min_cell_and_exact_reply_does_not() -> None: +def test_pyramid_reply_grid_bounded_by_source_cells() -> None: n = 2_500_000 rng = np.random.default_rng(3) x = rng.uniform(0.0, 100.0, n) @@ -65,20 +62,19 @@ def test_pyramid_reply_records_min_cell_and_exact_reply_does_not() -> None: assert tr["binning"].startswith("pyramid") d = tr["density"] base = getattr(t, "_pyr_base_dim", 0) or PYRAMID_BASE_DIM - assert d["min_cell"][0] == pytest.approx((t.x.max - t.x.min) / base) - assert d["min_cell"][1] == pytest.approx((t.y.max - t.y.min) / base) # The grid never exceeds the source cells under the window (+1 straddle). assert d["w"] <= math.ceil(base * 0.5) + 1 + 1 assert d["h"] <= math.ceil(base * 0.5) + 1 + 1 + # The reply carries the window's count — the fact the client's + # points-band gate (lodAggregateStands, T13) scales for later zooms. + assert tr["visible"] > 0 # A window near the drill budget (~260k in-window: over the budget, under - # the pyramid-serve margin) takes the exact path: true full-detail bins, - # so no min_cell rides the reply (nothing finer exists to attain). + # the pyramid-serve margin) takes the exact path: true full-detail bins. upd2, _ = fig.density_view(0, 0.0, 35.0, 0.0, 30.0, 512, 384) tr2 = upd2["traces"][0] assert tr2["mode"] == "density" assert tr2["binning"] == "exact" - assert "min_cell" not in tr2["density"] def test_pyramid_grid_clamped_to_source_resolution() -> None: From e974fc016c084814c33b738ac1506cd07a15e6e2 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 19:44:25 +0000 Subject: [PATCH 08/11] =?UTF-8?q?Mean-color=20density=20composites=20like?= =?UTF-8?q?=20the=20points=20it=20aggregates=20(LOD=20doc=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field feedback on PR #227: the count->alpha tone curve made the surface's lightness swing against the very points it aggregates — visibly at the texture<->points boundary, and between windows (each reply normalized against its own max). The §2 philosophy already defines a cell as the physically downsampled image of its points; this applies that to ALPHA: - A channel-bearing cell's displayed alpha is now the physical compositing of its own points, 1 - (1 - a_pt)^count, with a_pt = channel alpha x trace style opacity folded INSIDE the exponent — a one-point cell reads exactly like one point, dense cells saturate past the style opacity exactly like overplotted marks. No window normalization touches these textures (the upload is max-independent), so exposure easing (T4) no longer applies to them and the draw path passes only transition fades in u_opacity (count-only grids keep the log ramp + style opacity uniform — count is their only structure). - Mean-color drills swap at native opacity with no intensity handoff: the texture composites like the marks replacing it, so density_val / lod_blend are ignored on such traces (still shipped; count-only drills keep the count-alpha ramp). The recorded cost: per-cell density beyond a few points no longer reads in the surface — exactly as saturated as the overplotted truth it downsamples; drill-in is where per-point structure returns. Same law in the client texture upload (lodWriteGridTexture), the SVG and native-raster exporters (shared _svg._physical_density_alpha), and the standalone re-bin worker (shared upload path). Spec: LOD doc §2 rules 1/2/5, T3/T4 scoping, tier table, Phase-1 notes, dossier renderer notes, config/marks warning text, CHANGELOG. Tests: new test_density_physical_alpha (headless-Chromium texture-byte probe: law values, no norm anim, no handoff) and an exporter-law unit test in test_density_mean_color; full suite green. --- CHANGELOG.md | 11 ++ js/src/45_lod.ts | 101 ++++++++++++------ js/src/50_chartview.ts | 15 ++- js/src/54_kernel.ts | 10 +- python/xy/_raster.py | 20 ++-- python/xy/_svg.py | 41 ++++++-- python/xy/config.py | 5 +- python/xy/marks.py | 2 +- spec/design-dossier.md | 10 +- spec/design/lod-architecture.md | 103 +++++++++++------- tests/test_density_mean_color.py | 30 +++++- tests/test_density_physical_alpha.py | 151 +++++++++++++++++++++++++++ 12 files changed, 389 insertions(+), 110 deletions(-) create mode 100644 tests/test_density_physical_alpha.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2288444c..e4564a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,17 @@ in the README). the view's estimated in-view count fits the direct point budget, i.e. when individual points are actually resolvable; real points still ship the moment a window fits the budget, so drilldown behavior is unchanged. +- **Mean-color density surfaces composite like the points they aggregate + (LOD doc §2).** A channel-bearing cell's displayed alpha is now the + physical compositing of its own points — `1 − (1 − ā)^count` for mean + point alpha ā — instead of a per-window log-count tone curve. Lightness + no longer swings between windows or across the texture↔points boundary + (the aggregate is exactly as saturated as overplotted real marks), the + texture upload is normalization-free (no exposure re-uploads), and + mean-color drills swap at native opacity with no intensity handoff. + Count-only (constant-color) surfaces keep the log ramp — count is their + only structure. Same law in the client, the SVG/PNG exporters, and the + standalone re-bin worker. - **The aggregate tier no longer refines; density requests only probe the points band (LOD doc T13).** Whatever density texture already covers the view stands — however blurry — until the estimated in-view count comes diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index dbea7c1d..3492839e 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -54,37 +54,58 @@ export function lodCopyGrid(f32) { return f32.slice ? f32.slice() : new Float32Array(f32); } -// Log tone-mapped grid upload: stable perception across renormalization, and -// the u_max swings between rebins compress logarithmically (§5/§F6). + +// Grid upload. Count-only grids upload as R8 log tone-mapped (the shader +// tints/LUTs them): count is their only structure, and the u_max swings +// between rebins compress logarithmically (§5/§F6). // -// Count-only grids upload as R8 (the shader tints/LUTs them). Mean-color -// grids (LOD doc §2) pass the straight-alpha RGBA plane shipped by the -// kernel: rgb = per-cell mean point color, a = mean point alpha. The texture -// bakes the count tone curve into the alpha and stores rgb PREMULTIPLIED — -// in sRGB space, exactly like the mark shaders' outputs — so bilinear -// filtering weights color by coverage instead of dragging occupied cells -// toward transparent-black neighbors. Exposure easing re-calls this per -// norm step; rgb is re-premultiplied against the eased alpha each time. +// Mean-color grids (LOD doc §2) pass the straight-alpha RGBA plane shipped +// by the kernel: rgb = per-cell mean point color, a = mean point alpha. +// Their displayed alpha is the PHYSICAL compositing of the cell's own +// points — `1 − (1 − a_pt)^k` for k points whose drawn per-point alpha is +// a_pt = channel alpha × the trace's style opacity (`pointAlpha`) — exactly +// what the eye would see if every point were drawn sub-pixel, so the +// texture and real marks agree on lightness at every zoom and the +// texture↔points swap never shifts tone (the old window-normalized +// log-count curve made lightness swing across that boundary and between +// windows). Style opacity folds INSIDE the exponent — dense cells saturate +// past it exactly like overplotted marks do — so the draw path applies only +// transition fades on top of these textures, never the style opacity again. +// Count's display role reduces to occupancy and the compositing exponent; +// no per-window normalization touches these textures (max-independent +// upload). rgb stores PREMULTIPLIED — in sRGB space, exactly like the mark +// shaders' outputs — so bilinear filtering weights color by coverage +// instead of dragging occupied cells toward transparent-black neighbors. // `filter` picks the sampling the reply asked for (spatial-exact grids ship // "nearest"); it applies to both texture layouts. -export function lodWriteGridTexture(gl, tex, f32, w, h, maxVal, rgba = null, filter = "linear") { +export function lodWriteGridTexture( + gl, tex, f32, w, h, maxVal, rgba = null, filter = "linear", pointAlpha = 1, +) { const denom = Math.log1p(Math.max(0, maxVal || 0)); let data; if (rgba) { data = new Uint8Array(f32.length * 4); - if (denom > 0) { - for (let i = 0; i < f32.length; i++) { - const c = f32[i]; - if (!(c > 0) || !Number.isFinite(c)) continue; - const t = Math.min(1, Math.log1p(c) / denom); - const shaped = Math.min(1, t * 1.35) * (rgba[i * 4 + 3] / 255); - if (shaped <= 0) continue; - const a = Math.max(1, Math.round(255 * shaped)); - data[i * 4] = Math.round(rgba[i * 4] * a / 255); - data[i * 4 + 1] = Math.round(rgba[i * 4 + 1] * a / 255); - data[i * 4 + 2] = Math.round(rgba[i * 4 + 2] * a / 255); - data[i * 4 + 3] = a; - } + // ln(1 − a_pt) per possible channel-alpha byte, so the per-cell work is + // one exp(). NaN marks saturation (a_pt == 1 → any occupied cell is 1). + const op = Math.min(1, Math.max(0, pointAlpha)); + const logOneMinus = new Float64Array(256); + for (let a8 = 0; a8 < 256; a8++) { + const aPt = (a8 / 255) * op; + logOneMinus[a8] = aPt >= 1 ? NaN : Math.log1p(-aPt); + } + for (let i = 0; i < f32.length; i++) { + const c = f32[i]; + if (!(c > 0) || !Number.isFinite(c)) continue; + const a8 = rgba[i * 4 + 3]; + if (a8 === 0) continue; // an all-invisible cell never invents coverage + const lg = logOneMinus[a8]; + const shaped = Number.isNaN(lg) ? 1 : 1 - Math.exp(c * lg); + if (shaped <= 0) continue; + const a = Math.max(1, Math.round(255 * shaped)); + data[i * 4] = Math.round(rgba[i * 4] * a / 255); + data[i * 4 + 1] = Math.round(rgba[i * 4 + 1] * a / 255); + data[i * 4 + 2] = Math.round(rgba[i * 4 + 2] * a / 255); + data[i * 4 + 3] = a; } } else { data = new Uint8Array(f32.length); @@ -145,7 +166,7 @@ function lodStartNormAnim(view, g, start, target) { g.densityNormMax = target; lodWriteGridTexture( view.gl, g.density.tex, g.density.grid, g.density.w, g.density.h, target, - g.density.rgba, g.density.filter, + g.density.rgba, g.density.filter, view._fillOpacity(g.trace.style), ); return; } @@ -169,7 +190,10 @@ function lodStepNorm(view, g) { if (rel > 0.004 || t >= 1) { d.normMax = norm; g.densityNormMax = norm; - lodWriteGridTexture(view.gl, d.tex, d.grid, d.w, d.h, norm, d.rgba, d.filter); + lodWriteGridTexture( + view.gl, d.tex, d.grid, d.w, d.h, norm, d.rgba, d.filter, + view._fillOpacity(g.trace.style), + ); } if (t < 1) { view.draw(); @@ -820,12 +844,15 @@ export function lodApplyDrill(view, g, upd, buffers) { gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW); } view._pointMarkStyle(d, d.trace); - // Intensity-continuous handoff (§5): per-point local log-density + a blend - // weight. The density surface already wears the mean point color (LOD doc - // §2), so hue is continuous by construction; fresh at the boundary - // (blend≈1) each mark enters at its cell's count-alpha and deeper zooms - // ship smaller blends, easing marks to native opacity. - if (upd.density_val && upd.density_val.buf !== undefined) { + // Intensity handoff (§5): per-point local log-density + a blend weight. + // A mean-color surface needs NONE of it (§2, physical alpha): its texture + // composites exactly like the points themselves, so marks enter and exit + // at native opacity and the entry/exit alpha fades alone carry the swap — + // running the count-alpha ramp against a physically-composited backdrop + // would re-introduce the lightness step it exists to avoid. Count-only + // surfaces keep the ramp (their texture still wears the log tone curve). + const meanColorSurface = !!(g.density && g.density.rgba); + if (!meanColorSurface && upd.density_val && upd.density_val.buf !== undefined) { const dvalValues = upd.density_val.dtype === "u8" ? view._asU8(buffers[upd.density_val.buf]) : view._asF32(buffers[upd.density_val.buf]); @@ -1040,7 +1067,9 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { grid, rgba, filter, - tex: view._uploadGrid(grid, d.w, d.h, normMax, rgba, filter), + tex: view._uploadGrid( + grid, d.w, d.h, normMax, rgba, filter, view._fillOpacity(g.trace.style), + ), lut: g.density.lut, }; // Exact scans include a view-specific sample and replace the overlay. @@ -1051,7 +1080,11 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { if (Object.prototype.hasOwnProperty.call(d, "sample")) { view._applyDensitySample(g, d.sample, buffers); } - lodStartNormAnim(view, g, normMax, d.max); + // Exposure easing (T4) is a count-only concern: a mean-color texture's + // physical alpha is max-independent, so re-uploading it per norm step + // would rewrite identical bytes. + if (rgba) g._densityNormAnim = null; + else lodStartNormAnim(view, g, normMax, d.max); lodRememberDensity(view, g, g.density); } diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 3512a5f1..c9fb3fc2 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2117,7 +2117,7 @@ export class ChartView { grid: lodCopyGrid(grid), rgba, filter, - tex: this._uploadGrid(grid, d.w, d.h, d.max, rgba, filter), + tex: this._uploadGrid(grid, d.w, d.h, d.max, rgba, filter, this._fillOpacity(t.style)), lut: this._lut(d.colormap), }; g.sampleOverlay = this._buildDensitySample(t, d.sample, buffer); @@ -2864,10 +2864,10 @@ export class ChartView { return tex; } - _uploadGrid(f32, w, h, maxVal, rgba = null, filter = "linear") { + _uploadGrid(f32, w, h, maxVal, rgba = null, filter = "linear", pointAlpha = 1) { const gl = this.gl; const tex = gl.createTexture(); - lodWriteGridTexture(gl, tex, f32, w, h, maxVal, rgba, filter); + lodWriteGridTexture(gl, tex, f32, w, h, maxVal, rgba, filter, pointAlpha); return tex; } @@ -3453,7 +3453,14 @@ export class ChartView { this._axisCoord(xAxis, d.xRange[0]), this._axisCoord(xAxis, d.xRange[1]), this._axisCoord(yAxis, d.yRange[0]), this._axisCoord(yAxis, d.yRange[1]), ); - gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style) * opacityScale); + // Mean-color textures bake the style opacity INSIDE their physical + // compositing (LOD doc §2 rule 1 — dense cells saturate past it exactly + // like overplotted marks), so the uniform carries only the transition + // fades for them; count-only grids keep style opacity here as before. + gl.uniform1f( + u("u_opacity"), + (d.rgba ? 1 : this._fillOpacity(g.trace.style)) * opacityScale, + ); // Mean-color grids carry their colors in the texture (LOD doc §2); // count-only grids tint with the constant trace color or, failing that, // fall back to the LUT ramp (hand-built/legacy specs). diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 345d6da4..369dd8bb 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -239,7 +239,10 @@ Object.assign(ChartView.prototype, { const hd = g._homeDensity; this._applySampleRebinGrid(g, { ...hd, - tex: this._uploadGrid(hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.rgba, hd.filter), + tex: this._uploadGrid( + hd.grid, hd.w, hd.h, hd.normMax || hd.max || 1, hd.rgba, hd.filter, + this._fillOpacity(g.trace.style), + ), }, false); } return; @@ -336,7 +339,10 @@ Object.assign(ChartView.prototype, { xRange: [msg.x0, msg.x1], yRange: [msg.y0, msg.y1], grid, rgba, - tex: this._uploadGrid(grid, msg.w, msg.h, msg.max || 1, rgba), + tex: this._uploadGrid( + grid, msg.w, msg.h, msg.max || 1, rgba, "linear", + this._fillOpacity(g.trace.style), + ), lut: g.density.lut, }, true); }, diff --git a/python/xy/_raster.py b/python/xy/_raster.py index ce325ec8..2514840a 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -41,6 +41,7 @@ _heatmap_rgba_grid, _legend_layout, _lut, + _physical_density_alpha, _px_size, _resolve_static_css_vars, _Scale, @@ -1895,24 +1896,17 @@ def _emit_grid( dx, dy, dw, dh = _scene.grid_dest_rect(xr, yr, sx, sy) if g.get("rgba") is not None: # Mean point color per cell (LOD doc §2): rgb from the shipped - # plane; count drives only the alpha ramp, scaled by the cell's - # mean point alpha — the same law as _svg._density_image and the - # client's DENSITY_FS. Precomposed here and emitted as a plain - # image op; the count→LUT density op cannot express per-cell color. + # plane; displayed alpha is the PHYSICAL compositing of the + # cell's points (`_svg._physical_density_alpha` — the same law + # as _svg._density_image and the client's texture upload). + # Precomposed here and emitted as a plain image op; the + # count→LUT density op cannot express per-cell color. rgba_meta = cols[g["rgba"]] mean = np.frombuffer( blob, dtype=np.uint8, count=rgba_meta["len"], offset=rgba_meta["byte_offset"] ).reshape(h, w, 4) counts = _density_column(blob, meta, g).reshape(h, w) - gmax = float(g.get("max") or 1.0) or 1.0 - tnorm = np.clip(counts / gmax, 0.0, 1.0) - alpha = ( - np.clip(tnorm * 1.35, 0, 1) - * 255 - * _fill_opacity(style, 0.85) - * (mean[..., 3].astype(np.float64) / 255.0) - ).astype(np.uint8) - alpha[tnorm <= 0] = 0 + alpha = _physical_density_alpha(counts, mean[..., 3], _fill_opacity(style, 0.85)) rgba = np.ascontiguousarray(np.dstack([mean[..., :3], alpha])[::-1]) cmd.image(dx, dy, dw, dh, w, h, rgba.tobytes(), nearest=False) return diff --git a/python/xy/_svg.py b/python/xy/_svg.py index f26707e3..3f235e58 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -2570,6 +2570,30 @@ def _grid_image( ) +def _physical_density_alpha(counts: Any, mean_alpha_u8: Any, style_opacity: float) -> Any: + """Displayed alpha of a mean-color density cell (LOD doc §2 rule 1). + + The physical compositing of the cell's own points — ``1 − (1 − a_pt)^k`` + for k points whose drawn per-point alpha is ``a_pt = channel alpha × + style opacity`` — so the surface and real marks agree on lightness at + every zoom. Style opacity folds INSIDE the exponent: dense cells + saturate past it exactly like overplotted marks do. The same law as the + client's texture upload (``lodWriteGridTexture``); shared by the SVG and + native-raster exporters. Returns u8; empty or all-invisible cells are 0. + """ + counts = np.asarray(counts, dtype=np.float64) + a8 = np.asarray(mean_alpha_u8) + a_pt = np.clip((a8.astype(np.float64) / 255.0) * float(style_opacity), 0.0, 1.0) + coverage = np.zeros(a_pt.shape, dtype=np.float64) + saturated = a_pt >= 1.0 + partial = ~saturated & (a_pt > 0.0) + coverage[partial] = -np.expm1(counts[partial] * np.log1p(-a_pt[partial])) + coverage[saturated] = 1.0 + alpha = (np.clip(coverage, 0.0, 1.0) * 255.0).astype(np.uint8) + alpha[(counts <= 0) | (a8 == 0)] = 0 + return alpha + + def _density_image( d: dict, blob: bytes, cols: list, sx: _Scale, sy: _Scale, style: dict, svg: _Svg ) -> str: @@ -2577,18 +2601,23 @@ def _density_image( grid = _density_column(blob, cols[d["buf"]], d).reshape(h, w) gmax = float(d.get("max") or 1.0) or 1.0 tnorm = np.clip(grid / gmax, 0.0, 1.0) - paint_alpha: float | np.ndarray = 1.0 if d.get("rgba") is not None: - # Mean point color per cell (LOD doc §2): rgb from the shipped plane, - # count drives only the alpha ramp, scaled by the cell's mean point - # alpha — the same law as the client's DENSITY_FS. + # Mean point color per cell (LOD doc §2): rgb from the shipped plane; + # displayed alpha is the PHYSICAL compositing of the cell's points — + # 1 − (1 − a_pt)^count for drawn per-point alpha a_pt = channel alpha + # × style opacity (folded INSIDE the exponent: dense cells saturate + # past the style opacity exactly like overplotted marks). Same law as + # the client's texture upload. meta = cols[d["rgba"]] mean = np.frombuffer( blob, dtype=np.uint8, count=meta["len"], offset=meta["byte_offset"] ).reshape(h, w, 4) rgb = mean[..., :3] - paint_alpha = mean[..., 3].astype(np.float64) / 255.0 - elif d.get("color") is not None: + alpha = _physical_density_alpha(grid, mean[..., 3], _fill_opacity(style, 0.85)) + rgba = np.dstack([rgb, alpha])[::-1].tobytes() # flip: PNG rows are top-first + return _grid_image(w, h, rgba, d["x_range"], d["y_range"], sx, sy) + paint_alpha: float = 1.0 + if d.get("color") is not None: red, green, blue, alpha8 = _paint_rgba8(d["color"]) rgb = np.empty((h, w, 3), dtype=np.uint8) rgb[:] = (red, green, blue) diff --git a/python/xy/config.py b/python/xy/config.py index 947097a8..05cf633b 100644 --- a/python/xy/config.py +++ b/python/xy/config.py @@ -24,8 +24,9 @@ # Scatter above this many points switches to Tier-2 density aggregation (§5): # instead of shipping/drawing every point (fill-rate + the ~1 GB single-alloc # cliff, §5 F3), the kernel bins the viewport into a density grid the client -# draws with the trace's own colors — count drives only the alpha (LOD doc -# §2). Screen-bounded transport and VRAM regardless of point count. +# draws with the trace's own colors, composited at the points' own alpha +# (LOD doc §2; count-only surfaces keep the log count ramp). Screen-bounded +# transport and VRAM regardless of point count. SCATTER_DENSITY_THRESHOLD = 200_000 # Absolute direct-draw ceiling; above this, density is forced even if the user diff --git a/python/xy/marks.py b/python/xy/marks.py index 33463875..aad4d409 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -1464,7 +1464,7 @@ def scatter( ) mean_color_note = ( " The color channel is kept as the surface's per-cell mean point color" - " (count drives the alpha)." + " (composited at the points' own alpha)." if color_aggregates else "" ) diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 5e55d07c..e7686627 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -471,8 +471,9 @@ F3, still pending (above). - **WebGPU primary, WebGL2 fallback.** One ``, everything is GPU primitives. - **Instanced draws** for markers/bars/lines — one draw call for millions of marks. - **Density textures** for aggregated tiers (§5, Tier 2) — mean point color per - cell, count as alpha (LOD doc §2); premultiplied RGBA8 upload for - channel-bearing traces, tinted R8 count texture for constant-color ones. + cell, composited at the points' own alpha (`1−(1−a_pt)^count`, LOD doc §2); + premultiplied RGBA8 upload for channel-bearing traces, tinted R8 + log-count-ramp texture for constant-color ones. - **DOM/SVG only for chrome** — axis tick labels, legend, title, tooltip. Little of it, and it stays crisp/accessible/selectable. - **Retained scene graph**, spec-diff → buffer-diff. Pan/zoom is a view-matrix uniform @@ -1015,9 +1016,10 @@ means uniform in the axis's scale coordinates, not in raw data. Concretely: before `bin_2d`; the wire keeps raw `x_range`/`y_range` endpoints and the rule "cells are uniform in scale coordinates" (wire-protocol.md §3). Without this, clusters at x=1 and x=1000 share one cell on a symlog axis despite - being a third of the screen apart. The drill handoff's per-point + being a third of the screen apart. The count-only drill handoff's per-point `local_log_density` bins in the same space so its count-alpha matches the - grid's (hue already matches — the grid wears the mean point color, LOD doc §2). + grid's (mean-color surfaces need no handoff at all — they composite like + the points themselves, LOD doc §2 rules 1/5). - **The raw-space tile pyramid** (Tier 3) cannot compose a scale-coordinate grid, so traces on a nonlinear axis skip pyramid build/compose and always take the exact scan (`binning: "exact"`). Cost: the O(visible) rescan the diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 8cff70ca..6ff38dd5 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -28,7 +28,7 @@ pixel-area or overdraw term exists in `python/xy/` or `js/src/` today. |---|---|---|---|---| | 0 | Direct | every visible mark, exact | O(visible) verts | shipped (all kinds) | | 1 | Shape-preserving reduction | per-pixel-column aggregate that IS the mark's meaning (M4 for lines; OHLC-bucket for candles when finance returns; max-bin for bars) | O(px) verts | shipped (line) | -| 2 | Density / aggregate surface | mean-point-color texture, count as alpha (§2) | O(screen) texels | shipped (scatter) | +| 2 | Density / aggregate surface | mean-point-color texture composited at the points' own alpha (§2) | O(screen) texels | shipped (scatter) | | 3 | Out-of-core tiles | Tier-2 pyramid where not all tiles are resident | O(visible tiles) | **this doc** | **Invariant L1 (recorded reduction):** every update carries `tier`, `mode`, @@ -94,19 +94,33 @@ metadata, or encoded-buffer assembly. Aggregating positions is easy; aggregating **color/size/category** without lying is the hard part (§5-F5). The governing principle, shipped: **the -density surface wears the data's own colors; count drives only the alpha.** -A cell's color is what the eye would see if every one of its points were -drawn sub-pixel — the physically downsampled image — so the aggregate view -and the point view are two magnifications of the same picture, not two -different charts. More points → deeper (more opaque) color; fewer points → -lighter; the color itself always comes from the represented points. - -1. **Count is always channel 0, and it is the ALPHA channel.** The count - grid ships log-tone-mapped (`buf` + `max`, log-u8) and drives opacity - exclusively. Color rides on top of count, never instead of it — - otherwise sparse-but-extreme cells look as important as dense ones. No - shipped path colormaps counts anymore (the client keeps a count→LUT - fallback solely for hand-built/legacy count-only specs). +density surface wears the data's own colors, and it composites like the +data's own points.** A cell is what the eye would see if every one of its +points were drawn sub-pixel — the physically downsampled image — so the +aggregate view and the point view are two magnifications of the same +picture, not two different charts: same hue (the mean point color), same +lightness (the points' own alpha compositing). + +1. **A mean-color cell's displayed alpha is the PHYSICAL compositing of its + own points:** `1 − (1 − a_pt)^k` for k points whose drawn per-point + alpha is `a_pt = mean channel alpha × trace style opacity` — exactly + what k overplotted marks composite to, saturating after a few points + just as real marks do. Style opacity folds INSIDE the exponent (dense + cells saturate past it exactly like real overplot), so the draw path + applies only transition fades on top of these textures — never the + style opacity again. No window normalization touches the alpha, so + lightness cannot swing between windows or across the texture↔points + boundary (the previous law — a per-window log-count tone curve driving + opacity — did both: a field capture of the 100M drilldown showed the + surface visibly lighter/darker than the very points it aggregated). The + count grid still ships (`buf` + `max`, log-u8): it marks occupancy, is + the compositing exponent, and remains the ONLY structure — and the alpha + ramp — for count-only surfaces (constant-color traces, hand-built/legacy + specs), which keep the log tone curve and its eased normalization (T4). + The recorded cost of physical alpha: density beyond a few points per + cell no longer reads in the surface (real overplotted points saturate + identically — the aggregate is exactly as saturated as the truth it + downsamples; drill-in is where per-point structure returns). 2. **Per-point colors → per-cell alpha-weighted MEAN of the *resolved* colors, averaged in linear light.** One law for every channel mode — continuous (colormapped value), categorical (palette color), and @@ -115,9 +129,10 @@ lighter; the color itself always comes from the represented points. pipeline: checked-in sRGB⇄linear-u16 tables, u64 sums — bitwise deterministic across thread counts and platforms), then quantized back to sRGB. Ships as a `w*h*4` straight-alpha RGBA8 plane (`rgba`, mean color + - mean point alpha) with `color_agg: "mean"` recorded; displayed alpha = - count tone-curve × mean point alpha. A cell of one point shows that - point's exact color; an all-invisible (alpha-0) cell never invents one. + mean point alpha) with `color_agg: "mean"` recorded; displayed alpha + follows rule 1 (physical compositing of ā over the cell count). A cell + of one point shows that point's exact color at that point's own alpha; + an all-invisible (alpha-0) cell never invents one. Constant-color traces ship no plane — the mean of a constant IS the constant, so the wire keeps the 1-byte count grid plus `color` and the client tints (bit-equivalent, 4× cheaper). Cost, recorded: a @@ -146,13 +161,16 @@ lighter; the color itself always comes from the represented points. `channels_dropped: true` + `dropped_channels` naming it. Color is *not* listed there when it aggregates — `color_agg: "mean"` records the transform instead (aggregated ≠ dropped). -5. **The drill handoff is intensity-only now:** hue is continuous by - construction (both sides show the points' colors), so freshly drilled - points arrive in their native colors and only their opacity hands off — - entering at the cell's count-alpha (`density_val` through the same tone - curve as the texture) and easing to native opacity as `lod_blend` → 0. - The same rule runs outbound: exiting marks re-target the aggregate's - count-alpha and melt into a surface that already matches their hue. +5. **Mean-color surfaces need no drill handoff at all:** hue is continuous + by construction (both sides show the points' colors) and, under rule + 1's physical alpha, so is lightness — the texture composites exactly + like the marks replacing it. Drilled points therefore enter and exit at + NATIVE opacity; the entry/exit alpha fades (T2) alone carry the swap, + and the client ignores `density_val`/`lod_blend` on such traces (the + wire still carries them — count-only surfaces keep the intensity + handoff: marks enter at the cell's count-alpha through the same tone + curve as their texture and ease to native as `lod_blend` → 0, and + exiting marks re-target it to melt into the ramp). **Invariant L4 (badge):** any active reduction that drops or transforms a channel renders a visible "aggregated: mean/…" badge sourced from the spec @@ -311,20 +329,24 @@ invariants so future kinds don't regress them: the aggregate→marks transition only — restarting per refresh reads as flashing; exit fade with the "dying" state so buffers outlive the fade). - **T3 — color-continuous:** the two sides of a transition display the same - statistic at the boundary. Hue is continuous *by construction* — the - aggregate surface wears the mean point color (§2), so marks and texture - agree on color at every zoom — and intensity hands off via the lod_blend - count-alpha ramp. The kernel's blend weight (`visible/budget`) is only ≈1 - when the swap happens at the budget boundary — a fast zoom skips levels - and lands marks with a mostly-native weight, a visible intensity pop at - the swap (live-drilldown field capture). The BOUNDARY is therefore the - transition itself, client-side: fresh marks arrive with the shown blend - seeded at 1 (entering at the aggregate's local count-alpha) and ease to - the kernel's native weight, and dying/exiting marks re-target blend 1 so - they melt into the texture as they fade (`lodApplyDrill`, - `lodBeginDrillExitContinuous`; revives restore the native weight via - `lodEnterDrillContinuous`). -- **T4 — normalization is eased, never stepped** (exposure-style normMax). + statistic at the boundary. On a mean-color surface BOTH channels are + continuous by construction — hue because the surface wears the mean point + color (§2 rule 2), lightness because it composites like the points + themselves (§2 rule 1) — so marks swap at native opacity with no + intensity handoff at all (§2 rule 5). Count-only surfaces keep the + lod_blend count-alpha ramp: the kernel's blend weight (`visible/budget`) + is only ≈1 when the swap happens at the budget boundary — a fast zoom + skips levels and lands marks with a mostly-native weight, a visible + intensity pop at the swap (live-drilldown field capture) — so the + BOUNDARY is the transition itself, client-side: fresh marks arrive with + the shown blend seeded at 1 (entering at the aggregate's local + count-alpha) and ease to the kernel's native weight, and dying/exiting + marks re-target blend 1 so they melt into the texture as they fade + (`lodApplyDrill`, `lodBeginDrillExitContinuous`; revives restore the + native weight via `lodEnterDrillContinuous`). +- **T4 — normalization is eased, never stepped** (exposure-style normMax) — + count-only surfaces; a mean-color texture's physical alpha is + max-independent, so it has no normalization to ease. - **T5 — stale replies die:** seq on view updates, drill_seq on subsets, pending-view hold for prefetched drills. - **T6 — invalid requests do not mutate:** malformed viewport/screen requests @@ -558,8 +580,9 @@ contract entry before it lands. (`_payload._density_trace_spec`), `density_view` (exact and pyramid paths), the static SVG/PNG exporters, and the standalone re-bin worker; DENSITY_FS gained the `u_meanColor` branch (premultiplied RGBA8 texture, - count tone-curve baked into alpha at upload so bilinear filtering weights - color by coverage); the drill handoff became intensity-only (§2 rule 5). + the §2 rule-1 physical alpha baked at upload so bilinear filtering + weights color by coverage); mean-color drills swap at native opacity + with no intensity handoff (§2 rule 5; count-only drills keep it). The colorbar for a continuous channel now stays on density traces — the surface really shows the channel's colors. 3. Legend/badge rendering from spec facts (`color_agg`, `dropped_channels`) diff --git a/tests/test_density_mean_color.py b/tests/test_density_mean_color.py index 75d813c3..84ec2add 100644 --- a/tests/test_density_mean_color.py +++ b/tests/test_density_mean_color.py @@ -1,9 +1,10 @@ """Mean-color density surface (LOD doc §2): the aggregated view wears the data's own colors — per-cell alpha-weighted mean of the resolved point -colors, averaged in linear light — while the binned count drives only the -alpha channel. These tests pin the kernel against a NumPy oracle (the LOD -doc's exit criterion), the wire shape across the initial emit / exact -density_view / pyramid paths, and the static exporters' color law. +colors, averaged in linear light — composited at the points' own alpha +(`1 − (1 − ā)^count`, the physical downsample). These tests pin the kernel +against a NumPy oracle (the LOD doc's exit criterion), the wire shape across +the initial emit / exact density_view / pyramid paths, and the static +exporters' color law. """ from __future__ import annotations @@ -256,6 +257,27 @@ def test_svg_export_density_uses_mean_colors(): assert (right[:, :3] == palette[1, :3]).all() +def test_physical_density_alpha_law(): + # LOD doc §2 rule 1: alpha = 1 - (1 - a_pt)^count with a_pt = channel + # alpha x style opacity folded inside the exponent; empty/invisible + # cells stay 0; a_pt = 1 saturates any occupied cell; no window max + # enters anywhere. This is the exporters' twin of the client upload law. + from xy._svg import _physical_density_alpha + + counts = np.asarray([0.0, 1.0, 3.0, 50.0, 2.0, 5.0]) + mean_a = np.asarray([255, 255, 255, 255, 0, 255], dtype=np.uint8) + out = _physical_density_alpha(counts, mean_a, 0.72) + expect = lambda k: round(255 * (1.0 - (1.0 - 0.72) ** k)) # noqa: E731 + assert out[0] == 0 # empty + assert abs(int(out[1]) - expect(1)) <= 1 # one point IS the point alpha + assert abs(int(out[2]) - expect(3)) <= 1 + assert out[3] >= 254 # saturates like overplotted marks + assert out[4] == 0 # all-invisible cell never invents coverage + # Style opacity 1 + full channel alpha: any occupied cell is opaque. + full = _physical_density_alpha(counts, np.full(6, 255, dtype=np.uint8), 1.0) + assert full[0] == 0 and (full[1:4] == 255).all() + + def test_resolve_bin_colors_modes(): # constant -> None (count-only grid + client tint) assert ( diff --git a/tests/test_density_physical_alpha.py b/tests/test_density_physical_alpha.py new file mode 100644 index 00000000..cba6a608 --- /dev/null +++ b/tests/test_density_physical_alpha.py @@ -0,0 +1,151 @@ +"""Mean-color density textures composite like the points they aggregate +(LOD doc §2 rule 1). + +A channel-bearing cell uploads at alpha `1 − (1 − ā)^count` — the physical +compositing of k overplotted points of mean straight alpha ā — with NO +window normalization: a one-point cell reads exactly like one point, dense +cells saturate exactly like overplotted marks, and lightness cannot swing +between windows or across the texture↔points boundary (the previous +per-window log-count tone curve did both; field capture on the 100M +drilldown). Count-only grids keep the log ramp — count is their only +structure — and mean-color drills swap at native opacity with no intensity +handoff (`density_val`/`lod_blend` ignored on such traces). + +This drives the real client in headless Chromium, intercepting the texture +upload to read the exact bytes. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import xy +from conftest import run_browser_probe +from xy.export import find_chromium + +_RENDER_CALL = 'xy.renderStandalone(document.getElementById("chart"), spec, buf);' + +_PROBE = """ + const view = xy.renderStandalone(document.getElementById("chart"), spec, buf); + try { + view._drawNow(); + view._raf = null; + view._sampleRebinDisabled = true; + const g = view.gpuTraces.find((t) => t.tier === "density"); + const v0 = view.view0; + + // A mean-color reply: 4 cells with counts [0, 1, 3, 50], all wearing the + // same mean color at full channel alpha — the drawn per-point alpha is + // the trace's style opacity (0.72), folded INSIDE the exponent. + const gw = 4, gh = 1; + const counts = new Float32Array([0, 1, 3, 50]); + const abar = 0.72; // style opacity from the chart below + const rgba = new Uint8Array(gw * 4); + for (let i = 0; i < gw; i++) { + rgba[i * 4] = 200; rgba[i * 4 + 1] = 60; rgba[i * 4 + 2] = 30; + rgba[i * 4 + 3] = 255; + } + let uploaded = null; + const gl = view.gl; + const realTexImage = gl.texImage2D.bind(gl); + gl.texImage2D = function (...args) { + const data = args[args.length - 1]; + if (data && data.length === gw * gh * 4) uploaded = new Uint8Array(data); + return realTexImage(...args); + }; + view._onKernelMsg({ + type: "density_update", seq: view.seq, trace: g.trace.id, + traces: [{ + id: g.trace.id, mode: "density", visible: 5000000, + density: { + buf: 0, w: gw, h: gh, max: 50, + x_range: [v0.x0, v0.x1], y_range: [v0.y0, v0.y1], + rgba: 1, color_agg: "mean", + }, + }], + }, [counts.buffer, rgba.buffer]); + gl.texImage2D = realTexImage; + + const a = uploaded ? [uploaded[3], uploaded[7], uploaded[11], uploaded[15]] : null; + const expect = (k) => Math.round(255 * (1 - Math.pow(1 - abar, k))); + // Physical law, no window-max dependence: empty stays 0, k=1 IS the + // drawn per-point alpha, k=3 composites up, k=50 saturates past the + // style opacity exactly like overplotted marks. + const law = a && a[0] === 0 && + Math.abs(a[1] - expect(1)) <= 1 && + Math.abs(a[2] - expect(3)) <= 1 && + a[3] >= 254; + // No exposure easing on mean-color grids: nothing schedules re-uploads. + const noNormAnim = g._densityNormAnim === null || g._densityNormAnim === undefined; + + // A drill landing on this mean-color surface carries density_val and a + // blend weight — both must be ignored: marks enter at native opacity. + const N = 50; + const xs = new Float32Array(N), ys = new Float32Array(N), dv = new Float32Array(N); + for (let i = 0; i < N; i++) { + xs[i] = v0.x0 + (v0.x1 - v0.x0) * ((i * 0.618) % 1); + ys[i] = v0.y0 + (v0.y1 - v0.y0) * ((i * 0.314) % 1); + dv[i] = 0.9; + } + view._applyDrill(g, { + id: g.trace.id, mode: "points", visible: N, reduction: "none", drill_seq: 1, + x: { buf: 0, offset: 0, scale: 1, len: N }, + y: { buf: 1, offset: 0, scale: 1, len: N }, + x_range: [v0.x0, v0.x1], y_range: [v0.y0, v0.y1], + density_val: { buf: 2 }, lod_blend: 0.85, + }, [xs.buffer, ys.buffer, dv.buffer]); + const noHandoff = g.drill && g.drill.lodBlend === 0 && !g.drill.dBuf; + + document.body.setAttribute("data-xy-alpha-probe", JSON.stringify({ + hasDensity: !!g, gotUpload: !!uploaded, alphas: a, law, noNormAnim, noHandoff, + })); + } catch (err) { + document.body.setAttribute( + "data-xy-alpha-probe-error", + String((err && err.stack) || err), + ); + } +""" + + +def _density_html() -> str: + rng = np.random.default_rng(0) + n = 60_000 + x = rng.normal(0.0, 1.0, n) + y = rng.normal(0.0, 1.0, n) + c = np.hypot(x, y) + chart = xy.scatter_chart( + xy.scatter(x, y, color=c, colormap="viridis", opacity=0.72, density=True), + xy.x_axis(), + xy.y_axis(), + width=480, + height=360, + ) + html = chart.to_html() + assert _RENDER_CALL in html + return html + + +def test_mean_color_texture_uses_physical_alpha(tmp_path: Path) -> None: + chromium = find_chromium() + if chromium is None: + pytest.skip("Chromium unavailable") + + document = _density_html().replace(_RENDER_CALL, _PROBE) + result = run_browser_probe( + chromium, + document, + tmp_path / "density_physical_alpha.html", + "data-xy-alpha-probe", + label="mean-color physical-alpha probe", + ) + + assert result["hasDensity"] is True + assert result["gotUpload"] is True + # alpha(k) = 1-(1-0.72)^k, independent of the window max: 0, 184, 250, 255. + assert result["law"] is True, result["alphas"] + assert result["noNormAnim"] is True + assert result["noHandoff"] is True From 3dafce10395a4c83428f7346ef427749daef157f Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 20:31:23 +0000 Subject: [PATCH 09/11] Never repaint a covering aggregate with mid-band density replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field capture on PR #227 (screen recording): zooming around the points band read as "jumping between zoom levels of the density map". Frame analysis showed the drawn aggregate flipping between two texture characters — the smooth standing surface (coarse, linear-upscaled) and fresh transition-band exact grids (~16 points/cell), whose one-point cells now draw at the points' own alpha (§2 physical compositing) and so read as hard speckle. Every band probe repainted smooth -> speckle -> smooth, one jump per crossing. Completing the stands-rule (T13) on the display side: with a kernel attached, an aggregate reply never REPLACES a texture that already covers the view. The standing surface holds until real POINTS land; the mid-band reply contributes a facts-only cache entry (window + exact count, lodRememberDensityFacts) that recalibrates the points-band gate, and the drill lifecycle proceeds as before (dying marks fade into the standing surface). The picture changes only aggregate->points and back. A reply for an UNCOVERED view (pan past every cached window) still applies — silence never blanks a frame (T1) — and standalone clients keep applying everything (their re-binned grids are their only refinement). Facts entries are tex-less cache citizens: the draw path skips them, the gate reads them, eviction guards their missing texture. Spec: T13 display-side paragraph; CHANGELOG. Tests: the request-economy probe now asserts the standing texture is byte-identical after a covered mid-band reply, that its facts landed, and that an uncovered reply still applies. --- CHANGELOG.md | 9 ++++- js/src/45_lod.ts | 53 ++++++++++++++++++++++++++- spec/design/lod-architecture.md | 11 ++++++ tests/test_density_request_economy.py | 25 ++++++++++++- 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4564a23..f7ec3a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,14 @@ in the README). count fits. The estimate takes the lower of an area-scaled cached-window count and the retained first-payload sample counted in-view — the sample follows the data's actual distribution, so sparse regions reach their - points without being stranded in blur by uniform-density assumptions. A 100M-scatter field capture had shipped a ~2.7 MB full-screen + points without being stranded in blur by uniform-density assumptions. + Display-side, a kernel-attached client never repaints a covering + aggregate texture with a mid-band density reply (the band's exact grids + have a speckled character that read as zoom-level jumping against the + smooth standing surface); such replies land as facts-only cache entries + for the gate, and the picture changes only aggregate→points and back — + replies for uncovered views still apply, and standalone clients keep + applying everything. A 100M-scatter field capture had shipped a ~2.7 MB full-screen grid on every pan/zoom step (including sub-pixel window twins, now deduped within half an output texel) for what was the same aggregate with marginally different blur; intermediate-zoom blur is the accepted, diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index 3492839e..bba4e785 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -561,7 +561,7 @@ export function lodRememberDensity(view, g, d) { const old = g.densityCache[i]; if (old === d || lodDensityPinned(g, old) || !lodSameDensityWindow(old, d)) continue; g.densityCache.splice(i, 1); - view.gl.deleteTexture(old.tex); + if (old.tex) view.gl.deleteTexture(old.tex); if (old.overlay && old.overlay !== g.sampleOverlay) { view._destroySampleOverlay(old.overlay); old.overlay = null; @@ -585,7 +585,7 @@ export function lodRememberDensity(view, g, d) { if (drop < 0) break; const old = g.densityCache.splice(drop, 1)[0]; if (!lodDensityPinned(g, old)) { - view.gl.deleteTexture(old.tex); + if (old.tex) view.gl.deleteTexture(old.tex); // The window's sample overlay rides its cache entry (T9 pairing) and // dies with it — except the home/init overlay, which the standalone // re-bin worker keeps as its CPU-side source. @@ -1038,12 +1038,61 @@ function lodBeginDrillExitContinuous(view, g) { // -- aggregate updates & tier drawing ---------------------------------------- +// Facts-only cache entry for a display-suppressed aggregate reply (T13): no +// texture, just the window and its exact count — the points-band gate +// (lodAggregateStands) reads it to recalibrate local estimates while the +// standing texture keeps owning the frame. +function lodRememberDensityFacts(view, g, upd) { + const d = upd.density; + if (!d || !Array.isArray(d.x_range) || !Array.isArray(d.y_range)) return; + if (!Number.isFinite(upd.visible)) return; + const entry: any = { + xRange: [d.x_range[0], d.x_range[1]], + yRange: [d.y_range[0], d.y_range[1]], + visible: upd.visible, + }; + if (!g.densityCache) g.densityCache = []; + for (let i = g.densityCache.length - 1; i >= 0; i--) { + const old = g.densityCache[i]; + if (lodDensityPinned(g, old) || !lodSameDensityWindow(old, entry)) continue; + if (old.tex) continue; // never displace a real texture with bare facts + g.densityCache.splice(i, 1); + } + entry._stamp = ++view._densityStamp; + g.densityCache.push(entry); + while (g.densityCache.length > 12) { + const idx = g.densityCache.findIndex((c) => c && !c.tex && c !== entry); + if (idx < 0) break; + g.densityCache.splice(idx, 1); + } +} + // Apply a kernel "density"-mode update: new grid texture with eased exposure // normalization, previous grid kept for the crossfade, source remembered in // the per-trace cache. export function lodApplyDensityUpdate(view, g, upd, buffers) { lodMarkDrillDying(view, g); const d = upd.density; + // Display side of the stands-rule (T13, user-directed #225 follow-up): + // with a kernel attached, an aggregate reply never REPLACES a texture + // that already covers the view. The standing (smooth, possibly stale) + // surface holds until REAL points land — the transition band's exact + // grids have a hard speckled character (sparse cells at the points' own + // alpha), and repainting the smooth surface with one on every band probe + // read as jumping between zoom levels in the field capture. The reply + // still lands as FACTS (its window's exact count recalibrates the + // points-band gate), and the drill lifecycle above proceeds — marks fade + // into the standing surface. Coverage failure (a pan past every cached + // window) still applies the reply: silence must never blank a frame + // (T1). Standalone clients keep applying everything — their re-binned + // grids are the only refinement they have. + if (view.comm) { + const covering = lodDensityForView(view, g); + if (covering && covering.tex && view._viewInsideRange(covering.xRange, covering.yRange)) { + lodRememberDensityFacts(view, g, upd); + return; + } + } const grid = d.enc === "log-u8" ? lodDecodeLogU8(buffers[d.buf], d.max) : lodCopyGrid(view._asF32(buffers[d.buf])); diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index 6ff38dd5..e890c32f 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -541,6 +541,17 @@ invariants so future kinds don't regress them: with no recorded counts anywhere always requests. A fresh grid for an already-cached window supersedes its unpinned twin (`lodRememberDensity` dedupe), so the gate always reads current facts. + *Display side:* with a kernel attached, an aggregate reply never + REPLACES a texture that already covers the view — the standing surface + holds until real POINTS land, and the reply contributes only a + facts-only cache entry (window + exact count, + `lodRememberDensityFacts`) that recalibrates this gate. The + transition band's exact grids have a hard speckled character (sparse + cells at the points' own §2 alpha), and repainting the smooth standing + surface with one on every band probe read as jumping between zoom + levels (field capture). A reply for an UNCOVERED view still applies — + silence must never blank a frame (T1) — and standalone clients apply + everything (their re-binned grids are the only refinement they have). - **Source-clamped grids:** the transition-band replies that do go out stay cheap — a pyramid-served reply never composes more cells than the finest level resolves under the window diff --git a/tests/test_density_request_economy.py b/tests/test_density_request_economy.py index 86e820b8..f83cee2f 100644 --- a/tests/test_density_request_economy.py +++ b/tests/test_density_request_economy.py @@ -84,7 +84,16 @@ }; const wx0 = cx - sx * 0.25, wx1 = cx + sx * 0.25; const wy0 = cy - sy * 0.25, wy1 = cy + sy * 0.25; + const shownBefore = g.density; applyDensity(wx0, wx1, wy0, wy1, 200, 150, 10000000); + // Display side of the stands-rule: the home texture covers the view, so + // the reply changed no pixels — it landed as a FACTS-ONLY cache entry + // (window + exact count) that the points-band gate reads below. The + // field capture behind this: transition-band exact grids repainting the + // smooth standing surface on every probe read as zoom-level jumping. + const standingHeld = g.density === shownBefore; + const factsStored = (g.densityCache || []).some( + (c) => c && !c.tex && c.visible === 10000000); // Zooming inside W at half its span: ~2.5M estimated in view — nowhere // near points territory, so the texture stands and NOTHING is requested, @@ -133,11 +142,17 @@ view._drawNow(); view._drawDensity = real; + // Coverage failure still applies the reply: a view panned past every + // cached window must never sit on a blank frame waiting for points (T1). + view.view = { x0: v0.x1 + sx, x1: v0.x1 + 2 * sx, y0: v0.y0, y1: v0.y1 }; + applyDensity(v0.x1 + sx, v0.x1 + 2 * sx, v0.y0, v0.y1, 50, 40, 5000000); + const uncoveredApplied = g.density !== shownBefore && g.density.visible === 5000000; + document.body.setAttribute("data-xy-economy-probe", JSON.stringify({ - hasDensity: !!g, homeHasCount, + hasDensity: !!g, homeHasCount, standingHeld, factsStored, insideElided, elisionClearedPending, bandRequested, nearDrillRequested, sentBase, subTexelSuppressed, texelsRequested, - densityDraws, + densityDraws, uncoveredApplied, })); } catch (err) { document.body.setAttribute( @@ -181,6 +196,12 @@ def test_aggregate_stands_until_points_band(tmp_path: Path) -> None: assert result["hasDensity"] is True assert result["homeHasCount"] is True + # Display side: a mid-band aggregate reply repaints NOTHING while a + # covering texture stands — it lands as facts for the gate; a reply for + # an uncovered view still applies (never a blank frame, T1). + assert result["standingHeld"] is True + assert result["factsStored"] is True + assert result["uncoveredApplied"] is True # The aggregate stands: no refinement requests while the estimated # in-view count sits clearly above points territory. assert result["insideElided"] is True From c10bb759b210d08eb93dc1e381b9e08f75e3d480 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 21:09:51 +0000 Subject: [PATCH 10/11] Sharpen the standing aggregate in quantized ladder steps before points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field feedback on PR #227: the aggregate-to-points boundary now feels smooth, but the standing texture reads soft at times — the home texture can stretch ~10x by the time points land. Requested: one or two more density texture steps before real points, stepwise, NOT per-window (per-view textures were the recorded zoom/pan-jumping failure mode). - While the aggregate stands (outside the points band), the one density request allowed is the next LADDER STEP window (lodAggregateStepWindow): the view snapped outward to a power-of-LOD_AGG_STEP_FACTOR (4) block grid over the data extent, per axis, at most LOD_AGG_STEP_MAX (2) steps below home, and only when every covering texture is coarser than the step by more than LOD_AGG_STEP_SLACK. Quantized windows are pan-stable and dedupable: every view in a region resolves to the SAME window, pans inside a step repaint nothing, revisits hit the cache, and a zoom sees at most two smooth-to-smooth swaps before points — worst-case softness bounded at ~4x stretch per axis instead of unbounded home-stretch. - A step reply (matched against the marked request window, g._stepReqWin) is the ONE density reply that may repaint a covered view. Points-band probes keep requesting the RAW view — the kernel decides the tier with real counts — and their density replies stay facts-only, so the band's speckled exact grids still never repaint the smooth surface. Step windows are always >= the view, so their counts sit above the band and the kernel serves them from the pyramid: smooth by construction. A step window sparse enough to fit the budget comes back as points — applied as a (padded) drill, points early. - Request routing unified in _densityRequestPlan: drill/point-cache service first, then band probe (raw view) or ladder step; the sub-texel dedup memo now keys on whichever window is actually sent. Spec: T13 display-side rewritten around the ladder (+ recorded trade-off: in-band the probe owns the single request slot), dossier constants, CHANGELOG. Tests: the request-economy probe asserts the step request is the aligned block window (never the raw view), that its reply repaints while probe replies stay facts-only, that a covered step elides further requests, and that band probes request raw views. --- CHANGELOG.md | 14 +-- js/src/45_lod.ts | 117 ++++++++++++++++++++++---- js/src/54_kernel.ts | 58 +++++++------ spec/design-dossier.md | 3 +- spec/design/lod-architecture.md | 37 +++++--- tests/test_density_request_economy.py | 92 ++++++++++++++------ 6 files changed, 238 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ec3a82..2b136839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,12 +64,16 @@ in the README). count and the retained first-payload sample counted in-view — the sample follows the data's actual distribution, so sparse regions reach their points without being stranded in blur by uniform-density assumptions. - Display-side, a kernel-attached client never repaints a covering - aggregate texture with a mid-band density reply (the band's exact grids + Display-side, the aggregate sharpens in QUANTIZED ladder steps between + home and points (`LOD_AGG_STEP_FACTOR`/`LOD_AGG_STEP_MAX`: the view + snapped outward to a power-of-4 block grid over the extent, at most two + steps) — pan-stable, dedupable windows, so a zoom sees at most two + smooth-to-smooth texture swaps and worst-case softness is bounded at + ~4× stretch per axis. A step reply is the only density reply that may + repaint a covered view; mid-band probe replies (the band's exact grids have a speckled character that read as zoom-level jumping against the - smooth standing surface); such replies land as facts-only cache entries - for the gate, and the picture changes only aggregate→points and back — - replies for uncovered views still apply, and standalone clients keep + smooth standing surface) land as facts-only cache entries for the gate. + Replies for uncovered views still apply, and standalone clients keep applying everything. A 100M-scatter field capture had shipped a ~2.7 MB full-screen grid on every pan/zoom step (including sub-pixel window twins, now deduped within half an output texel) for what was the same aggregate with diff --git a/js/src/45_lod.ts b/js/src/45_lod.ts index bba4e785..8d5bab9f 100644 --- a/js/src/45_lod.ts +++ b/js/src/45_lod.ts @@ -385,6 +385,79 @@ function lodDensityForView(view, g) { // never be the default when the kernel might hold a sharper representation. const LOD_POINTS_REQUEST_BAND = 4; // × LOD_DIRECT_POINT_BUDGET +// Stepped aggregate ladder (T13): between the home texture and real points +// the aggregate sharpens in QUANTIZED steps, never per-view. A step-k +// texture covers the view snapped outward to a power-of-STEP_FACTOR block +// grid over the data extent, per axis — the same aligned-window law as the +// kernel's padded drills — so every view in a region resolves to the SAME +// window (pan-stable, dedupable, cacheable) and a zoom sees at most +// STEP_MAX smooth-to-smooth texture swaps before points land. Per-view +// windows were the field-captured failure mode (a fresh texture per +// pan/zoom step reads as jumping); the ladder bounds worst-case softness +// (≈ STEP_FACTOR× stretch per axis) instead of eliminating blur. +const LOD_AGG_STEP_FACTOR = 4; // block span shrinks 4× per step, per axis +const LOD_AGG_STEP_MAX = 2; // intermediate steps below home before points +// A covering texture within this area factor of the desired step window is +// "fine enough" — no upgrade request. +const LOD_AGG_STEP_SLACK = 1.5; + +function lodAlignedStepSpan(lo, hi, e0, e1, blocks) { + const extent = e1 - e0; + if (!(extent > 0) || !(blocks >= 1)) return [lo, hi]; + const block = extent / blocks; + const b0 = Math.floor((lo - e0) / block); + const b1 = Math.ceil((hi - e0) / block); + // min/max guards keep containment through float rounding at block edges. + return [Math.min(lo, e0 + b0 * block), Math.max(hi, e0 + b1 * block)]; +} + +// The aligned step window the view's aggregate SHOULD be served at, or null +// when no upgrade is needed (home is step 0; a covering cached texture +// already at — or finer than — the desired step satisfies it). +export function lodAggregateStepWindow(view, g, x0, x1, y0, y1) { + const cache = g.densityCache || (g.density ? [g.density] : []); + const vx0 = Math.min(x0, x1), vx1 = Math.max(x0, x1); + const vy0 = Math.min(y0, y1), vy1 = Math.max(y0, y1); + const vSpanX = vx1 - vx0, vSpanY = vy1 - vy0; + if (!(vSpanX > 0 && vSpanY > 0)) return null; + // The broadest textured entry is the extent anchor (the home texture). + let extent = null; + for (const d of cache) { + if (!d || !d.tex || !d.xRange || !d.yRange) continue; + if (!extent || lodDensityArea(d) > lodDensityArea(extent)) extent = d; + } + if (!extent) return null; + const ex0 = Math.min(extent.xRange[0], extent.xRange[1]); + const ex1 = Math.max(extent.xRange[0], extent.xRange[1]); + const ey0 = Math.min(extent.yRange[0], extent.yRange[1]); + const ey1 = Math.max(extent.yRange[0], extent.yRange[1]); + const exSpan = ex1 - ex0, eySpan = ey1 - ey0; + if (!(exSpan > 0 && eySpan > 0)) return null; + const stepOf = (extentSpan, viewSpan) => Math.max(0, Math.min( + LOD_AGG_STEP_MAX, + Math.floor(Math.log(extentSpan / Math.max(viewSpan, 1e-300)) / Math.log(LOD_AGG_STEP_FACTOR)), + )); + const kx = stepOf(exSpan, vSpanX); + const ky = stepOf(eySpan, vSpanY); + if (kx === 0 && ky === 0) return null; + const wx = lodAlignedStepSpan(vx0, vx1, ex0, ex1, Math.pow(LOD_AGG_STEP_FACTOR, kx)); + const wy = lodAlignedStepSpan(vy0, vy1, ey0, ey1, Math.pow(LOD_AGG_STEP_FACTOR, ky)); + const winArea = (wx[1] - wx[0]) * (wy[1] - wy[0]); + if (!(winArea > 0)) return null; + // Fine enough already? The smallest covering TEXTURE decides. + let covering = null; + const exEps = vSpanX * 1e-4, eyEps = vSpanY * 1e-4; + for (const d of cache) { + if (!d || !d.tex || !d.xRange || !d.yRange) continue; + const cx0 = Math.min(d.xRange[0], d.xRange[1]), cx1 = Math.max(d.xRange[0], d.xRange[1]); + const cy0 = Math.min(d.yRange[0], d.yRange[1]), cy1 = Math.max(d.yRange[0], d.yRange[1]); + if (vx0 < cx0 - exEps || vx1 > cx1 + exEps || vy0 < cy0 - eyEps || vy1 > cy1 + eyEps) continue; + if (!covering || lodDensityArea(d) < lodDensityArea(covering)) covering = d; + } + if (covering && lodDensityArea(covering) <= winArea * LOD_AGG_STEP_SLACK) return null; + return [wx[0], wx[1], wy[0], wy[1]]; +} + // Unbiased local count estimate from the retained deterministic sample: the // first-payload sample is a fixed-rate thinning of the whole trace, so // (in-view sample rows) × (total ÷ sample size) estimates the in-view count @@ -714,6 +787,7 @@ export function lodPromoteCachedDrill(view, g, x0, x1, y0, y1) { // trace; the tier draw uses it until the kernel switches back. export function lodApplyDrill(view, g, upd, buffers) { const gl = view.gl; + g._stepReqWin = null; // real points supersede any outstanding ladder step const win = { x0: upd.x_range[0], x1: upd.x_range[1], y0: upd.y_range[0], y1: upd.y_range[1], @@ -1074,23 +1148,34 @@ export function lodApplyDensityUpdate(view, g, upd, buffers) { lodMarkDrillDying(view, g); const d = upd.density; // Display side of the stands-rule (T13, user-directed #225 follow-up): - // with a kernel attached, an aggregate reply never REPLACES a texture - // that already covers the view. The standing (smooth, possibly stale) - // surface holds until REAL points land — the transition band's exact - // grids have a hard speckled character (sparse cells at the points' own - // alpha), and repainting the smooth surface with one on every band probe - // read as jumping between zoom levels in the field capture. The reply - // still lands as FACTS (its window's exact count recalibrates the - // points-band gate), and the drill lifecycle above proceeds — marks fade - // into the standing surface. Coverage failure (a pan past every cached - // window) still applies the reply: silence must never blank a frame - // (T1). Standalone clients keep applying everything — their re-binned - // grids are the only refinement they have. + // with a kernel attached, an aggregate reply REPLACES a covering texture + // only when the client asked for exactly this window as a LADDER STEP + // (the quantized aligned windows of the stepped aggregate — smooth, + // pan-stable, at most STEP_MAX swaps before points). Every other covered + // reply — the points-band probes above all — lands as FACTS only (its + // window's exact count recalibrates the gate): the band's exact grids + // have a hard speckled character (sparse cells at the points' own + // alpha), and repainting the smooth surface with one on every probe read + // as jumping between zoom levels in the field capture. The drill + // lifecycle above proceeds either way — dying marks fade into the + // standing surface. Coverage failure (a pan past every cached window) + // still applies the reply: silence must never blank a frame (T1). + // Standalone clients keep applying everything — their re-binned grids + // are the only refinement they have. if (view.comm) { - const covering = lodDensityForView(view, g); - if (covering && covering.tex && view._viewInsideRange(covering.xRange, covering.yRange)) { - lodRememberDensityFacts(view, g, upd); - return; + const sw = g._stepReqWin; + const tol = sw ? (Math.abs(sw[1] - sw[0]) + Math.abs(sw[3] - sw[2])) * 1e-6 + 1e-300 : 0; + const isStepReply = !!sw && Array.isArray(d.x_range) && Array.isArray(d.y_range) && + Math.abs(d.x_range[0] - sw[0]) <= tol && Math.abs(d.x_range[1] - sw[1]) <= tol && + Math.abs(d.y_range[0] - sw[2]) <= tol && Math.abs(d.y_range[1] - sw[3]) <= tol; + if (isStepReply) { + g._stepReqWin = null; + } else { + const covering = lodDensityForView(view, g); + if (covering && covering.tex && view._viewInsideRange(covering.xRange, covering.yRange)) { + lodRememberDensityFacts(view, g, upd); + return; + } } } const grid = d.enc === "log-u8" diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 369dd8bb..b3859bbe 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -2,8 +2,8 @@ import { payloadBuffers } from "./00_header"; import { buildLutData } from "./10_colormaps"; import { parseColor } from "./20_theme"; import { - lodAggregateStands, lodApplyDensityUpdate, lodApplyDrill, lodDrillServesView, - lodDropDrill, lodPromoteCachedDrill, lodRememberDensity, + lodAggregateStands, lodAggregateStepWindow, lodApplyDensityUpdate, lodApplyDrill, + lodDrillServesView, lodDropDrill, lodPromoteCachedDrill, lodRememberDensity, } from "./45_lod"; import { xyCreateRebinWorker } from "./46_worker"; import { ChartView } from "./50_chartview"; @@ -39,7 +39,8 @@ Object.assign(ChartView.prototype, { // goes neither pending nor on the wire. The seq bump above stands, so // an in-flight reply for an older, wider view dies stale instead of // yanking the exact marks out from under the view it can't improve. - if (this._drillServesView(g, view) || this._aggregateStandsForView(g, view)) { + const plan = this._drillServesView(g, view) ? null : this._densityRequestPlan(g, view); + if (!plan) { g._lodPendingView = null; g._lodPendingSeq = null; g._lodPendingAt = null; @@ -49,7 +50,7 @@ Object.assign(ChartView.prototype, { // screen): keep waiting on ITS seq instead of arming a new one that // would kill the incoming reply just to resend the same window — // the "constantly re-requesting the same points" loop (#225 notes). - const dup = this._densityRequestDup(g, view, plotW, plotH, now); + const dup = this._densityRequestDup(g, plan.win, plotW, plotH, now); if (dup) { g._lodPendingView = view; g._lodPendingSeq = dup.seq; @@ -89,10 +90,11 @@ Object.assign(ChartView.prototype, { if (g.tier !== "density") continue; // T12/T13 re-check at actual send time: a drill that landed during // the debounce — or a reply that moved the view back out of the - // points band — elides the request it made unnecessary; a drill - // that died during it re-arms the request the schedule-time check - // skipped. - if (this._drillServesView(g, view) || this._aggregateStandsForView(g, view)) { + // points band with its step already covered — elides the request + // it made unnecessary; a drill that died during it re-arms the + // request the schedule-time check skipped. + const plan = this._drillServesView(g, view) ? null : this._densityRequestPlan(g, view); + if (!plan) { if (g._lodPendingSeq === seq) { g._lodPendingView = null; g._lodPendingSeq = null; @@ -105,7 +107,7 @@ Object.assign(ChartView.prototype, { // reply is deterministic for unchanged data; a data change rebuilds // this GPU record and clears the memo) or still in flight (its // reply was adopted as this trace's pending marker above). - const dup = this._densityRequestDup(g, view, plotW, plotH, sendNow); + const dup = this._densityRequestDup(g, plan.win, plotW, plotH, sendNow); if (dup) { if (dup.answered && g._lodPendingSeq === seq) { g._lodPendingView = null; @@ -114,12 +116,15 @@ Object.assign(ChartView.prototype, { } continue; } - const win = this._densityRequestWindow(g, view); + const win = plan.win; this.comm.send({ type: "density_view", seq, trace: g.trace.id, x0: win[0], x1: win[1], y0: win[2], y1: win[3], w: plotW, h: plotH, }); + // A ladder-step request's reply is the one density reply allowed + // to repaint a covered view (lodApplyDensityUpdate). + if (plan.step) g._stepReqWin = win; g._lastDensityReq = { win, w: plotW, h: plotH, seq, sentAt: sendNow, answered: false }; } } @@ -132,12 +137,6 @@ Object.assign(ChartView.prototype, { return seq; }, - _densityRequestWindow(g, view) { - const [x0, x1] = this._axisRange(g.xAxis, view); - const [y0, y1] = this._axisRange(g.yAxis, view); - return [Math.min(x0, x1), Math.max(x0, x1), Math.min(y0, y1), Math.max(y0, y1)]; - }, - // Same request within half an output texel per edge: gesture-end and // settle produce windows differing by sub-pixel amounts (field HAR: 0.03% // shifts back-to-back), and a grid shifted below half a texel is visually @@ -157,22 +156,31 @@ Object.assign(ChartView.prototype, { // pending hold, so a lost reply can never suppress refresh forever). The // memo lives on the GPU record — a rebuild (append, payload update, context // restore) starts it fresh, so data changes are never suppressed. - _densityRequestDup(g, view, plotW, plotH, now) { + _densityRequestDup(g, win, plotW, plotH, now) { const last = g._lastDensityReq; - if (!last || !this._densityRequestSame(last, this._densityRequestWindow(g, view), plotW, plotH)) { - return null; - } + if (!last || !this._densityRequestSame(last, win, plotW, plotH)) return null; if (last.answered) return last; return now - last.sentAt < 1200 ? last : null; }, - // The aggregate never refines (T13, revised): the covering texture stands - // until the view could plausibly resolve into real points, so a density - // request goes out only inside that band (lodAggregateStands). - _aggregateStandsForView(g, view) { + // What (if anything) this trace should ask the kernel for at `view` + // (T13, revised): inside the points band, the RAW VIEW window — the + // kernel decides the tier there, and its density replies land as facts + // only; while the aggregate stands, the next LADDER STEP window when + // every covering texture is coarser than the view's step (a quantized + // aligned window, so pans resolve to the same request), else nothing. + // Drill/point-cache service is the caller's earlier check. + _densityRequestPlan(g, view) { const [x0, x1] = this._axisRange(g.xAxis, view); const [y0, y1] = this._axisRange(g.yAxis, view); - return lodAggregateStands(this, g, x0, x1, y0, y1); + if (!lodAggregateStands(this, g, x0, x1, y0, y1)) { + return { + win: [Math.min(x0, x1), Math.max(x0, x1), Math.min(y0, y1), Math.max(y0, y1)], + step: false, + }; + } + const win = lodAggregateStepWindow(this, g, x0, x1, y0, y1); + return win ? { win, step: true } : null; }, // A reply whose seq lost the global race can still be current in substance: diff --git a/spec/design-dossier.md b/spec/design-dossier.md index e7686627..07c0068e 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -423,7 +423,8 @@ re-exports several of them as a historic import path and is not listed for those | `DRILL_PAD_SPAN_CAP` | `64.0` | Hard per-axis cap on a padded drill window's span (× the view span), kept well under the client's §16 re-encode bound (1/256 of window span) so deep zooms can always re-tighten the f32 offset encoding. | `interaction.py` | | `DRILL_HISTORY_KEEP` | `8` | Recent drilled subsets kept resolvable per trace, so picks against a retired cached point window (T13) still translate exactly; older seqs drop the pick, data changes clear the history. | `lod.py` (`enter_drill`, `drill_history`), `interaction.py` (`pick`) | | `LOD_POINT_CACHE_WINDOWS` | `3` | Retired exact point windows kept per trace client-side beyond the live drill; LRU-bounded VRAM, swept by the T11 outgrown rule. | `js/src/45_lod.ts` (`lodRetireDrill`, `lodPromoteCachedDrill`) | -| `LOD_POINTS_REQUEST_BAND` | `4` | The aggregate tier never refines (T13, revised): a `density_view` goes out only when the estimated in-view count sits within `budget × 4` of points territory — the LOWER of an area-scaled cached-window count and the retained sample counted in-view (`lodSampleViewCount`, distribution-true where area-scaling over-estimates sparse tails). Outside the band the covering texture stands, however blurry — an accepted, recorded cost. | `js/src/45_lod.ts` (`lodAggregateStands`) | +| `LOD_POINTS_REQUEST_BAND` | `4` | The aggregate tier never refines per view (T13, revised): a raw-view `density_view` goes out only when the estimated in-view count sits within `budget × 4` of points territory — the LOWER of an area-scaled cached-window count and the retained sample counted in-view (`lodSampleViewCount`, distribution-true where area-scaling over-estimates sparse tails). | `js/src/45_lod.ts` (`lodAggregateStands`) | +| `LOD_AGG_STEP_FACTOR` / `LOD_AGG_STEP_MAX` / `LOD_AGG_STEP_SLACK` | `4` / `2` / `1.5` | The stepped aggregate ladder (T13): while standing, the only density request is the view snapped outward to a power-of-4 block grid over the extent (per axis), at most 2 steps below home, and only when every covering texture is coarser than the step by more than the slack. Quantized windows are pan-stable and dedupable — at most 2 smooth-to-smooth swaps before points, worst-case softness ≈ 4× stretch per axis. | `js/src/45_lod.ts` (`lodAggregateStepWindow`) | | `DEFAULT_PALETTE` | 10 CVD-safe hex entries | Per-trace default color cycle and the fallback categorical palette when a channel supplies none (§20/§36). | `marks.py`, `_payload.py`, `_svg.py`, `_raster.py` | | `PYRAMID_MIN_POINTS` | `2_000_000` | Trace size at/above which a Tier-3 tile pyramid is built lazily; smaller traces never pay for one. | `interaction.py` | | `PYRAMID_BASE_DIM` | `2048` | Edge of the pyramid's base level in cells (`dim²` u32 counts, ~1/3 overhead for the coarser levels); sets resident pyramid bytes. | `interaction.py` | diff --git a/spec/design/lod-architecture.md b/spec/design/lod-architecture.md index e890c32f..a83082cf 100644 --- a/spec/design/lod-architecture.md +++ b/spec/design/lod-architecture.md @@ -541,17 +541,34 @@ invariants so future kinds don't regress them: with no recorded counts anywhere always requests. A fresh grid for an already-cached window supersedes its unpinned twin (`lodRememberDensity` dedupe), so the gate always reads current facts. - *Display side:* with a kernel attached, an aggregate reply never - REPLACES a texture that already covers the view — the standing surface - holds until real POINTS land, and the reply contributes only a + *Display side — the stepped ladder:* the aggregate sharpens in + QUANTIZED steps, never per view. While standing, the one density + request allowed is the next LADDER STEP window + (`lodAggregateStepWindow`): the view snapped outward to a + power-of-`LOD_AGG_STEP_FACTOR` block grid over the data extent, per + axis, at most `LOD_AGG_STEP_MAX` steps below home, requested only when + every covering texture is coarser than the step + (`LOD_AGG_STEP_SLACK`). Quantization makes step windows pan-stable and + dedupable — every view in a region resolves to the SAME window, pans + inside a step repaint nothing, revisits hit the cache — so a zoom sees + at most `LOD_AGG_STEP_MAX` smooth-to-smooth swaps before points, and + worst-case softness is bounded (≈ `LOD_AGG_STEP_FACTOR`× stretch per + axis) instead of unbounded home-stretch. Per-view refinement was the + recorded failure mode twice over: multi-MB re-ships per pan/zoom step + (the HAR), and fresh textures per view reading as jumping. A step + reply is the ONE density reply that may repaint a covered view + (matched against the marked request window, `g._stepReqWin`); every + other covered reply — the band probes above all — lands as a facts-only cache entry (window + exact count, - `lodRememberDensityFacts`) that recalibrates this gate. The - transition band's exact grids have a hard speckled character (sparse - cells at the points' own §2 alpha), and repainting the smooth standing - surface with one on every band probe read as jumping between zoom - levels (field capture). A reply for an UNCOVERED view still applies — - silence must never blank a frame (T1) — and standalone clients apply - everything (their re-binned grids are the only refinement they have). + `lodRememberDensityFacts`) that recalibrates this gate: the band's + exact grids have a hard speckled character (sparse cells at the + points' own §2 alpha), and repainting the smooth surface with one per + probe read as jumping between zoom levels (field capture). A reply for + an UNCOVERED view still applies — silence must never blank a frame + (T1) — and standalone clients apply everything (their re-binned grids + are the only refinement they have). Recorded trade-off: inside the + points band the raw-view probe takes the single request slot, so a + view can briefly outrun its ladder step until points land. - **Source-clamped grids:** the transition-band replies that do go out stay cheap — a pyramid-served reply never composes more cells than the finest level resolves under the window diff --git a/tests/test_density_request_economy.py b/tests/test_density_request_economy.py index f83cee2f..8d74594b 100644 --- a/tests/test_density_request_economy.py +++ b/tests/test_density_request_economy.py @@ -1,19 +1,24 @@ -"""The aggregate tier never refines; requests only probe the points band -(LOD doc T13, revised on #225 field feedback). +"""The aggregate sharpens in QUANTIZED ladder steps and probes the points +band with raw views (LOD doc T13, revised on #225 field feedback). The field HAR behind this (100M live drilldown): every pan and zoom step re-requested a multi-MB density grid — including back-to-back windows differing by sub-pixel amounts — for a picture that was the same aggregate -with marginally different blur. The revised contract: whatever density -texture already covers the view STANDS, however blurry, until the view could -plausibly resolve into REAL points (estimated in-view count within -LOD_POINTS_REQUEST_BAND × the direct budget); only then does a density_view -go out, and the kernel answers it with exact points once the count fits. -Sub-texel twins of the last sent request are suppressed outright, and the -frame always draws ONE aggregate texture (a tried fine-over-broad detail -layer was reverted: density textures alpha-composite, so overlaps -double-count opacity, and per-window normalization makes the seam a -brightness step — the field capture's "stale rectangle"). +with marginally different blur, and per-view textures read as zoom/pan +jumping. The revised contract: while the estimated in-view count sits above +the points band (LOD_POINTS_REQUEST_BAND × the direct budget), the only +density request allowed is the next LADDER STEP — the view snapped outward +to a power-of-LOD_AGG_STEP_FACTOR block grid over the extent, at most +LOD_AGG_STEP_MAX steps below home — whose reply is the one density reply +that may repaint a covered view (a smooth-to-smooth swap; pans inside a +step re-resolve to the same window). Inside the band the RAW view is +requested so the kernel decides the tier with real counts; its density +replies land as facts only. Sub-texel twins of the last sent request are +suppressed outright, and the frame always draws ONE aggregate texture (a +tried fine-over-broad detail layer was reverted: density textures +alpha-composite, so overlaps double-count opacity, and per-window +normalization makes the seam a brightness step — the field capture's +"stale rectangle"). This drives the real client in headless Chromium with a captured comm. """ @@ -96,15 +101,37 @@ (c) => c && !c.tex && c.visible === 10000000); // Zooming inside W at half its span: ~2.5M estimated in view — nowhere - // near points territory, so the texture stands and NOTHING is requested, - // pending markers included. - const insideElided = request(viewAt(0.5, 0.5, 0.25, 0.25)) === 0; + // near points territory, so the aggregate stands. The one request the + // stepped ladder allows: the QUANTIZED step-1 window (the view snapped + // outward to extent/4 blocks — here exactly W's bounds), never the raw + // view window. + const stepView = viewAt(0.5, 0.5, 0.25, 0.25); + const stepSent = request(stepView) === 1; + const stepMsg = sent.filter((m) => m.type === "density_view").pop(); + const stepTol = sx * 1e-9; + const stepAligned = stepMsg && + Math.abs(stepMsg.x0 - wx0) <= stepTol && Math.abs(stepMsg.x1 - wx1) <= stepTol && + Math.abs(stepMsg.y0 - wy0) <= stepTol && Math.abs(stepMsg.y1 - wy1) <= stepTol; + const stepMarked = Array.isArray(g._stepReqWin); + // The step reply is the ONE density reply allowed to repaint a covered + // view: it replaces the standing texture (smooth-to-smooth swap). + applyDensity(stepMsg.x0, stepMsg.x1, stepMsg.y0, stepMsg.y1, 200, 150, 10000000); + const stepApplied = g.density !== shownBefore && + Math.abs(g.density.xRange[0] - wx0) <= stepTol && g._stepReqWin === null; + + // Re-requesting a view the step texture now covers sends nothing. + const insideElided = request(stepView) === 0; const elisionClearedPending = g._lodPendingView === null; // Deeper inside W (1/16 of its area): ~625k estimated — inside the - // points band (budget x 4), so the request goes out and the kernel - // decides with real counts. - const bandRequested = request(viewAt(0.5, 0.5, 0.12, 0.12)) === 1; + // points band (budget x 4), so the RAW VIEW request goes out and the + // kernel decides with real counts. + const bandView = viewAt(0.5, 0.5, 0.12, 0.12); + const bandRequested = request(bandView) === 1; + const bandMsg = sent.filter((m) => m.type === "density_view").pop(); + const bandIsRawView = bandMsg && + Math.abs(bandMsg.x0 - bandView.x0) <= stepTol && + Math.abs(bandMsg.x1 - bandView.x1) <= stepTol; // A window whose count is already points-scale requests immediately. const w2x0 = v0.x0 + sx * 0.10, w2x1 = v0.x0 + sx * 0.40; @@ -114,9 +141,12 @@ // Sub-texel dedup: a settle re-request shifted by 0.2 output texels is // the same picture — suppressed while the first is in flight; a shift of - // 3 texels is a genuinely different window — sent. (These views sit - // inside the 60k home count, i.e. always in the points band, so only - // the dedup memo gates them.) + // 3 texels is a genuinely different window — sent. Restore the honest + // 60k sample weighting first so these views sit in the points band and + // request RAW windows (a standing view would quantize to the same + // aligned step window under any sub-block shift, gating on alignment + // rather than the dedup memo this section pins). + g.sampleOverlay.sample.visible = 60000; const plotW = Math.round(view.plot.w); const base = viewAt(0.75, 0.75, 0.2, 0.2); const sentBase = request(base) === 1; @@ -150,7 +180,9 @@ document.body.setAttribute("data-xy-economy-probe", JSON.stringify({ hasDensity: !!g, homeHasCount, standingHeld, factsStored, - insideElided, elisionClearedPending, bandRequested, nearDrillRequested, + stepSent, stepAligned, stepMarked, stepApplied, + insideElided, elisionClearedPending, bandRequested, bandIsRawView, + nearDrillRequested, sentBase, subTexelSuppressed, texelsRequested, densityDraws, uncoveredApplied, })); @@ -202,13 +234,21 @@ def test_aggregate_stands_until_points_band(tmp_path: Path) -> None: assert result["standingHeld"] is True assert result["factsStored"] is True assert result["uncoveredApplied"] is True - # The aggregate stands: no refinement requests while the estimated - # in-view count sits clearly above points territory. + # The stepped ladder: a standing view whose covering texture is coarser + # than its step requests the QUANTIZED aligned step window (never the + # raw view window), marks it, and that reply repaints; the same view + # afterwards requests nothing. + assert result["stepSent"] is True + assert result["stepAligned"] is True + assert result["stepMarked"] is True + assert result["stepApplied"] is True assert result["insideElided"] is True assert result["elisionClearedPending"] is True - # The points band and points-scale windows still request — the kernel - # keeps deciding with real counts everywhere it could answer sharper. + # The points band and points-scale windows still request the RAW view — + # the kernel keeps deciding with real counts everywhere it could answer + # sharper. assert result["bandRequested"] is True + assert result["bandIsRawView"] is True assert result["nearDrillRequested"] is True # Sub-texel dedup: same picture, no second request; a real shift sends. assert result["sentBase"] is True From 8a0c81bfffee108048d5ef7fe8249cb63c8d30a1 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 23 Jul 2026 21:37:48 +0000 Subject: [PATCH 11/11] Update the codspeed drilldown oracle to the padded-window contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_adaptive_drilldown_cycle pinned the drill reply's visible count to the raw view window's population — a pre-T13 contract. Drills now ship the widest ALIGNED window around the view that still fits the budget (LOD doc T13), so the fixture derives the oracle from the warm-up reply's shipped window instead: alignment is a pure function of the extent and the view's span bucket, so every benchmark iteration resolves to the same window and the same exact count. The cycle now also asserts that window's determinism per iteration. --- benchmarks/test_codspeed_kernels.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/benchmarks/test_codspeed_kernels.py b/benchmarks/test_codspeed_kernels.py index c5f207fc..5eee704a 100644 --- a/benchmarks/test_codspeed_kernels.py +++ b/benchmarks/test_codspeed_kernels.py @@ -231,13 +231,26 @@ def drilldown_figure() -> Figure: x = rng.uniform(0.0, 100.0, DRILL_N).astype(np.float64, copy=False) y = rng.uniform(0.0, 100.0, DRILL_N).astype(np.float64, copy=False) fig = xy.chart(xy.scatter(x=x, y=y, density=True)).figure() - fig._benchmark_deep_expected = int(np.count_nonzero((x < 10.0) & (y < 10.0))) # Warm the lazily-built pyramid so CodSpeed tracks interactive viewport # refresh cost, not one-time index construction. fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H) - fig.density_view(0, 0.0, 10.0, 0.0, 10.0, GRID_W, GRID_H) + deep, _ = fig.density_view(0, 0.0, 10.0, 0.0, 10.0, GRID_W, GRID_H) fig.density_view(0, 0.0, 100.0, 0.0, 100.0, GRID_W, GRID_H) + + # The drill ships the widest ALIGNED window around the view that still + # fits the budget (LOD doc T13), so the count oracle is the SHIPPED + # window's, taken from the warm-up reply — alignment is deterministic, + # every benchmark iteration resolves to the same window. + trace = deep["traces"][0] + assert trace["mode"] == "points" + (wx0, wx1), (wy0, wy1) = trace["x_range"], trace["y_range"] + assert wx0 <= 0.0 and wx1 >= 10.0 and wy0 <= 0.0 and wy1 >= 10.0 + fig._benchmark_deep_window = (wx0, wx1, wy0, wy1) + fig._benchmark_deep_expected = int( + np.count_nonzero((x >= wx0) & (x <= wx1) & (y >= wy0) & (y <= wy1)) + ) + assert fig._benchmark_deep_expected == trace["visible"] return fig @@ -1068,6 +1081,10 @@ def _adaptive_drilldown_cycle(fig: Figure) -> int: assert wide_trace["mode"] == "density" assert deep_trace["mode"] == "points" assert str(wide_trace.get("binning", "")).startswith("pyramid-L") + # T13: the shipped window is the deterministic padded aligned superset of + # the view, so both the window and its exact count repeat every cycle. + assert tuple(deep_trace["x_range"]) == fig._benchmark_deep_window[:2] + assert tuple(deep_trace["y_range"]) == fig._benchmark_deep_window[2:] assert deep_trace["visible"] == fig._benchmark_deep_expected drill_seq = deep_trace["drill_seq"] row = fig.pick(0, 0, drill_seq)