// 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& 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).