sim_lib_view_device/
attention.rs1use std::collections::VecDeque;
6
7#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct Prompt {
10 pub key: String,
12 pub summary: String,
14}
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct AttentionEvidence {
19 pub coalesced: usize,
21 pub interruptions_spent: u32,
23 pub reason: &'static str,
25}
26
27#[derive(Clone, Debug, Eq, PartialEq)]
29pub enum AttentionDecision {
30 Present(Prompt, AttentionEvidence),
32 ContinueManually(AttentionEvidence),
34 Silent(AttentionEvidence),
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct AttentionPolicy {
41 pub quiet_start_hour: u8,
43 pub quiet_end_hour: u8,
45 pub max_interruptions: u32,
47}
48
49#[derive(Debug)]
51pub struct AttentionProjector {
52 policy: AttentionPolicy,
53 active: Option<Prompt>,
54 pending: VecDeque<Prompt>,
55 interruptions: u32,
56}
57
58impl AttentionProjector {
59 pub fn new(policy: AttentionPolicy) -> Self {
61 Self {
62 policy,
63 active: None,
64 pending: VecDeque::new(),
65 interruptions: 0,
66 }
67 }
68
69 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 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}