hrv: multiMs overflows a 32-bit Int, so the R-R delivery census reports a negative percentage - #1685
Merged
Merged
Conversation
…ntage `deliveryHistogram` calls `pct(msToInt(multiMs), msToInt(knownMs))`, where both arguments are beat-time in milliseconds summed over a whole night rather than a row count. `part * 200` was evaluated in a 32-bit `Int` and overflows above 2147483647 / 200 = 10,737,418 ms, i.e. 2.98 h of beat-time on multi-delivery seconds; a real over-count night carries three to four times that. Kotlin's `Int` wraps silently, so `multiMs` was reported negative on every such night. It is a share of a positive subset of a positive total and cannot be negative. Swift's `Int` is 64-bit on the platforms that run it today, so the same source computed the right answer there and the two twins disagreed. Widen the operation rather than the signature, so no caller changes and the integer half-up tie behaviour is untouched. The Swift twin states `Int64` explicitly rather than inheriting the platform width, since StrandAnalytics also builds for watchOS, where `Int` is 32-bit. The same guard already exists thirty lines below in `duplicatePairRatios` and again in `V18AuxCodec`; `pct` predates both. Adds a regression case at night scale to the file that already pins `pct`. The existing tie cases are unchanged by the widening.
ryanbr
added a commit
that referenced
this pull request
Aug 27, 2026
…and positive (#1688) Follow-up to #1685, which widened HrvAnalyzer.pct correctly. What that PR left slightly wrong is the doc comments it added, and those are what the next person reasons from. THE QUOTED BOUND UNDERSTATES THE EXPOSURE. Both comments say the overflow starts above 2147483647 / 200 = 10,737,418 ms. That is the bound for `part * 200` in isolation; the numerator is `part * 200 + total`. With part <= total it first exceeds Int.MAX_VALUE at total >= 10,683,999 (2147483647 / 201) - about 53,000 ms lower, 2.97 h rather than 2.98 h. 10,737,418 is also the last value that does NOT overflow even for the multiply alone; that starts one higher, at 10,737,419. THE WRAP DID NOT ALWAYS LAND NEGATIVE, AND THAT IS THE HALF THAT MATTERS. A 100% multi-share 8 h night, pct(28_800_000, 28_800_000), returned 25 in 32 bits: positive, plausible, and wrong by 75 points. So a negative multiMs was a SYMPTOM of this bug and never a TEST for it. Anyone re-reading pre-fix Android captures for #1008 / #1118 / #1331 / #1451 cannot filter on the sign - every Android multiMs past the bound is suspect, including the ones that read fine, and those are precisely the ones that would have been trusted. This is a stronger claim than the original made and it changes what the existing logs are worth. Comments corrected on both platforms, plus a twin test pinning the exact boundary and the readable-wrong case: 10,683,998 was still computed correctly in 32 bits, 10,683,999 wrapped to -100, 28,800,000 wrapped to 25. No behaviour change. pct itself is untouched and all three assertions pass on both platforms today; they are a parity pin and a guard for arm64_32, where the Kotlin failure reproduces exactly. VERIFIED RATHER THAN REASONED. Two independently written models of 32-bit truncation - one in Swift via the standalone oracle, one in Python - agree on all five values, and both reproduce the -16 that @Zebsi235's real 08-22 and 08-26 nights printed. Reproducing the observed data is what earns them the 25. The new test was falsified before being trusted: with pct reverted to Int arithmetic it fails expected:<100> but was:<-100> on the 10,683,999 case, a value the original comment described as safe. That is the correction, executable. The revert was undone and the pushed branch re-checked to confirm both merged arithmetic changes are intact. 10 tests, 0 failures, on freshly cleared result XMLs. Tools/doc_comment_lint.py: OK, no new detached doc comments. swift test not run locally (StrandAnalytics needs macOS via GRDB); swift-packages.yml covers it, and the asserted values came from the oracle rather than from reading the code. Refs #1685. Docs and tests only.
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
HrvAnalyzer.pctis used for two different kinds of input.multiSecandmultiRowspass row and second counts, which are small.multiMspasses beat-time in milliseconds summed over a whole night:" multiMs=${pct(msToInt(multiMs), msToInt(knownMs))}%"part * 200is evaluated in a 32-bitIntand overflows above2147483647 / 200 = 10,737,418ms, so 2.98 h of beat-time on multi-delivery seconds. A real over-count night is 7 to 8 h at 60-83%multiSec, three to four times past that, so it wraps on every night the field exists for.Kotlin's
Intwraps silently, somultiMscame out negative. It is a share of a positive subset of a positive total, so it cannot be negative and the value was never usable. From my own MG and 4.0:Swift's
Intis 64-bit on iOS and macOS, so the same source computed the right answer there. That is the split in the table, and it is a parity break between two functions documented as byte-parity twins whose percentages "cannot disagree on a tie (the #1473 lesson)".This is not a new hazard for this codebase, which is the main reason I think the fix is uncontroversial. The same widening, with the same reasoning, is already applied thirty lines below
pctin the same file, insideduplicatePairRatios:and again in
V18AuxCodec.kt:pctcame in with the delivery census (#1331) and predates both guards;duplicatePairRatiosarrived a day later with #1510 and got the guard. So this applies the rule the file already states to the one arithmetic site written before it.The fix widens the operation rather than the signature, so no caller changes and the integer half-up tie behaviour is untouched:
The Swift twin states
Int64explicitly rather than inheriting the platform width.StrandAnalyticsdeclares.watchOS(.v10)and is a dependency ofNOOPWatch, and Apple Watch isarm64_32whereIntis 32 bits, so today it is correct by where it happens to run rather than by construction. Nothing underNOOPWatch/callsHRVAnalyzerat the moment, so there is no live watch bug, but the width seemed worth stating.Why this is more than a log cosmetic.
deliveryHistogram's own doc says of this field:So the field exists to size the R-R over-count fix, and on Android it has been returning a number that cannot be read at all. Anyone sizing from an Android capture (#1008 / #1118 / #1331 / #1451) was sizing from that.
This PR does not touch the over-count itself. It only makes the diagnostic readable.
Type of change
How it was tested
Not on the BLE path.
pctis a pure function, no bytes change on the wire, and no strap is involved in the fix itself.New regression case on both platforms, in the file that already pins
pct. Ran./gradlew testFullDebugUnitTest --tests "*HrvAnalyzerSampleOrdTest*"on Windows 11, JDK 17, compileSdk 35, againstmainat 39f284b:BUILD SUCCESSFUL, 9 tests, 0 failures.pctline reverted:pctSurvivesAWholeNightOfBeatTime FAILED,java.lang.AssertionError: expected:<43> but was:<-16>.BUILD SUCCESSFUL, 9 tests, 0 failures.The
-16is not a contrived number — it is what my 08-26 and 08-22 nights actually printed formultiMs, so the test reproduces the reported symptom from real values and then fixes it.The existing
pct(1, 8) == 13/pct(3, 8) == 38/pct(0, 0) == 0cases are unchanged by the widening, which is the point of doing it this way. Those cases are also why this was never caught: the largestrrMsanywhere in that test file is1309.0, sopart * 200never gets near2^31.The inputs are real:
beats × meanNNfor two nights on my MG (34,002 beats at meanNN 1044 ms, and 42,981 at 928 ms), with the multi-delivery share taken from the loggedmultiRows.python3 Tools/doc_comment_lint.pypasses with this applied and adds no findings.Two gaps I would rather state than have you find.
The expected literals are not oracle-verified. CLAUDE.md asks for the twin compiled standalone (
swiftc -O twin.swift main.swift -o t && ./t) with its stdout pasted verbatim as the Kotlin expectation. I have no Swift toolchain here, so43and91were derived by integer arithmetic and confirmed against the Kotlin run, not against a compiled Swift oracle. Both sides now evaluate the same expression in 64 bits and the existing tie cases are unchanged, so I believe the divergence risk is nil — but that is reasoning, not the oracle the guide asks for. Happy for someone on macOS or Linux to run it properly.swift testnot run locally, same reason.swift-packages.ymlcoversPackages/**andandroid.ymlcoversandroid/**, so both halves of this change are CI-covered; I have run only the Android half.Checklist
swift-packages.yml. Expected literals not oracle-verified, see above.android/(./gradlew testFullDebugUnitTest)BaselinesTraceTest.ktandHrvCaptureWindowTest.kt, untouched here)StrandDesigntokens — n/a, no UIdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
Refs #1008, #1118, #1331, #1451 — all four read or size from this census.
Not closing any of them; this only fixes the instrument.