fix(udpscope): keep the packet counter in lockstep with the tick reference

The counter is the denominator of the very period lastAccHrt is the
numerator of, so any packet that cannot move the tick reference must not
move the counter either. Two paths were violating that: a reordered
datagram rolled the counter back while the reference correctly held
(next burst drawn 0.048x too narrow at distance 20, 83.3% worst spacing
error under 2% sustained reordering), and a stray hrt == 0 packet
advanced the counter from the warm-up branch without a tick to match
(+22.5 ms of future-dating per stray packet).

Rules 1 and 2 now record a counter too. The duplicate-datagram guard is
keyed on one, so an array rule that recorded none was exempt and plotted
every doubly-delivered update twice.

Also: rule 2 divides by the count the anchor actually spans and falls
back to the last period it derived; the counter-gap test is wrap-safe so
2^32 rollover reads as no information rather than 2e9 lost packets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-28 06:41:47 +02:00
co-authored by Claude Opus 4.6
parent f97fd825c4
commit 1c61e814c0
4 changed files with 644 additions and 144 deletions
+186 -26
View File
@@ -89,6 +89,40 @@ TEST(FrameDecoder, FullArrayTakesOneStampPerElementFromTheTimeSignal) {
EXPECT_NEAR(ts[3], 1000.003, 1e-9);
}
// A host joined on two interfaces receives every unfragmented update twice, and
// the second copy is a different signal's problem only if the guard can see it.
// It is keyed on a counter each rule leaves behind, so an array rule that never
// records one is silently exempt — and would plot every array twice, at two
// arrival times, doubling back on the X axis. Rules 1 and 2 join rule 3's
// counter-keeping for this reason alone; neither reads the value back.
TEST(FrameDecoder, ArrayRulesDropADuplicatedDatagram) {
for (uint8_t mode : {kTimeFullArray, kTimeFirstSample}) {
FrameDecoder dec;
dec.setSignals({burst("Sine", mode, 1000.0, 4, 1),
timeSignal("Time", mode == kTimeFullArray ? 4u : 1u)});
FrameBuilder fb;
fb.addSignal({1.0, 2.0, 3.0, 4.0});
if (mode == kTimeFullArray) {
fb.addSignal({5.0e9, 5.001e9, 5.002e9, 5.003e9});
} else {
fb.addSignal({5.0e9});
}
const FrameView& first = fb.build(0, 1000.0, 4, 77u);
dec.beginFrame(first);
std::vector<double> ts;
ASSERT_TRUE(dec.timestamps(first, 0, ts)) << "mode " << int(mode);
/* Same counter, same payload, a fraction of a millisecond later off the
* second interface. */
const FrameView& dup = fb.build(0, 1000.0004, 4, 77u);
dec.beginFrame(dup);
EXPECT_FALSE(dec.timestamps(dup, 0, ts))
<< "mode " << int(mode) << " emitted the duplicate array twice";
}
}
TEST(FrameDecoder, FirstSampleAnchorsElementZeroAndCountsForward) {
FrameDecoder dec;
dec.setSignals({burst("Sine", kTimeFirstSample, 1000.0, 4, 1),
@@ -699,6 +733,41 @@ std::vector<HrtPacket> cleanUndeclaredStream() {
return pkts;
}
/** Runs a schedule of 10-sample bursts; returns one entry per DELIVERED
* datagram, empty where the decoder emitted nothing. Per-packet rather than
* concatenated because the interesting quantity is the spacing INSIDE a
* particular burst, and which burst that is depends on the schedule. */
std::vector<std::vector<double> >
runUndeclaredPerPacket(const std::vector<HrtPacket>& pkts) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
std::vector<std::vector<double> > out;
for (const HrtPacket& p : pkts) {
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(p.hrt, p.arrival, 10, p.counter);
dec.beginFrame(f);
std::vector<double> ts;
if (!dec.timestamps(f, 0, ts)) { ts.clear(); }
out.push_back(ts);
}
return out;
}
/** Delays the datagram at slot @p k by @p dist delivery slots: the payloads
* behind it each move up one and it lands after them. Only the payload moves —
* the arrival time belongs to the slot, because delivery order is what the
* socket actually saw. */
std::vector<HrtPacket> delayOne(const std::vector<HrtPacket>& clean,
size_t k, size_t dist) {
std::vector<HrtPacket> out = clean;
for (size_t i = 0; i < dist; i++) {
std::swap(out[k + i].hrt, out[k + i + 1].hrt);
std::swap(out[k + i].counter, out[k + i + 1].counter);
}
return out;
}
} /* namespace */
// A datagram that overtakes its neighbour arrives with an hrt BEHIND the one
@@ -729,6 +798,77 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarIgnoresReorderedDatagrams) {
<< "reordering left " << (reorderedEnd - cleanEnd) << " s of offset";
}
// Leaving the hrt reference alone on a reordered datagram is only half the rule:
// the packet counter is the DENOMINATOR of the very period that reference is the
// numerator of, so it has to stay behind too. Rolling lastCounter back while
// lastAccHrt holds gives the next in-order packet a gap of dist+1 against an
// elapsed spanning a single interval, and it derives a period dist+1 times too
// short. The test above cannot see this: it compares end times, and a burst drawn
// too NARROW ends early rather than late, so the damage hides inside the burst.
//
// The bound asserted here is not "the true spacing". A reorder legitimately
// squeezes bursts, because the late datagram's samples belong in the past and
// downstream demands increasing stamps, so the monotonic clamp walks them
// forward instead — and the timeline it leaves ahead of the producer takes a few
// packets to bleed off, squeezing those too. But that clamp has an exact floor:
// its cap is kWallBleedFraction * wallElapsed / nElems, and wallElapsed / nElems
// IS the producer's true period at steady cadence, so no burst it touches can
// ever be narrower than kWallBleedFraction of true. Anything below that floor
// did not come from the clamp; it came from a mis-derived period. That is what
// separates the defect from the design, and it is why the check is a floor
// rather than a target.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingAfterAReorder) {
const double trueDt = 0.0025; /* 10 samples per 25 ms packet */
const double floorDt = 0.5 * trueDt; /* kWallBleedFraction * trueDt */
/* Distance 1 sits exactly ON the floor either way and is here to pin it;
* 5 and 20 are where the defect drops through it, to 0.167x and 0.048x. */
for (size_t dist : {size_t(1), size_t(5), size_t(20)}) {
const std::vector<std::vector<double> > out =
runUndeclaredPerPacket(delayOne(cleanUndeclaredStream(), 150, dist));
/* The late payload lands at slot 150 + dist; the slot after it is the
* first in-order packet to divide by the poisoned counter. */
const size_t after = 150u + dist + 1u;
ASSERT_GE(out[after].size(), 2u) << "distance " << dist;
const double dt = out[after][1] - out[after][0];
EXPECT_GE(dt, floorDt - 1.0e-9)
<< "distance " << dist << " drew its burst at " << dt << " s/sample, "
<< (dt / trueDt) << "x the true spacing";
EXPECT_LE(dt, trueDt + 1.0e-9) << "distance " << dist;
}
}
// The same defect under a network that reorders continuously rather than once.
// Same floor, applied to every burst in the run including the late datagrams'
// own — under sustained reordering there is no quiet packet to exempt, and the
// floor holds for all of them anyway.
TEST(FrameDecoder, UndeclaredAccumulatedScalarKeepsItsSpacingUnderSustainedReordering) {
std::vector<HrtPacket> pkts = cleanUndeclaredStream();
/* Six of 300 datagrams — 2% — delayed by one to five slots. */
for (int n = 0; n < 6; n++) {
pkts = delayOne(pkts, 40u + static_cast<size_t>(n) * 40u,
1u + static_cast<size_t>(n) % 5u);
}
const std::vector<std::vector<double> > out = runUndeclaredPerPacket(pkts);
const double trueDt = 0.0025;
double narrow = 1.0; /* smallest ratio to true seen */
size_t narrowAt = 0u;
for (size_t p = 0; p < out.size(); p++) {
for (size_t e = 1; e < out[p].size(); e++) {
const double ratio = (out[p][e] - out[p][e - 1u]) / trueDt;
if (ratio < narrow) { narrow = ratio; narrowAt = p; }
}
}
/* Bottomed out at 0.167x — an 83.3% spacing error — before the counter moved
* in lockstep with the reference. The clamp's own floor is 0.5x. */
EXPECT_GE(narrow, 0.5 - 1.0e-9)
<< "narrowest burst " << narrow << "x true spacing at packet " << narrowAt;
}
// The counterweight. A producer restart drops hrt from the machine's whole
// uptime back to near zero, and that is the one case where the hrt reference
// MUST be allowed to regress: refusing every backward step would leave each
@@ -1037,39 +1177,59 @@ TEST(FrameDecoder, UndeclaredAccumulatedScalarReturnsToTheWallClockAfterARestart
// 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.
//
// The counter must sit out that detour with it, for the same lockstep reason as
// the reorder case: a zero-hrt packet advances the counter but cannot advance
// the tick reference, so the next real packet divides an elapsed spanning one
// interval by a gap reporting two, drawing that burst twice too wide and — since
// the burst is anchored on its LAST element — ending it in the future. Measured
// +22.5 ms for one such packet, +45 ms for two, +112.5 ms for five.
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;
for (int run : {1, 2, 5}) {
FrameDecoder dec;
dec.setSignals({undeclaredAcc()});
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 bool zero = (p >= 153) && (p < 153 + run);
const uint64_t hrt = zero
? 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) << "run of " << run
<< " zero-hrt packets 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 2.74 s in the
* past, and a counter that ran on without the tick reference put the
* recovery burst 22.5 ms per stray packet into the future. The
* tolerance is a fifth of a packet period: four and a half times
* tighter than the smallest error it has to reject, and still far
* enough above HrtRateFit's residual not to chase regression noise.
* The 50 ms it replaced admitted every one of those errors. */
if (p > 100) {
EXPECT_NEAR(ts.back(), arrival, packetSec / 5.0)
<< "run of " << run << " 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; }
}
}