Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-03 16:03:36 -04:00
commit 1f028c129e

View file

@ -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