fix: store the stream clock as f64, so a stamp's precision does not decay with uptime

The time ring was f32 like the values, holding each stamp relative to an epoch
fixed at the first sample ever seen -- so the number stored grew for as long as
the app ran while the interval it had to resolve stayed one sample period. At
1 MS/s the f32 ULP reaches the sample period after about sixteen seconds, so two
to four consecutive samples rounded onto one stamp after half a minute and eight
after a minute. On screen that is a plateau the LOD envelope draws as a
zero-width segment -- a vertical spike where the waveform should be -- and in an
export it was three distinct diffs at 2, 2.5 and 3 ULP instead of one clock.

A value's precision is a property of the signal; a timestamp's is a property of
how long the scope has been running. Only the clock widens: the values are the
memory budget. That makes the two slices different sizes per slot, so
plan_partition() weighs its growth loop in bytes rather than in slots (and
bytes_for() is what bounds the doubling); live_window_locked() loses the f32
round-trip of its bounds, which existed only to keep a sample sitting exactly on
one; and EventSnapshot::stamps is f64 because a pause view is not rebased and so
carries full stream time.

Verified against the fast mock: np.diff over two million exported rows now holds
exactly one step (1e-06, 1999999 times), no duplicate or backwards stamps, and
the 200 MB ring still spans 8.39 s. Every documented probe assertion unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 10:41:57 +02:00
1 parent eb4372242c
commit ec93a9831d
10 files changed
+159 -81

No files matched your search

+43 -6
View File
@@ -343,7 +343,9 @@ clock is resampled too (`Partition::time_stride`, an averaging decimation — se
"A window longer than the buffer is resampled"). Because the split is by
*time span* rather than by bytes, every channel and the time base retain the same
history -- which is what makes one shared time slice meaningful. With no
decimation the two rules coincide and every slice is the same size.
decimation the two rules coincide and every slice holds the same number of samples
(not the same number of bytes: the shared clock is `f64` and the values are `f32`, so
the growth loop weighs the budget in bytes -- see "The time ring is `f64`" below).
The ring is always filling, whatever the trigger is doing. The pre-trigger history
has to already be there when an edge arrives; while acquisition followed the
@@ -456,11 +458,12 @@ the post window had been waiting for. That cost one sample of twenty thousand on
The axis is still pinned to what the snapshot actually captured, read off its own
stamps rather than off the request: the two can differ by a sample at each end, and
pinning the request would claim coverage the data does not have. The window's bounds
are rounded to the time ring's own `f32` before being compared against it, so a sample
sitting exactly on a bound is kept whatever the stream's absolute time happens to be —
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and
a third of the capture at 1 S/s.
pinning the request would claim coverage the data does not have. The cut's bounds are
compared in the ring's own type, which is now the `f64` they arrive in — see "The time
ring is `f64`" below. While the ring was `f32` the bounds had to be *rounded to it*
before comparing, or a sample sitting exactly on a bound fell to how its stamp
rounded, which cost a sample at 10 kS/s and a third of the capture at 1 S/s. That
rule is gone with its cause.
**Where the capture is drawn was a separate bug from what it contained**, and it
outlived the fixes above. The plotting library's `gather()` emits x *relative to*
@@ -791,6 +794,40 @@ signature. The same torn read still exists for a small `i > 0` — the oldest fr
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
speculatively.
**The time ring is `f64`; the value rings stay `f32`.** It was `f32` like everything else,
with the stamps stored relative to an epoch fixed at the first sample ever seen — so the
number being stored grew for as long as the app ran while the interval it had to resolve
stayed one sample period. `f32` carries 24 bits of mantissa, which is a *relative*
precision, so the ULP at 16 s of uptime is 1.9 µs, at 32 s 3.8 µs, at 64 s 7.6 µs and over
a 600 s window 61 µs. At 1 MS/s the sample period is 1 µs, so after half a minute two to
four consecutive samples rounded onto the same stamp, after a minute eight, and so on
without bound. That is the "zoom in to about 10 µs and there are three to five points with
the same timestamp" report, and the export showed it unambiguously: `np.diff(t)` over two
million rows held **three** distinct steps, 0.953 µs, 1.192 µs and 1.431 µs — exactly
2, 2.5 and 3 ULP of the magnitude the stamps had reached, a stair instead of a clock. The
min/max artefacts are the same cause read through the LOD pyramid: a bucket of samples
sharing one x is a zero-width segment, so an envelope drew as a vertical spike at a
plateau's edge rather than as the waveform.
Nothing was wrong with the arithmetic, so no amount of care at a call site could have
fixed it; the type was. **A value's precision is a property of the signal and a
timestamp's is a property of how long the scope has been running** — 24 bits is plenty for
a sample and runs out on a clock. Widening only the clock keeps the cost where it belongs:
the rings are the memory budget, and the values are the overwhelming majority of them. The
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
slots, because a time slot and a value sample are now different sizes — it weighs the
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
stream time, which quantised at exactly the rate the live ring did.
`RingBuffer<T>` stores its slots as `std::atomic<T>` behind a
`static_assert(is_always_lock_free)`, so this was a question worth asking rather than
assuming: `std::atomic<double>` is lock-free on every target this app builds for, and the
assertion is a compile-time guard rather than a silent fallback.
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
laggy with large data" was. The renderer picks a level per trace per frame with
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid