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
+78 -56
View File
@@ -23,21 +23,12 @@ static constexpr double kDefaultDt = 1.0e-3;
static constexpr double kBurstResyncThresholdS = 0.5;
/**
* Largest counter gap still read as a loss count.
*
* A producer restart returns the counter to zero and a reordered datagram makes
* the unsigned gap wrap to near 2^32; multiplying either by a sample count and
* calling it elapsed time would fabricate centuries. A million lost updates is
* already far beyond any outage worth reconstructing.
* Narrowest a burst may be drawn, as a fraction of its nominal width, while a
* leading timeline is being pulled back. Only a floor: the squeeze is normally
* proportional to the excess and removes it in a single burst. See the sole use
* site.
*/
static constexpr uint32_t kMaxCounterGap = 1000000u;
/**
* Burst width, as a fraction of nominal, while a leading timeline is being
* pulled back. See the sole use site for why a leading chain cannot be
* corrected in one burst and must be bled off instead.
*/
static constexpr double kLeadBleedFactor = 0.9;
static constexpr double kMinBleedFactor = 0.05;
void FrameDecoder::setSignals(const std::vector<SignalMeta>& signals) {
signals_ = signals;
@@ -150,8 +141,16 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
* When samplingRate is absent we must derive dt from the hrt gap, which
* requires the HrtRateFit to be ready. Until then we fall back to
* packetBurst (arrival-time spanning), which is accurate during the normal
* pre-burst delivery phase that precedes the fit becoming ready. */
if (d.numElements() == 1u && nElems > 1u) {
* pre-burst delivery phase that precedes the fit becoming ready.
*
* A signal that has already produced a burst stays on this rule even when a
* later packet carries a single sample — Accumulate mode flushes on a timer,
* so a short cycle legitimately yields one. Dropping such a packet to rule 5
* would date it from arrival while its neighbours are chained, and would
* leave lastCounter behind so the next real burst read the skip as a lost
* datagram and reinstated a hole that never existed. A signal that has never
* burst is a genuine scalar and is left to rule 5. */
if (d.numElements() == 1u && (nElems > 1u || st.lastEmittedValid)) {
const double dt = (d.samplingRate > 0.0)
? (1.0 / d.samplingRate)
: 0.0;
@@ -177,11 +176,16 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
double step = dt;
if (st.lastEmittedValid) {
/* Unsigned subtraction wraps, so this stays right across the
* counter's own 2^32 rollover. A gap far larger than any real
* outage is a restart or a reordered datagram rather than a
* loss count; claim nothing and let the backstop below decide. */
* counter's own 2^32 rollover.
*
* A producer restart or a reordered datagram makes the wrapped
* gap enormous, and this deliberately does NOT special-case
* that: an absurd gap yields an absurd prediction, which the
* arrival backstop below then rejects on its own. Clamping the
* gap first would only decide the same question earlier, by a
* second rule that no stream can distinguish from this one. */
const uint32_t gap = f.counter - st.lastCounter;
const double lost = (gap > 1u && gap <= kMaxCounterGap)
const double lost = (gap > 1u)
? static_cast<double>(gap - 1u) *
static_cast<double>(st.prevAccCount)
: 0.0;
@@ -216,17 +220,27 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
base = st.lastEmittedEnd + step;
} else {
/* The timeline has run PAST arrival: our last burst is
* dated later than the moment this packet landed. There
* is no room to spread into, and no single burst can
* remove the excess without stepping back. So bleed it
* off — draw each burst a fixed fraction narrower than
* nominal until the timeline is back inside the
* threshold, then normal chaining resumes. The factor
* only has to shrink a burst faster than the clock
* mismatch grows it, and a 10 % squeeze outruns the
* tens-of-ppm crystal error that causes this by orders
* of magnitude. */
step = dt * kLeadBleedFactor;
* dated later than the moment this packet landed, so
* there is no room to spread into and no burst can end
* on arrival without starting before it. Squeeze this
* one by exactly the excess instead. That lands its end
* one nominal burst ahead of arrival — the closest a
* forward-only timeline can legally get — and the excess
* settles at (nominal width - true burst period), a
* couple of hundred microseconds for the ppm-scale
* crystal mismatch that causes this.
*
* The floor keeps the step positive when the excess is
* larger than a whole burst (a declared rate that is
* wrong by a factor, not by ppm). It only slows the
* recovery: each burst then advances by almost nothing
* while arrival keeps advancing, so the excess still
* falls to zero, just over several packets. */
const double nominal = static_cast<double>(nElems) * dt;
const double excess = st.lastEmittedEnd - wallNow;
double factor = 1.0 - excess / nominal;
if (factor < kMinBleedFactor) { factor = kMinBleedFactor; }
step = dt * factor;
base = st.lastEmittedEnd + step;
}
}
@@ -248,43 +262,51 @@ bool FrameDecoder::timestamps(const FrameView& f, uint32_t idx,
}
const double rate = hrtFit_.ticksPerSecond();
/* Difference raw TICKS, never two toSeconds() results.
/* Integrate short tick DELTAS. Never convert an absolute tick count, and
* never subtract two such conversions.
*
* hrt counts from the producer's boot, so it is already ~1e11 ticks when
* the scope attaches, while the fit is re-estimated on every packet and
* wobbles by a few parts in 1e4. toSeconds() multiplies that relative
* wobble by the whole elapsed epoch: tens of milliseconds of jitter on a
* value whose consecutive difference is a few milliseconds. Subtracting
* two such results measures the wobble, not the interval.
* wobbles by a few parts in 1e4. Any absolute hrt/rate therefore carries
* that relative wobble multiplied by the whole elapsed epoch — tens of
* milliseconds, moving in either direction from one packet to the next.
* As a burst's position that is not merely imprecise, it is
* NON-MONOTONIC: on a 2 h stream with ordinary scheduling jitter a few
* percent of samples land before their own predecessor.
*
* Anchoring on the first usable packet keeps the wobble on the elapsed
* interval since attach, which is short, and ClockOffset absorbs the
* arbitrary epoch that anchoring leaves behind exactly as it would
* absorb the producer's boot epoch. */
if (!st.hrtRefValid) {
st.hrtRef = f.hrt;
st.hrtRefValid = true;
* A delta spans one packet, so its share of the wobble is microseconds,
* and summing deltas keeps it there. ClockOffset then latches the
* arbitrary epoch that leaves behind, exactly as it would have latched
* the producer's boot epoch. */
double elapsed = 0.0;
if (st.lastAccValid && f.hrt > st.lastAccHrt) {
elapsed = static_cast<double>(f.hrt - st.lastAccHrt) / rate;
}
const double sinceRef = (f.hrt >= st.hrtRef)
? static_cast<double>(f.hrt - st.hrtRef) / rate
: -static_cast<double>(st.hrtRef - f.hrt) / rate;
const double base = st.offset.map(sinceRef, wallNow);
st.accProdSec += elapsed;
double base = st.offset.map(st.accProdSec, wallNow);
double hrtDt = kDefaultDt;
if (st.lastAccValid && st.prevAccCount > 0u && f.hrt > st.lastAccHrt) {
/* The flushes carry contiguous RT cycles, so the gap divided by the
* previous packet's sample count is exactly one cycle period. */
hrtDt = (static_cast<double>(f.hrt - st.lastAccHrt) / rate) /
static_cast<double>(st.prevAccCount);
/* 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;
/* ClockOffset recalibrates once true drift passes its threshold, and a
* recalibration can land behind where this signal already is.
* Downstream requires increasing stamps, so step forward minimally. */
if (st.lastEmittedValid && base <= st.lastEmittedEnd) {
base = st.lastEmittedEnd + hrtDt;
}
tsOut.resize(nElems);
for (uint32_t e = 0; e < nElems; e++) {
tsOut[e] = base + static_cast<double>(e) * hrtDt;
}
st.lastAccHrt = f.hrt;
st.lastAccValid = true;
st.prevAccCount = nElems;
st.lastAccHrt = f.hrt;
st.lastAccValid = true;
st.prevAccCount = nElems;
st.lastEmittedEnd = tsOut[nElems - 1u];
st.lastEmittedValid = true;
return true;
}