Skip to content

fix(test): size the periodic rate bound by its error term instead of picking a statistic - #111

Merged
dborgards merged 3 commits into
mainfrom
fix/periodic-test-sample-count
Sep 13, 2026
Merged

dborgards merged 3 commits into
mainfrom
fix/periodic-test-sample-count

Conversation

@dborgards

Copy link
Copy Markdown
Owner

What does this change?

J1939NodeTests.StartPeriodicSend_SingleFrame_FiresAtConfiguredPeriod asserted a median gap of at least 0.9 × period. That is the third statistic this test has used and, like the two before it, it was chosen by intuition rather than sized against anything. It fails runs where the scheduler was correct.

This is the debt from #101 — the median is what #101 merged — and it comes before new work.

Why the median is wrong, and what replaces it

Every emission lands on its own grid slot, late by however long the host stalled: t(i) = slot(i)·P + late(i). The gaps telescope, so

mean gap = (slots spanned / gaps) · P  +  (late(last) − late(first)) / gaps

The first term is at least the period, because slots are distinct. The second term is the entire problem, and it is bounded by the number of gaps and nothing else. That disposes of all three attempts at once:

statistic error behaviour
median band [0.7, 1.6]×P (original) upper bound broken by coalescing to 2 × period, which the scheduler documents
per-gap slot floor broken by a late tick legitimately shortening the distance to the next anchor
plain mean over 9 gaps divides a cold start by nine — a 239 ms late first tick lands at 106.8 ms
median (currently on main) no error term to shrink at all; it tracks the shape of the jitter rather than its size, so it cannot be sized

So: 21 samples, the first gap discarded as the only one measured from a cold schedule, and the mean of the remaining twenty. The sample count is what makes the assertion sound, which is why it is commented as not being a free parameter. The collection budget moves off ShortTimeout for the same reason — 21 samples at 120 ms need 2.5 s even when nothing is dropped.

The evidence that it fixes the observed failures

This is deterministic rather than a re-run, because it replays the gap sequences #92 actually recorded from macOS, against both statistics (bound 108 ms):

recorded run gaps plain mean (true rate) median — on main trimmed mean — this branch
15e2094 83, 132, 73, 173, 67, 188, 62, 105, 185 118.7 ✅ 105.0 ❌ 123.1 ✅
8db8b5c 182, 107, 70, 117, 131, 108, 181, 84, 95 119.4 ✅ 107.7 ❌ 111.6 ✅

In both, the mean says the scheduler held the requested rate to within about 1 %. The median rejected them anyway — the first because nine alternating gaps have one more short than long, the second by 0.27 ms. (The medians are as CI reported them; my own arithmetic on the rounded integers above gives 105.0 and 108.0.)

Mutation results, including one that corrects the record

Run against this branch's test, each mutation built and run on its own:

mutation result
period halved caught — mean 60 ms vs bound 108
rate 15 % fast caught — mean 102 ms vs bound 108
skipped ticks released back to back passed — the mutation is a no-op here

The third needs correcting rather than reporting, because I have claimed on #92 that this test catches it. It does not, and the reason is not the test: OnTick drops any tick whose predecessor is still in flight, so a tick fired immediately after an emission produces no frame at all. The mutation cannot express itself against the current scheduler, under load or otherwise — I ran it 3/3 under an 8× CPU overload and it passed there too.

The assertion's power is real, and separating the two took one more mutation: with the burst and the in-flight guard removed, gaps come out 116, 0, 120, 0, …, mean 57 ms, and the test fails. So the bound detects bursting; the single-line burst mutation simply never reaches it.

What my local load test does not show

Twelve runs of this branch's test under an 8× CPU overload: 12 passed. Twelve runs of main's median version under the same load: also 12 passed. This container does not reproduce the macOS failure mode, so those runs are not evidence that the change fixes anything — the recorded-gap table above is. Stating it because a reader trying to verify locally will get the same non-result.

Type of change

  • feat — new behaviour (minor release)
  • fix / perf — bug or performance fix (patch release)
  • docs / test / refactor / chore / ci — no release
  • Breaking change

Test-only: fix(test):, so no release. No production code is touched.

Checklist

macos-latest may be red as on any pull request; #92 counts the population, and this branch touches one of its members without claiming to fix the rest.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj


Generated by Claude Code

…picking a statistic

The median gap I merged in #101 fails on a real macOS runner, on a run where the
scheduler was right. Observed gaps 83, 132, 73, 173, 67, 188, 62, 105, 185:
mean 118.7 ms against a 120 ms period, so the rate was correct to within 1 %,
but the median was 105 ms and the bound is 108. Nine alternating gaps have one
more short than long, and the median picks the short side. My argument that the
median is immune held for a minority of collapsed gaps and not for oscillation,
which is what a loaded host actually produces.

Third statistic on this assertion, and the first chosen by its error rather than
by intuition. Writing out what is measured:

  t(i) = slot(i) * period + late(i), slots distinct and increasing

so the gaps telescope and

  mean gap = (slots spanned / gaps) * period + (late(last) - late(first)) / gaps

