Skip to main content

sim_lib_view_device/
attention.rs

1//! Body-level attention arbitration shared by worn projections.
2
3// conformance: attention tests prove quiet hours, coalescing, budgets, and manual continuation.
4
5use std::collections::VecDeque;
6
7/// A bounded prompt offered by a channel projection.
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct Prompt {
10    /// Stable coalescing key.
11    pub key: String,
12    /// Human-visible reduced summary.
13    pub summary: String,
14}
15
16/// Explicit evidence explaining an attention decision.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct AttentionEvidence {
19    /// Prompts combined behind this decision.
20    pub coalesced: usize,
21    /// Interruptions already spent in the current window.
22    pub interruptions_spent: u32,
23    /// Human-readable policy reason.
24    pub reason: &'static str,
25}
26
27/// Normal outcomes of projecting a prompt.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum AttentionDecision {
30    /// Exactly one body-level prompt is visible.
31    Present(Prompt, AttentionEvidence),
32    /// Work remains available for manual continuation without interruption.
33    ContinueManually(AttentionEvidence),
34    /// Silence is the correct projection.
35    Silent(AttentionEvidence),
36}
37
38/// Attention limits supplied by local user policy.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct AttentionPolicy {
41    /// Inclusive start hour of quiet time.
42    pub quiet_start_hour: u8,
43    /// Exclusive end hour of quiet time.
44    pub quiet_end_hour: u8,
45    /// Maximum body interruptions in one policy window.
46    pub max_interruptions: u32,
47}
48
49/// Stateful arbiter that admits at most one active body prompt.
50#[derive(Debug)]
51pub struct AttentionProjector {
52    policy: AttentionPolicy,
53    active: Option<Prompt>,
54    pending: VecDeque<Prompt>,
55    interruptions: u32,
56}
57
58impl AttentionProjector {
59    /// Creates an empty projector.
60    pub fn new(policy: AttentionPolicy) -> Self {
61        Self {
62            policy,
63            active: None,
64            pending: VecDeque::new(),
65            interruptions: 0,
66        }
67    }
68
69    /// Offers a reduced prompt; offline, dropped, and quiet channels stay silent.
70    pub fn offer(&mut self, prompt: Prompt, hour: u8, online: bool) -> AttentionDecision {
71        if !online {
72            return self.silent("offline-or-dropped");
73        }
74        if self.is_quiet(hour) {
75            self.coalesce(prompt);
76            return self.silent("quiet-hours");
77        }
78        if self.interruptions >= self.policy.max_interruptions {
79            self.coalesce(prompt);
80            return AttentionDecision::ContinueManually(self.evidence("interruption-budget-spent"));
81        }
82        if self.active.is_some() {
83            if self
84                .active
85                .as_ref()
86                .is_some_and(|active| active.key == prompt.key)
87            {
88                self.active.as_mut().expect("active checked above").summary = prompt.summary;
89            } else {
90                self.coalesce(prompt);
91            }
92            return AttentionDecision::Present(
93                self.active.clone().expect("active checked above"),
94                self.evidence("one-active-prompt"),
95            );
96        }
97        self.interruptions += 1;
98        self.active = Some(prompt.clone());
99        AttentionDecision::Present(prompt, self.evidence("admitted"))
100    }
101
102    /// Acknowledges the active prompt without automatically interrupting again.
103    pub fn acknowledge(&mut self) {
104        self.active = None;
105    }
106
107    fn coalesce(&mut self, prompt: Prompt) {
108        if let Some(existing) = self.pending.iter_mut().find(|p| p.key == prompt.key) {
109            existing.summary = prompt.summary;
110        } else {
111            self.pending.push_back(prompt);
112        }
113    }
114
115    fn is_quiet(&self, hour: u8) -> bool {
116        let start = self.policy.quiet_start_hour;
117        let end = self.policy.quiet_end_hour;
118        if start <= end {
119            hour >= start && hour < end
120        } else {
121            hour >= start || hour < end
122        }
123    }
124
125    fn evidence(&self, reason: &'static str) -> AttentionEvidence {
126        AttentionEvidence {
127            coalesced: self.pending.len(),
128            interruptions_spent: self.interruptions,
129            reason,
130        }
131    }
132
133    fn silent(&self, reason: &'static str) -> AttentionDecision {
134        AttentionDecision::Silent(self.evidence(reason))
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn projector(max: u32) -> AttentionProjector {
143        AttentionProjector::new(AttentionPolicy {
144            quiet_start_hour: 22,
145            quiet_end_hour: 7,
146            max_interruptions: max,
147        })
148    }
149
150    #[test]
151    fn burst_never_exceeds_one_body_level_prompt() {
152        let mut p = projector(2);
153        for n in 0..50 {
154            let decision = p.offer(
155                Prompt {
156                    key: format!("job-{}", n % 3),
157                    summary: format!("update-{n}"),
158                },
159                12,
160                true,
161            );
162            let AttentionDecision::Present(_, evidence) = decision else {
163                panic!("active prompt must remain visible")
164            };
165            assert!(evidence.interruptions_spent <= 1);
166        }
167    }
168
169    #[test]
170    fn silence_offline_quiet_and_manual_continuation_are_normal() {
171        let prompt = Prompt {
172            key: "mission".into(),
173            summary: "ready".into(),
174        };
175        let mut p = projector(1);
176        assert!(matches!(
177            p.offer(prompt.clone(), 12, false),
178            AttentionDecision::Silent(_)
179        ));
180        assert!(matches!(
181            p.offer(prompt.clone(), 23, true),
182            AttentionDecision::Silent(_)
183        ));
184        assert!(matches!(
185            p.offer(prompt.clone(), 12, true),
186            AttentionDecision::Present(_, _)
187        ));
188        p.acknowledge();
189        assert!(matches!(
190            p.offer(prompt, 12, true),
191            AttentionDecision::ContinueManually(_)
192        ));
193    }
194}