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 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-13 12:10:05 -04:00
parent 0c197894ff
commit 8d942a88b4
7 changed files with 161 additions and 15 deletions
@@ -62,7 +62,6 @@ object OrientationMath {
} }
const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5 const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
/** /**
* Unsigned angle between two gravity directions — the directional basis for * Unsigned angle between two gravity directions — the directional basis for
* relative zero in the angle meter. Unlike subtracting tilt magnitudes, this * relative zero in the angle meter. Unlike subtracting tilt magnitudes, this
@@ -0,0 +1,58 @@
package com.onthelevel.core.sensors
/**
* Surface-only presentation state. Tilt magnitude remains authoritative from 0180°;
* 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
}
}
@@ -8,6 +8,8 @@ import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.OrientationMath import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.core.sensors.SurfaceCalibration 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). * 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, val mode: LevelMode,
/** Deadbanded stable magnitude for the big readout, in degrees. */ /** Deadbanded stable magnitude for the big readout, in degrees. */
val displayPrimaryDegrees: Double, val displayPrimaryDegrees: Double,
/** Surface: pitch. Edge: signed level deviation. Deadbanded. */ /** Surface: pitch when it is meaningful. Edge: signed level deviation. */
val secondaryADegrees: Double, val secondaryADegrees: Double?,
/** Surface: roll. Edge: plumb lean. Deadbanded. */ /** Surface: roll when it is meaningful. Edge: plumb lean. */
val secondaryBDegrees: Double, 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. */ /** False when the device is not physically in the selected mode's geometry. */
val placementOk: Boolean, val placementOk: Boolean,
val isLocked: Boolean, val isLocked: Boolean,
@@ -36,9 +40,10 @@ data class LevelReading(
* changes; the LockDetector is shared across recreations so the haptic debounce * changes; the LockDetector is shared across recreations so the haptic debounce
* survives mode switches. * 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 * 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( class LevelPipeline(
private val mode: LevelMode, private val mode: LevelMode,
@@ -52,6 +57,7 @@ class LevelPipeline(
private val primaryDeadband = DisplayDeadband() private val primaryDeadband = DisplayDeadband()
private val aDeadband = DisplayDeadband() private val aDeadband = DisplayDeadband()
private val bDeadband = DisplayDeadband() private val bDeadband = DisplayDeadband()
private val surfacePresentationDetector = SurfacePresentationDetector()
private var lastTimestampNanos: Long? = null private var lastTimestampNanos: Long? = null
fun process(g: GravitySample): LevelReading { fun process(g: GravitySample): LevelReading {
@@ -71,15 +77,23 @@ class LevelPipeline(
OrientationMath.surfaceTiltMagnitudeDegrees(corrected), OrientationMath.surfaceTiltMagnitudeDegrees(corrected),
dtSeconds, dtSeconds,
) )
val lock = lockDetector.update( val displayMagnitude = primaryDeadband.update(magnitude)
if (placementOk) magnitude else Double.MAX_VALUE, val presentation = surfacePresentationDetector.update(displayMagnitude)
nowMillis, val lock = lockDetector.update(magnitude, nowMillis)
)
LevelReading( LevelReading(
mode = mode, mode = mode,
displayPrimaryDegrees = primaryDeadband.update(magnitude), displayPrimaryDegrees = displayMagnitude,
secondaryADegrees = aDeadband.update(pitch), secondaryADegrees = if (presentation == SurfacePresentation.FACE_UP) {
secondaryBDegrees = bDeadband.update(roll), aDeadband.update(pitch)
} else {
null
},
secondaryBDegrees = if (presentation == SurfacePresentation.FACE_UP) {
bDeadband.update(roll)
} else {
null
},
surfacePresentation = presentation,
placementOk = placementOk, placementOk = placementOk,
isLocked = lock.isLocked, isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback, fireFeedback = lock.fireFeedback,
@@ -41,6 +41,7 @@ import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.SensorSource import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.sensors.SurfaceCalibration import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfacePresentation
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import java.util.Locale import java.util.Locale
@@ -134,9 +135,10 @@ fun LevelScreen(container: AppContainer) {
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val placementOk = reading?.placementOk ?: true val placementOk = reading?.placementOk ?: true
val surfacePresentation = reading?.surfacePresentation
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
text = if (!placementOk) "" else reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "", text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "",
style = MaterialTheme.typography.displayLarge, style = MaterialTheme.typography.displayLarge,
color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary, 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. // rounded readout and the "level" claim can never contradict.
Text( Text(
text = when { 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 && mode == LevelMode.SURFACE -> "Lay the phone flat, screen up"
!placementOk -> "Stand the phone upright on a long edge" !placementOk -> "Stand the phone upright on a long edge"
isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}" isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}"
@@ -56,6 +56,11 @@ class OrientationMathTest {
assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9) 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 @Test
fun `edge mode reads zero when the long edge is horizontal`() { fun `edge mode reads zero when the long edge is horizontal`() {
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9) assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
@@ -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))
}
}
@@ -5,6 +5,7 @@ import com.onthelevel.core.sensors.GravitySample
import com.onthelevel.core.sensors.LevelMode import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.SurfaceCalibration import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -75,6 +76,41 @@ class LevelPipelineTest {
assertFalse(tilted.last().isLocked) 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 @Test
fun `edge mode never locks while the phone lies flat on a table`() { fun `edge mode never locks while the phone lies flat on a table`() {
val pipeline = LevelPipeline( val pipeline = LevelPipeline(