Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,12 @@ extension RecoveryScorer {
}

static func skinTempDevText(_ dev: Double) -> String {
// Foundation's `%+f` renders negative zero as "-+0.0" on some runtimes. Preserve the
// IEEE sign explicitly so this edge stays byte-identical to Java Formatter.
// Negative zero needs its sign taken from the IEEE bit, never from a comparison: `-0.0 >= 0` is
// TRUE, so the hand-built sign this replaced prepended "+" to a value that already formatted as
// "-0.0", printing "+-0.0". `%+.1f` on its own gets BOTH zeroes right (checked against C printf
// and Java's Formatter, which the Kotlin twin uses); the explicit branch is redundant there and
// kept only to pin this edge, so the two zeroes cannot drift apart from the twin on a Foundation
// whose formatter we have not checked.
if dev == 0 {
return dev.sign == .minus ? "-0.0 C vs baseline" : "+0.0 C vs baseline"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ final class RecoveryScorerTraceTests: XCTestCase {
XCTAssertTrue(base!.contains("status=provisional"))
}

/// #1437 follow-up: a term just below baseline rounds to zero but must keep its SIGN. Swift's
/// `.rounded()` yields -0.0 here and interpolates as "-0.0"; the Kotlin twin negated a Long (which
/// has no negative zero) and printed "0.0", so the two traces disagreed on the one line whose whole
/// purpose is being byte-identical across platforms. Twin:
/// `traceKeepsNegativeZeroOnNearBaselineTerm`.
func testTraceKeepsNegativeZeroOnNearBaselineTerm() {
let hrvB = baseline(mean: 50, sigma: 6)
let (_, lines) = RecoveryScorer.recoveryTrace(
hrv: 50, rhr: 55, resp: nil,
hrvBaseline: hrvB, rhrBaseline: nil, respBaseline: nil,
sleepPerf: nil, skinTempDev: 0.001)
XCTAssertEqual(
lines.first { $0.hasPrefix("charge term skinTempDev ") },
"charge term skinTempDev z=-0.0 w=0.05 (dev=0.0C penalty=-|dev|/1.0)"
)
}

/// The second trap in the same helper: an exact -0.0 cannot be routed by a sign COMPARISON, because
/// `-0.0 < 0.0` is false. It is reachable — a skin-temp deviation of exactly 0.0 (skin temp sitting
/// on the personal baseline) gives z = -|dev| = -0.0. Swift's `.rounded()` carries the sign for
/// free; this pins that the Kotlin twin does too. Twin: `traceKeepsExactNegativeZeroTerm`.
func testTraceKeepsExactNegativeZeroTerm() {
let hrvB = baseline(mean: 50, sigma: 6)
let (_, lines) = RecoveryScorer.recoveryTrace(
hrv: 50, rhr: 55, resp: nil,
hrvBaseline: hrvB, rhrBaseline: nil, respBaseline: nil,
sleepPerf: nil, skinTempDev: 0.0)
XCTAssertEqual(
lines.first { $0.hasPrefix("charge term skinTempDev ") },
"charge term skinTempDev z=-0.0 w=0.05 (dev=0.0C penalty=-|dev|/1.0)"
)
}

func testTraceRoundsHalfTiesAwayFromZeroWithoutChangingScore() {
let hrvB = baseline(mean: 50, sigma: 6)
let plain = RecoveryScorer.recovery(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,24 @@ import kotlin.math.abs

object RecoveryScorerTrace {

/** Trace numbers use nearest rounding with half-ties away from zero on both platforms. */
/**
* Trace numbers use nearest rounding with half-ties away from zero on both platforms.
*
* Round the MAGNITUDE and reapply the sign, rather than branching on `scaled < 0.0`. Two traps sit
* here, and Swift's `.rounded(.toNearestOrAwayFromZero)` avoids both for free:
*
* * Math.round returns a Long, which has no negative zero, so negating it before the division
* collapses -0.0 to +0.0 for any value that rounds to zero;
* * `-0.0 < 0.0` is FALSE, so a sign test cannot even route an exact -0.0 to a negating branch —
* and -0.0 is reachable here, since a skin-temp deviation of exactly 0.0 gives z = -|dev| = -0.0.
*
* These values are interpolated straight into the trace, so either trap printed `z=0.0` on Android
* against `z=-0.0` on Apple. copySign carries the IEEE sign bit itself and handles both (#1437
* follow-up).
*/
private fun r2(x: Double): Double {
val scaled = x * 100.0
return if (scaled < 0.0) -Math.round(-scaled) / 100.0 else Math.round(scaled) / 100.0
return Math.copySign(Math.round(abs(scaled)) / 100.0, scaled)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,45 @@ class RecoveryScorerTraceTest {
assertTrue(base.contains("status=provisional"))
}

/**
* #1437 follow-up: a term just below baseline rounds to zero but must keep its SIGN. Math.round
* returns a Long, which has no negative zero, so negating it before the division collapsed -0.0 to
* +0.0 and this line printed `z=0.0` while Swift's .rounded() kept `z=-0.0` — a disagreement on the
* one line whose whole purpose is being byte-identical across platforms. Twin of Swift
* `testTraceKeepsNegativeZeroOnNearBaselineTerm`.
*/
@Test fun traceKeepsNegativeZeroOnNearBaselineTerm() {
val hrvB = baseline(50.0, 6.0)
val (_, lines) = RecoveryScorerTrace.recoveryTrace(
hrv = 50.0, rhr = 55.0, resp = null,
hrvBaseline = hrvB, rhrBaseline = null, respBaseline = null,
sleepPerf = null, skinTempDev = 0.001,
)
assertEquals(
"charge term skinTempDev z=-0.0 w=0.05 (dev=0.0C penalty=-|dev|/1.0)",
lines.first { it.startsWith("charge term skinTempDev ") },
)
}

/**
* The second trap in the same helper: an exact -0.0 cannot be routed by a sign COMPARISON, because
* `-0.0 < 0.0` is false. It is reachable — a skin-temp deviation of exactly 0.0 (skin temp sitting
* on the personal baseline) gives z = -|dev| = -0.0 — and Swift prints `z=-0.0` for it. Twin of
* Swift `testTraceKeepsExactNegativeZeroTerm`.
*/
@Test fun traceKeepsExactNegativeZeroTerm() {
val hrvB = baseline(50.0, 6.0)
val (_, lines) = RecoveryScorerTrace.recoveryTrace(
hrv = 50.0, rhr = 55.0, resp = null,
hrvBaseline = hrvB, rhrBaseline = null, respBaseline = null,
sleepPerf = null, skinTempDev = 0.0,
)
assertEquals(
"charge term skinTempDev z=-0.0 w=0.05 (dev=0.0C penalty=-|dev|/1.0)",
lines.first { it.startsWith("charge term skinTempDev ") },
)
}

@Test fun traceRoundsHalfTiesAwayFromZeroWithoutChangingScore() {
val hrvB = baseline(50.0, 6.0)
val plain = RecoveryScorer.recovery(
Expand Down
Loading