From b4ee219c5002ca03e6c16e83cb6d5a5e658396e7 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 7 Sep 2026 17:29:47 +0200 Subject: [PATCH 1/3] fix(analytics): define the log-map denominator domain identically on both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trimpToStrain evaluated 100 × ln(TRIMP+1) / ln(D) for any D. At D = 1 the division is by zero, so Swift returned +Inf while Kotlin's roundToLong() saturated to 9.223372036854776E16 — the same input, two different answers. Below 1 the sign flips, at or below 0 ln(D) is NaN. D ≤ 1 (and NaN) is now out of domain and scores 0 on both sides, like TRIMP ≤ 0. Kotlin additionally rounds in Double space, since roundToLong() still clipped a D just above 1 where Swift's .rounded() does not; above 2^53 a Double is already an integer, so the value passes through unchanged. The default D = 7201 and every fitted D (exp of a positive) are unaffected — no shipped Effort number moves. Mirrored regression tests cover D = 1, D below 1, NaN, valid denominators and the just-above-1 boundary; the Kotlin expected literals are the stdout of the Swift formula compiled standalone. Refs: issue #36 (fork) --- .../StrandAnalytics/StrainScorer.swift | 7 ++- .../StrainDenominatorDomainTests.swift | 35 +++++++++++ .../java/com/noop/analytics/StrainScorer.kt | 14 ++++- .../StrainScorerDenominatorDomainTest.kt | 61 +++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift index 1151d99de5..9a5f5255c4 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift @@ -291,13 +291,18 @@ public enum StrainScorer { // MARK: - Logarithmic map /// Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - /// TRIMP ≤ 0 → 0. + /// TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). /// /// The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is /// scored against the wrong ceiling and reads low — prefer `strain(…)`, which resolves the /// method's own denominator, or pass `logMapDenominator(method:sex:)` yourself. (#1545) public static func trimpToStrain(_ trimp: Double, denominator: Double = strainDenominator) -> Double { if trimp <= 0 { return 0 } + // D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 + // flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like + // TRIMP ≤ 0 — before this guard D = 1 returned +Inf here and a saturated 9.2e16 on Android + // for the same input (#36). The default 7201 is unaffected. + guard denominator > 1 else { return 0 } let value = maxStrain * log(trimp + 1.0) / log(denominator) return (value * 100).rounded() / 100 } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift new file mode 100644 index 0000000000..2a56e3c14f --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import StrandAnalytics + +/// Domain of the log map's denominator D (#36). +/// +/// D ≤ 1 has no ln-based score: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 flips the sign, and ln(D) +/// is NaN at or below 0. Before the guard, Swift returned `+Inf` for D = 1 while Kotlin's +/// `roundToLong()` saturated to 9.223372036854776E16 — the same input, two different answers. +/// The Kotlin twin is `StrainScorerDenominatorDomainTest`; its expected literals are the stdout of +/// this file's formula compiled standalone. +final class StrainDenominatorDomainTests: XCTestCase { + + func testDenominatorAtOrBelowOneScoresZero() { + for denominator in [1.0, 0.5, 0.0, -3.0, Double.nan] { + let s = StrainScorer.trimpToStrain(1, denominator: denominator) + XCTAssertTrue(s.isFinite, "D = \(denominator) produced a non-finite Effort") + XCTAssertEqual(s, 0.0, accuracy: 0, "D = \(denominator)") + } + } + + func testValidDenominatorsAreUnchanged() { + XCTAssertEqual(StrainScorer.trimpToStrain(1, denominator: 7201), 7.8, accuracy: 1e-9) + XCTAssertEqual(StrainScorer.trimpToStrain(100, denominator: 7201), 51.96, accuracy: 1e-9) + XCTAssertEqual(StrainScorer.trimpToStrain(7200, denominator: 7201), 100.0, accuracy: 1e-9) + XCTAssertEqual(StrainScorer.trimpToStrain(1, denominator: 2), 100.0, accuracy: 1e-9) + } + + /// Just above the boundary the score is enormous but finite, and it must stay a Double on both + /// platforms — this is the value Kotlin's `roundToLong()` clipped to Long.MAX / 100. + func testDenominatorJustAboveOneStaysFinite() { + let s = StrainScorer.trimpToStrain(1, denominator: 1.0000000000000002) + XCTAssertTrue(s.isFinite) + XCTAssertEqual(s, 3.1216573840826803e+17, accuracy: 0) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt index 99bb9d37af..f7b1498d78 100644 --- a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt +++ b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt @@ -340,7 +340,7 @@ object StrainScorer { /** * Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - * TRIMP ≤ 0 → 0. + * TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). * * The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is * scored against the wrong ceiling and reads low — prefer [strain], which resolves the method's own @@ -348,8 +348,18 @@ object StrainScorer { */ fun trimpToStrain(trimp: Double, denominator: Double = strainDenominator): Double { if (trimp <= 0) return 0.0 + // D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 + // flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like + // TRIMP ≤ 0 — before this guard D = 1 returned a saturated 9.2e16 here and +Inf on Swift + // for the same input (#36). The default 7201 is unaffected. + if (!(denominator > 1)) return 0.0 val value = maxStrain * ln(trimp + 1.0) / ln(denominator) - return (value * 100).roundToLong() / 100.0 + val scaled = value * 100 + // Round in Double space: roundToLong() clips anything past Long.MAX_VALUE, which Swift's + // .rounded() does not, so a D just above 1 still disagreed across platforms (#36). Above + // 2^53 every Double is already an integer, so passing it through IS the rounded value. + val rounded = if (abs(scaled) < 9007199254740992.0) scaled.roundToLong().toDouble() else scaled + return rounded / 100.0 } // ---- Denominator calibration ---- diff --git a/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt b/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt new file mode 100644 index 0000000000..0bc9583a49 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt @@ -0,0 +1,61 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Domain of the log map's denominator D (#36), against the compiled Swift twin. + * + * D ≤ 1 has no ln-based score: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 flips the sign, and ln(D) + * is NaN at or below 0. Before the guard, Swift returned `+Inf` for D = 1 while this side's + * `roundToLong()` saturated to 9.223372036854776E16 — the same input, two different answers. + * + * Every expected value below is the verbatim stdout of `StrainScorer.swift`'s `trimpToStrain`, + * extracted and compiled standalone on Linux (`swiftc -O main.swift -o t && ./t`), printed as + * `trimp|D|effort` with `%.17g`: + * + * ``` + * 1.0000|1|0 + * 1.0000|0.5|0 + * 1.0000|0|0 + * 1.0000|-3|0 + * 1.0000|nan|0 + * 0.0000|7201|0 + * 1.0000|7201|7.7999999999999998 + * 100.0000|7201|51.960000000000001 + * 7200.0000|7201|100 + * 1.0000|1.0000000000000002|3.1216573840826803e+17 + * 1.0000|2|100 + * ``` + */ +class StrainScorerDenominatorDomainTest { + + @Test + fun `denominator at or below one scores zero`() { + for (denominator in listOf(1.0, 0.5, 0.0, -3.0, Double.NaN)) { + val s = StrainScorer.trimpToStrain(1.0, denominator) + assertTrue("D = $denominator produced a non-finite Effort", s.isFinite()) + assertEquals("D = $denominator", 0.0, s, 0.0) + } + } + + @Test + fun `valid denominators are unchanged`() { + assertEquals(7.7999999999999998, StrainScorer.trimpToStrain(1.0, 7201.0), 1e-9) + assertEquals(51.960000000000001, StrainScorer.trimpToStrain(100.0, 7201.0), 1e-9) + assertEquals(100.0, StrainScorer.trimpToStrain(7200.0, 7201.0), 1e-9) + assertEquals(100.0, StrainScorer.trimpToStrain(1.0, 2.0), 1e-9) + } + + /** + * Just above the boundary the score is enormous but finite, and it must stay a Double on both + * platforms — this is the value `roundToLong()` clipped to Long.MAX / 100. + */ + @Test + fun `denominator just above one stays finite`() { + val s = StrainScorer.trimpToStrain(1.0, 1.0000000000000002) + assertTrue(s.isFinite()) + assertEquals(3.1216573840826803e+17, s, 0.0) + } +} From c58b825a428ff165a4df1710bac46272fb9fd6f1 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Mon, 7 Sep 2026 18:49:48 +0200 Subject: [PATCH 2/3] test(analytics): tolerance the near-boundary case and pin non-finite TRIMP Review follow-up on the log-map denominator domain, tests and docs only. The just-above-1 assertion compared bit-for-bit against a value derived from ln(1 + 2^-52) ~ 2.2e-16, where a 1-ulp libm difference (Darwin vs glibc, and CI runs the Swift job on macOS) moves the quotient by ~30 absolute. The case stays, now as isFinite plus a 1e4 tolerance, with the exact Linux-observed literal kept in the comment. Also pins the behaviour the Double-space rounding made identical: TRIMP = +inf scores +inf and TRIMP = NaN scores NaN on both platforms, confirmed against the standalone Swift oracle before asserting it. The domain guard is about D, not TRIMP. Doc comments now say the output is unbounded as D approaches 1 from above; the upper clamp is tracked separately (#12). Refs: issue #36 (fork) --- .../StrandAnalytics/StrainScorer.swift | 3 ++- .../StrainDenominatorDomainTests.swift | 17 ++++++++++++++- .../java/com/noop/analytics/StrainScorer.kt | 3 ++- .../StrainScorerDenominatorDomainTest.kt | 21 ++++++++++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift index 9a5f5255c4..25cc95367a 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift @@ -291,7 +291,8 @@ public enum StrainScorer { // MARK: - Logarithmic map /// Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - /// TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). + /// TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). The output is + /// unbounded as D → 1⁺ — an upper clamp is tracked separately (#12). /// /// The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is /// scored against the wrong ceiling and reads low — prefer `strain(…)`, which resolves the diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift index 2a56e3c14f..f4fbb484a4 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift @@ -27,9 +27,24 @@ final class StrainDenominatorDomainTests: XCTestCase { /// Just above the boundary the score is enormous but finite, and it must stay a Double on both /// platforms — this is the value Kotlin's `roundToLong()` clipped to Long.MAX / 100. + /// + /// Loosely toleranced on purpose: ln(1 + 2⁻⁵²) is ~2.2e-16, so a 1-ulp difference between + /// Darwin's and glibc's `log` moves the quotient by ~±30 absolute. Linux/glibc observes exactly + /// 3.1216573840826803e+17; the assertion checks the magnitude, not the last bits. func testDenominatorJustAboveOneStaysFinite() { let s = StrainScorer.trimpToStrain(1, denominator: 1.0000000000000002) XCTAssertTrue(s.isFinite) - XCTAssertEqual(s, 3.1216573840826803e+17, accuracy: 0) + XCTAssertEqual(s, 3.1216573840826803e+17, accuracy: 1e4) + } + + /// Non-finite TRIMP propagates rather than being caught by the domain guard — the guard is about + /// D, not TRIMP. Both platforms agree here only because Kotlin no longer rounds through Long + /// (`roundToLong()` mapped +∞ to Long.MAX), so pin it (#36). + func testNonFiniteTrimpPropagates() { + let inf = StrainScorer.trimpToStrain(.infinity, denominator: 7201) + XCTAssertFalse(inf.isFinite) + XCTAssertFalse(inf.isNaN) + XCTAssertGreaterThan(inf, 0) + XCTAssertTrue(StrainScorer.trimpToStrain(.nan, denominator: 7201).isNaN) } } diff --git a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt index f7b1498d78..8b1905b27a 100644 --- a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt +++ b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt @@ -340,7 +340,8 @@ object StrainScorer { /** * Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - * TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). + * TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). The output is + * unbounded as D → 1⁺ — an upper clamp is tracked separately (#12). * * The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is * scored against the wrong ceiling and reads low — prefer [strain], which resolves the method's own diff --git a/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt b/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt index 0bc9583a49..5c7f63e912 100644 --- a/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt +++ b/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt @@ -51,11 +51,30 @@ class StrainScorerDenominatorDomainTest { /** * Just above the boundary the score is enormous but finite, and it must stay a Double on both * platforms — this is the value `roundToLong()` clipped to Long.MAX / 100. + * + * Loosely toleranced on purpose: ln(1 + 2⁻⁵²) is ~2.2e-16, so a 1-ulp difference between the + * JVM's and Darwin's `log` moves the quotient by ~±30 absolute. Linux observes exactly + * 3.1216573840826803e+17 on both sides; the assertion checks the magnitude, not the last bits. */ @Test fun `denominator just above one stays finite`() { val s = StrainScorer.trimpToStrain(1.0, 1.0000000000000002) assertTrue(s.isFinite()) - assertEquals(3.1216573840826803e+17, s, 0.0) + assertEquals(3.1216573840826803e+17, s, 1e4) + } + + /** + * Non-finite TRIMP propagates rather than being caught by the domain guard — the guard is about + * D, not TRIMP. Both platforms agree here only because this side no longer rounds through Long + * (`roundToLong()` mapped +∞ to Long.MAX), so pin it (#36). Swift oracle, same formula: + * `inf|7201.0|inf`, `nan|7201.0|nan`. + */ + @Test + fun `non-finite trimp propagates`() { + val inf = StrainScorer.trimpToStrain(Double.POSITIVE_INFINITY, 7201.0) + assertTrue(!inf.isFinite()) + assertTrue(!inf.isNaN()) + assertTrue(inf > 0) + assertTrue(StrainScorer.trimpToStrain(Double.NaN, 7201.0).isNaN()) } } From eb3a6532a7fed6457a622d4f0eaea510283dcba4 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Wed, 9 Sep 2026 00:25:12 +0200 Subject: [PATCH 3/3] docs(analytics): describe the log-map domain instead of citing fork issue numbers Reword the doc comments and test docs so they name the thing (outside the map's domain, an upper clamp tracked separately) rather than fork issue numbers that mean something else upstream. Add one sentence explaining why the abs(scaled) < 2^53 comparison must keep its direction: NaN takes the pass-through branch on purpose, and reversing it would collapse NaN to 0.0 through roundToLong() and break parity with Swift. --- .../Sources/StrandAnalytics/StrainScorer.swift | 6 +++--- .../StrainDenominatorDomainTests.swift | 4 ++-- .../main/java/com/noop/analytics/StrainScorer.kt | 14 +++++++++----- .../analytics/StrainScorerDenominatorDomainTest.kt | 5 +++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift index 25cc95367a..aff399c2af 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/StrainScorer.swift @@ -291,8 +291,8 @@ public enum StrainScorer { // MARK: - Logarithmic map /// Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - /// TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). The output is - /// unbounded as D → 1⁺ — an upper clamp is tracked separately (#12). + /// TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain. The output is + /// unbounded as D → 1⁺ — an upper clamp is tracked separately. /// /// The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is /// scored against the wrong ceiling and reads low — prefer `strain(…)`, which resolves the @@ -302,7 +302,7 @@ public enum StrainScorer { // D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 // flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like // TRIMP ≤ 0 — before this guard D = 1 returned +Inf here and a saturated 9.2e16 on Android - // for the same input (#36). The default 7201 is unaffected. + // for the same input (the denominator-domain fix). The default 7201 is unaffected. guard denominator > 1 else { return 0 } let value = maxStrain * log(trimp + 1.0) / log(denominator) return (value * 100).rounded() / 100 diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift index f4fbb484a4..dc021102f1 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/StrainDenominatorDomainTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import StrandAnalytics -/// Domain of the log map's denominator D (#36). +/// Log-map denominator behavior outside the map's domain. /// /// D ≤ 1 has no ln-based score: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 flips the sign, and ln(D) /// is NaN at or below 0. Before the guard, Swift returned `+Inf` for D = 1 while Kotlin's @@ -39,7 +39,7 @@ final class StrainDenominatorDomainTests: XCTestCase { /// Non-finite TRIMP propagates rather than being caught by the domain guard — the guard is about /// D, not TRIMP. Both platforms agree here only because Kotlin no longer rounds through Long - /// (`roundToLong()` mapped +∞ to Long.MAX), so pin it (#36). + /// (`roundToLong()` mapped +∞ to Long.MAX), so pin the non-finite propagation behavior. func testNonFiniteTrimpPropagates() { let inf = StrainScorer.trimpToStrain(.infinity, denominator: 7201) XCTAssertFalse(inf.isFinite) diff --git a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt index 8b1905b27a..fa6b0502eb 100644 --- a/android/app/src/main/java/com/noop/analytics/StrainScorer.kt +++ b/android/app/src/main/java/com/noop/analytics/StrainScorer.kt @@ -340,8 +340,8 @@ object StrainScorer { /** * Map accumulated TRIMP onto [0, 100] via 100 × ln(TRIMP+1) / ln(D), 2 dp. - * TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain (#36). The output is - * unbounded as D → 1⁺ — an upper clamp is tracked separately (#12). + * TRIMP ≤ 0 → 0, and D ≤ 1 (or NaN) → 0, being outside the map's domain. The output is + * unbounded as D → 1⁺ — an upper clamp is tracked separately. * * The default D is **Edwards'**. A Banister TRIMP passed here without an explicit denominator is * scored against the wrong ceiling and reads low — prefer [strain], which resolves the method's own @@ -352,13 +352,17 @@ object StrainScorer { // D ≤ 1 (and NaN) is outside the map's domain: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 // flips the sign, and ln(D) is NaN at or below 0. Out-of-domain D is no score, like // TRIMP ≤ 0 — before this guard D = 1 returned a saturated 9.2e16 here and +Inf on Swift - // for the same input (#36). The default 7201 is unaffected. + // for the same input (the denominator-domain fix). The default 7201 is unaffected. if (!(denominator > 1)) return 0.0 val value = maxStrain * ln(trimp + 1.0) / ln(denominator) val scaled = value * 100 // Round in Double space: roundToLong() clips anything past Long.MAX_VALUE, which Swift's - // .rounded() does not, so a D just above 1 still disagreed across platforms (#36). Above - // 2^53 every Double is already an integer, so passing it through IS the rounded value. + // .rounded() does not, so a D just above 1 still disagreed across platforms (the + // denominator-domain fix). Above 2^53 every Double is already an integer, so passing it + // through IS the rounded value. + // Keep this comparison in this direction: abs(NaN) < 2^53 is false, so NaN intentionally + // takes the pass-through; reversing it to `>= 2^53` would send NaN through `roundToLong()`, + // collapse it to 0.0, and break parity with Swift. val rounded = if (abs(scaled) < 9007199254740992.0) scaled.roundToLong().toDouble() else scaled return rounded / 100.0 } diff --git a/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt b/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt index 5c7f63e912..de6d98a24c 100644 --- a/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt +++ b/android/app/src/test/java/com/noop/analytics/StrainScorerDenominatorDomainTest.kt @@ -5,7 +5,7 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * Domain of the log map's denominator D (#36), against the compiled Swift twin. + * Log-map denominator behavior outside the map's domain, against the compiled Swift twin. * * D ≤ 1 has no ln-based score: ln(1) = 0 divides to ±∞, ln(D) < 0 below 1 flips the sign, and ln(D) * is NaN at or below 0. Before the guard, Swift returned `+Inf` for D = 1 while this side's @@ -66,7 +66,8 @@ class StrainScorerDenominatorDomainTest { /** * Non-finite TRIMP propagates rather than being caught by the domain guard — the guard is about * D, not TRIMP. Both platforms agree here only because this side no longer rounds through Long - * (`roundToLong()` mapped +∞ to Long.MAX), so pin it (#36). Swift oracle, same formula: + * (`roundToLong()` mapped +∞ to Long.MAX), so pin the non-finite propagation behavior. Swift + * oracle, same formula: * `inf|7201.0|inf`, `nan|7201.0|nan`. */ @Test