1use ralph_proto::{Event, HatId};
8use std::collections::{HashMap, HashSet};
9use std::hash::{DefaultHasher, Hash, Hasher};
10use std::time::{Duration, Instant};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct EventSignature {
15 pub topic: String,
16 pub source: Option<HatId>,
17 pub payload_fingerprint: u64,
18}
19
20#[derive(Debug)]
22pub struct LoopState {
23 pub iteration: u32,
25 pub consecutive_failures: u32,
27 pub cumulative_cost: f64,
29 pub started_at: Instant,
31 pub last_hat: Option<HatId>,
33 pub consecutive_blocked: u32,
35 pub last_blocked_hat: Option<HatId>,
37 pub task_block_counts: HashMap<String, u32>,
39 pub abandoned_tasks: Vec<String>,
41 pub abandoned_task_redispatches: u32,
43 pub consecutive_malformed_events: u32,
45 pub completion_requested: bool,
47
48 pub hat_activation_counts: HashMap<HatId, u32>,
50
51 pub exhausted_hats: HashSet<HatId>,
53
54 pub last_checkin_at: Option<Instant>,
57
58 pub last_active_hat_ids: Vec<HatId>,
61
62 pub seen_topics: HashSet<String>,
64
65 pub last_emitted_signature: Option<EventSignature>,
67
68 pub consecutive_same_signature: u32,
70
71 pub cancellation_requested: bool,
73
74 pub peak_input_tokens: u64,
76
77 pub last_input_tokens: Option<u64>,
79
80 pub hat_peak_input_tokens: HashMap<HatId, u64>,
82
83 pub unacknowledged_guidance: Vec<String>,
85}
86
87impl Default for LoopState {
88 fn default() -> Self {
89 Self {
90 iteration: 0,
91 consecutive_failures: 0,
92 cumulative_cost: 0.0,
93 started_at: Instant::now(),
94 last_hat: None,
95 consecutive_blocked: 0,
96 last_blocked_hat: None,
97 task_block_counts: HashMap::new(),
98 abandoned_tasks: Vec::new(),
99 abandoned_task_redispatches: 0,
100 consecutive_malformed_events: 0,
101 completion_requested: false,
102 hat_activation_counts: HashMap::new(),
103 exhausted_hats: HashSet::new(),
104 last_checkin_at: None,
105 last_active_hat_ids: Vec::new(),
106 seen_topics: HashSet::new(),
107 last_emitted_signature: None,
108 consecutive_same_signature: 0,
109 cancellation_requested: false,
110 peak_input_tokens: 0,
111 last_input_tokens: None,
112 hat_peak_input_tokens: HashMap::new(),
113 unacknowledged_guidance: Vec::new(),
114 }
115 }
116}
117
118impl LoopState {
119 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn elapsed(&self) -> Duration {
126 self.started_at.elapsed()
127 }
128
129 fn event_counts_toward_stale_loop(event: &Event) -> bool {
130 !matches!(event.topic.as_str(), "task.complete")
131 }
132
133 pub fn record_event(&mut self, event: &Event) {
137 self.seen_topics.insert(event.topic.to_string());
138
139 if !Self::event_counts_toward_stale_loop(event) {
140 self.consecutive_same_signature = 0;
141 self.last_emitted_signature = Some(EventSignature::from_event(event));
142 return;
143 }
144
145 let signature = EventSignature::from_event(event);
146 if self.last_emitted_signature.as_ref() == Some(&signature) {
147 self.consecutive_same_signature += 1;
148 } else {
149 self.consecutive_same_signature = 1;
150 self.last_emitted_signature = Some(signature);
151 }
152 }
153
154 pub fn record_iteration_tokens(&mut self, hat: &HatId, tokens: u64) {
160 if tokens == 0 {
161 return;
162 }
163 let entry = self.hat_peak_input_tokens.entry(hat.clone()).or_insert(0);
164 *entry = (*entry).max(tokens);
165 self.peak_input_tokens = self.peak_input_tokens.max(tokens);
166 self.last_input_tokens = Some(tokens);
167 }
168
169 pub fn missing_required_events<'a>(&self, required: &'a [String]) -> Vec<&'a String> {
171 required
172 .iter()
173 .filter(|topic| !self.seen_topics.contains(topic.as_str()))
174 .collect()
175 }
176}
177
178impl EventSignature {
179 pub fn from_event(event: &Event) -> Self {
180 Self {
181 topic: event.topic.to_string(),
182 source: event.source.clone(),
183 payload_fingerprint: fingerprint_payload(&event.payload),
184 }
185 }
186}
187
188fn fingerprint_payload(payload: &str) -> u64 {
189 let mut hasher = DefaultHasher::new();
190 payload.hash(&mut hasher);
191 hasher.finish()
192}
193
194#[cfg(test)]
195mod tests {
196 use super::LoopState;
197 use ralph_proto::{Event, HatId};
198
199 #[test]
200 fn repeated_task_complete_does_not_accumulate_stale_loop_count() {
201 let mut state = LoopState::new();
202
203 state.record_event(&Event::new("task.complete", "task 1 complete"));
204 assert_eq!(state.consecutive_same_signature, 0);
205
206 state.record_event(&Event::new("task.complete", "task 2 complete"));
207 state.record_event(&Event::new("task.complete", "task 3 complete"));
208
209 assert_eq!(state.consecutive_same_signature, 0);
210 assert_eq!(
211 state
212 .last_emitted_signature
213 .as_ref()
214 .map(|s| s.topic.as_str()),
215 Some("task.complete")
216 );
217 }
218
219 #[test]
220 fn record_iteration_tokens_tracks_per_hat_and_global_peak() {
221 let mut state = LoopState::new();
222 let builder = HatId::new("builder");
223 let critic = HatId::new("critic");
224
225 state.record_iteration_tokens(&builder, 10_000);
226 assert_eq!(
227 state.hat_peak_input_tokens.get(&builder).copied(),
228 Some(10_000)
229 );
230 assert_eq!(state.peak_input_tokens, 10_000);
231 assert_eq!(state.last_input_tokens, Some(10_000));
232
233 state.record_iteration_tokens(&builder, 5_000);
234 assert_eq!(
235 state.hat_peak_input_tokens.get(&builder).copied(),
236 Some(10_000),
237 "per-hat peak must retain the max across iterations"
238 );
239 assert_eq!(
240 state.peak_input_tokens, 10_000,
241 "global peak must retain the overall max"
242 );
243 assert_eq!(
244 state.last_input_tokens,
245 Some(5_000),
246 "last_input_tokens tracks the most recent iteration, not the peak"
247 );
248
249 state.record_iteration_tokens(&critic, 25_000);
250 assert_eq!(
251 state.hat_peak_input_tokens.get(&builder).copied(),
252 Some(10_000)
253 );
254 assert_eq!(
255 state.hat_peak_input_tokens.get(&critic).copied(),
256 Some(25_000)
257 );
258 assert_eq!(state.peak_input_tokens, 25_000);
259 assert_eq!(state.last_input_tokens, Some(25_000));
260
261 state.record_iteration_tokens(&builder, 0);
262 assert_eq!(
263 state.hat_peak_input_tokens.get(&builder).copied(),
264 Some(10_000),
265 "zero-token iteration must be a no-op"
266 );
267 assert_eq!(state.peak_input_tokens, 25_000);
268 assert_eq!(
269 state.last_input_tokens,
270 Some(25_000),
271 "zero tokens must not overwrite last_input_tokens"
272 );
273 }
274
275 #[test]
276 fn repeated_non_progress_topics_still_accumulate_stale_loop_count() {
277 let mut state = LoopState::new();
278
279 state.record_event(&Event::new("task.resume", "same payload"));
280 state.record_event(&Event::new("task.resume", "same payload"));
281 state.record_event(&Event::new("task.resume", "same payload"));
282
283 assert_eq!(state.consecutive_same_signature, 3);
284 assert_eq!(
285 state
286 .last_emitted_signature
287 .as_ref()
288 .map(|s| s.topic.as_str()),
289 Some("task.resume")
290 );
291 }
292}