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:
co-authored by
Claude Opus 4.6
parent
3270284cfe
commit
a2efc142c3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user