1use std::collections::HashMap;
24
25use jiff::civil::Date;
26use jiff::Timestamp;
27use serde::Serialize;
28
29use crate::analysis::{utc_date, EST_CHARS_PER_TOKEN};
30use crate::model::{EventKind, Session, Usage};
31
32#[derive(Debug, Clone, Serialize)]
34pub struct UsageRecord {
35 pub session_id: String,
36 pub parent_session: Option<String>,
38 pub project: Option<String>,
39 pub request_id: Option<String>,
40 pub model: Option<String>,
41 pub ts: Option<Timestamp>,
42 pub usage: Usage,
43 pub sidechain: bool,
45 pub thinking_chars: u64,
51 pub thinking_encrypted: bool,
56 pub has_thinking: bool,
58}
59
60#[derive(Debug, Default, Clone, Serialize)]
62pub struct DedupStats {
63 pub duplicate_lines_collapsed: u64,
66 pub naive_known_tokens: u64,
69 pub requests_with_thinking: u64,
71 pub requests_with_encrypted_thinking: u64,
74 pub thinking_chars_total: u64,
78}
79
80impl DedupStats {
81 pub fn thinking_tokens_estimate(&self) -> u64 {
87 self.thinking_chars_total / EST_CHARS_PER_TOKEN
88 }
89}
90
91#[derive(Default)]
92struct Acc {
93 usage: Usage,
94 model: Option<String>,
95 request_id: Option<String>,
96 ts: Option<Timestamp>,
97 lines: u64,
98 thinking_chars: u64,
99 has_thinking: bool,
100 sidechain: bool,
101}
102
103pub fn dedup_session(session: &Session, since: Option<Date>) -> (Vec<UsageRecord>, DedupStats) {
109 let mut stats = DedupStats::default();
110 let mut keys: Vec<String> = Vec::new();
111 let mut groups: HashMap<String, Acc> = HashMap::new();
112
113 for (idx, event) in session.events.iter().enumerate() {
114 if event.kind != EventKind::Assistant {
115 continue;
116 }
117 if let Some(since) = since {
118 match event.ts {
119 Some(ts) if utc_date(ts) >= since => {}
120 _ => continue,
121 }
122 }
123 let Some(usage) = event.usage else { continue };
126 stats.naive_known_tokens += usage.known_total();
127
128 let key = event
130 .request_id
131 .clone()
132 .unwrap_or_else(|| format!("\u{0}line:{idx}"));
133 let acc = groups.entry(key.clone()).or_insert_with(|| {
134 keys.push(key);
135 Acc::default()
136 });
137 if acc.lines > 0 {
138 stats.duplicate_lines_collapsed += 1;
139 }
140 acc.lines += 1;
141 acc.usage = acc.usage.merge_max(usage);
142 if acc.model.is_none() {
143 acc.model.clone_from(&event.model);
144 }
145 if acc.request_id.is_none() {
146 acc.request_id.clone_from(&event.request_id);
147 }
148 acc.ts = match (acc.ts, event.ts) {
149 (Some(a), Some(b)) => Some(a.min(b)),
150 (a, b) => a.or(b),
151 };
152 acc.thinking_chars += event.thinking_chars;
154 acc.has_thinking |= event.has_thinking;
155 acc.sidechain |= event.sidechain;
156 }
157
158 let records = keys
159 .into_iter()
160 .filter_map(|key| {
161 let acc = groups.remove(&key)?;
162 let thinking_chars = acc.thinking_chars;
163 let thinking_encrypted = acc.has_thinking && thinking_chars == 0;
166 if acc.has_thinking {
167 stats.requests_with_thinking += 1;
168 }
169 if thinking_encrypted {
170 stats.requests_with_encrypted_thinking += 1;
171 }
172 stats.thinking_chars_total += thinking_chars;
173 Some(UsageRecord {
174 session_id: session.id.clone(),
175 parent_session: session.parent_session.clone(),
176 project: session.project.clone(),
177 request_id: acc.request_id,
178 model: acc.model,
179 ts: acc.ts,
180 usage: acc.usage,
181 sidechain: acc.sidechain || session.parent_session.is_some(),
182 thinking_chars,
183 thinking_encrypted,
184 has_thinking: acc.has_thinking,
185 })
186 })
187 .collect();
188
189 (records, stats)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::model::Event;
196
197 fn usage(input: u64, output: u64, cc: u64, cr: u64) -> Usage {
198 Usage {
199 input: Some(input),
200 output: Some(output),
201 cache_creation: Some(cc),
202 cache_read: Some(cr),
203 ..Usage::default()
204 }
205 }
206
207 fn assistant(request_id: Option<&str>, u: Option<Usage>) -> Event {
208 Event {
209 kind: EventKind::Assistant,
210 ts: Some("2026-06-01T10:00:00Z".parse().unwrap()),
211 request_id: request_id.map(str::to_string),
212 model: Some("claude-sonnet-4-5".into()),
213 usage: u,
214 tool_calls: Vec::new(),
215 sidechain: false,
216 content_summary: None,
217 content_chars: 0,
218 thinking_chars: 0,
219 has_thinking: false,
220 tool_use_id: None,
221 attachment_kind: None,
222 item_count: 0,
223 }
224 }
225
226 fn session(events: Vec<Event>) -> Session {
227 Session {
228 id: "s1".into(),
229 agent: "claude-code".into(),
230 project: Some("proj".into()),
231 model: None,
232 parent_session: None,
233 started_at: None,
234 ended_at: None,
235 events,
236 sub_agents: Vec::new(),
237 skipped_lines: 0,
238 }
239 }
240
241 #[test]
244 fn identical_duplicate_lines_collapse_to_one_request() {
245 let u = usage(1000, 200, 300, 5000);
246 let s = session(vec![
247 assistant(Some("req_1"), Some(u)),
248 assistant(Some("req_1"), Some(u)),
249 assistant(Some("req_1"), Some(u)),
250 ]);
251 let (records, stats) = dedup_session(&s, None);
252 assert_eq!(records.len(), 1);
253 assert_eq!(records[0].usage, u, "deduped == single request's usage");
254 assert_eq!(stats.duplicate_lines_collapsed, 2);
255 assert_eq!(stats.naive_known_tokens, 3 * u.known_total());
256 }
257
258 #[test]
260 fn streaming_partials_take_field_wise_max() {
261 let s = session(vec![
262 assistant(Some("req_1"), Some(usage(1000, 50, 300, 5000))),
263 assistant(Some("req_1"), Some(usage(1000, 120, 300, 5000))),
264 assistant(Some("req_1"), Some(usage(1000, 200, 300, 5000))),
265 ]);
266 let (records, _) = dedup_session(&s, None);
267 assert_eq!(records.len(), 1);
268 assert_eq!(records[0].usage.output, Some(200));
269 assert_eq!(records[0].usage.input, Some(1000), "input not multiplied");
270 }
271
272 #[test]
273 fn lines_without_request_id_are_never_merged() {
274 let u = usage(10, 5, 0, 0);
275 let s = session(vec![assistant(None, Some(u)), assistant(None, Some(u))]);
276 let (records, stats) = dedup_session(&s, None);
277 assert_eq!(records.len(), 2);
278 assert_eq!(stats.duplicate_lines_collapsed, 0);
279 }
280
281 #[test]
282 fn missing_usage_is_unknown_not_zero() {
283 let s = session(vec![assistant(Some("req_err"), None)]);
284 let (records, stats) = dedup_session(&s, None);
285 assert!(
286 records.is_empty(),
287 "no usage -> no record, not a zero record"
288 );
289 assert_eq!(stats.naive_known_tokens, 0);
290 }
291
292 #[test]
296 fn thinking_is_attributed_and_encrypted_is_flagged() {
297 let mut visible = assistant(Some("req_vis"), Some(usage(1500, 800, 0, 0)));
299 visible.has_thinking = true;
300 visible.thinking_chars = 1600; let mut encrypted = assistant(Some("req_enc"), Some(usage(1500, 500, 0, 0)));
304 encrypted.has_thinking = true;
305 encrypted.thinking_chars = 0;
306
307 let (records, stats) = dedup_session(&session(vec![visible, encrypted]), None);
308 assert_eq!(stats.requests_with_thinking, 2);
309 assert_eq!(stats.requests_with_encrypted_thinking, 1);
310 assert_eq!(stats.thinking_chars_total, 1600);
311 assert_eq!(
312 stats.thinking_tokens_estimate(),
313 400,
314 "1600 / 4, a lower bound"
315 );
316
317 let vis = records
318 .iter()
319 .find(|r| r.request_id.as_deref() == Some("req_vis"))
320 .unwrap();
321 let enc = records
322 .iter()
323 .find(|r| r.request_id.as_deref() == Some("req_enc"))
324 .unwrap();
325 assert_eq!(vis.thinking_chars, 1600);
326 assert!(!vis.thinking_encrypted, "visible thinking is measurable");
327 assert!(enc.has_thinking);
328 assert!(enc.thinking_encrypted, "0 measurable chars -> encrypted");
329 assert_eq!(enc.thinking_chars, 0);
330 }
331
332 #[test]
333 fn since_filters_events_by_utc_date() {
334 let mut old = assistant(Some("req_old"), Some(usage(100, 10, 0, 0)));
335 old.ts = Some("2026-06-01T10:00:00Z".parse().unwrap());
336 let mut new = assistant(Some("req_new"), Some(usage(200, 20, 0, 0)));
337 new.ts = Some("2026-06-02T09:00:00Z".parse().unwrap());
338
339 let since: Date = "2026-06-02".parse().unwrap();
340 let (records, stats) = dedup_session(&session(vec![old, new]), Some(since));
341 assert_eq!(records.len(), 1);
342 assert_eq!(records[0].request_id.as_deref(), Some("req_new"));
343 assert_eq!(stats.naive_known_tokens, 220);
344 }
345}