From de4b240d5e20979d7a6e4be0ca090d96740aab52 Mon Sep 17 00:00:00 2001 From: Artur Iskakov Date: Mon, 21 Sep 2026 10:30:28 +0200 Subject: [PATCH] parity(stress): the LF/HF ratio prints through the same tenths arithmetic on both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The level adopted shared arithmetic (#2165), but the ratio tile one line below still went through each platform's printf family — which round exact halves in OPPOSITE directions (Java %.1f half-up, C %.1f half-even), so an exact quotient such as 9/4 spelled "2.3" on Android and "2.2" on iOS. Unlike the logistic-squashed level, a quotient can land on exact binary fractions. StressTrace gains formatRatio (the level's tenths arithmetic, floor brace, no domain ceiling) on both platforms, both ratio tiles route through it, and the pins land on both sides — including the exact-quarter formatLevel trio the Kotlin suite was missing, since each side's pin guards its own direction. Refs #2167 --- Strand/Screens/StressView.swift | 2 +- StrandTests/StressTraceTests.swift | 10 ++++++++++ StrandiOSShared/StressTrace.swift | 12 ++++++++++++ .../src/main/java/com/noop/ui/StressScreen.kt | 2 +- .../main/java/com/noop/widget/StressTrace.kt | 14 ++++++++++++++ .../java/com/noop/widget/StressTraceTest.kt | 18 ++++++++++++++++++ 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Strand/Screens/StressView.swift b/Strand/Screens/StressView.swift index d999b96c34..1dff8b2899 100644 --- a/Strand/Screens/StressView.swift +++ b/Strand/Screens/StressView.swift @@ -484,7 +484,7 @@ struct StressView: View { if let ratio = f.lfhf { StatTile( label: "Autonomic balance (LF/HF)", - value: String(format: "%.1f", ratio), + value: StressTrace.formatRatio(ratio), caption: String(localized: "Sympathetic vs parasympathetic tone from frequency-domain HRV. Higher leans sympathetic (stress-ward)."), accent: StressRamp.steady ) diff --git a/StrandTests/StressTraceTests.swift b/StrandTests/StressTraceTests.swift index c3f7b75442..c08db83072 100644 --- a/StrandTests/StressTraceTests.swift +++ b/StrandTests/StressTraceTests.swift @@ -35,6 +35,16 @@ final class StressTraceTests: XCTestCase { XCTAssertEqual(StressTrace.formatLevel(2.25), "2.3") } + func testTheLfHfRatioSharesTheLevelArithmeticWithoutItsCeiling() { + // 9/4 is an exact binary 2.25: Java's %.1f said "2.3" while C's said "2.2" (#2167). + XCTAssertEqual(StressTrace.formatRatio(9.0 / 4.0), "2.3") + XCTAssertEqual(StressTrace.formatRatio(0.25), "0.3") + XCTAssertEqual(StressTrace.formatRatio(1.75), "1.8") + // No stress-domain ceiling: a sympathetic-leaning ratio prints as itself, not as 3.0. + XCTAssertEqual(StressTrace.formatRatio(4.25), "4.3") + XCTAssertEqual(StressTrace.formatRatio(-0.3), "0.0") + } + func testAStressLevelRoundsToOneDecimal() { XCTAssertEqual(StressTrace.formatLevel(1.85), "1.9") XCTAssertEqual(StressTrace.formatLevel(1.96), "2.0") diff --git a/StrandiOSShared/StressTrace.swift b/StrandiOSShared/StressTrace.swift index 85f2319bdc..4f9245e481 100644 --- a/StrandiOSShared/StressTrace.swift +++ b/StrandiOSShared/StressTrace.swift @@ -105,6 +105,18 @@ public enum StressTrace { return "\(tenths / 10).\(tenths % 10)" } + /// Prints the LF/HF ratio to one decimal with the same tenths arithmetic as `formatLevel`. + /// + /// The two platforms disagreed through their printf-family formatters in OPPOSITE directions — + /// Java's `%.1f` rounds half UP where C's rounds half to EVEN — so an exact quotient such as + /// 9/4 spelled "2.3" on Android and "2.2" here. A ratio has no domain ceiling, so only the + /// floor is braced; band powers cannot go negative, but the brace keeps the two surfaces + /// identical rather than trusting that. + public static func formatRatio(_ value: Double) -> String { + let tenths = max(Int((value * 10).rounded(.toNearestOrAwayFromZero)), 0) + return "\(tenths / 10).\(tenths % 10)" + } + /// The one placement rule, shared so a dot and its vertex cannot land apart. private static func place(ts: Int64, level: Double, t0: Int64, span: CGFloat, width: CGFloat, height: CGFloat) -> Pt { diff --git a/android/app/src/main/java/com/noop/ui/StressScreen.kt b/android/app/src/main/java/com/noop/ui/StressScreen.kt index ad3d12961d..56235571b1 100644 --- a/android/app/src/main/java/com/noop/ui/StressScreen.kt +++ b/android/app/src/main/java/com/noop/ui/StressScreen.kt @@ -551,7 +551,7 @@ private fun StressAdvancedCard( StatTile( modifier = m, label = uiString(R.string.l10n_stress_screen_autonomic_balance_lf_hf_776cb6f7), - value = String.format(Locale.US, "%.1f", ratio), + value = StressTrace.formatRatio(ratio), caption = "Sympathetic vs parasympathetic tone from frequency-domain HRV. Higher leans sympathetic (stress-ward).", accent = StressRamp.STEADY, ) diff --git a/android/app/src/main/java/com/noop/widget/StressTrace.kt b/android/app/src/main/java/com/noop/widget/StressTrace.kt index d875e94414..7ffd324345 100644 --- a/android/app/src/main/java/com/noop/widget/StressTrace.kt +++ b/android/app/src/main/java/com/noop/widget/StressTrace.kt @@ -265,6 +265,20 @@ object StressTrace { return "${tenths / 10}.${tenths % 10}" } + /** + * Prints the LF/HF ratio to one decimal with the same tenths arithmetic as [formatLevel]. + * + * The two platforms disagreed through their printf-family formatters in OPPOSITE directions — + * Java's `%.1f` rounds half UP where C's rounds half to EVEN — so an exact quotient such as + * 9/4 spelled "2.3" here and "2.2" on iOS. A ratio has no domain ceiling, so only the floor is + * braced; band powers cannot go negative, but the brace keeps the two surfaces identical rather + * than trusting that. + */ + fun formatRatio(value: Double): String { + val tenths = (value * 10).roundToInt().coerceAtLeast(0) + return "${tenths / 10}.${tenths % 10}" + } + /** * The labels up the left edge, top-down: the top of the domain down to zero. * diff --git a/android/app/src/test/java/com/noop/widget/StressTraceTest.kt b/android/app/src/test/java/com/noop/widget/StressTraceTest.kt index e48eddbf99..4ebb495b98 100644 --- a/android/app/src/test/java/com/noop/widget/StressTraceTest.kt +++ b/android/app/src/test/java/com/noop/widget/StressTraceTest.kt @@ -280,6 +280,24 @@ class StressTraceTest { assertEquals("0.0", StressTrace.formatLevel(0.04)) } + /** The exact binary quarters, matching the Swift pin — each side guards its own direction (#2167). */ + @Test + fun `a level rounds half away from zero like iOS`() { + assertEquals("0.3", StressTrace.formatLevel(0.25)) + assertEquals("1.3", StressTrace.formatLevel(1.25)) + assertEquals("2.3", StressTrace.formatLevel(2.25)) + } + + /** The LF/HF ratio shares the level's tenths arithmetic without its ceiling. */ + @Test + fun `the lf hf ratio shares the level arithmetic without its ceiling`() { + assertEquals("2.3", StressTrace.formatRatio(9.0 / 4.0)) + assertEquals("0.3", StressTrace.formatRatio(0.25)) + assertEquals("1.8", StressTrace.formatRatio(1.75)) + assertEquals("4.3", StressTrace.formatRatio(4.25)) + assertEquals("0.0", StressTrace.formatRatio(-0.3)) + } + // #2166: the snapshot used to round to two decimals, so the widget rounded twice where the card // rounded once and the two printed different tenths on 5% of levels.