From 8d942a88b42125c9ddc2223a78c252869f0aca6c Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 13 Jul 2026 12:10:05 -0400 Subject: [PATCH] Slice 1: honest full-range Surface readings, self-gating lock (Codex) - Surface tilt magnitude stays visible 0-180 degrees; never blanked by placement. Lock is self-gating (only near-zero magnitude can enter), so the placement gate now drives hints only. Edge gating unchanged. - Pitch/Roll suppressed as ambiguous at >=80 degrees; explicit screen-down state past 90 degrees with the true magnitude retained. - SurfacePresentationDetector classifies from the same deadbanded stable magnitude the UI displays, with 1-degree hysteresis at both boundaries and a direct face-up -> screen-down transition. - Tests: angle sweep (30/80/near-vertical/screen-down), hysteretic boundary tests, and a sustained 30-degree run proving a steep phone never locks or fires feedback. 43 tests passing. Audited-by: Claude (2 findings raised and resolved) Co-Authored-By: Claude Fable 5 --- .../core/sensors/OrientationMath.kt | 1 - .../core/sensors/SurfacePresentation.kt | 58 +++++++++++++++++++ .../onthelevel/feature/level/LevelPipeline.kt | 40 ++++++++----- .../onthelevel/feature/level/LevelScreen.kt | 8 ++- .../core/sensors/OrientationMathTest.kt | 5 ++ .../SurfacePresentationDetectorTest.kt | 28 +++++++++ .../feature/level/LevelPipelineTest.kt | 36 ++++++++++++ 7 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/onthelevel/core/sensors/SurfacePresentation.kt create mode 100644 app/src/test/java/com/onthelevel/core/sensors/SurfacePresentationDetectorTest.kt diff --git a/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt b/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt index e3ed031..fe24e45 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt @@ -62,7 +62,6 @@ object OrientationMath { } const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5 - /** * Unsigned angle between two gravity directions — the directional basis for * relative zero in the angle meter. Unlike subtracting tilt magnitudes, this diff --git a/app/src/main/java/com/onthelevel/core/sensors/SurfacePresentation.kt b/app/src/main/java/com/onthelevel/core/sensors/SurfacePresentation.kt new file mode 100644 index 0000000..87c0661 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/SurfacePresentation.kt @@ -0,0 +1,58 @@ +package com.onthelevel.core.sensors + +/** + * Surface-only presentation state. Tilt magnitude remains authoritative from 0–180°; + * Pitch and roll become ill-conditioned near vertical and are deliberately suppressed. + */ +enum class SurfacePresentation { + FACE_UP, + NEAR_VERTICAL, + SCREEN_DOWN, +} + +/** + * Hysteretic presentation classifier driven by the same deadbanded stable magnitude + * the UI displays. This prevents Pitch/Roll panels and placement copy from flapping + * around the 80° and 90° boundaries due to sensor noise. + */ +class SurfacePresentationDetector { + private var presentation = SurfacePresentation.FACE_UP + + fun update(displayedTiltDegrees: Double): SurfacePresentation { + presentation = when (presentation) { + SurfacePresentation.FACE_UP -> { + if (displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES) { + SurfacePresentation.SCREEN_DOWN + } else if (displayedTiltDegrees >= AXIS_SUPPRESSION_ENTER_DEGREES) { + SurfacePresentation.NEAR_VERTICAL + } else { + SurfacePresentation.FACE_UP + } + } + SurfacePresentation.NEAR_VERTICAL -> when { + displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES -> SurfacePresentation.SCREEN_DOWN + displayedTiltDegrees < AXIS_SUPPRESSION_EXIT_DEGREES -> SurfacePresentation.FACE_UP + else -> SurfacePresentation.NEAR_VERTICAL + } + SurfacePresentation.SCREEN_DOWN -> { + if (displayedTiltDegrees <= SCREEN_DOWN_EXIT_DEGREES) { + SurfacePresentation.NEAR_VERTICAL + } else { + SurfacePresentation.SCREEN_DOWN + } + } + } + return presentation + } + + fun reset() { + presentation = SurfacePresentation.FACE_UP + } + + companion object { + const val AXIS_SUPPRESSION_ENTER_DEGREES = 80.0 + const val AXIS_SUPPRESSION_EXIT_DEGREES = 79.0 + const val SCREEN_DOWN_ENTER_DEGREES = 90.0 + const val SCREEN_DOWN_EXIT_DEGREES = 89.0 + } +} diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt index 7048fd9..ea5bd29 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt @@ -8,6 +8,8 @@ import com.onthelevel.core.sensors.LevelMode import com.onthelevel.core.sensors.LockDetector import com.onthelevel.core.sensors.OrientationMath import com.onthelevel.core.sensors.SurfaceCalibration +import com.onthelevel.core.sensors.SurfacePresentation +import com.onthelevel.core.sensors.SurfacePresentationDetector /** * One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3). @@ -18,10 +20,12 @@ data class LevelReading( val mode: LevelMode, /** Deadbanded stable magnitude for the big readout, in degrees. */ val displayPrimaryDegrees: Double, - /** Surface: pitch. Edge: signed level deviation. Deadbanded. */ - val secondaryADegrees: Double, - /** Surface: roll. Edge: plumb lean. Deadbanded. */ - val secondaryBDegrees: Double, + /** Surface: pitch when it is meaningful. Edge: signed level deviation. */ + val secondaryADegrees: Double?, + /** Surface: roll when it is meaningful. Edge: plumb lean. */ + val secondaryBDegrees: Double?, + /** Surface-only presentation state; null for Edge mode. */ + val surfacePresentation: SurfacePresentation? = null, /** False when the device is not physically in the selected mode's geometry. */ val placementOk: Boolean, val isLocked: Boolean, @@ -36,9 +40,10 @@ data class LevelReading( * changes; the LockDetector is shared across recreations so the haptic debounce * survives mode switches. * - * Invalid placement (e.g. Edge mode selected but the phone lying flat) suppresses + * Invalid Edge placement (e.g. Edge selected but the phone lying flat) suppresses * lock detection — asin(gy) reads near zero there too, and locking on it would be - * a lie. + * a lie. Surface lock is self-gating: only a near-zero surface tilt can acquire it, + * while its magnitude remains visible at every orientation. */ class LevelPipeline( private val mode: LevelMode, @@ -52,6 +57,7 @@ class LevelPipeline( private val primaryDeadband = DisplayDeadband() private val aDeadband = DisplayDeadband() private val bDeadband = DisplayDeadband() + private val surfacePresentationDetector = SurfacePresentationDetector() private var lastTimestampNanos: Long? = null fun process(g: GravitySample): LevelReading { @@ -71,15 +77,23 @@ class LevelPipeline( OrientationMath.surfaceTiltMagnitudeDegrees(corrected), dtSeconds, ) - val lock = lockDetector.update( - if (placementOk) magnitude else Double.MAX_VALUE, - nowMillis, - ) + val displayMagnitude = primaryDeadband.update(magnitude) + val presentation = surfacePresentationDetector.update(displayMagnitude) + val lock = lockDetector.update(magnitude, nowMillis) LevelReading( mode = mode, - displayPrimaryDegrees = primaryDeadband.update(magnitude), - secondaryADegrees = aDeadband.update(pitch), - secondaryBDegrees = bDeadband.update(roll), + displayPrimaryDegrees = displayMagnitude, + secondaryADegrees = if (presentation == SurfacePresentation.FACE_UP) { + aDeadband.update(pitch) + } else { + null + }, + secondaryBDegrees = if (presentation == SurfacePresentation.FACE_UP) { + bDeadband.update(roll) + } else { + null + }, + surfacePresentation = presentation, placementOk = placementOk, isLocked = lock.isLocked, fireFeedback = lock.fireFeedback, diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt index 97fa5dd..ec6c45b 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -41,6 +41,7 @@ import com.onthelevel.core.sensors.LevelMode import com.onthelevel.core.sensors.LockDetector import com.onthelevel.core.sensors.SensorSource import com.onthelevel.core.sensors.SurfaceCalibration +import com.onthelevel.core.sensors.SurfacePresentation import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import java.util.Locale @@ -134,9 +135,10 @@ fun LevelScreen(container: AppContainer) { contentAlignment = Alignment.Center, ) { val placementOk = reading?.placementOk ?: true + val surfacePresentation = reading?.surfacePresentation Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = if (!placementOk) "—" else reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—", + text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—", style = MaterialTheme.typography.displayLarge, color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary, ) @@ -145,6 +147,10 @@ fun LevelScreen(container: AppContainer) { // rounded readout and the "level" claim can never contradict. Text( text = when { + mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.SCREEN_DOWN -> + "Screen facing down · turn phone screen up" + mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.NEAR_VERTICAL -> + "Near vertical · pitch and roll unavailable" !placementOk && mode == LevelMode.SURFACE -> "Lay the phone flat, screen up" !placementOk -> "Stand the phone upright on a long edge" isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}" diff --git a/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt b/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt index d3c7fde..b12db68 100644 --- a/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt +++ b/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt @@ -56,6 +56,11 @@ class OrientationMathTest { assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9) } + @Test + fun `surface magnitude remains authoritative through screen down`() { + assertEquals(120.0, OrientationMath.surfaceTiltMagnitudeDegrees(pitched(120.0)), 1e-9) + } + @Test fun `edge mode reads zero when the long edge is horizontal`() { assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9) diff --git a/app/src/test/java/com/onthelevel/core/sensors/SurfacePresentationDetectorTest.kt b/app/src/test/java/com/onthelevel/core/sensors/SurfacePresentationDetectorTest.kt new file mode 100644 index 0000000..7c91787 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/sensors/SurfacePresentationDetectorTest.kt @@ -0,0 +1,28 @@ +package com.onthelevel.core.sensors + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SurfacePresentationDetectorTest { + + @Test + fun `axis suppression boundary is hysteretic`() { + val detector = SurfacePresentationDetector() + + assertEquals(SurfacePresentation.FACE_UP, detector.update(79.9)) + assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(80.0)) + assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(79.2)) + assertEquals(SurfacePresentation.FACE_UP, detector.update(78.9)) + } + + @Test + fun `screen down boundary is hysteretic`() { + val detector = SurfacePresentationDetector() + + detector.update(80.0) + assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(90.0)) + assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(90.1)) + assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(89.2)) + assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(89.0)) + } +} diff --git a/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt b/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt index 7c76ed6..74f90c9 100644 --- a/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt +++ b/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt @@ -5,6 +5,7 @@ import com.onthelevel.core.sensors.GravitySample import com.onthelevel.core.sensors.LevelMode import com.onthelevel.core.sensors.LockDetector import com.onthelevel.core.sensors.SurfaceCalibration +import com.onthelevel.core.sensors.SurfacePresentation import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -75,6 +76,41 @@ class LevelPipelineTest { assertFalse(tilted.last().isLocked) } + @Test + fun `surface magnitude remains available through near vertical and screen down`() { + fun readingAt(degrees: Double): LevelReading = + surfacePipeline().process(pitched(degrees, 0)) + + val atThirty = readingAt(30.0) + assertEquals(30.0, atThirty.displayPrimaryDegrees, 1e-9) + assertTrue(atThirty.secondaryADegrees != null) + assertTrue(atThirty.secondaryBDegrees != null) + assertEquals(SurfacePresentation.FACE_UP, atThirty.surfacePresentation) + assertFalse(atThirty.isLocked) + + val atEighty = readingAt(80.0) + assertEquals(80.0, atEighty.displayPrimaryDegrees, 1e-9) + assertEquals(null, atEighty.secondaryADegrees) + assertEquals(null, atEighty.secondaryBDegrees) + assertEquals(SurfacePresentation.NEAR_VERTICAL, atEighty.surfacePresentation) + assertFalse(atEighty.isLocked) + + val screenDown = readingAt(120.0) + assertEquals(120.0, screenDown.displayPrimaryDegrees, 1e-9) + assertEquals(null, screenDown.secondaryADegrees) + assertEquals(null, screenDown.secondaryBDegrees) + assertEquals(SurfacePresentation.SCREEN_DOWN, screenDown.surfacePresentation) + assertFalse(screenDown.isLocked) + } + + @Test + fun `sustained steep Surface reading never locks or fires feedback`() { + val readings = run(surfacePipeline(), degrees = 30.0, fromMillis = 0, untilMillis = 2_000) + + assertTrue(readings.all { !it.isLocked }) + assertTrue(readings.none { it.fireFeedback }) + } + @Test fun `edge mode never locks while the phone lies flat on a table`() { val pipeline = LevelPipeline(