Skip to content

hrv: multiMs overflows a 32-bit Int, so the R-R delivery census reports a negative percentage - #1685

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
Zebsi235:fix/hrv-multims-overflow
Aug 27, 2026
Merged

hrv: multiMs overflows a 32-bit Int, so the R-R delivery census reports a negative percentage#1685
ryanbr merged 1 commit into
ryanbr:mainfrom
Zebsi235:fix/hrv-multims-overflow

Conversation

@Zebsi235

Copy link
Copy Markdown

What this PR does

HrvAnalyzer.pct is used for two different kinds of input. multiSec and multiRows pass row and second counts, which are small. multiMs passes beat-time in milliseconds summed over a whole night:

" multiMs=${pct(msToInt(multiMs), msToInt(knownMs))}%"

part * 200 is evaluated in a 32-bit Int and overflows above 2147483647 / 200 = 10,737,418 ms, 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 Int wraps silently, so multiMs came 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:

night strap logged multiMs multiRows
2026-08-13 MG (Android) -16% 76%
2026-08-21 MG (Android) -8% 79%
2026-08-22 MG (Android) -16% 91%
2026-08-24 MG (Android) -6% 82%
2026-08-25 MG (Android) -24% 78%
2026-08-26 MG (Android) -23% 43%
2026-08-26 4.0 (iOS) 84% 85%

Swift's Int is 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 pct in the same file, inside duplicatePairRatios:

// Long so the multiply cannot overflow on a corrupt row — Kotlin's Int is 32-bit and would wrap
// where Swift's would not, and a diagnostic that disagrees across platforms is worthless.
ppts.add(((hi.toLong() * 1000L + lo / 2L) / lo.toLong()).toInt())

and again in V18AuxCodec.kt:

Long, not Int: a 4-byte slot with bit 31 set … would decode NEGATIVE in a 32-bit Int while Swift's 64-bit Int reads it positive, from the very same bytes. Widening here is what makes the two platforms agree on the NUMBER, not just the blob.

pct came in with the delivery census (#1331) and predates both guards; duplicatePairRatios arrived 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:

internal fun pct(part: Int, total: Int): Int =
    if (total > 0) ((part.toLong() * 200 + total) / (total.toLong() * 2)).toInt() else 0

The Swift twin states Int64 explicitly rather than inheriting the platform width. StrandAnalytics declares .watchOS(.v10) and is a dependency of NOOPWatch, and Apple Watch is arm64_32 where Int is 32 bits, so today it is correct by where it happens to run rather than by construction. Nothing under NOOPWatch/ calls HRVAnalyzer at 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:

multiMs is the share of attributable BEAT-TIME on those seconds, and it is the number the fix is sized against: coverage is Σ(rrMs) over wall span, so beat-time is what inflates it.

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

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Not on the BLE path. pct is 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, against main at 39f284b:

  • with the fix: BUILD SUCCESSFUL, 9 tests, 0 failures.
  • with the test kept and only the pct line reverted: pctSurvivesAWholeNightOfBeatTime FAILED, java.lang.AssertionError: expected:<43> but was:<-16>.
  • with the fix restored: BUILD SUCCESSFUL, 9 tests, 0 failures.

The -16 is not a contrived number — it is what my 08-26 and 08-22 nights actually printed for multiMs, 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) == 0 cases are unchanged by the widening, which is the point of doing it this way. Those cases are also why this was never caught: the largest rrMs anywhere in that test file is 1309.0, so part * 200 never gets near 2^31.

The inputs are real: beats × meanNN for 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 logged multiRows.

python3 Tools/doc_comment_lint.py passes 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, so 43 and 91 were 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 test not run locally, same reason. swift-packages.yml covers Packages/** and android.yml covers android/**, so both halves of this change are CI-covered; I have run only the Android half.

Checklist

  • Swift package tests pass for any package I touched — not run locally (no Apple machine); left to swift-packages.yml. Expected literals not oracle-verified, see above.
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest)
  • No new build warnings introduced (the warnings in that build are pre-existing, in BaselinesTraceTest.kt and HrvCaptureWindowTest.kt, untouched here)
  • UI changes use only StrandDesign tokens — n/a, no UI
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders — n/a
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Refs #1008, #1118, #1331, #1451 — all four read or size from this census.

Not closing any of them; this only fixes the instrument.

…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
ryanbr merged commit ea6a326 into ryanbr:main Aug 27, 2026
14 checks passed
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.
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