fix(udpscope): stop a wrong hrtDt from displacing the trace permanently

On the hrt branch the derived period is not just a spacing: it is the
burst width ClockOffset latches against, so a wrong one shifts the whole
trace by an amount that is usually too small for kRecalibThresholdS to
ever heal. Three routes to a wrong period were open.

Packet loss. elapsed spans every packet since the last one seen, but it
was divided by prevAccCount alone, so a lost datagram scaled the period
by the whole counter gap. Since a burst is anchored on its LAST element,
too wide means it ends in the FUTURE: +22.5 ms for one loss, +225 ms for
ten, at 10 samples per 25 ms packet, mis-spacing 2.7% of all samples at
1% loss. The declared branch already reads the counter for exactly this;
the hrt branch now does too.

Producer restart and reorder. Both leave elapsed at zero, so no period
can be measured -- and the restart packet is also the one that re-latches
after offset.reset(). Falling back to kDefaultDt is only right at 1 kHz;
measured standing displacement was +13.5 ms at 10 samples per 25 ms and
-89 ms at 100 per 10 ms. Remember the last measured period instead.

A stray hrt == 0 packet re-enters the warm-up branch, which spans from
packetBurst's lastPacketWall -- a field the hrt branch never wrote, so it
still held the start of the session. After 153 packets that emitted a
burst 3.8 s in the past, worse the longer the scope had run.

Also: rule 2 with no declared rate stacked every element of the array on
one instant (as UDPSourceSession.cpp:522 does, harmlessly, for a
host-local consumer). Spread it from consecutive time-signal anchors,
which measure the burst on the producer's own clock.

Reverts the previous commit's wallElapsed <= 0 change: it was measurably
inert -- the step floor two lines below already yields the same number --
and its comment claimed a divergence it did not stop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-28 06:11:45 +02:00
co-authored by Claude Opus 4.6
parent 3add2c42b9
commit f97fd825c4
4 changed files with 665 additions and 87 deletions
+96 -34
View File
@@ -172,9 +172,36 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
/* Rule 2: anchor from the time signal, spread by the sampling rate. */
if ((d.timeMode == kTimeFirstSample || d.timeMode == kTimeLastSample) &&
hasTimeSig && f.counts[tIdx] >= 1u && f.values[tIdx] != nullptr) {
const double anchor = st.offset.map(f.values[tIdx][0] * tScale, wallNow);
const double rate = DeclaredRate(d.samplingRate);
const double dt = (rate > 0.0) ? (1.0 / rate) : 0.0;
const double prodSec = f.values[tIdx][0] * tScale;
const double anchor = st.offset.map(prodSec, wallNow);
const double rate = DeclaredRate(d.samplingRate);
double dt = (rate > 0.0) ? (1.0 / rate) : 0.0;
/* No rate declared. UDPSourceSession.cpp:522 leaves dt at zero here,
* which stacks every element of the array on one instant — harmless for
* a host-local consumer that only stores them, but this scope's ring,
* decimator and trigger all require a signal's stamps to increase, and a
* plot of N points at one X is not a trace.
*
* The spread is recoverable without a rate: consecutive anchors come
* from the time signal, so their difference is the burst's true duration
* in producer seconds, measured on the producer's own clock rather than
* on arrival — immune to the bursty delivery that corrupts everything
* arrival-derived. Divide by the counter gap for the same reason rule 3
* does: a lost datagram widens the anchor difference without widening
* the array. Until a second packet arrives there is nothing to measure
* and the elements do stack; that is one packet, not the whole run. */
if (!(dt > 0.0) && nElems > 1u && st.prevAnchorValid &&
prodSec > st.prevAnchorProdSec) {
const uint32_t gap = (f.counter != 0u && f.counter > st.lastCounter)
? (f.counter - st.lastCounter) : 1u;
dt = (prodSec - st.prevAnchorProdSec) /
(static_cast<double>(nElems) * static_cast<double>(gap));
}
st.prevAnchorProdSec = prodSec;
st.prevAnchorValid = true;
st.lastCounter = f.counter;
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = (d.timeMode == kTimeFirstSample)
@@ -329,24 +356,24 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
double advance = nominal * factor;
/* A non-positive elapsed means the wall has not moved
* since this signal's previous burst — a coarse arrival
* clock, or two packets stamped within one tick of it.
* There is no wall time to spend, so the cap is zero.
* Skipping the cap in that case (which is what this code
* used to do) hands back the full proportional advance,
* so a run of same-tick arrivals gains lead while no wall
* time passes at all — the divergence the cap exists to
* stop, in its purest form. */
* since this signal's previous burst. Skipping the cap
* then is deliberate and, more to the point, makes no
* difference: forcing the cap to zero instead sends step
* through the floor below to dt * kMinBleedFactor, which
* is the same number the proportional factor already
* yields once the excess exceeds one burst. Both leave
* the same-tick case diverging; only real elapsed wall
* time can bleed lead off, and a recv_time from
* CLOCK_REALTIME (udps_client.c:120) does not repeat. */
const double wallElapsed = wallNow - st.lastEmittedWall;
const double cap = (wallElapsed > 0.0)
? (kWallBleedFraction * wallElapsed)
: 0.0;
if (cap < advance) { advance = cap; }
if (wallElapsed > 0.0) {
const double cap = kWallBleedFraction * wallElapsed;
if (cap < advance) { advance = cap; }
}
step = advance / static_cast<double>(nElems);
/* Reached whenever the cap is zero, and a backstop
* against a nonsensical dt off the wire: downstream
* requires strictly increasing stamps, so the burst must
* still advance by something. */
/* Unreachable with a finite positive dt — kept because
* downstream monotonicity must not depend on that
* argument holding for every value off the wire. */
if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
base = st.lastEmittedEnd + step;
}
@@ -459,11 +486,43 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
}
st.accProdSec += elapsed;
/* The flushes carry contiguous RT cycles, so the gap divided by the
* previous packet's sample count is exactly one cycle period. */
const double hrtDt = (elapsed > 0.0 && st.prevAccCount > 0u)
? (elapsed / static_cast<double>(st.prevAccCount))
: kDefaultDt;
/* The flushes carry contiguous RT cycles, so the tick gap divided by the
* number of cycles it spans is exactly one cycle period. That count is
* NOT prevAccCount: elapsed spans every packet since the last one we
* saw, so a lost datagram makes the tick gap wider without making
* prevAccCount larger. Dividing by prevAccCount alone therefore returns
* a period scaled by the whole counter gap — 2x for one lost datagram,
* 11x for ten — which draws the recovery burst that many times too wide
* and, because the burst is anchored on its LAST element, ends it in the
* FUTURE (measured: +22.5 ms for one loss, +225 ms for ten, at 10
* samples per 25 ms packet). At 1% loss that mis-spaced 2.7% of all
* samples. The declared branch already reads the counter for exactly
* this purpose (`lost`, above); the hrt branch must too.
*
* Only a FORWARD gap counts. A backward or repeated counter is the
* reorder case handled above, where elapsed is zero anyway. */
const uint32_t accGap = (f.counter != 0u && st.lastEmittedValid &&
f.counter > st.lastCounter)
? (f.counter - st.lastCounter) : 1u;
const double cycles = static_cast<double>(st.prevAccCount) *
static_cast<double>(accGap);
/* Falling back to kDefaultDt is a last resort, not a default: see
* SigState::lastHrtDt. The fallback is reached on the first hrt packet
* of a producer restart (elapsed is zero because hrt went backwards) and
* on a reordered datagram, and in both cases the wrong burst width is
* latched into ClockOffset permanently — measured 13.5 ms of standing
* displacement at 10 samples per 25 ms packet, 89 ms at 100 per 10 ms,
* both below kRecalibThresholdS and so never corrected. */
double hrtDt;
if (elapsed > 0.0 && cycles > 0.0) {
hrtDt = elapsed / cycles;
st.lastHrtDt = hrtDt;
} else if (st.lastHrtDt > 0.0) {
hrtDt = st.lastHrtDt;
} else {
hrtDt = kDefaultDt;
}
/* Anchor the burst's LAST element on arrival, not its first. The
* packet's hrt is the tick count of sample 0 (UDPSourceSession.cpp:574),
@@ -495,16 +554,11 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
* at kWallBleedFraction: only that makes the lead bleed off. */
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
const double wallElapsed = wallNow - st.lastEmittedWall;
/* No wall movement, no wall time to spend: see the same cap in the
* declared branch. Zero rather than "skip the cap", so a run of
* same-tick arrivals cannot advance a full hrtDt per sample while
* the wall stands still. */
const double cap = (wallElapsed > 0.0)
? (kWallBleedFraction * wallElapsed /
static_cast<double>(nElems))
: 0.0;
if (cap < step) { step = cap; }
if (!(step > 0.0)) { step = hrtDt * kMinBleedFactor; }
if (wallElapsed > 0.0) {
const double cap = kWallBleedFraction * wallElapsed /
static_cast<double>(nElems);
if (cap < step) { step = cap; }
}
base = st.lastEmittedEnd + step;
}
@@ -517,6 +571,14 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
st.prevAccCount = nElems;
st.lastEmittedEnd = tsOut[nElems - 1u];
st.lastEmittedWall = wallNow;
/* Keep packetBurst's reference current even though this branch does not
* use it. A single packet with hrt == 0 re-enters the warm-up branch
* above, and packetBurst would otherwise span from whenever this signal
* last took that branch — the whole session. Measured: after 153 hrt
* packets, one zero-hrt packet emitted a burst starting 3.8 s in the
* past, growing without bound with session length. */
st.lastPacketWall = wallNow;
st.lastPacketValid = true;
/* Same duplicate-datagram exposure as the declared branch: a host joined
* on two interfaces receives every unfragmented update twice, and the
* guard at the top of timestamps() can only fire if this branch leaves a
+33 -1
View File
@@ -10,7 +10,17 @@
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
* solves it; these are the same rules, computed from udps_frame_t's own fields.
*
* Three rules deliberately differ, all in the accumulated-scalar case (rule 3).
* They are NOT the same code, and the differences are not a short list. Every
* one of them comes from the same root: StreamHub runs on the producer's host,
* so its arrival time IS the producer's clock and its local
* HighResolutionTimer::Frequency() IS the frequency behind the packet's hrt.
* Neither holds over a network, so anything StreamHub can read directly this
* decoder has to estimate (HrtRateFit, ClockOffset), and anything it estimates
* it must also defend — hence the monotonic clamps, the kWallBleedFraction
* bleed, the reorder and restart guards and the duplicate-datagram drop, none of
* which exist in UDPSourceSession.cpp. Do not read the three sections below as
* exhaustive; they are the three that change where a sample LANDS, and so the
* three worth checking first when a trace looks wrong.
*
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
@@ -42,6 +52,15 @@
* datagram and reinstates a hole that never existed. So a signal that has
* already burst keeps every later update on rule 3 regardless of its length; a
* signal that has never burst is a genuine scalar and is left to rule 5.
*
* Two divergences OUTSIDE rule 3 are known and deliberately left as they are.
* Rule 1 keys ClockOffset on the consuming signal, where UDPSourceSession.cpp:516
* keys it on the time-signal index, so signals sharing a time signal share an
* offset there and not here — immaterial, since the mapping they compute is the
* same. And a FIRST_SAMPLE/LAST_SAMPLE signal whose time signal is absent falls
* through to rule 4 rather than using its declared rate; that is a malformed
* CONFIG, and spanning arrivals is the more honest answer than trusting a rate
* whose anchor never arrived.
*/
#pragma once
@@ -92,6 +111,19 @@ private:
double accProdSec = 0.0;
bool lastAccValid = false;
uint32_t prevAccCount = 0;
/** Last inter-element period the hrt branch actually MEASURED, used
* whenever this packet cannot measure one of its own (no previous tick,
* or hrt went backwards). The constant kDefaultDt is a poor substitute:
* it is only right at 1 kHz, and a wrong period here is not merely a
* wrong spacing for one burst — it is the burst width ClockOffset
* latches against, and the resulting displacement is usually too small
* for kRecalibThresholdS to ever heal. Zero until first measured. */
double lastHrtDt = 0.0;
/** Rule 2 only: the previous packet's time-signal anchor, in PRODUCER
* seconds. Consecutive anchors are what lets an array with no declared
* sampling rate be spread at all. */
double prevAnchorProdSec = 0.0;
bool prevAnchorValid = false;
/** For accumulated scalars (rule 3, either branch): end timestamp of the
* most recently emitted burst, and the packet counter it came from. The
* next burst is chained onto that end, with the counter gap reinstating
+198 -8
View File
@@ -107,6 +107,49 @@ TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) {
EXPECT_NEAR(ts[3], 2000.003, 1e-9);
}
// With no declared rate there is nothing to spread the array by, and
// UDPSourceSession.cpp:522 leaves the step at zero — every element of the array
// on one instant. A host-local consumer only stores them; this scope's ring,
// decimator and trigger all require increasing stamps, and N points at one X is
// not a trace. Consecutive time-signal anchors carry the burst duration on the
// PRODUCER'S clock, so the spread is recoverable without a rate.
TEST(FrameDecoder, FirstSampleWithNoRateSpreadsFromConsecutiveAnchors) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeFirstSample, 0.0, 4, 1),
timeSignal("Time", 1)});
/* 4 samples per packet, anchors 4 ms apart: a 1 ms period. Arrivals are
* jittered so a spread accidentally taken from arrival would be visible. */
const double jitter[4] = {0.0, 0.0021, -0.0017, 0.0};
std::vector<double> ts;
for (int p = 0; p < 5; p++) {
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9 + p * 4.0e6}); /* ns, +4 ms per packet */
const FrameView& f = fb.build(0, 2000.0 + p * 0.004 + jitter[p % 4], 4,
static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
ASSERT_EQ(ts.size(), 4u);
for (size_t i = 1; i < ts.size(); i++) {
/* The first packet has no predecessor to measure against and legally
* stacks; from the second on the array must be spread. */
if (p > 0) { ASSERT_GT(ts[i], ts[i - 1]) << "packet " << p; }
}
if (p > 0) { EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "packet " << p; }
}
/* A lost datagram doubles the anchor difference; without reading the counter
* the recovery packet would be spread twice as wide. */
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9 + 5 * 4.0e6 + 4.0e6}); /* packet 6 arrives, 5 lost */
const FrameView& f = fb.build(0, 2000.024, 4, 7u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "loss stretched the array";
}
TEST(FrameDecoder, LastSampleAnchorsTheFinalElementAndCountsBackward) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeLastSample, 1000.0, 4, 1),
@@ -795,10 +838,13 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
/* 10 ms per packet of 10 samples, so the derived period is 1 ms — equal to
* the fallback the very first hrt-branch packet has to use, which is what
* ClockOffset latches against. Any other period would bake that one packet's
* fallback into the offset and blur the convention this test is pinning. */
/* 10 ms per packet of 10 samples. The cadence used to matter — the first
* hrt-branch packet had no measurable interval, latched ClockOffset using
* kDefaultDt, and only a 1 ms derived period made that harmless — but the
* warm-up now hands over a real tick reference, so this assertion holds at
* every cadence. See UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly,
* which is the test that pins that down; this one only fixes the convention
* that a burst ends, rather than starts, on arrival. */
double lastArrival = 0.0;
std::vector<double> last;
for (int p = 0; p < 60; p++) {
@@ -822,10 +868,14 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
// packetBurst spans arrival gaps until HrtRateFit has collected enough packets,
// then the hrt branch takes over. They place a burst differently — packetBurst
// ends it at wallNow, the hrt branch at wallNow - (nElems-1)*hrtDt — so the
// handover is where a discontinuity hides. It is invisible at 10 samples per
// 10 ms packet, the one cadence where the derived period equals the kDefaultDt
// fallback, which is exactly why the other tests here could not see it. Sweep
// cadences either side of that coincidence.
// handover is where a discontinuity hides, and it took two separate blind spots
// for the other tests to miss it. UndeclaredAccumulatedScalarEndsItsBurstOnArrival
// runs at 10 samples per 10 ms, the one cadence where the derived period equals
// the kDefaultDt fallback, so nothing was wrong to see. The two long-run tests
// run at 10 samples per 25 ms, where the fallback burst is 9 ms against a 25 ms
// packet interval — too narrow to invert, so their monotonicity assertions held
// while the trace sat 13.5 ms off the wall clock, which neither of them measures.
// So sweep cadences either side of the coincidence AND assert absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
struct Case { uint32_t nElems; double packetSec; };
const Case cases[] = {
@@ -883,6 +933,146 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
}
}
// Lost datagrams widen the hrt tick gap without widening the sample count that
// gap is divided by, so a recovery burst is drawn as many times too wide as the
// counter gap — and because a burst is anchored on its LAST element, too wide
// means it ends in the FUTURE. The declared branch reads the counter to
// reinstate the hole exactly; this pins the hrt branch to the same standard.
// Assert POSITION, not just spacing: a burst can be correctly spaced and still
// be drawn across the wrong stretch of the axis.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingThroughPacketLoss) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
const double sampleDt = 0.0025;
/* Runs of 1, 4 and 10 consecutive losses, well clear of each other and of
* the fit warm-up. Ten losses is the interesting one: it used to stretch the
* recovery burst 11x and date its last sample 225 ms into the future. */
const int dropFrom[3] = {120, 200, 300};
const int dropLen[3] = {1, 4, 10};
double worstFuture = 0.0;
double last = 0.0;
bool seen = false;
for (int p = 0; p < 500; p++) {
bool dropped = false;
for (int k = 0; k < 3; k++) {
if (p >= dropFrom[k] && p < dropFrom[k] + dropLen[k]) { dropped = true; }
}
if (dropped) { continue; }
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const double arrival = 700.0 + p * packetSec;
const FrameView& f =
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
if (!dec.timestamps(f, 0, ts)) { continue; }
for (double t : ts) {
if (seen) { ASSERT_GT(t, last) << "backwards at packet " << p; }
last = t;
seen = true;
}
if (p > 100) {
/* The samples were acquired BEFORE the packet carrying them landed,
* so none of them may be stamped after its arrival. */
const double future = ts.back() - arrival;
if (future > worstFuture) { worstFuture = future; }
EXPECT_NEAR(ts[1] - ts[0], sampleDt, sampleDt * 1e-3)
<< "spacing stretched at packet " << p;
}
}
EXPECT_LT(worstFuture, 1e-6)
<< "a recovery burst ended " << worstFuture << " s in the future";
}
// A restart is the other way kDefaultDt gets latched: hrt goes backwards, so the
// restart packet measures no interval of its own, and whatever burst width it
// falls back on is baked into ClockOffset. The displacement that leaves — 13.5 ms
// at this cadence — is below ClockOffset::kRecalibThresholdS, so it never heals.
// AccumulatedScalarSurvivesAProducerRestart asserts only order and spacing and
// passes right through it; this asserts absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
std::vector<double> lastTs;
double lastArrival = 0.0;
for (int p = 0; p < 400; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
/* Packet 200 restarts the producer: hrt returns to a fresh boot and the
* counter to 1. The wall clock does not restart. */
const bool after = (p >= 200);
const uint64_t hrt = after
? static_cast<uint64_t>((p - 200) * packetSec * ticks)
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const uint32_t counter = after ? static_cast<uint32_t>(p - 199)
: static_cast<uint32_t>(p + 1);
const double arrival = 700.0 + p * packetSec;
const FrameView& f = fb.build(hrt, arrival, 10, counter);
dec.beginFrame(f);
std::vector<double> ts;
if (dec.timestamps(f, 0, ts)) { lastTs = ts; lastArrival = arrival; }
}
ASSERT_EQ(lastTs.size(), 10u);
EXPECT_NEAR(lastTs.back(), lastArrival, 1e-6)
<< "still displaced from the wall clock 200 packets after the restart";
EXPECT_NEAR(lastTs[1] - lastTs[0], 0.0025, 2.5e-6);
}
// hrt == 0 sends the packet back to the warm-up branch, which spans from
// packetBurst's own lastPacketWall. The hrt branch does not otherwise touch that
// field, so it would be left at whenever this signal last took the warm-up
// branch — the start of the session — and one stray packet would emit a burst
// starting seconds in the past, worse the longer the scope has been running.
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAStrayZeroHrtPacket) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
double last = 0.0;
bool seen = false;
for (int p = 0; p < 200; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const uint64_t hrt = (p == 153)
? 0u
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const double arrival = 700.0 + p * packetSec;
const FrameView& f =
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
if (!dec.timestamps(f, 0, ts)) { continue; }
for (double t : ts) {
if (seen) {
ASSERT_GT(t, last) << "stray zero-hrt packet stepped back "
<< (last - t) << " s at packet " << p;
}
last = t;
seen = true;
}
/* And it must not land far from where the stream already is: spanning
* from a session-old reference put the burst 3.8 s in the past. */
if (p > 100) { EXPECT_NEAR(ts.back(), arrival, 0.05) << "at packet " << p; }
}
}
// The same double delivery that the declared branch guards against — a host
// joined on two interfaces receives every unfragmented update twice — reaches an
// undeclared-rate signal identically. The guard can only fire if this branch
+338 -44
View File
@@ -1539,6 +1539,49 @@ TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) {
EXPECT_NEAR(ts[3], 2000.003, 1e-9);
}
// With no declared rate there is nothing to spread the array by, and
// UDPSourceSession.cpp:522 leaves the step at zero — every element of the array
// on one instant. A host-local consumer only stores them; this scope's ring,
// decimator and trigger all require increasing stamps, and N points at one X is
// not a trace. Consecutive time-signal anchors carry the burst duration on the
// PRODUCER'S clock, so the spread is recoverable without a rate.
TEST(FrameDecoder, FirstSampleWithNoRateSpreadsFromConsecutiveAnchors) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeFirstSample, 0.0, 4, 1),
timeSignal("Time", 1)});
/* 4 samples per packet, anchors 4 ms apart: a 1 ms period. Arrivals are
* jittered so a spread accidentally taken from arrival would be visible. */
const double jitter[4] = {0.0, 0.0021, -0.0017, 0.0};
std::vector<double> ts;
for (int p = 0; p < 5; p++) {
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9 + p * 4.0e6}); /* ns, +4 ms per packet */
const FrameView& f = fb.build(0, 2000.0 + p * 0.004 + jitter[p % 4], 4,
static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
ASSERT_EQ(ts.size(), 4u);
for (size_t i = 1; i < ts.size(); i++) {
/* The first packet has no predecessor to measure against and legally
* stacks; from the second on the array must be spread. */
if (p > 0) { ASSERT_GT(ts[i], ts[i - 1]) << "packet " << p; }
}
if (p > 0) { EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "packet " << p; }
}
/* A lost datagram doubles the anchor difference; without reading the counter
* the recovery packet would be spread twice as wide. */
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
fb.addSignal({7.0e9 + 5 * 4.0e6 + 4.0e6}); /* packet 6 arrives, 5 lost */
const FrameView& f = fb.build(0, 2000.024, 4, 7u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
EXPECT_NEAR(ts[1] - ts[0], 0.001, 1e-9) << "loss stretched the array";
}
TEST(FrameDecoder, LastSampleAnchorsTheFinalElementAndCountsBackward) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeLastSample, 1000.0, 4, 1),
@@ -2227,10 +2270,13 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
/* 10 ms per packet of 10 samples, so the derived period is 1 ms — equal to
* the fallback the very first hrt-branch packet has to use, which is what
* ClockOffset latches against. Any other period would bake that one packet's
* fallback into the offset and blur the convention this test is pinning. */
/* 10 ms per packet of 10 samples. The cadence used to matter — the first
* hrt-branch packet had no measurable interval, latched ClockOffset using
* kDefaultDt, and only a 1 ms derived period made that harmless — but the
* warm-up now hands over a real tick reference, so this assertion holds at
* every cadence. See UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly,
* which is the test that pins that down; this one only fixes the convention
* that a burst ends, rather than starts, on arrival. */
double lastArrival = 0.0;
std::vector<double> last;
for (int p = 0; p < 60; p++) {
@@ -2254,10 +2300,14 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarEndsItsBurstOnArrival) {
// packetBurst spans arrival gaps until HrtRateFit has collected enough packets,
// then the hrt branch takes over. They place a burst differently — packetBurst
// ends it at wallNow, the hrt branch at wallNow - (nElems-1)*hrtDt — so the
// handover is where a discontinuity hides. It is invisible at 10 samples per
// 10 ms packet, the one cadence where the derived period equals the kDefaultDt
// fallback, which is exactly why the other tests here could not see it. Sweep
// cadences either side of that coincidence.
// handover is where a discontinuity hides, and it took two separate blind spots
// for the other tests to miss it. UndeclaredAccumulatedScalarEndsItsBurstOnArrival
// runs at 10 samples per 10 ms, the one cadence where the derived period equals
// the kDefaultDt fallback, so nothing was wrong to see. The two long-run tests
// run at 10 samples per 25 ms, where the fallback burst is 9 ms against a 25 ms
// packet interval — too narrow to invert, so their monotonicity assertions held
// while the trace sat 13.5 ms off the wall clock, which neither of them measures.
// So sweep cadences either side of the coincidence AND assert absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
struct Case { uint32_t nElems; double packetSec; };
const Case cases[] = {
@@ -2315,6 +2365,146 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly) {
}
}
// Lost datagrams widen the hrt tick gap without widening the sample count that
// gap is divided by, so a recovery burst is drawn as many times too wide as the
// counter gap — and because a burst is anchored on its LAST element, too wide
// means it ends in the FUTURE. The declared branch reads the counter to
// reinstate the hole exactly; this pins the hrt branch to the same standard.
// Assert POSITION, not just spacing: a burst can be correctly spaced and still
// be drawn across the wrong stretch of the axis.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingThroughPacketLoss) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
const double sampleDt = 0.0025;
/* Runs of 1, 4 and 10 consecutive losses, well clear of each other and of
* the fit warm-up. Ten losses is the interesting one: it used to stretch the
* recovery burst 11x and date its last sample 225 ms into the future. */
const int dropFrom[3] = {120, 200, 300};
const int dropLen[3] = {1, 4, 10};
double worstFuture = 0.0;
double last = 0.0;
bool seen = false;
for (int p = 0; p < 500; p++) {
bool dropped = false;
for (int k = 0; k < 3; k++) {
if (p >= dropFrom[k] && p < dropFrom[k] + dropLen[k]) { dropped = true; }
}
if (dropped) { continue; }
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const double arrival = 700.0 + p * packetSec;
const FrameView& f =
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
if (!dec.timestamps(f, 0, ts)) { continue; }
for (double t : ts) {
if (seen) { ASSERT_GT(t, last) << "backwards at packet " << p; }
last = t;
seen = true;
}
if (p > 100) {
/* The samples were acquired BEFORE the packet carrying them landed,
* so none of them may be stamped after its arrival. */
const double future = ts.back() - arrival;
if (future > worstFuture) { worstFuture = future; }
EXPECT_NEAR(ts[1] - ts[0], sampleDt, sampleDt * 1e-3)
<< "spacing stretched at packet " << p;
}
}
EXPECT_LT(worstFuture, 1e-6)
<< "a recovery burst ended " << worstFuture << " s in the future";
}
// A restart is the other way kDefaultDt gets latched: hrt goes backwards, so the
// restart packet measures no interval of its own, and whatever burst width it
// falls back on is baked into ClockOffset. The displacement that leaves — 13.5 ms
// at this cadence — is below ClockOffset::kRecalibThresholdS, so it never heals.
// AccumulatedScalarSurvivesAProducerRestart asserts only order and spacing and
// passes right through it; this asserts absolute position.
TEST(FrameDecoder, UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
std::vector<double> lastTs;
double lastArrival = 0.0;
for (int p = 0; p < 400; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
/* Packet 200 restarts the producer: hrt returns to a fresh boot and the
* counter to 1. The wall clock does not restart. */
const bool after = (p >= 200);
const uint64_t hrt = after
? static_cast<uint64_t>((p - 200) * packetSec * ticks)
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const uint32_t counter = after ? static_cast<uint32_t>(p - 199)
: static_cast<uint32_t>(p + 1);
const double arrival = 700.0 + p * packetSec;
const FrameView& f = fb.build(hrt, arrival, 10, counter);
dec.beginFrame(f);
std::vector<double> ts;
if (dec.timestamps(f, 0, ts)) { lastTs = ts; lastArrival = arrival; }
}
ASSERT_EQ(lastTs.size(), 10u);
EXPECT_NEAR(lastTs.back(), lastArrival, 1e-6)
<< "still displaced from the wall clock 200 packets after the restart";
EXPECT_NEAR(lastTs[1] - lastTs[0], 0.0025, 2.5e-6);
}
// hrt == 0 sends the packet back to the warm-up branch, which spans from
// packetBurst's own lastPacketWall. The hrt branch does not otherwise touch that
// field, so it would be left at whenever this signal last took the warm-up
// branch — the start of the session — and one stray packet would emit a burst
// starting seconds in the past, worse the longer the scope has been running.
TEST(FrameDecoder, UndeclaredAccumulatedScalarSurvivesAStrayZeroHrtPacket) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks);
const double packetSec = 0.025;
double last = 0.0;
bool seen = false;
for (int p = 0; p < 200; p++) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const uint64_t hrt = (p == 153)
? 0u
: bootHrt + static_cast<uint64_t>(p * packetSec * ticks);
const double arrival = 700.0 + p * packetSec;
const FrameView& f =
fb.build(hrt, arrival, 10, static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
if (!dec.timestamps(f, 0, ts)) { continue; }
for (double t : ts) {
if (seen) {
ASSERT_GT(t, last) << "stray zero-hrt packet stepped back "
<< (last - t) << " s at packet " << p;
}
last = t;
seen = true;
}
/* And it must not land far from where the stream already is: spanning
* from a session-old reference put the burst 3.8 s in the past. */
if (p > 100) { EXPECT_NEAR(ts.back(), arrival, 0.05) << "at packet " << p; }
}
}
// The same double delivery that the declared branch guards against — a host
// joined on two interfaces receives every unfragmented update twice — reaches an
// undeclared-rate signal identically. The guard can only fire if this branch
@@ -2455,7 +2645,17 @@ Create `Client/udpscope/FrameDecoder.h`:
* Source/Applications/StreamHub/UDPSourceSession.cpp documents this failure and
* solves it; these are the same rules, computed from udps_frame_t's own fields.
*
* Three rules deliberately differ, all in the accumulated-scalar case (rule 3).
* They are NOT the same code, and the differences are not a short list. Every
* one of them comes from the same root: StreamHub runs on the producer's host,
* so its arrival time IS the producer's clock and its local
* HighResolutionTimer::Frequency() IS the frequency behind the packet's hrt.
* Neither holds over a network, so anything StreamHub can read directly this
* decoder has to estimate (HrtRateFit, ClockOffset), and anything it estimates
* it must also defend — hence the monotonic clamps, the kWallBleedFraction
* bleed, the reorder and restart guards and the duplicate-datagram drop, none of
* which exist in UDPSourceSession.cpp. Do not read the three sections below as
* exhaustive; they are the three that change where a sample LANDS, and so the
* three worth checking first when a trace looks wrong.
*
* First, the anchor. StreamHub anchors every accumulated-scalar burst on the
* packet's own hrt, converted with the LOCAL MARTe HighResolutionTimer
@@ -2487,6 +2687,15 @@ Create `Client/udpscope/FrameDecoder.h`:
* datagram and reinstates a hole that never existed. So a signal that has
* already burst keeps every later update on rule 3 regardless of its length; a
* signal that has never burst is a genuine scalar and is left to rule 5.
*
* Two divergences OUTSIDE rule 3 are known and deliberately left as they are.
* Rule 1 keys ClockOffset on the consuming signal, where UDPSourceSession.cpp:516
* keys it on the time-signal index, so signals sharing a time signal share an
* offset there and not here — immaterial, since the mapping they compute is the
* same. And a FIRST_SAMPLE/LAST_SAMPLE signal whose time signal is absent falls
* through to rule 4 rather than using its declared rate; that is a malformed
* CONFIG, and spanning arrivals is the more honest answer than trusting a rate
* whose anchor never arrived.
*/
#pragma once
@@ -2537,6 +2746,19 @@ private:
double accProdSec = 0.0;
bool lastAccValid = false;
uint32_t prevAccCount = 0;
/** Last inter-element period the hrt branch actually MEASURED, used
* whenever this packet cannot measure one of its own (no previous tick,
* or hrt went backwards). The constant kDefaultDt is a poor substitute:
* it is only right at 1 kHz, and a wrong period here is not merely a
* wrong spacing for one burst — it is the burst width ClockOffset
* latches against, and the resulting displacement is usually too small
* for kRecalibThresholdS to ever heal. Zero until first measured. */
double lastHrtDt = 0.0;
/** Rule 2 only: the previous packet's time-signal anchor, in PRODUCER
* seconds. Consecutive anchors are what lets an array with no declared
* sampling rate be spread at all. */
double prevAnchorProdSec = 0.0;
bool prevAnchorValid = false;
/** For accumulated scalars (rule 3, either branch): end timestamp of the
* most recently emitted burst, and the packet counter it came from. The
* next burst is chained onto that end, with the counter gap reinstating
@@ -2743,9 +2965,36 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
/* Rule 2: anchor from the time signal, spread by the sampling rate. */
if ((d.timeMode == kTimeFirstSample || d.timeMode == kTimeLastSample) &&
hasTimeSig && f.counts[tIdx] >= 1u && f.values[tIdx] != nullptr) {
const double anchor = st.offset.map(f.values[tIdx][0] * tScale, wallNow);
const double rate = DeclaredRate(d.samplingRate);
const double dt = (rate > 0.0) ? (1.0 / rate) : 0.0;
const double prodSec = f.values[tIdx][0] * tScale;
const double anchor = st.offset.map(prodSec, wallNow);
const double rate = DeclaredRate(d.samplingRate);
double dt = (rate > 0.0) ? (1.0 / rate) : 0.0;
/* No rate declared. UDPSourceSession.cpp:522 leaves dt at zero here,
* which stacks every element of the array on one instant — harmless for
* a host-local consumer that only stores them, but this scope's ring,
* decimator and trigger all require a signal's stamps to increase, and a
* plot of N points at one X is not a trace.
*
* The spread is recoverable without a rate: consecutive anchors come
* from the time signal, so their difference is the burst's true duration
* in producer seconds, measured on the producer's own clock rather than
* on arrival — immune to the bursty delivery that corrupts everything
* arrival-derived. Divide by the counter gap for the same reason rule 3
* does: a lost datagram widens the anchor difference without widening
* the array. Until a second packet arrives there is nothing to measure
* and the elements do stack; that is one packet, not the whole run. */
if (!(dt > 0.0) && nElems > 1u && st.prevAnchorValid &&
prodSec > st.prevAnchorProdSec) {
const uint32_t gap = (f.counter != 0u && f.counter > st.lastCounter)
? (f.counter - st.lastCounter) : 1u;
dt = (prodSec - st.prevAnchorProdSec) /
(static_cast<double>(nElems) * static_cast<double>(gap));
}
st.prevAnchorProdSec = prodSec;
st.prevAnchorValid = true;
st.lastCounter = f.counter;
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = (d.timeMode == kTimeFirstSample)
@@ -2900,24 +3149,24 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
double advance = nominal * factor;
/* A non-positive elapsed means the wall has not moved
* since this signal's previous burst — a coarse arrival
* clock, or two packets stamped within one tick of it.
* There is no wall time to spend, so the cap is zero.
* Skipping the cap in that case (which is what this code
* used to do) hands back the full proportional advance,
* so a run of same-tick arrivals gains lead while no wall
* time passes at all — the divergence the cap exists to
* stop, in its purest form. */
* since this signal's previous burst. Skipping the cap
* then is deliberate and, more to the point, makes no
* difference: forcing the cap to zero instead sends step
* through the floor below to dt * kMinBleedFactor, which
* is the same number the proportional factor already
* yields once the excess exceeds one burst. Both leave
* the same-tick case diverging; only real elapsed wall
* time can bleed lead off, and a recv_time from
* CLOCK_REALTIME (udps_client.c:120) does not repeat. */
const double wallElapsed = wallNow - st.lastEmittedWall;
const double cap = (wallElapsed > 0.0)
? (kWallBleedFraction * wallElapsed)
: 0.0;
if (cap < advance) { advance = cap; }
if (wallElapsed > 0.0) {
const double cap = kWallBleedFraction * wallElapsed;
if (cap < advance) { advance = cap; }
}
step = advance / static_cast<double>(nElems);
/* Reached whenever the cap is zero, and a backstop
* against a nonsensical dt off the wire: downstream
* requires strictly increasing stamps, so the burst must
* still advance by something. */
/* Unreachable with a finite positive dt — kept because
* downstream monotonicity must not depend on that
* argument holding for every value off the wire. */
if (!(step > 0.0)) { step = dt * kMinBleedFactor; }
base = st.lastEmittedEnd + step;
}
@@ -3030,11 +3279,43 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
}
st.accProdSec += elapsed;
/* The flushes carry contiguous RT cycles, so the gap divided by the
* previous packet's sample count is exactly one cycle period. */
const double hrtDt = (elapsed > 0.0 && st.prevAccCount > 0u)
? (elapsed / static_cast<double>(st.prevAccCount))
: kDefaultDt;
/* The flushes carry contiguous RT cycles, so the tick gap divided by the
* number of cycles it spans is exactly one cycle period. That count is
* NOT prevAccCount: elapsed spans every packet since the last one we
* saw, so a lost datagram makes the tick gap wider without making
* prevAccCount larger. Dividing by prevAccCount alone therefore returns
* a period scaled by the whole counter gap — 2x for one lost datagram,
* 11x for ten — which draws the recovery burst that many times too wide
* and, because the burst is anchored on its LAST element, ends it in the
* FUTURE (measured: +22.5 ms for one loss, +225 ms for ten, at 10
* samples per 25 ms packet). At 1% loss that mis-spaced 2.7% of all
* samples. The declared branch already reads the counter for exactly
* this purpose (`lost`, above); the hrt branch must too.
*
* Only a FORWARD gap counts. A backward or repeated counter is the
* reorder case handled above, where elapsed is zero anyway. */
const uint32_t accGap = (f.counter != 0u && st.lastEmittedValid &&
f.counter > st.lastCounter)
? (f.counter - st.lastCounter) : 1u;
const double cycles = static_cast<double>(st.prevAccCount) *
static_cast<double>(accGap);
/* Falling back to kDefaultDt is a last resort, not a default: see
* SigState::lastHrtDt. The fallback is reached on the first hrt packet
* of a producer restart (elapsed is zero because hrt went backwards) and
* on a reordered datagram, and in both cases the wrong burst width is
* latched into ClockOffset permanently — measured 13.5 ms of standing
* displacement at 10 samples per 25 ms packet, 89 ms at 100 per 10 ms,
* both below kRecalibThresholdS and so never corrected. */
double hrtDt;
if (elapsed > 0.0 && cycles > 0.0) {
hrtDt = elapsed / cycles;
st.lastHrtDt = hrtDt;
} else if (st.lastHrtDt > 0.0) {
hrtDt = st.lastHrtDt;
} else {
hrtDt = kDefaultDt;
}
/* Anchor the burst's LAST element on arrival, not its first. The
* packet's hrt is the tick count of sample 0 (UDPSourceSession.cpp:574),
@@ -3066,16 +3347,11 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
* at kWallBleedFraction: only that makes the lead bleed off. */
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
const double wallElapsed = wallNow - st.lastEmittedWall;
/* No wall movement, no wall time to spend: see the same cap in the
* declared branch. Zero rather than "skip the cap", so a run of
* same-tick arrivals cannot advance a full hrtDt per sample while
* the wall stands still. */
const double cap = (wallElapsed > 0.0)
? (kWallBleedFraction * wallElapsed /
static_cast<double>(nElems))
: 0.0;
if (cap < step) { step = cap; }
if (!(step > 0.0)) { step = hrtDt * kMinBleedFactor; }
if (wallElapsed > 0.0) {
const double cap = kWallBleedFraction * wallElapsed /
static_cast<double>(nElems);
if (cap < step) { step = cap; }
}
base = st.lastEmittedEnd + step;
}
@@ -3088,6 +3364,14 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
st.prevAccCount = nElems;
st.lastEmittedEnd = tsOut[nElems - 1u];
st.lastEmittedWall = wallNow;
/* Keep packetBurst's reference current even though this branch does not
* use it. A single packet with hrt == 0 re-enters the warm-up branch
* above, and packetBurst would otherwise span from whenever this signal
* last took that branch — the whole session. Measured: after 153 hrt
* packets, one zero-hrt packet emitted a burst starting 3.8 s in the
* past, growing without bound with session length. */
st.lastPacketWall = wallNow;
st.lastPacketValid = true;
/* Same duplicate-datagram exposure as the declared branch: a host joined
* on two interfaces receives every unfragmented update twice, and the
* guard at the top of timestamps() can only fire if this branch leaves a
@@ -3127,7 +3411,7 @@ set(CORE_SOURCES
cd Client/udpscope && cmake --build build -j && ./build/udpscope_tests --gtest_filter='FrameDecoder*'
```
Expected: PASS, 26 `FrameDecoder` tests — 55 across the whole `udpscope_tests` binary.
Expected: PASS, 30 `FrameDecoder` tests — 59 across the whole `udpscope_tests` binary.
If `AccumulatedScalarSurvivesBurstyDelivery` fails, do NOT reach for the hrt fit: with a declared `samplingRate` rule 3 never consults it, precisely because the fit is not ready for the first 32 packets and — since `HrtRateFit` regresses `hrt` against ARRIVAL time — is itself corrupted by the very bursts it would be asked to survive. Check instead that `lastEmittedEnd`, `lastCounter`, `prevAccCount` and `lastEmittedValid` are updated on every emitted burst.
@@ -3153,6 +3437,16 @@ That test alone is NOT sufficient cover for the hrt branch's anchoring, which is
If `UndeclaredAccumulatedScalarCrossesTheHrtHandoverCleanly` fails on the monotonicity assertion, the warm-up branch is returning `packetBurst()`'s result directly without recording state. Two different fields matter and they fix two different symptoms. `lastEmittedEnd`/`lastEmittedWall`/`lastEmittedValid` are what the monotonic clamp needs; without them the first hrt packet skips the clamp entirely and steps back by up to a burst width (-6.5 ms at 10 samples per 2.5 ms, -0.99 s at 1000 samples per 10 ms). `lastAccHrt`/`prevAccCount` are what stops the failure being merely hidden: without a previous tick to subtract, the first hrt packet has no measurable interval, falls back to `kDefaultDt` and latches `ClockOffset` against `wallNow - (nElems - 1) * kDefaultDt`. At 100 samples per 10 ms packet that is ten times too wide and parks the trace 89 ms in the past permanently, since 89 ms is below `ClockOffset::kRecalibThresholdS`. That is why the test asserts the settled burst still ends on arrival and steps at the true sample period, not just that it never goes backwards — the clamp on its own would satisfy monotonicity while leaving the trace displaced.
Three more tests exist because `hrtDt` is not just a spacing — it is the burst width `ClockOffset` latches against, so a wrong one displaces the whole trace permanently whenever the error stays below `ClockOffset::kRecalibThresholdS`. Every route to a wrong `hrtDt` therefore needs its own cover, and each of these asserts ABSOLUTE POSITION rather than only order and spacing, because the monotonic clamp restores order while leaving the trace parked in the wrong place.
If `UndeclaredAccumulatedScalarKeepsItsSpacingThroughPacketLoss` fails, `hrtDt` is dividing the tick delta by `prevAccCount` alone. `elapsed` spans every packet since the last one seen, so a lost datagram widens it without widening the sample count — the period comes out scaled by the whole counter gap, and since a burst is anchored on its LAST element, too wide means it ends in the FUTURE. Measured at 10 samples per 25 ms packet: +22.5 ms for one loss, +90 ms for four, +225 ms for ten, mis-spacing 2.7% of all samples at 1% loss. Divide by `prevAccCount * counterGap`, exactly as the declared branch's `lost` already does.
If `UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart` fails, the `kDefaultDt` fallback is being used where `lastHrtDt` should be. On the restart packet `hrt` goes backwards, so `elapsed` is zero and no period can be measured — but `st.offset.reset()` on that same packet means it is also the packet that re-latches. `kDefaultDt` is only right at 1 kHz: measured standing displacement is +13.5 ms at 10 samples per 25 ms and -89 ms at 100 per 10 ms, both too small for recalibration to ever heal. Note `AccumulatedScalarSurvivesAProducerRestart` runs at exactly the +13.5 ms cadence and passes right through this, because it asserts only order and spacing.
If `UndeclaredAccumulatedScalarSurvivesAStrayZeroHrtPacket` fails, the hrt branch has stopped keeping `lastPacketWall` current. A packet with `hrt == 0` re-enters the warm-up branch, and `packetBurst` spans from that field — which the hrt branch does not otherwise write, so it would still hold whenever this signal last took the warm-up branch. Measured: after 153 hrt packets, one zero-hrt packet emitted a burst starting 3.8 s in the past, and the displacement grows without bound with session length.
If `FirstSampleWithNoRateSpreadsFromConsecutiveAnchors` fails, rule 2 has gone back to `UDPSourceSession.cpp:522`'s behaviour of leaving the step at zero when no rate is declared. That stacks every element of the array on one instant, which a host-local consumer can store but this scope cannot plot, and which contradicts the strictly-increasing invariant rule 3 defends everywhere. The spread is recoverable from consecutive time-signal anchors — the producer's own clock, so immune to the bursty delivery that corrupts anything arrival-derived — and must be divided by the counter gap for the same reason rule 3 divides by it.
- [ ] **Step 8: Commit**
```bash