sessiond: a one-second proximity blip no longer flaps the state
The phone's trail was almost entirely Locked -> Observed -> Locked. The snapshots said why: prox=true on the way in, prox=false one second later on the way out, over and over. `near` must hold 700 ms to be believed and `far` is believed instantly, so a one-second episode is long enough to enter and its end is immediate. The debounce cannot be where this is fixed. suppress_wake reads the debounced value, so slowing `far` there would keep vetoing tap-to-wake after the sensor was uncovered — which is exactly what PROXIMITY_FAR_DEBOUNCE's zero exists to prevent. One reading, two consumers, opposite needs: the veto wants far fast, the state wants it stable. So the hysteresis is on the state and the veto keeps its instant edge, with a test pinning that separation. 3 s, bounded by data already in this file rather than by feel: the measured blips ran ~1 s, and of the 44 recorded episodes the nine real ones all ran >= 5 s. It sits above the noise and below every genuine episode. It reduces the flapping rather than abolishing it — a sensor that keeps blipping still enters Observed on each 700 ms near. Raising the entry bar needs the same split applied to the near edge, which is a second change with its own justification to earn. Two existing tests asserted the instant exit and now advance past the dwell. The property they are named for is unchanged: far ends Observed.
This commit is contained in:
parent
6cb245492e
commit
1f028c129e
1 changed files with 171 additions and 16 deletions
|
|
@ -126,6 +126,36 @@ pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
|
||||||
/// wake through, which is the recoverable direction.
|
/// wake through, which is the recoverable direction.
|
||||||
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
|
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
|
||||||
|
|
||||||
|
/// How long `Observed` must be held before a `far` reading may end it.
|
||||||
|
///
|
||||||
|
/// Measured on the phone 2026-08-03, which is what this is for. The trail was
|
||||||
|
/// almost entirely `Locked → Observed → Locked`, and reading the snapshots
|
||||||
|
/// showed why: `prox=true` on the way in and `prox=false` **one second later**
|
||||||
|
/// on the way out, over and over. `near` must hold 700 ms to be believed and
|
||||||
|
/// `far` is believed instantly, so a one-second blip is long enough to enter
|
||||||
|
/// and its end is immediate — the asymmetry that protects the wake veto is the
|
||||||
|
/// same asymmetry that makes this state chatter.
|
||||||
|
///
|
||||||
|
/// The debounce itself cannot be the place to fix it. [`suppress_wake`] reads
|
||||||
|
/// the *debounced* value, so slowing `far` there would keep vetoing tap-to-wake
|
||||||
|
/// after the sensor was uncovered, which is precisely what
|
||||||
|
/// [`PROXIMITY_FAR_DEBOUNCE`]'s zero exists to prevent. One reading, two
|
||||||
|
/// consumers, opposite needs: the veto wants `far` fast, the state wants it
|
||||||
|
/// stable. So the hysteresis goes here, on the state, and the veto keeps its
|
||||||
|
/// instant edge.
|
||||||
|
///
|
||||||
|
/// 3 s, and the bound comes from data already in this file rather than from
|
||||||
|
/// feel: the measured blips ran ~1 s, and of the 44 recorded proximity episodes
|
||||||
|
/// the nine real ones — pocket, ear, deliberate cover — all ran **≥5 s**
|
||||||
|
/// (max 173 s). 3 s sits above the noise and below every genuine episode, so it
|
||||||
|
/// suppresses the chatter without shortening a single real one.
|
||||||
|
///
|
||||||
|
/// This reduces the flapping rather than abolishing it: a sensor that keeps
|
||||||
|
/// blipping still enters `Observed` on each ≥700 ms `near`. Raising the *entry*
|
||||||
|
/// bar would need the same split applied to the near edge, and that is a second
|
||||||
|
/// change with its own justification to earn.
|
||||||
|
pub const OBSERVED_MIN_DWELL: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
/// How long a changed bearer preference must hold before the machine acts.
|
/// How long a changed bearer preference must hold before the machine acts.
|
||||||
///
|
///
|
||||||
/// 20 s, chosen against the failure rather than against a feel. The gate this
|
/// 20 s, chosen against the failure rather than against a feel. The gate this
|
||||||
|
|
@ -1337,6 +1367,8 @@ pub struct DeviceStateMachine {
|
||||||
/// Per-button gesture recognition. Keyed rather than a field per button so
|
/// Per-button gesture recognition. Keyed rather than a field per button so
|
||||||
/// adding volume costs nothing and binds nothing.
|
/// adding volume costs nothing and binds nothing.
|
||||||
buttons: std::collections::HashMap<Button, ButtonRecognizer>,
|
buttons: std::collections::HashMap<Button, ButtonRecognizer>,
|
||||||
|
/// When `Observed` was entered, for [`OBSERVED_MIN_DWELL`].
|
||||||
|
observed_since: Option<Instant>,
|
||||||
/// Whether the panel was dark when the current press began.
|
/// Whether the panel was dark when the current press began.
|
||||||
///
|
///
|
||||||
/// Latched on the DOWN edge because **the press itself changes the
|
/// Latched on the DOWN edge because **the press itself changes the
|
||||||
|
|
@ -1467,6 +1499,7 @@ impl DeviceStateMachine {
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
buttons: std::collections::HashMap::new(),
|
buttons: std::collections::HashMap::new(),
|
||||||
blank_requested: false,
|
blank_requested: false,
|
||||||
|
observed_since: None,
|
||||||
// Boot comes up lit, so no press is in flight and the latch would
|
// Boot comes up lit, so no press is in flight and the latch would
|
||||||
// only ever be read after a real DOWN edge has set it.
|
// only ever be read after a real DOWN edge has set it.
|
||||||
press_began_dark: false,
|
press_began_dark: false,
|
||||||
|
|
@ -2396,6 +2429,22 @@ impl DeviceStateMachine {
|
||||||
/// a panel *off* rather than decline to turn one on. That needs a
|
/// a panel *off* rather than decline to turn one on. That needs a
|
||||||
/// call-state input (ModemManager / callaudiod) — a factor, never an
|
/// call-state input (ModemManager / callaudiod) — a factor, never an
|
||||||
/// authority.
|
/// authority.
|
||||||
|
/// May a `far` reading end `Observed` yet? See [`OBSERVED_MIN_DWELL`].
|
||||||
|
///
|
||||||
|
/// Anything other than `Observed` answers yes: this gates one edge, and a
|
||||||
|
/// state we are not in has no dwell to serve. `None` also answers yes —
|
||||||
|
/// an `Observed` entered before this was tracked (a daemon that restarted
|
||||||
|
/// into it) must still be able to leave, or the phone would sit in a state
|
||||||
|
/// nothing could clear.
|
||||||
|
fn observed_dwell_elapsed(&self, now: Instant) -> bool {
|
||||||
|
if !matches!(self.state, DeviceState::Observed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.observed_since
|
||||||
|
.map(|t| now.saturating_duration_since(t) >= OBSERVED_MIN_DWELL)
|
||||||
|
.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
|
pub fn suppress_wake(&self, trigger: InputTrigger) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
trigger,
|
trigger,
|
||||||
|
|
@ -2448,6 +2497,14 @@ impl DeviceStateMachine {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.state = next;
|
self.state = next;
|
||||||
|
// Cleared here, on any path out, so a later entry cannot inherit an old
|
||||||
|
// timestamp and believe its dwell is already served. The *stamp* is set
|
||||||
|
// by the caller instead — `Instant::now()` here would read the real
|
||||||
|
// clock while every test drives an injected one, and the dwell would
|
||||||
|
// then be untestable.
|
||||||
|
if !matches!(next, DeviceState::Observed) {
|
||||||
|
self.observed_since = None;
|
||||||
|
}
|
||||||
// Log the pair the right way round. This read
|
// Log the pair the right way round. This read
|
||||||
// "Locked → Locked (from Active)" on the first live run, which looks
|
// "Locked → Locked (from Active)" on the first live run, which looks
|
||||||
// like a refused self-transition rather than the real Active → Locked.
|
// like a refused self-transition rather than the real Active → Locked.
|
||||||
|
|
@ -2614,8 +2671,13 @@ impl DeviceStateMachine {
|
||||||
|
|
||||||
let should_be_observed = self.state == DeviceState::Locked && believed;
|
let should_be_observed = self.state == DeviceState::Locked && believed;
|
||||||
if should_be_observed {
|
if should_be_observed {
|
||||||
self.transition(DeviceState::Observed);
|
if self.transition(DeviceState::Observed) {
|
||||||
} else if matches!(self.state, DeviceState::Observed) && !believed {
|
self.observed_since = Some(now);
|
||||||
|
}
|
||||||
|
} else if matches!(self.state, DeviceState::Observed)
|
||||||
|
&& !believed
|
||||||
|
&& self.observed_dwell_elapsed(now)
|
||||||
|
{
|
||||||
self.transition(DeviceState::Locked);
|
self.transition(DeviceState::Locked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2724,8 +2786,10 @@ impl DeviceStateMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_be_observed && !was_observed {
|
if should_be_observed && !was_observed {
|
||||||
self.transition(DeviceState::Observed);
|
if self.transition(DeviceState::Observed) {
|
||||||
} else if was_observed && !proximity_near {
|
self.observed_since = Some(now);
|
||||||
|
}
|
||||||
|
} else if was_observed && !proximity_near && self.observed_dwell_elapsed(now) {
|
||||||
self.transition(DeviceState::Locked);
|
self.transition(DeviceState::Locked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3533,6 +3597,86 @@ mod tests {
|
||||||
settled
|
settled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_believed_near_followed_by_far_a_second_later_does_not_flap() {
|
||||||
|
// The measured case, 2026-08-03. The phone's trail was almost entirely
|
||||||
|
// Locked -> Observed -> Locked, and the snapshots said why: prox=true
|
||||||
|
// on the way in, prox=false one second later on the way out. `near`
|
||||||
|
// holds 700 ms and is believed; `far` is believed instantly; the round
|
||||||
|
// trip costs two transitions and two trail entries for a reading that
|
||||||
|
// never meant anything.
|
||||||
|
let (mut sm, t0) = locked_for_proximity();
|
||||||
|
|
||||||
|
let settled = hold_prox_near(&mut sm, t0);
|
||||||
|
assert_eq!(sm.state, DeviceState::Observed, "a held near is believed");
|
||||||
|
|
||||||
|
// Far, one second after entering — inside the dwell.
|
||||||
|
let blip_ends = settled + Duration::from_secs(1);
|
||||||
|
report_prox(&mut sm, false, blip_ends);
|
||||||
|
sm.tick_at(blip_ends);
|
||||||
|
assert_eq!(
|
||||||
|
sm.state,
|
||||||
|
DeviceState::Observed,
|
||||||
|
"a one-second episode must not bounce the state back"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past the dwell, `far` still ends it — this must not become a state
|
||||||
|
// the phone cannot leave.
|
||||||
|
let after = settled + OBSERVED_MIN_DWELL + Duration::from_millis(1);
|
||||||
|
report_prox(&mut sm, false, after);
|
||||||
|
sm.tick_at(after);
|
||||||
|
assert_eq!(
|
||||||
|
sm.state,
|
||||||
|
DeviceState::Locked,
|
||||||
|
"the dwell delays the exit, it does not remove it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_real_proximity_episode_still_ends_when_it_ends() {
|
||||||
|
// The nine real episodes of the 44 measured all ran >= 5 s. The dwell
|
||||||
|
// is 3 s precisely so it sits under every one of them: covering a
|
||||||
|
// sensor for a real interval must behave exactly as it did before.
|
||||||
|
let (mut sm, t0) = locked_for_proximity();
|
||||||
|
let settled = hold_prox_near(&mut sm, t0);
|
||||||
|
assert_eq!(sm.state, DeviceState::Observed);
|
||||||
|
|
||||||
|
let uncovered = settled + Duration::from_secs(5);
|
||||||
|
report_prox(&mut sm, false, uncovered);
|
||||||
|
sm.tick_at(uncovered);
|
||||||
|
assert_eq!(
|
||||||
|
sm.state,
|
||||||
|
DeviceState::Locked,
|
||||||
|
"a five-second episode ends on the reading that ends it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wake_veto_still_lifts_the_instant_the_sensor_clears() {
|
||||||
|
// The dwell must not leak into `suppress_wake`. That reads the
|
||||||
|
// debounced evidence, and PROXIMITY_FAR_DEBOUNCE is zero exactly so a
|
||||||
|
// wake is never refused after the phone is out of the pocket. Holding
|
||||||
|
// the *state* longer must not hold the *veto* longer.
|
||||||
|
let (mut sm, t0) = locked_for_proximity();
|
||||||
|
let settled = hold_prox_near(&mut sm, t0);
|
||||||
|
assert!(
|
||||||
|
sm.suppress_wake(InputTrigger::DoubleTapToWake),
|
||||||
|
"covered: a pocket double-tap is vetoed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let clear = settled + Duration::from_millis(200);
|
||||||
|
report_prox(&mut sm, false, clear);
|
||||||
|
assert!(
|
||||||
|
!sm.suppress_wake(InputTrigger::DoubleTapToWake),
|
||||||
|
"uncovered: the veto lifts at once, dwell or no dwell"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sm.state,
|
||||||
|
DeviceState::Observed,
|
||||||
|
"and the state is still holding its dwell, which is the whole point"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_sub_second_near_blip_is_never_believed() {
|
fn a_sub_second_near_blip_is_never_believed() {
|
||||||
// The measured case, 2026-07-26: 44 near-episodes in 2.4 hours, median
|
// The measured case, 2026-07-26: 44 near-episodes in 2.4 hours, median
|
||||||
|
|
@ -3715,13 +3859,19 @@ mod tests {
|
||||||
// Enter observed
|
// Enter observed
|
||||||
let settled = hold_prox_near(&mut sm, Instant::now());
|
let settled = hold_prox_near(&mut sm, Instant::now());
|
||||||
assert_eq!(sm.state(), DeviceState::Observed);
|
assert_eq!(sm.state(), DeviceState::Observed);
|
||||||
let _ = settled;
|
|
||||||
|
|
||||||
// Leave observed
|
// Leave observed. Past OBSERVED_MIN_DWELL, because a `far` inside the
|
||||||
sm.update_sensors(SensorEvidence {
|
// dwell is now deliberately ignored — see that constant. The property
|
||||||
proximity_near: false,
|
// this test is named for is unchanged: far ends Observed.
|
||||||
..Default::default()
|
let after = settled + OBSERVED_MIN_DWELL + Duration::from_millis(1);
|
||||||
});
|
sm.update_sensors_from_at(
|
||||||
|
SensorSource::Proximity,
|
||||||
|
SensorEvidence {
|
||||||
|
proximity_near: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
after,
|
||||||
|
);
|
||||||
assert_eq!(sm.state(), DeviceState::Locked);
|
assert_eq!(sm.state(), DeviceState::Locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3808,14 +3958,19 @@ mod tests {
|
||||||
|
|
||||||
// 8. Phone goes back in pocket. Held — "briefly" is precisely what the
|
// 8. Phone goes back in pocket. Held — "briefly" is precisely what the
|
||||||
// debounce now refuses to believe, which is the point of it.
|
// debounce now refuses to believe, which is the point of it.
|
||||||
hold_prox_near(&mut sm, Instant::now());
|
let pocketed = hold_prox_near(&mut sm, Instant::now());
|
||||||
assert_eq!(sm.state(), DeviceState::Observed);
|
assert_eq!(sm.state(), DeviceState::Observed);
|
||||||
|
|
||||||
// 9. Phone comes back out
|
// 9. Phone comes back out — after OBSERVED_MIN_DWELL, since a pocket
|
||||||
sm.update_sensors(SensorEvidence {
|
// that lasted less than that is a blip rather than a pocket.
|
||||||
proximity_near: false,
|
sm.update_sensors_from_at(
|
||||||
..Default::default()
|
SensorSource::Proximity,
|
||||||
});
|
SensorEvidence {
|
||||||
|
proximity_near: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
pocketed + OBSERVED_MIN_DWELL + Duration::from_millis(1),
|
||||||
|
);
|
||||||
assert_eq!(sm.state(), DeviceState::Locked);
|
assert_eq!(sm.state(), DeviceState::Locked);
|
||||||
|
|
||||||
// 10. System suspends
|
// 10. System suspends
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue