sessiond: evidence-source health as device state
A source that reported and then went silent past 90s is DOWN, not quiet, and says so as an error-security entry. Sources that never reported stay unknown — a light sensor with no reporter is correctly silent forever. Every forensic snapshot now carries sensors_degraded, so a decision taken during an outage doesn't read like a healthy one. This is the 2026-07-25 SLPI case: hexagonrpcd-sdsp exited 0, every sensor dead for hours, nothing in any log. Health is not read from SensorProxy Has* — measured lying both ways. Also: evidence_fresh meant "ever seen" and read true through the outage; repeated readings no longer append to the trail, since reporters heartbeat. 55 sessiond tests.
This commit is contained in:
parent
bd8a4eca50
commit
862d1d7bba
2 changed files with 549 additions and 77 deletions
|
|
@ -52,6 +52,24 @@ pub const LOCK_DIM_GRACE: Duration = Duration::from_secs(10);
|
|||
/// a dead sensor looked exactly like a consumer of a quiet one.
|
||||
pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a source that was previously reporting may say nothing before the
|
||||
/// machine calls it **down** rather than merely stale.
|
||||
///
|
||||
/// The two are different claims and the difference is the whole point.
|
||||
/// `EVIDENCE_TTL` is about the *reading*: stop believing a 30-second-old "near".
|
||||
/// This is about the *source*: proximity that sat at `far` all afternoon has an
|
||||
/// unexpired flag of `false` and looks identical whether the sensor is healthy
|
||||
/// and the phone is on a table, or the DSP took a CHRE fatal an hour ago. The
|
||||
/// staleness rule cannot tell those apart, because nothing about a `false` flag
|
||||
/// going unreported is anomalous.
|
||||
///
|
||||
/// It is longer than the TTL on purpose. A source is allowed to be quiet for a
|
||||
/// while — that is normal. What is not normal is a source that was speaking and
|
||||
/// then stopped for longer than any legitimate quiet period, which is what this
|
||||
/// threshold names. Reporters keep this honest by re-reporting their last value
|
||||
/// periodically, so silence means silence rather than "nothing changed".
|
||||
pub const SOURCE_DOWN_AFTER: Duration = Duration::from_secs(90);
|
||||
|
||||
/// How long a blank waits for the compositor to acknowledge the lock it asked
|
||||
/// for before going dark anyway.
|
||||
///
|
||||
|
|
@ -91,6 +109,8 @@ pub struct DeviceStatePolicy {
|
|||
/// disease this machine exists to end. The field is settable so the
|
||||
/// capability is real rather than implied.
|
||||
pub unlocked_blank_after: Option<Duration>,
|
||||
/// A source silent for this long, having previously reported, is down.
|
||||
pub source_down_after: Duration,
|
||||
}
|
||||
|
||||
impl Default for DeviceStatePolicy {
|
||||
|
|
@ -103,6 +123,7 @@ impl Default for DeviceStatePolicy {
|
|||
dim_warning: true,
|
||||
lock_ack_budget: LOCK_ACK_BUDGET,
|
||||
unlocked_blank_after: None,
|
||||
source_down_after: SOURCE_DOWN_AFTER,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -144,6 +165,84 @@ pub struct EvidenceSeen {
|
|||
pub touch: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Whether an evidence source is *there*, as opposed to what it last said.
|
||||
///
|
||||
/// Doctrine §9 says sensor readings are evidence, not fact. This is the
|
||||
/// sentence underneath that one: the machine must also know whether it is
|
||||
/// receiving evidence at all. `DEVICE-STATE-MACHINE.md` §0 states the
|
||||
/// requirement — "'No evidence' and 'evidence says nothing is happening' must
|
||||
/// not be the same state" — and until now they were.
|
||||
///
|
||||
/// Health is deliberately NOT read from `net.hadess.SensorProxy`'s
|
||||
/// `HasProximity`/`HasAccelerometer` properties, which is the obvious place to
|
||||
/// look and is wrong. Those lie in both directions, measured: on 2026-07-25 at
|
||||
/// 12:19 the stack was dead and the proxy answered `HasProximity: true` for
|
||||
/// hours (`PAF/slpi.md`), and in the 06:04 incident it answered `false` while
|
||||
/// remoteproc had already recovered SLPI. A property that is wrong both ways is
|
||||
/// not an authority. What the machine can actually trust is its own experience:
|
||||
/// whether readings arrive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SourceHealth {
|
||||
/// Nothing has ever been heard from this source. Not an error — a light
|
||||
/// sensor with no reporter installed is silent, correctly, forever. Only a
|
||||
/// source that spoke and then stopped has failed.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// Reporting within `source_down_after`.
|
||||
Live,
|
||||
/// Reported once and has now been silent past the threshold.
|
||||
Down,
|
||||
}
|
||||
|
||||
impl SourceHealth {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SourceHealth::Unknown => "unknown",
|
||||
SourceHealth::Live => "live",
|
||||
SourceHealth::Down => "down",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-source health, in the same shape as `EvidenceSeen`.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SourceHealthTable {
|
||||
pub proximity: SourceHealth,
|
||||
pub accel: SourceHealth,
|
||||
pub light: SourceHealth,
|
||||
pub touch: SourceHealth,
|
||||
}
|
||||
|
||||
impl SourceHealthTable {
|
||||
fn get_mut(&mut self, source: SensorSource) -> &mut SourceHealth {
|
||||
match source {
|
||||
SensorSource::Proximity => &mut self.proximity,
|
||||
SensorSource::Accelerometer => &mut self.accel,
|
||||
SensorSource::Light => &mut self.light,
|
||||
SensorSource::Touch => &mut self.touch,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when any source that once worked has gone silent. This is the
|
||||
/// single question the rest of the system asks — a surface showing
|
||||
/// "sensors degraded" does not need to know which one.
|
||||
pub fn any_down(&self) -> bool {
|
||||
[self.proximity, self.accel, self.light, self.touch]
|
||||
.iter()
|
||||
.any(|h| *h == SourceHealth::Down)
|
||||
}
|
||||
|
||||
pub fn as_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"proximity": self.proximity.as_str(),
|
||||
"accel": self.accel.as_str(),
|
||||
"light": self.light.as_str(),
|
||||
"touch": self.touch.as_str(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forensic logging ─────────────────────────────────────────────────
|
||||
// Every decision point emits a ForensicEntry capturing the full state
|
||||
// at that moment. These are appended to a JSONL file alongside the
|
||||
|
|
@ -229,6 +328,15 @@ pub struct StateSnapshot {
|
|||
pub screen_lock_secure: bool,
|
||||
pub idle_coordinator_state: String,
|
||||
pub sleep_inhibitor_held: bool,
|
||||
/// True when some evidence source that used to report has gone silent.
|
||||
///
|
||||
/// On the snapshot rather than only on the health entry because this is
|
||||
/// what makes the trail diagnostic after the fact: every decision taken
|
||||
/// during an outage is stamped with the fact that the machine was deciding
|
||||
/// on absent evidence. Without it, a `panel-off` recorded during four dead
|
||||
/// hours is indistinguishable from a healthy one, and the log answers
|
||||
/// "what happened" but not "why was it wrong".
|
||||
pub sensors_degraded: bool,
|
||||
}
|
||||
|
||||
/// Forensic log — accumulates entries for post-hoc analysis.
|
||||
|
|
@ -408,6 +516,9 @@ pub struct DeviceStateMachine {
|
|||
idle_since: Option<Instant>,
|
||||
/// Per-source freshness, for the staleness rule.
|
||||
evidence_seen: EvidenceSeen,
|
||||
/// Per-source health — whether evidence is arriving at all, which is a
|
||||
/// different question from what it says. See `SourceHealth`.
|
||||
pub source_health: SourceHealthTable,
|
||||
/// True once a blank has been asked for, so we ask exactly once per wake
|
||||
/// instead of every tick.
|
||||
blank_requested: bool,
|
||||
|
|
@ -485,6 +596,7 @@ impl DeviceStateMachine {
|
|||
panel_on: true,
|
||||
idle_since: None,
|
||||
evidence_seen: EvidenceSeen::default(),
|
||||
source_health: SourceHealthTable::default(),
|
||||
blank_requested: false,
|
||||
dimmed: false,
|
||||
pending_blank: None,
|
||||
|
|
@ -508,6 +620,7 @@ impl DeviceStateMachine {
|
|||
/// point on the timeline instead of by sleeping.
|
||||
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
|
||||
self.expire_stale_evidence(now);
|
||||
self.evaluate_source_health(now);
|
||||
|
||||
let mut actions = Vec::new();
|
||||
|
||||
|
|
@ -604,7 +717,11 @@ impl DeviceStateMachine {
|
|||
if idle_for >= budget {
|
||||
info!(
|
||||
"[device-state] {}, idle {:.1}s of {}s — blanking",
|
||||
if is_locked(self.state) { "locked" } else { "unlocked" },
|
||||
if is_locked(self.state) {
|
||||
"locked"
|
||||
} else {
|
||||
"unlocked"
|
||||
},
|
||||
idle_for.as_secs_f32(),
|
||||
budget.as_secs()
|
||||
);
|
||||
|
|
@ -743,15 +860,88 @@ impl DeviceStateMachine {
|
|||
}
|
||||
}
|
||||
|
||||
/// Decide whether each evidence source is still there.
|
||||
///
|
||||
/// Separate from `expire_stale_evidence` because it answers a different
|
||||
/// question, and because the staleness rule structurally cannot answer this
|
||||
/// one: it only acts on a source whose flag is currently `true`. A
|
||||
/// proximity sensor resting at `far` has a `false` flag, so it expires
|
||||
/// nothing, reports nothing, and dies in complete silence. That is exactly
|
||||
/// the 2026-07-25 outage — hours of dead sensors with nothing in any log.
|
||||
fn evaluate_source_health(&mut self, now: Instant) {
|
||||
let limit = self.policy.source_down_after;
|
||||
let sources = [
|
||||
(SensorSource::Proximity, self.evidence_seen.proximity),
|
||||
(SensorSource::Accelerometer, self.evidence_seen.accel),
|
||||
(SensorSource::Light, self.evidence_seen.light),
|
||||
(SensorSource::Touch, self.evidence_seen.touch),
|
||||
];
|
||||
|
||||
let mut newly_down: Vec<&str> = Vec::new();
|
||||
for (source, seen) in sources {
|
||||
let Some(t) = seen else { continue };
|
||||
if now.saturating_duration_since(t) <= limit {
|
||||
continue;
|
||||
}
|
||||
let health = self.source_health.get_mut(source);
|
||||
if *health == SourceHealth::Down {
|
||||
continue;
|
||||
}
|
||||
*health = SourceHealth::Down;
|
||||
newly_down.push(source.as_str());
|
||||
}
|
||||
|
||||
// One entry per outage edge, not per tick. A source stays Down until
|
||||
// it speaks again, and a trail that repeated this every second would
|
||||
// bury the transition that actually diagnoses anything.
|
||||
for source in &newly_down {
|
||||
warn!(
|
||||
"[device-state] evidence source {source} has said nothing for {}s — treating it as DOWN, not quiet",
|
||||
limit.as_secs()
|
||||
);
|
||||
self.record_error(
|
||||
"sensor-health",
|
||||
"source-down",
|
||||
&format!(
|
||||
"{source} reported before but has been silent for over {}s; its readings are absent, not negative",
|
||||
limit.as_secs()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp a source as freshly reported. Called alongside the reading.
|
||||
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
|
||||
let now = Instant::now();
|
||||
self.mark_evidence_seen_at(source, Instant::now())
|
||||
}
|
||||
|
||||
/// `mark_evidence_seen` with the clock passed in, so health transitions can
|
||||
/// be tested on a timeline instead of by sleeping for 90 seconds.
|
||||
pub fn mark_evidence_seen_at(&mut self, source: SensorSource, now: Instant) {
|
||||
match source {
|
||||
SensorSource::Proximity => self.evidence_seen.proximity = Some(now),
|
||||
SensorSource::Accelerometer => self.evidence_seen.accel = Some(now),
|
||||
SensorSource::Light => self.evidence_seen.light = Some(now),
|
||||
SensorSource::Touch => self.evidence_seen.touch = Some(now),
|
||||
}
|
||||
|
||||
let health = self.source_health.get_mut(source);
|
||||
let was = *health;
|
||||
*health = SourceHealth::Live;
|
||||
if was == SourceHealth::Down {
|
||||
// Recovery is as diagnostic as the outage: the pair of entries
|
||||
// bounds the window in which every sensor-driven rule was running
|
||||
// on nothing, which is what makes the trail usable after the fact.
|
||||
info!(
|
||||
"[device-state] evidence source {} is reporting again",
|
||||
source.as_str()
|
||||
);
|
||||
self.record_decision(
|
||||
"source-recovered",
|
||||
serde_json::json!({ "source": source.as_str() }),
|
||||
"a source previously recorded as down has resumed reporting",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Real user input. Resets the blank budget, re-arms the rule, and undoes
|
||||
|
|
@ -834,7 +1024,15 @@ impl DeviceStateMachine {
|
|||
}
|
||||
|
||||
/// Build a forensic snapshot of the current state.
|
||||
pub fn snapshot(&self, phase: &str, shell_alive: bool, screen_locked: bool, screen_lock_secure: bool, idle_state: &str, inhibitor_held: bool) -> StateSnapshot {
|
||||
pub fn snapshot(
|
||||
&self,
|
||||
phase: &str,
|
||||
shell_alive: bool,
|
||||
screen_locked: bool,
|
||||
screen_lock_secure: bool,
|
||||
idle_state: &str,
|
||||
inhibitor_held: bool,
|
||||
) -> StateSnapshot {
|
||||
StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
|
|
@ -849,6 +1047,7 @@ impl DeviceStateMachine {
|
|||
screen_lock_secure,
|
||||
idle_coordinator_state: idle_state.to_string(),
|
||||
sleep_inhibitor_held: inhibitor_held,
|
||||
sensors_degraded: self.source_health.any_down(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -887,9 +1086,14 @@ impl DeviceStateMachine {
|
|||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
sensors_degraded: self.source_health.any_down(),
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Transition { from: prev, to: next, legal: false },
|
||||
ForensicEvent::Transition {
|
||||
from: prev,
|
||||
to: next,
|
||||
legal: false,
|
||||
},
|
||||
snapshot,
|
||||
&format!("illegal transition {:?} → {:?} refused", prev, next),
|
||||
);
|
||||
|
|
@ -915,9 +1119,14 @@ impl DeviceStateMachine {
|
|||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
sensors_degraded: self.source_health.any_down(),
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Transition { from: prev, to: next, legal: true },
|
||||
ForensicEvent::Transition {
|
||||
from: prev,
|
||||
to: next,
|
||||
legal: true,
|
||||
},
|
||||
snapshot,
|
||||
&format!("{:?} → {:?}", prev, next),
|
||||
);
|
||||
|
|
@ -997,49 +1206,72 @@ impl DeviceStateMachine {
|
|||
let should_be_observed = self.state == DeviceState::Locked && proximity_near;
|
||||
let confidence = evidence.confidence();
|
||||
|
||||
// A reading identical to the one already held is not a decision point.
|
||||
//
|
||||
// Reporters heartbeat now — they re-send their last value on a fixed
|
||||
// interval so that silence means a dead source rather than a quiet one
|
||||
// (see `SOURCE_DOWN_AFTER`). Those repeats must not each become a trail
|
||||
// entry: proximity alone would add ~2,900 lines a day saying nothing
|
||||
// changed, to a log that §0 already records as unbounded and on tmpfs.
|
||||
// Freshness is still stamped by the caller either way, which is what
|
||||
// the keepalive is actually for.
|
||||
let unchanged = self.sensor_evidence.proximity_near == evidence.proximity_near
|
||||
&& self.sensor_evidence.accel_moving == evidence.accel_moving
|
||||
&& self.sensor_evidence.light_changing == evidence.light_changing
|
||||
&& self.sensor_evidence.touch_active == evidence.touch_active;
|
||||
|
||||
self.sensor_evidence = evidence;
|
||||
|
||||
// Forensic: record the sensor input and its evaluation
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence,
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::SensorInput {
|
||||
// The reporting source, not a hardcoded Proximity. Accel,
|
||||
// light and touch readings were all being written to the trail
|
||||
// labelled as proximity, which makes post-hoc reconstruction —
|
||||
// the entire point of the trail — lie about what was measured.
|
||||
source,
|
||||
value: match source {
|
||||
SensorSource::Proximity => SensorValue::Near(proximity_near),
|
||||
SensorSource::Accelerometer => {
|
||||
SensorValue::Moving(self.sensor_evidence.accel_moving)
|
||||
}
|
||||
SensorSource::Light => {
|
||||
SensorValue::Changing(self.sensor_evidence.light_changing)
|
||||
}
|
||||
SensorSource::Touch => SensorValue::Active(self.sensor_evidence.touch_active),
|
||||
},
|
||||
// Only the trail write is skipped, never the evaluation below. An
|
||||
// identical reading can still change the answer: proximity held `near`
|
||||
// across a lock means `should_be_observed` flips from false to true
|
||||
// with no change in the evidence at all.
|
||||
if !unchanged {
|
||||
// Forensic: record the sensor input and its evaluation
|
||||
let snapshot = StateSnapshot {
|
||||
device_state: self.state,
|
||||
locked: is_locked(self.state),
|
||||
display_active: is_display_active(self.state),
|
||||
phase: String::new(),
|
||||
shell_alive: false,
|
||||
proximity_near: self.sensor_evidence.proximity_near,
|
||||
confidence,
|
||||
},
|
||||
snapshot,
|
||||
&format!(
|
||||
"proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
|
||||
proximity_near, confidence, should_be_observed, was_observed
|
||||
),
|
||||
);
|
||||
suppress_dpms_wake: self.sensor_evidence.should_suppress_dpms_wake(),
|
||||
promote_idle_faster: self.sensor_evidence.should_promote_idle_faster(),
|
||||
screen_locked: false,
|
||||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
sensors_degraded: self.source_health.any_down(),
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::SensorInput {
|
||||
// The reporting source, not a hardcoded Proximity. Accel,
|
||||
// light and touch readings were all being written to the trail
|
||||
// labelled as proximity, which makes post-hoc reconstruction —
|
||||
// the entire point of the trail — lie about what was measured.
|
||||
source,
|
||||
value: match source {
|
||||
SensorSource::Proximity => SensorValue::Near(proximity_near),
|
||||
SensorSource::Accelerometer => {
|
||||
SensorValue::Moving(self.sensor_evidence.accel_moving)
|
||||
}
|
||||
SensorSource::Light => {
|
||||
SensorValue::Changing(self.sensor_evidence.light_changing)
|
||||
}
|
||||
SensorSource::Touch => {
|
||||
SensorValue::Active(self.sensor_evidence.touch_active)
|
||||
}
|
||||
},
|
||||
confidence,
|
||||
},
|
||||
snapshot,
|
||||
&format!(
|
||||
"proximity={}, confidence={:.2}, should_observe={}, was_observed={}",
|
||||
proximity_near, confidence, should_be_observed, was_observed
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if should_be_observed && !was_observed {
|
||||
self.transition(DeviceState::Observed);
|
||||
|
|
@ -1064,12 +1296,10 @@ impl DeviceStateMachine {
|
|||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
sensors_degraded: self.source_health.any_down(),
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Wake { trigger },
|
||||
snapshot,
|
||||
reason,
|
||||
);
|
||||
self.forensic
|
||||
.append(ForensicEvent::Wake { trigger }, snapshot, reason);
|
||||
}
|
||||
|
||||
/// Record an error that affected device state.
|
||||
|
|
@ -1088,6 +1318,7 @@ impl DeviceStateMachine {
|
|||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
sensors_degraded: self.source_health.any_down(),
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Error {
|
||||
|
|
@ -1116,6 +1347,7 @@ impl DeviceStateMachine {
|
|||
screen_lock_secure: false,
|
||||
idle_coordinator_state: String::new(),
|
||||
sleep_inhibitor_held: false,
|
||||
sensors_degraded: self.source_health.any_down(),
|
||||
};
|
||||
self.forensic.append(
|
||||
ForensicEvent::Decision {
|
||||
|
|
@ -1141,12 +1373,28 @@ impl DeviceStateMachine {
|
|||
"idle_secs": self.idle_secs(),
|
||||
"dimmed": self.dimmed,
|
||||
"blank_requested": self.blank_requested,
|
||||
// Freshness means "reported within the TTL", not "has ever been
|
||||
// heard from". The `is_some()` version read as fresh for the whole
|
||||
// life of the daemon, because `expire_stale_evidence` only clears
|
||||
// the timestamp for a source whose flag was set — so a sensor
|
||||
// resting at `far` reported `fresh: true` indefinitely, including
|
||||
// through the outage this field exists to make visible.
|
||||
"evidence_fresh": {
|
||||
"proximity": self.evidence_seen.proximity.is_some(),
|
||||
"accel": self.evidence_seen.accel.is_some(),
|
||||
"light": self.evidence_seen.light.is_some(),
|
||||
"touch": self.evidence_seen.touch.is_some(),
|
||||
"proximity": self.is_fresh(self.evidence_seen.proximity),
|
||||
"accel": self.is_fresh(self.evidence_seen.accel),
|
||||
"light": self.is_fresh(self.evidence_seen.light),
|
||||
"touch": self.is_fresh(self.evidence_seen.touch),
|
||||
},
|
||||
// Health is the other axis: `fresh` says whether the reading may
|
||||
// be believed, `health` says whether the source is there at all.
|
||||
"sensor_health": self.source_health.as_json(),
|
||||
"sensors_degraded": self.source_health.any_down(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_fresh(&self, seen: Option<Instant>) -> bool {
|
||||
seen.is_some_and(|t| {
|
||||
Instant::now().saturating_duration_since(t) <= self.policy.evidence_ttl
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1279,10 +1527,7 @@ mod tests {
|
|||
|
||||
// The budget expires: the machine wants the panel dark, but the
|
||||
// session is unlocked, so the lock goes first — LOCK-DPMS §1.
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + Duration::from_secs(31)),
|
||||
vec![Action::Lock]
|
||||
);
|
||||
assert_eq!(sm.tick_at(t0 + Duration::from_secs(31)), vec![Action::Lock]);
|
||||
assert!(sm.pending_blank.is_some());
|
||||
assert!(!sm.blank_requested, "the panel must not be dark yet");
|
||||
}
|
||||
|
|
@ -1411,9 +1656,7 @@ mod tests {
|
|||
let tap = t0 + LOCK_BLANK_AFTER * 10;
|
||||
sm.note_idle_start(tap);
|
||||
let at_old_deadline = tap + LOCK_BLANK_AFTER - Duration::from_secs(1);
|
||||
assert!(!sm
|
||||
.tick_at(at_old_deadline)
|
||||
.contains(&Action::Blank));
|
||||
assert!(!sm.tick_at(at_old_deadline).contains(&Action::Blank));
|
||||
|
||||
// And it does eventually blank, one full budget after the tap.
|
||||
assert_eq!(
|
||||
|
|
@ -1779,13 +2022,17 @@ mod tests {
|
|||
ForensicEvent::Transition { from, to, legal } => {
|
||||
format!("transition {:?} → {:?} (legal={})", from, to, legal)
|
||||
}
|
||||
ForensicEvent::SensorInput { source, confidence, .. } => {
|
||||
ForensicEvent::SensorInput {
|
||||
source, confidence, ..
|
||||
} => {
|
||||
format!("sensor {:?} conf={:.2}", source, confidence)
|
||||
}
|
||||
ForensicEvent::Wake { trigger } => {
|
||||
format!("wake {:?}", trigger)
|
||||
}
|
||||
ForensicEvent::Error { component, action, .. } => {
|
||||
ForensicEvent::Error {
|
||||
component, action, ..
|
||||
} => {
|
||||
format!("error [{}] {}", component, action)
|
||||
}
|
||||
ForensicEvent::Decision { decision, .. } => {
|
||||
|
|
@ -1807,16 +2054,41 @@ mod tests {
|
|||
println!("=== END FORENSIC LOG ===\n");
|
||||
|
||||
// Verify we captured the key events
|
||||
assert!(entries.len() >= 12, "expected at least 12 forensic entries, got {}", entries.len());
|
||||
assert!(
|
||||
entries.len() >= 12,
|
||||
"expected at least 12 forensic entries, got {}",
|
||||
entries.len()
|
||||
);
|
||||
// Verify transitions were captured
|
||||
let transitions: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Transition { .. })).collect();
|
||||
assert!(transitions.len() >= 8, "expected at least 8 transitions, got {}", transitions.len());
|
||||
let transitions: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event, ForensicEvent::Transition { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
transitions.len() >= 8,
|
||||
"expected at least 8 transitions, got {}",
|
||||
transitions.len()
|
||||
);
|
||||
// Verify sensor inputs were captured
|
||||
let sensors: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
|
||||
assert!(sensors.len() >= 2, "expected at least 2 sensor inputs, got {}", sensors.len());
|
||||
let sensors: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
sensors.len() >= 2,
|
||||
"expected at least 2 sensor inputs, got {}",
|
||||
sensors.len()
|
||||
);
|
||||
// Verify wake events were captured
|
||||
let wakes: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::Wake { .. })).collect();
|
||||
assert!(wakes.len() >= 2, "expected at least 2 wake events, got {}", wakes.len());
|
||||
let wakes: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event, ForensicEvent::Wake { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
wakes.len() >= 2,
|
||||
"expected at least 2 wake events, got {}",
|
||||
wakes.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1825,11 +2097,18 @@ mod tests {
|
|||
// Try an illegal transition: Active → DozeLight (must go through Locked)
|
||||
assert!(!sm.transition(DeviceState::DozeLight));
|
||||
let entries = sm.forensic.recent(10);
|
||||
let refused: Vec<_> = entries.iter().filter(|e| match &e.event {
|
||||
ForensicEvent::Transition { legal, .. } => !legal,
|
||||
_ => false,
|
||||
}).collect();
|
||||
assert_eq!(refused.len(), 1, "expected 1 refused transition in forensic log");
|
||||
let refused: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| match &e.event {
|
||||
ForensicEvent::Transition { legal, .. } => !legal,
|
||||
_ => false,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
refused.len(),
|
||||
1,
|
||||
"expected 1 refused transition in forensic log"
|
||||
);
|
||||
match &refused[0].event {
|
||||
ForensicEvent::Transition { from, to, legal } => {
|
||||
assert_eq!(*from, DeviceState::Active);
|
||||
|
|
@ -1853,14 +2132,193 @@ mod tests {
|
|||
touch_active: false,
|
||||
});
|
||||
let entries = sm.forensic.recent(10);
|
||||
let sensor_entries: Vec<_> = entries.iter().filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. })).collect();
|
||||
let sensor_entries: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event, ForensicEvent::SensorInput { .. }))
|
||||
.collect();
|
||||
assert!(!sensor_entries.is_empty());
|
||||
// The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5)
|
||||
match &sensor_entries[0].event {
|
||||
ForensicEvent::SensorInput { confidence, .. } => {
|
||||
assert!((confidence - 0.5).abs() < 0.01, "expected 0.5 confidence, got {}", confidence);
|
||||
assert!(
|
||||
(confidence - 0.5).abs() < 0.01,
|
||||
"expected 0.5 confidence, got {}",
|
||||
confidence
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Evidence-source health ───────────────────────────────────────
|
||||
// The distinction these cover is DEVICE-STATE-MACHINE.md §0's: "'No
|
||||
// evidence' and 'evidence says nothing is happening' must not be the same
|
||||
// state." The 2026-07-25 outage ran four hours with nothing in any log.
|
||||
|
||||
#[test]
|
||||
fn a_source_that_never_reported_is_unknown_not_down() {
|
||||
// A light sensor with no reporter installed is silent, correctly,
|
||||
// forever. Calling that an outage would make the signal worthless.
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let t0 = Instant::now();
|
||||
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(60));
|
||||
assert_eq!(sm.source_health.light, SourceHealth::Unknown);
|
||||
assert_eq!(sm.source_health.proximity, SourceHealth::Unknown);
|
||||
assert!(!sm.source_health.any_down());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_source_that_reported_and_went_silent_is_down_and_loud() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let t0 = Instant::now();
|
||||
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
||||
|
||||
// Still within the window: quiet is allowed.
|
||||
sm.tick_at(t0 + SOURCE_DOWN_AFTER - Duration::from_secs(1));
|
||||
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
|
||||
|
||||
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
|
||||
assert_eq!(sm.source_health.proximity, SourceHealth::Down);
|
||||
assert!(sm.source_health.any_down());
|
||||
|
||||
// The outage is an error in the trail, not just a state field —
|
||||
// otherwise nothing after the fact can find the window.
|
||||
let errors: Vec<_> = sm
|
||||
.forensic
|
||||
.recent(50)
|
||||
.into_iter()
|
||||
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-down"))
|
||||
.collect();
|
||||
assert_eq!(errors.len(), 1, "expected exactly one source-down entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_down_source_is_recorded_once_not_every_tick() {
|
||||
// A tick loop that re-logged this every second would bury the
|
||||
// transition under 3600 identical lines an hour.
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let t0 = Instant::now();
|
||||
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
||||
for i in 0..10 {
|
||||
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1 + i));
|
||||
}
|
||||
let errors = sm
|
||||
.forensic
|
||||
.recent(50)
|
||||
.into_iter()
|
||||
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-down"))
|
||||
.count();
|
||||
assert_eq!(errors, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_source_goes_live_and_bounds_the_outage() {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let t0 = Instant::now();
|
||||
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
||||
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
|
||||
assert_eq!(sm.source_health.proximity, SourceHealth::Down);
|
||||
|
||||
sm.mark_evidence_seen_at(SensorSource::Proximity, t0 + Duration::from_secs(200));
|
||||
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
|
||||
assert!(!sm.source_health.any_down());
|
||||
|
||||
// The recovery entry is what closes the interval. Without it the trail
|
||||
// says when the sensors died and never says when they came back.
|
||||
let recovered = sm
|
||||
.forensic
|
||||
.recent(50)
|
||||
.into_iter()
|
||||
.filter(|e| matches!(&e.event, ForensicEvent::Decision { decision, .. } if decision == "source-recovered"))
|
||||
.count();
|
||||
assert_eq!(recovered, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decisions_taken_during_an_outage_are_stamped_degraded() {
|
||||
// The point of the whole feature: a panel-off recorded during four
|
||||
// dead hours must not read like a healthy one.
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let t0 = Instant::now();
|
||||
sm.mark_evidence_seen_at(SensorSource::Proximity, t0);
|
||||
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
|
||||
|
||||
sm.record_decision("panel-off", serde_json::json!({}), "test");
|
||||
let entry = sm
|
||||
.forensic
|
||||
.recent(1)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("a decision was just recorded");
|
||||
assert!(
|
||||
entry.snapshot.sensors_degraded,
|
||||
"a decision taken while a source is down must say so"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freshness_expires_rather_than_meaning_ever_seen() {
|
||||
// `evidence_fresh` used to be `is_some()`, which read true for the
|
||||
// life of the daemon and stayed true straight through an outage.
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.mark_evidence_seen_at(
|
||||
SensorSource::Proximity,
|
||||
Instant::now() - (EVIDENCE_TTL + Duration::from_secs(5)),
|
||||
);
|
||||
let json = sm.to_ipc_json();
|
||||
assert_eq!(
|
||||
json["evidence_fresh"]["proximity"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
|
||||
sm.mark_evidence_seen(SensorSource::Proximity);
|
||||
let json = sm.to_ipc_json();
|
||||
assert_eq!(json["evidence_fresh"]["proximity"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_reading_is_not_a_trail_entry() {
|
||||
// Reporters heartbeat every 30s so silence is meaningful. If each
|
||||
// repeat were logged, proximity alone would add ~2,900 lines a day to
|
||||
// an unbounded tmpfs file, all of them saying nothing happened.
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
sm.transition(DeviceState::Locked);
|
||||
let near = SensorEvidence {
|
||||
proximity_near: true,
|
||||
..Default::default()
|
||||
};
|
||||
sm.update_sensors(near.clone());
|
||||
let after_first = sm.forensic.recent(100).len();
|
||||
|
||||
for _ in 0..5 {
|
||||
sm.update_sensors(near.clone());
|
||||
}
|
||||
assert_eq!(
|
||||
sm.forensic.recent(100).len(),
|
||||
after_first,
|
||||
"identical readings must not each append to the trail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_reading_still_reevaluates_observed() {
|
||||
// The dedupe skips the trail write, never the decision. Proximity held
|
||||
// `near` across a lock flips should_be_observed with no change in the
|
||||
// evidence at all — an early return here would strand the machine.
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
let near = SensorEvidence {
|
||||
proximity_near: true,
|
||||
..Default::default()
|
||||
};
|
||||
sm.update_sensors(near.clone());
|
||||
assert_eq!(sm.state, DeviceState::Active);
|
||||
|
||||
sm.transition(DeviceState::Locked);
|
||||
sm.update_sensors(near);
|
||||
assert_eq!(
|
||||
sm.state,
|
||||
DeviceState::Observed,
|
||||
"an unchanged reading must still be re-evaluated against the new state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,20 @@ pub enum SensorSource {
|
|||
Touch,
|
||||
}
|
||||
|
||||
impl SensorSource {
|
||||
/// The name this source is known by in the forensic trail and in logs.
|
||||
/// Matches the `evidence_fresh` / `sensor_health` keys so a reader can
|
||||
/// join them without a translation table.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
SensorSource::Proximity => "proximity",
|
||||
SensorSource::Accelerometer => "accel",
|
||||
SensorSource::Light => "light",
|
||||
SensorSource::Touch => "touch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SensorValue {
|
||||
|
|
|
|||
Loading…
Reference in a new issue