Repository navigation
Round recovery-trace values in the double domain on Android - #1979
Conversation
Math.round returns a Long, so the Kotlin trace helper saturated at 2^63-1: 1e20 rendered as 9.223372036854776e16 where the Swift twin kept 1e20, on a diagnostic whose whole purpose is being comparable across platforms. Round the magnitude with rint plus an explicit half-away-from-zero tie step instead, which keeps every finite input (and the existing signed-zero handling) intact. Names the shared contract as RecoveryScorer.traceRound2 on the Swift side and pins it on both platforms by raw bit pattern (Swift and Kotlin print a Double differently, so text would hide the divergence), plus an exact differential fixture over one full-term night. Charge scores are unchanged; only trace text is affected. Refs: issue #47 (fork)
|
Reviewed. Correct on both sides, and the Kotlin tie handling is right for a reason worth spelling out. Thank you @bhelm. This is the same defect class you found in the strain map: I worked through the replacement rather than trusting it: val rounded = if (magnitude - floored == 0.5) floored + 1.0 else Math.rint(magnitude)
Your comment already explains why Extracting CI green, 16 of 16 against the head SHA. Swift I cannot compile here, so that side rests on CI. Nothing blocking. |
Problem
The recovery trace rounds each rendered number to two decimals. Swift does
(x * 100).rounded(.toNearestOrAwayFromZero) / 100; Kotlin routed the same value throughMath.round, which returns aLongand therefore saturates. Any|x * 100| >= 2^63came backas
Long.MAX_VALUE, so1e20rendered as9.223372036854776e16on Android where Apple kept1e20— the same trace line, two different numbers. The signed-zero half of bhelm#47 wasalready fixed by the #1437 follow-up (
copySign); this is the remaining large-finite half.Change
android/app/src/main/java/com/noop/analytics/RecoveryScorerTrace.kt:r2now rounds themagnitude in the Double domain —
Math.rintplus an explicit half-away-from-zero step for anexact
.5tie — and reapplies the sign withcopySign.Math.rintis half-to-even, so thetie step is required;
floor(m + 0.5)is not usable for it, becausem + 0.5is itself roundedand would push
0.49999999999999994up to1.Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer+Trace.swift: the Swift math isunchanged, but the local
r2closure now forwards to a new namedRecoveryScorer.traceRound2(_:)carrying the contract in its doc comment. Kotlin'sr2wentprivate→internalfor the same reason: without one named declaration per side,±1e20isuntestable through the public trace API at all, since Swift renders it
1e+20and Kotlin1.0E20— the trace strings differ on formatting regardless of rounding. That is why thecontract test compares raw IEEE bit patterns, not text.
RecoveryScorerTraceTests.swift(+57) andRecoveryScorerTraceTest.kt(+85),including a full 11-line trace fixture for one complete night with identical expected strings on
both platforms.
Verification
swiftc -O twin.swift -o t && ./t) produced every expectedliteral in the Kotlin test, used verbatim — e.g.
-0.004 -> -0.0 bits=0x8000000000000000,1e20 -> 1e+20 bits=0x4415af1d78b58c40,1e307 -> inf bits=0x7ff0000000000000. The twin sourceis reproduced in both test files' comments, per the existing repo convention.
r2made testable but its body unchanged):./gradlew testFullDebugUnitTest --tests "com.noop.analytics.RecoveryScorerTraceTest"→10 tests completed, 1 failed—ComparisonFailure: r2(1e20) expected:<4[415af1d78b58c40]> but was:<4[3747ae147ae147b]>(=9.223372036854776e16, theLongceiling).--rerun-tasks→BUILD SUCCESSFUL, XMLtests="10" failures="0" errors="0", includingtraceRound2MatchesTheSwiftContractandtraceFixtureIsByteIdenticalAcrossPlatforms.cd Packages/StrandAnalytics && swift test --filter RecoveryScorerTraceTests→ Executed 10tests, with 0 failures (green before and after; the Swift math did not change).
python3 Tools/doc_comment_lint.py→OK no NEW detached doc comments, exit 0.Tools/i18n_audit.pynot applicable: no user-facing strings.is compiled into an app-only target beyond
StrandAnalytics, which is covered above. No hardwarerequired.
Behaviour change / user-visible effects
Diagnostic trace text only. Charge, and every stored value, are unaffected. On Android a driver
term whose magnitude exceeds the
Longceiling now prints its real value instead of the saturated9.2e16; in normal physiological ranges nothing changes. Documented in both doc comments: thecontract is defined for finite inputs, and non-finite inputs — or finite ones whose
x * 100overflows (|x| > ~1.79e306) — pass through as the matching infinity or NaN on both platforms. That
is pinned by the
1e306/1e307rows rather than rejected, because Swift already behaved thatway and rejecting would have changed Swift.
Scope limits and follow-ups
RecoveryScorerTrace.r2was changed. The identicalMath.round-to-Longsaturationpattern still exists at four other Android sites:
HrvAnalyzerTrace.kt:15,SleepStagerTrace.kt:75,StepsEstimateEngineTrace.kt:29, andAnalyticsEngine.kt:1567,1585.Deliberately untouched here to keep one concern per PR — happy to follow up with a single PR that
routes all five through one shared helper if you'd prefer that shape.
two twin test classes.
Branch:
fix/issue-47-recovery-trace-double-rounding. Contribution offered under the repository'sPolyForm Noncommercial 1.0.0 license.