fix(test): size the periodic rate bound by its error term instead of picking a statistic - #111
Conversation
…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
PR SummaryLow Risk Overview
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
💡 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".
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
macOS is red, and the interesting part is which test passedOne comment for the macOS situation on this branch; I will not repeat it per head. What failed on Why it is not this branch's. The diff is one test method in 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: 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 |
What does this change?
J1939NodeTests.StartPeriodicSend_SingleFrame_FiresAtConfiguredPeriodasserted 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, soThe 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:
[0.7, 1.6]×P(original)main)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
ShortTimeoutfor 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):
main15e20948db8b5cIn 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:
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:
OnTickdrops 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 releaseTest-only:
fix(test):, so no release. No production code is touched.Checklist
dotnet build CanKit.Pro.sln -c Release -p:CI=truesucceeds — 0 warnings, 0 errorsdotnet test CanKit.Pro.sln -c Release --no-build --framework net10.0passes — 614/614dotnet format CanKit.Pro.sln --verify-no-changescleandotnet packinto a fresh directory +python3 eng/verify-packages.py— 9 packages checkedmacos-latestmay 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