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

+6 -6
View File
@@ -109,9 +109,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
if (snap != nullptr) {
// The snapshot is immutable and already index-aligned, so the visible window is two
// binary searches over its stamps and the same index range for every channel.
const std::vector<f32>& st = snap->stamps;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
const std::vector<f64>& st = snap->stamps;
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
if (hi <= lo) return table;
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
const std::size_t n = static_cast<std::size_t>(hi - lo);
@@ -122,7 +122,7 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
table.time.resize(n);
table.stream_time.resize(n);
for (std::size_t i = 0; i < n; ++i) {
table.time[i] = static_cast<f64>(st[first + i]);
table.time[i] = st[first + i];
table.stream_time[i] = edge + table.time[i];
}
for (const int c : chans) {
@@ -151,12 +151,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
if (n == 0) return table;
BufferStore& store = model.live();
std::vector<f32> stamps(static_cast<std::size_t>(n));
std::vector<f64> stamps(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data());
table.time.resize(static_cast<std::size_t>(n));
table.stream_time.resize(static_cast<std::size_t>(n));
for (std::size_t i = 0; i < stamps.size(); ++i) {
table.stream_time[i] = static_cast<f64>(stamps[i]);
table.stream_time[i] = stamps[i];
table.time[i] = epoch + table.stream_time[i];
}
for (const int c : chans) {