The first term is at least the period. The second is the entire problem, and it
is bounded by the number of gaps and nothing else. That settles both earlier
attempts at once: the plain mean over nine gaps divides a cold start by nine and
loses 27 ms of a 120 ms period, which is how a 239 ms late first tick produced
106.8 ms; and the median has no such term to shrink, so its error tracks the
shape of the jitter rather than its size and cannot be sized at all.

So the sample count becomes the knob that makes the assertion sound rather than
a free parameter: 21 samples, the first gap discarded as the only one measured
from a cold schedule, and the mean of the remaining twenty. The residual
endpoint term then stays under a tenth of a period unless the lateness swings by
240 ms between the second emission and the last -- an order of magnitude beyond
anything observed. Oscillation cancels in a mean by construction, so the run
above passes.

The collection budget moves off ShortTimeout with it: 21 samples need 2.5 s
before any tick is dropped, and a loaded runner coalescing to 2x or 3x needs
several times that. Four times the nominal run keeps bounding the rate from
above -- the one direction this assertion deliberately does not cover -- without
failing for slowness.

Verified: all three scheduler mutations still caught (period halved, skipped
ticks released back to back, rate 15 % fast); 20/20 clean under an 8x CPU
overload; suite 614/614; build 0 warnings / 0 errors with CI=true; format clean.

Refs #92.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj
@cursor

cursor Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

PR Summary

Low Risk
Test-only change to J1939 periodic-send timing assertions; production behavior is unchanged.

Overview
StartPeriodicSend_SingleFrame_FiresAtConfiguredPeriod no longer uses a median inter-emission gap (≥ 0.9× the 120 ms period). It now drops the first gap as cold-start warm-up and asserts the mean of the remaining gaps meets the same floor, with comments tying sample count to the telescoping lateness error term.

requiredSamples rises from 10 to 22 so twenty post-trim gaps shrink endpoint error enough for the 10% bound to stay meaningful. The collection deadline switches from ShortTimeout to 4 × period × requiredSamples so slow or coalescing CI runners can finish gathering emissions without timing out early.

Test-only; no scheduler or product code changes.

Reviewed by Cursor Bugbot for commit dc47793. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36195635ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/CanKit.Pro.Tests/TestCases/J1939/J1939NodeTests.cs Outdated
The review caught an off-by-one that this test's own failure messages had been
printing all along: 21 emissions give 20 gaps, and discarding the warm-up
leaves 19, while the comment claimed twenty. The mutation runs said "mean gap
over 19 samples" and I read past it.

It matters because the count is what sizes the assertion. The 10 % allowance
below the period is worth 12 ms per gap, so 19 gaps absorb 228 ms of endpoint
lateness and 20 absorb 240 - and 239 ms is the worst cold start on record here,
which is the number the sizing was chosen against. At 19 the test misses its
own design target: a 239 ms swing reaching the second emission lands the mean
near 107.4 ms against a 108 ms bound, and fails a correct schedule.

So 22 emissions. The comment now names both subtractions rather than the
result, since it was the arithmetic that went unchecked, not the intent.

Re-verified rather than assumed: build 0/0, 614/614, format clean, 9 packages;
both effective mutations still caught and now reporting "over 20 samples"
(period halved 60 ms, rate 15 % fast 102 ms, bound 108 ms); 10/10 under an 8x
CPU overload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zd6AyAtcZApgfRC2Rkitj

Copy link
Copy Markdown
Owner Author

macOS is red, and the interesting part is which test passed

One comment for the macOS situation on this branch; I will not repeat it per head.

What failed on dc47793 (job), 613/614:

Expected a <CanKit.Pro.Uds.UdsTimeoutException> to be thrown, but no exception was thrown.
  at UdsClientTests.TimedOut_Request_Does_Not_Poison_Next_Same_Service_Transaction (UdsClientTests.cs:425)

Why it is not this branch's. The diff is one test method in J1939NodeTests.cs. It touches no production code and no other test. This failure is in CanKit.Pro.Uds against a deadline in ProtocolActor's scheduler — no shared path, and it is #92's step-1 candidate product bug, now at its fifth recorded occurrence across five commits and two operating systems.

And the test this pull request exists to fix passed on that same run. That is the first macOS evidence for the change rather than a replay of recorded gaps: StartPeriodicSend_SingleFrame_FiresAtConfiguredPeriod was red on three consecutive heads of #104 with the median statistic, and it is green here on the runner that produced those failures. One green run is not proof — the test was intermittent before, and #92 exists because a single observation on this suite means little — but it is the right sign, and it is the only leg that could have produced it.

Nothing to port. No fix exists for the UDS case: #92 step 1 is an investigation that has not been started, and its whole point is that it is not yet known whether the client or the test is at fault. Guessing at it inside a pull request about a J1939 timing assertion would be exactly the widening the working agreement forbids.

I have re-run the failed job once. If it is red again, that is the same finding and still not this branch's.


Generated by Claude Code

@dborgards
dborgards merged commit eed21cc into main Sep 13, 2026
17 of 18 checks passed
@dborgards
dborgards deleted the fix/periodic-test-sample-count branch September 13, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants