Files
plotter/docs/superpowers/specs/2026-09-29-plot-hardening-and-features-design.md
Martino Ferrari 38ef459897
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +02:00

18 KiB
Raw Permalink Blame History

// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari

Hardening and features v0.2 — shared contracts

Design record for the bug-fix + robustness + feature wave. This file is the interface bible for the five parallel streams. File ownership is exclusive: a stream edits only the files listed under it. Everything not listed is the integrator's.

Bug list (B1–B7) and robustness list (R1–R9) being fixed

  • B1 f32 staging of large-magnitude x destroys geometry (epoch axes).
  • B2 tick major/minor classification ULP-fragile (ticks.cpp:176).
  • B3 LOD gather emits buckets straddling [begin,end) (series.cpp:168).
  • B4 XFollow::Live on log x holds linear span (plot.cpp:494, figure.cpp).
  • B5 zero-notch wheel disarms auto_scale; factor==inf unguarded (controller.cpp:158, glfw forwards dy=0).
  • B6 single text flush paints legend text over the crosshair readout panel.
  • B7 NaN policy incoherent (min/max drop unless seeding; sum propagates).
  • R1 std::clamp UB on negative viewport (plot.cpp:886).
  • R2 set_x_range/set_y_range store non-finite verbatim (plot.cpp:377-384).
  • R3 kBucket << level UB for level >= 58 via public gather.
  • R4 ring capacity rounds up but docs/reserve assume raw capacity.
  • R5 GLFW figure capture cleared by any button release.
  • R6 renderer residue: blend func, UNPACK_ALIGNMENT, no GL shutdown, epoch() wrap, annotation caption overflow.
  • R7 GLFW host re-lays out the figure on every event.
  • R8 measure accepts horizontal rulers, silently measures [0,0].
  • R9 minor-tick cap truncates the far end of the axis, majors included.

Cross-cutting decisions

  • NaN policy (library-wide, documented): a non-finite sample is excluded from every aggregate (min, max, sum, sumsq) and from cached bounds. A window with no finite samples aggregates to a zero-span Range / zero sums. Applies in MinMaxPyramid::append, Series::reduce/sum/sumsq edge folds, ArraySeries::commit, StreamSeries::acquire bounds. docs/data-and-performance.md gets a "Non-finite samples" paragraph.
  • Gather x contract (breaking, B1): Series::gather now emits x relative to x0() (exactly i*dx for uniform series, x_at(i)-x0() for XY), never absolute x. y is unchanged. The renderer reconstructs absolute data x in f64 (x0() + rel) before transforming, so no precision is lost at the f32 narrowing: the staged f32 is to_space(x_abs) - to_space(origin), which is O(visible pixels), not O(epoch).
  • Live follow on log axes (B4): Axis::offset and the live-window span live in axis space (log10 when log), matching how the controller's gestures already work (to_space/from_space). place_live_x_window and the figure link-group copies hold the log-space span constant.
  • Key-repeat policy (library-wide): a held key repeats in every adapter (GLFW/Qt/GTK already do; ImGui switches to repeat=true).
  • Modifiers for box zoom: Shift+Left = both axes (unchanged, default); Ctrl+Left = x-only; Alt+Left = y-only. Other modifier combos = both.

Shared-header changes (made by the integrator before the fan-out)

  • include/plot/input/events.hpp
    • PointerEvent gains f64 time_s = 0.0; (host-supplied monotonic seconds; powers double-click). Hosts that leave 0 simply never double-click.
    • New GestureEvent { f32 x, y; f32 scale = 1.0f; f32 dx = 0.0f, dy = 0.0f; } — pinch: zoom about (x, y) by scale, pan by (dx, dy). Controller gains void on_gesture(const GestureEvent&).
    • New key constants: kKeyEscape = 27, kKeyLeft = 256, kKeyRight = 257, kKeyUp = 258, kKeyDown = 259 (host-translated, like the ASCII ones).
  • include/plot/data/series.hpp
    • enum class GatherMode { Envelope, Nearest, Mean }; — downsample policy. Envelope = today's min/max pairs. Nearest = one raw sample per bucket (via snap), x at the sample. Mean = one point per bucket, y = mean (bucket sum / count), x at bucket centre.
    • virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy, GatherMode mode = GatherMode::Envelope) const — signature change; every override updates; renderer passes the mode explicitly.
    • New virtuals (uniform default, XY override): virtual f64 x_at(u64 i) const (x0() + i*dx()), virtual bool neighbours(f64 x, u64& i0, u64& i1) const (false when x is outside [x_at(0), x_at(n-1)]; else the bracketing indices), virtual u64 snap_index(f64 x) const (nearest index, clamped).
    • MinMaxPyramid gains f64 bucket_sum(u32 l, u64 i) const and f64 bucket_sumsq(u32 l, u64 i) const (Mean/RMS need them).
  • include/plot/plot/trace.hpp — Trace/TraceDesc gain (trailing, defaults keep aggregate init source-compatible): GatherMode mode = GatherMode::Envelope; f32 dash_on = 0.0f, dash_off = 0.0f; (logical px, 0 = solid), enum class Marker { None, Cross, Circle, Square }, Marker marker = Marker::None; f32 marker_size_px = 3.0f; u32 marker_every = 0; (0 = auto: stride ceil(samples_per_pixel), so markers only appear where samples are sparse). width == 0 draws markers only (scatter) — documented.
  • include/plot/render/style.hpp — gains f32 line_aa_px = 1.0f; (logical, the AA fringe half-width, clamped >= 0.25f at use). No text-colour fields: text colour becomes a per-call parameter (see C).
  • include/plot/plot/plot.hpp — set_viewport normalises negative w/h to 0 (R1).
  • include/plot/plot/crosshair.hpp — CrosshairRow gains Color color DROPPED: CrosshairRow already names its trace; the renderer resolves the colour as plot.trace(row.trace).color at draw time. No field added.

Stream A — data layer (exclusive)

Files: include/plot/data/*, src/data/*, tests/test_series.cpp, tests/test_ring_buffer.cpp, tests/test_minmax_pyramid.cpp, tests/test_xy_series.cpp (new), plus the Series::gather override forwarders and docs/data-and-performance.md NaN/policy paragraphs.

  1. B3: bucket-edge selection uses first = max(ceil_div(origin+b, span), lbase), last = min((origin+e)/span, lend); lead/trail raw spans cover the rest. Regression: gather at unaligned begin/end mid-window asserts no emitted x outside [begin, end) and envelope values equal exact reduce over the range.
  2. B7: NaN policy per above; regression: NaN at first/middle/last position — bounds and reduce stay finite, sum skips NaN.
  3. R3: level_span caps at level 57 (span 2^63); gather(level > 57) clamps; no UB/div-zero for any u32 level.
  4. R4: docs say "at most capacity() rounded up to a power of two"; StreamSeries reserves the rounded ring_.capacity().
  5. GatherMode implementation in gather_impl (Mean/Nearest via the new pyramid accessors); XY/Array/Stream forwarders updated; relative-x contract implemented everywhere (x emitted as x_at(i) - x0()).
  6. XY series (full feature): include/plot/data/xy_series.hpp, src/data/xy_series.cpp, target plot_core. Sorted-ascending x (documented precondition, assert in debug); binary-search neighbours/snap_index; pyramid over sample index; commit() rebuilds.
  7. Snapshot: ArraySeries StreamSeries::freeze() const — consumer-thread only; copies the retained window (copy_raw) into a fresh ArraySeries with this window's x0()/dx(). Freezes against the last acquire (documented).
  8. Incremental append: void ArraySeries::push(f32) — appends, folds into the pyramid (append), extends bounds incrementally (NaN-aware), extends x_bounds_. commit() stays the wholesale path. Acceptance: new tests for every item above; plot_tests builds and passes.

Stream B — plot + input (exclusive)

Files: src/plot/ticks.cpp, src/plot/plot.cpp, src/plot/figure.cpp, src/input/controller.cpp, include/plot/input/controller.hpp, include/plot/plot/axis.hpp (offset doc), tests/test_ticks.cpp, tests/test_controller.cpp, tests/test_plot.cpp, tests/test_figure.cpp, tests/test_measure.cpp, and the plot-layer consumers of the new Series virtuals (crosshair_value, crosshair_span, covered_samples, measurement — route through x_at/neighbours/snap_index so an XY series crosshairs and measures correctly).

  1. B2: major = integer-index test (k % ratio == 0 on the minor index, k derived from (v - first_major)/minor_step rounded); no float tolerance. Regression: epoch range 1.75e9 ± 300 s at 800 px produces labelled majors.
  2. R9: majors are never truncated; only minors count against kMaxTicks.
  3. B4: live window placement in axis space (log-aware span/offset) in Plot::place_live_x_window, Plot::set_x_range live branch, and the two figure link-group copies. Regression: streaming log axis holds visible decades constant across samples; scrolled-back window keeps span.
  4. B5: on_wheel — dy == 0 && dx == 0 returns; dy == 0, dx != 0 pans x only (dx notches, 5% of the x span per notch, log-aware via to_space); zoom path rejects factor == 1 and non-finite. Same guard in the middle-drag axis_zoom.
  5. R1: layout() clamp guards hi >= lo before clamping (no UB even for a hostile viewport; set_viewport normalises, already done by integrator).
  6. R2: range setters refuse non-finite endpoints (Off branch + y), keep the previous range and auto_scale.
  7. R8: add_measure/measurement refuse horizontal-ruler (and vertical-ruler dead-field) cursors — valid stays false, add_measure returns an id that reports invalid.
  8. Interaction features:
    • Keyboard pan: Left/Right/Up/Down pan 10% of the span per press/repeat (x-only for Left/Right, y-only for Up/Down; log-aware).
    • Escape (kKeyEscape): cancels the active drag — restores the ranges the axes had at begin_drag (captured then), drops box-zoom without applying, clears grab state.
    • Box-zoom masks per the modifier table above; applied per-axis at release.
    • Double-click reset: two presses of the same button within 0.35 s and 4 px (uses PointerEvent::time_s) re-fits every axis (auto_scale = true + fit). Second press does not begin a drag. Acceptance: controller tests for each gesture incl. cancel-restore; tick regressions; XY crosshair/measure unit tests via an XY fixture.

Stream C — render (exclusive)

Files: src/render/*, include/plot/render/* (renderer/primitives/text/buffer interfaces), src/gl/handle.cpp, include/plot/gl/handle.hpp, tests/test_render_smoke.cpp, tests/test_primitives.cpp.

  1. B1: implement the relative-x staging contract (see Cross-cutting): trace staging reconstructs x_abs = series.x0() + rel in f64, stages f32(to_space(x_abs) - to_space(origin)); every data-space line draw (grid, cursors, regions, measures, crosshair, annotations) picks a natural origin (its own reference data x/y) and mapping() folds origin into the offset uniform in f64 before the single narrowing. Regression: EGL smoke renders an epoch-window series and a zero-origin twin and asserts both rasterise to the same pixel column set (tolerance).
  2. B6: two text batches — text_under_ (legend names, cursor labels, measure labels, annotation captions) flushed immediately before draw_crosshair_readout; text_over_ (readout text, tick labels, titles) flushed at end_frame. The readout panel occludes under-batch text. Regression: legend + overlapping readout frame — legend name pixels are not lit inside the readout panel rect.
  3. R6: begin_frame saves GL_BLEND_SRC/_DST via glGetIntegerv and end_frame restores function + enable bit; Texture::upload_rgba saves/restores GL_UNPACK_ALIGNMENT; Renderer::release_gl() (public) destroys all GL handles explicitly, called first by the destructor (safe when the context is current) and documented for host teardown order; epoch() widened to u64 end-to-end (buffer generation + slot epoch + VAO cached epoch); annotation captions clamp their box inside axes (skip the caption when it cannot fit).
  4. Dash: trace dash reaches u_dash per trace flush (device px, logical converted); legend swatch samples the dash. dash_off == 0 = solid.
  5. Markers: staged as line segments through the existing batch (Cross 2 segs, Circle 8-gon, Square 4 segs), colour = trace colour, size = device(marker_size_px), stride marker_every or auto ceil(spp); width == 0 draws markers only. Render-smoke regression: a marker'd trace lights vertices a plain line does not, and a marker_every = 3 trace stages 1/3 the marker count.
  6. AA knob: u_aa uniform = max(0.25, device(style.line_aa_px)); shaders drop the hardcoded fringe. Default output unchanged (1.0 logical).
  7. Text colour: per-vertex a_colour (vec4); stage_text(..., Color); flush_text stops setting u_color; crosshair readout value rows draw in their row's trace colour (CrosshairRow::color), labels in axis_text. Smoke regression: a coloured readout row differs from the axis-text pass.
  8. GatherMode: renderer passes trace.mode to gather. Acceptance: smoke tests above; full suite green; no uniform/attribute drift (cross-check against GLSL in the same PR).

Stream D — adapters (exclusive)

Files: adapters/** only, tests/test_glfw_host.cpp, tests/test_qt_pointer.cpp, tests/test_gtk_input.cpp, tests/test_imgui_widget.cpp, docs/embedding.md (shared conventions: key repeat, touch parity, DPR note).

  1. R5: GLFW figure capture records the button; only the owning button's release ends it; a second button does not steal or clear the capture (documented single-gesture policy). Test: press left row0, press right row1, release right — left drag still routes to row0.
  2. R7: Host caches row rects; invalidated on resize + set_figure, refreshed once per frame; event handlers read the cache (one-frame-stale rows during resize — documented).
  3. Key repeat: ImGui IsKeyPressed(..., repeat=true); policy comment.
  4. Timestamps: all four adapters fill PointerEvent::time_s (glfwGetTime / QEvent timestamps /1000 / gdk time /1000 / ImGui::GetTime).
  5. New keys: GLFW maps arrows + Escape to the new constants (with GLFW_KEY_LEFT etc.); Qt maps Key_Left...; GTK maps GDK_KEY_Left...; ImGui ImGuiKey_LeftArrow..., ImGuiKey_Escape.
  6. Qt: wheelEvent uses pixelDelta()/40 notches when angleDelta is 0 (pixelDelta path); DevicePixelRatioChange refreshes the controller viewport + scales; pinch via grabGesture(Qt::PinchGesture) -> GestureEvent.
  7. GTK4: GtkGestureZoom attached in the plot area factory -> GestureEvent (scale + focal delta); scroll ScrollUnit::Surface path already exists — keep.
  8. GLFW: on_scroll forwards unchanged (controller owns the dy=0 case). Acceptance: adapter-gated tests updated; toolkit-free seams (qt_pointer, gtk_input) carry the new logic so headless tests cover it.

Stream E — build, packaging, docs, CI (exclusive)

Files: root CMakeLists.txt, cmake/*, CMakePresets.json, .github/workflows/ci.yml (new), plot.pc.in (new), docs/building-and-testing.md, docs/getting-started.md, README.md (feature list + versioning), tests/test_series.cpp (delete the wall-clock steady-state case only), readme_main.cpp (new, referenced by README).

  1. GNUInstallDirs for every install path; VERSION/SOVERSION on plot_core when shared; SameMinorVersion compatibility (0.x reality); generated include/plot/version.hpp (configure_file, installed, added to the umbrella + exemption handling as needed); plot.pc pkg-config file; install LICENSE + licences/OFL-1.1.txt under ${CMAKE_INSTALL_DATADIR}/licenses/plot/; install(DIRECTORY) gets FILES_MATCHING PATTERN "*.hpp".
  2. PLOT_BUILD_TTF_FONT moves to cmake/plot_options.cmake (docs match); coverage-flag detection switches to a PLOT_COVERAGE option check on the config profile, not a CMAKE_CXX_FLAGS string match.
  3. CI: .github/workflows/ci.yml — ubuntu-latest, apt deps (libglfw3-dev, qt6-base-dev, libgtk-4-dev, libegl1, libglvnd-dev, xvfb, lcov, python3-fonttools), jobs: debug (configure/build/xvfb ctest), coverage (tools/coverage.sh, uploads the table), asan (linux-asan preset). continue-on-error on the ImGui fetch is not needed (network is available on GH runners).
  4. Docs: correct the case-count math (recount from the tree at integration time), remove the stale hard-coded coverage figures from README and building-and-testing (coverage.md stays the single source, regenerated), document the new keys/gestures/touch parity in interaction.md + embedding.md (note: B/C own feature code; E only documents what the contract above specifies). Acceptance: cmake --install smoke into a temp prefix finds plot via find_package in a throwaway consumer; umbrella tests stay green; suite counts match docs.

Integration (integrator only)

Merge all streams; reconcile include/plot/plot.hpp umbrella; full configure+build+ctest; regenerate coverage via tools/coverage.sh; update the per-file coverage table and README numbers from the fresh run; run the GLFW demo headless under Xvfb as the final smoke; update this design record's status.

Status: COMPLETE (2026-09-30)

All five streams landed and were integrated on main (uncommitted working tree). Controller::on_gesture was implemented by the integrator (Stream B was cut off before it). Incident: the root filesystem hit 100% mid-wave and truncated src/render/renderer.cpp to 0 bytes; Stream C restored it from its transcript and re-verified. Final state:

  • linux-debug suite: 350 cases, 0 failed (was 283); 4/4 ctest entries pass.
  • Coverage: 93.9% aggregate (3730/3971) over 24 files, every file >= 80% (floor 80/90) — regenerated in docs/coverage.md by tools/coverage.sh.
  • Headless smoke: plot_glfw_demo + plot_multiplot_demo under xvfb-run, exit 0, screenshots contain rendered ink (65.4% / 20.0% non-background at 1280x720); the multiplot run reports 20,901 staged GPU segments for ~4.5M visible samples, GL error 0 (README paragraph updated).
  • Known follow-ups: real touch hardware has never exercised the Qt/GTK pinch paths; macOS/Windows remain untested; CI YAML is written but has run only locally in structure (no GitHub remote execution during this wave).