fix(udpscope): keep the accumulated-scalar timeline monotonic and bounded

Round 4 of Task 4 review. Four defects in FrameDecoder's rule 3:

- The undeclared-rate (hrt) path positioned each burst at an ABSOLUTE
  hrt/ticksPerSecond(). hrt counts from the producer's boot, so it is ~1e11
  ticks by the time a scope attaches, and the rate is refitted every packet
  with a few parts in 1e4 of wobble. The product is tens of milliseconds of
  jitter in BOTH directions -- not merely imprecise, non-monotonic. Integrate
  short tick deltas into accProdSec instead and let ClockOffset latch the
  epoch that leaves behind.
- The lead bleed used a fixed 0.9 factor, which converges only while the
  declared rate is within ~10%. Squeeze proportionally to the excess instead
  (floored at kMinBleedFactor), settling it in a single burst.
- A single-sample flush fell through to the plain-scalar rule, dating it from
  arrival and leaving lastCounter stale so the next real burst reinstated a
  hole that never existed. Accumulate mode flushes on a timer, so a short
  cycle legitimately yields one sample; keep it on the chain.
- kMaxCounterGap was inert: an absurd gap yields an absurd prediction that the
  arrival backstop already rejects, and no input can distinguish the two
  rules. Removed rather than left implying a behaviour it did not have.

FrameDecoder.h now states the deliberate divergence from StreamHub -- which
converts hrt with the LOCAL MARTe timer frequency, valid only because it runs
on the producer's host -- and why a remote scope's drift is irreducible.

