Specify the host timestamp-unwrap rule, and give it conformance vectors - #136
Conversation
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
Four host APIs unwrap this counter and all four got it wrong the same way: a
record the firmware never stamped, read as a roll-over, put every later sample
512 s late. Fixing that in four places showed the spec was part of the problem.
Section 2.1 recommended comparing against half the modulo and cross-checking
long gaps against host elapsed time - and said in the same breath that at 2^16
"the host cross-check is not optional". An SD file has no host clock, so the
recommendation could not be followed by the case that needed it most, and each
implementation improvised something different.
Replaced with a rule that stands on its own: classify each sample by its modular
forward distance, with forward motion as the default, a reorder window sized in
sample periods, and rejection reserved for a 3-byte counter reading exactly zero
from mid-range. Four things the prose now calls out, each of which a real
implementation got wrong:
- compare modular distances, not unwrapped values, or a packet arriving late
from before a boundary costs a modulo and the next sample costs another
- forward motion is the default, so a roll-over preceded by a long dropout is
still a roll-over
- size the reorder window in sample periods; modulo/8 reads every 1.75-2.0 s
dropout on the 2-byte counter as a reorder and loses the wrap
- an unknown rate means no window, not an infinite one
Test/conformance/timestamp_unwrap.json carries 26 vectors and 11 window
derivations covering every case named above, including the three that separate
this rule from the ones it replaces. Test/host/crosscheck_timestamp_unwrap.py is
the reference implementation: it generates the file, and `make timestamp-unwrap`
re-checks that every expectation still reproduces, so prose, vectors and the
implementations that consume them cannot drift apart at the source. It also
emits the vectors as a C# array for a host whose test project cannot load data
files.
It sits with the other cross-checks rather than in its own CI job, so the suite
stays driven entirely by Test/host/Makefile. Like crosscheck_host_constants.py
it needs no compiler and no binary; unlike every other check here, its subject
is outside this repository - the firmware does not unwrap its own counter.
No firmware source is touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267f693 to
8e76966
Compare
The Swift host API's tests live in a classic Xcode project, where carrying a data file into a test bundle means four hand-edited entries in project.pbxproj for something nothing compiles. Generated source costs one entry and cannot go stale: this script is the only thing that writes it, and it reads the same definitions the JSON is built from. Also emits the window-derivation cases, which the C# emitter does not - those were transcribed by hand there, and there is no reason for the next host to repeat that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule keeps the previous raw value. A host that instead keeps the unwrapped value and a cycle count - which is how three of the five host APIs are written, because that is the state they already had - derives the raw value back out and needs some other way to say "no sample yet". (0, 0) is the obvious encoding, and it is wrong: a reorder that lands exactly on the counter's origin produces lastUnwrapped = 0, cycle = 0 in the middle of a stream. The next packet is then treated as the first one and passed through, so a packet from just before the origin is placed a whole modulo late instead of sixteen ticks behind. Found by running the two formulations against each other rather than by reading them: [520, 0, 16777200] gives -16 where the previous-raw form is used and 16777200 where (0, 0) is the reset state. A 512 second disagreement between host APIs that are supposed to be identical, and nothing in the vector set could see it. So: a vector for it, and a fifth entry in the list of things the section says are easy to get wrong. It is also the first vector whose final cycle is negative, which is a real state and worth a host proving it handles. Revision stays 1 - it has not been merged yet, so there is no published revision 1 for this to differ from. Every consumer copy is updated in the same round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Added a 27th vector, and a fifth entry in the list of things §2.1 says are easy The rule here keeps Found by running the two formulations against each other rather than by reading
A 512 second disagreement between host APIs that are meant to be identical, and
Revision stays 1. This branch has not merged, so there is no published |
Docs and test material only — no firmware source is touched, and nothing here
compiles into an image. The firmware does not unwrap its own counter; hosts do.
Why
Four host APIs unwrap this counter — the Java driver behind Consensys, the C# API,
pyshimmer and the TypeScript web SDK — and all four got it wrong the same way: a record
the firmware never stamped, read as a roll-over, put every later sample 512 s late.
Fixing that in four places showed the spec was part of the problem. §2.1 recommended
comparing against half the modulo and cross-checking long gaps against host elapsed
time, and then said in the same breath that at 2^16 "the host cross-check is not
optional". An SD file has no host clock. The recommendation could not be followed by
the case that needed it most, and each implementation improvised something different.
What the rule is now
Classify each sample by its modular forward distance from the last one, with forward
motion as the default:
Four things the prose now calls out, each of which a real implementation got wrong:
16777206, 5, 16777206, 70is the smallest casemodulo / 8reads every 1.75–2.0 s dropout on the 2-byte counter as a reorder and silently loses the wrap — an ordinary Bluetooth gap. Eight periods shrinks that band to ~16 ms32768 / 0is infinity in most languages, which classifies every backward step as a reorder and loses every wrap — a silent return to worse-than-naive behaviourThe tick domain is called out explicitly as the 32768 Hz real-time clock the packet
counter runs on, never a TCXO sampling clock (312500 / 255765.625 Hz on the boards that
have one) — that one is easy to get wrong and silently widens the window ~9.5×.
Two limits are stated rather than hidden: a packet more than eight sample periods late
is indistinguishable from a roll-over, and a gap longer than a whole modulo cannot be
recovered from the counter at all.
The vectors
Test/conformance/timestamp_unwrap.json— 26 vectors and 11 window derivations,covering every case named above. Three of them are the ones that separate this rule from
the ones it replaces:
wrap-spanning-dropout-1p8s-16bitmodulo / 8window (placed 1.8 s early)reorder-across-wrap-boundary-24bitwrap-after-heavy-loss-24bit/-16bitTest/host/crosscheck_timestamp_unwrap.pyis the reference implementation. It generatesthe file, and
make timestamp-unwrapre-checks that every expectation in it stillreproduces — so the prose, the vectors and the implementations that consume them
cannot drift apart at the source. A hand-edited expectation, a vector added without
regenerating, or a rule change the file was not updated for all fail it. Verified by
tampering with one expectation: exit 1, with the differing field named.
It also has
--emit csharp, because one of the consuming test projects has no way toload a data file and has to transcribe.
Where it sits
Rebased onto the new
Test/hostsuite, so it is a cross-check in that Makefile ratherthan a CI job of its own — the workflow stays driven entirely by the Makefile, as its
header asks. Like
crosscheck_host_constants.pyit needs no compiler and no binary;unlike every other check there, its subject is outside this repository, because the
firmware does not unwrap its own counter.
The vectors go in
Test/conformance/rather thandocs/:docs/is prose-only anddocs-in-step.ymlmatches^docs/.+\.md$, so a JSON there would be both clutter andinvisible to that check. One file per topic, so
crc.jsonand friends have somewhereobvious to go later.
Verification
→
26 vectors, 11 derivation cases, revision 1 - all expectations reproduce.Every expectation in the file was computed by the reference implementation, not typed;
the nine most load-bearing were independently hand-derived first and matched.
🤖 Generated with Claude Code