Three new tests, each sabotage-proven non-vacuous: producer restart, short
flushes staying on the chain, and a 20000-packet undeclared run after a
day of producer uptime that asserts SPACING as well as ordering (the
monotonic guard alone restores order while leaving positions wrong).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-08-27 22:16:11 +02:00
co-authored by Claude Opus 4.6
parent 3270284cfe
commit a2efc142c3
4 changed files with 421 additions and 123 deletions
+118 -5
View File
@@ -270,9 +270,10 @@ TEST(FrameDecoder, AccumulatedScalarNeverStepsBackwardsWhenResyncing) {
ASSERT_TRUE(dec.timestamps(f, 0, ts));
/* Arrival (500.000) is behind our timeline, so there is nothing to spread
* into; the burst is drawn narrower instead, which starts to bleed the lead
* off while still moving strictly forwards. */
EXPECT_NEAR(ts[0], 500.0909, 1e-9);
* into; the burst is squeezed instead, which bleeds the lead off while still
* moving strictly forwards. The excess (90 ms) is nine nominal burst widths,
* so the squeeze hits its floor of 0.05 and the step is 50 us. */
EXPECT_NEAR(ts[0], 500.09005, 1e-9);
EXPECT_GT(ts[0], prevEnd) << "resync stepped backwards over the previous burst";
for (size_t i = 1; i < ts.size(); i++) {
EXPECT_GT(ts[i], ts[i - 1]);
@@ -290,8 +291,13 @@ TEST(FrameDecoder, AccumulatedScalarCompressesOneBurstRatherThanStepBack) {
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
/* A counter gap far beyond any real outage: the prediction is unusable, and
* the arrival anchor (500.086) sits behind the previous burst end. */
/* The compress branch needs the prediction to be rejected while arrival
* still sits between the previous burst's end and one burst beyond it —
* which a plain rate mismatch cannot produce, since the prediction is then
* only a burst away from arrival. It takes a fabricated loss: this gap
* claims 900000 lost packets, putting the prediction 2.5 hours out, while
* the packet itself lands 5 ms after the last burst ended so its arrival
* anchor (500.086) falls just behind that end. */
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(0, 500.095, 10, 900011u);
@@ -364,6 +370,63 @@ TEST(FrameDecoder, AccumulatedScalarDropsADuplicatedDatagram) {
EXPECT_NEAR(ts[0], endBefore + 0.001, 1e-9);
}
// A producer restart returns the counter to zero mid-stream. The unsigned gap
// then wraps to near 2^32; the loss it implies puts the chained prediction
// centuries out, the arrival backstop rejects it, and arrival becomes the only
// usable reference.
TEST(FrameDecoder, AccumulatedScalarSurvivesAProducerRestart) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
const double prevEnd = ts[9];
/* Restarted producer: counter 1 again, and the outage lasted 3 s. */
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
const FrameView& f = fb.build(0, 503.100, 10, 1u);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts));
/* Reading the wrapped gap as a loss count would claim ~4.3e9 lost packets,
* some 5e8 seconds of fabricated signal. */
EXPECT_NEAR(ts[9], 503.100, 1e-9) << "restart must re-anchor on arrival";
EXPECT_GT(ts[0], prevEnd);
}
// Accumulate mode flushes on a timer, so a short cycle legitimately delivers a
// single sample between two full bursts. That packet must stay on the chain: if
// it fell through to the plain-scalar rule it would be dated from arrival while
// its neighbours are chained, and would leave lastCounter behind so the next
// real burst read the skip as a lost datagram.
TEST(FrameDecoder, AccumulatedScalarKeepsShortFlushesOnTheChain) {
FrameDecoder dec;
dec.setSignals({accSignal()});
std::vector<double> ts;
primeTenBursts(dec, ts, /*withCounter=*/true);
double last = ts[9];
uint32_t counter = 10u;
double arrival = 500.090;
for (int p = 0; p < 500; p++) {
/* Alternating 10-sample and 1-sample flushes, 10 ms and 1 ms of signal. */
const uint32_t n = (p % 2 == 0) ? 1u : 10u;
arrival += 0.001 * static_cast<double>(n);
FrameBuilder fb;
fb.addSignal(std::vector<double>(n, 1.0));
const FrameView& f = fb.build(0, arrival, n, ++counter);
dec.beginFrame(f);
ASSERT_TRUE(dec.timestamps(f, 0, ts)) << "short flush dropped at " << p;
ASSERT_EQ(ts.size(), n);
for (size_t i = 0; i < ts.size(); i++) {
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
/* Contiguous: no phantom loss was ever reinstated. */
ASSERT_NEAR(ts[i] - last, 0.001, 1e-6) << "gap opened at packet " << p;
last = ts[i];
}
}
}
TEST(FrameDecoder, AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared) {
FrameDecoder dec;
SignalMeta m;
@@ -401,6 +464,56 @@ TEST(FrameDecoder, AccumulatedScalarDerivesDtFromTheHrtGapWhenNoRateIsDeclared)
EXPECT_NEAR(last[1] - last[0], 0.0025, 2e-5);
}
// The trap the hrt path fell into once: positioning each burst at
// hrt / ticksPerSecond(). hrt counts from the PRODUCER'S BOOT, so it is already
// ~1e11 ticks for a machine that has been up a day, while the rate is refitted
// on every packet and wobbles by parts in 1e4 as arrival jitter enters and
// leaves the window. The wobble arrives multiplied by that whole epoch — tens of
// milliseconds, in both directions — so bursts land out of order. The producer
// clock here is EXACT; every timestamp inversion this test can see comes from
// the client's own arithmetic.
TEST(FrameDecoder, AccumulatedScalarStaysMonotonicOnALongUndeclaredRunAfterBoot) {
FrameDecoder dec;
SignalMeta m;
m.name = "Acc";
m.typeCode = 9;
m.samplingRate = 0.0; /* undeclared: the hrt path */
dec.setSignals({m});
const double ticks = 1.0e9;
const uint64_t bootHrt = static_cast<uint64_t>(86400.0 * ticks); /* up 1 day */
double last = 0.0;
uint32_t seed = 12345u;
for (int p = 0; p < 20000; p++) { /* 500 s of stream */
FrameBuilder fb;
fb.addSignal(std::vector<double>(10, 1.0));
/* Exact producer clock: 25 ms per packet, 2.5 ms per sample. */
const uint64_t hrt = bootHrt + static_cast<uint64_t>(p * 0.025 * ticks);
/* Ordinary scheduling jitter, +/- 1 ms, zero mean. */
seed = seed * 1103515245u + 12345u;
const double jitter = (static_cast<double>((seed >> 16) & 0xFFFFu) /
65535.0 - 0.5) * 0.002;
const FrameView& f = fb.build(hrt, 700.0 + p * 0.025 + jitter, 10,
static_cast<uint32_t>(p + 1));
dec.beginFrame(f);
std::vector<double> ts;
if (!dec.timestamps(f, 0, ts)) { continue; }
for (size_t i = 0; i < ts.size(); i++) {
ASSERT_GT(ts[i], last) << "timeline went backwards at packet " << p;
/* Ordering alone is too weak to pin this down: clamping a wrong
* absolute position to "just after the last one" restores the
* ordering while leaving the positions wrong, and every forward
* lurch is still accepted. The producer clock is exact, so the
* spacing must be exact too. */
if (p > 100) { /* past the fit warm-up and its packetBurst fallback */
ASSERT_NEAR(ts[i] - last, 0.0025, 1e-5)
<< "sample spacing wrong at packet " << p;
}
last = ts[i];
}
}
}
// A PACKET burst has no per-element time at all. Elements span
// (lastPacket, thisPacket] — backwards from arrival, because the samples were
// acquired before the packet landed. Forward extrapolation would let a jittered