Skip to main content

luft_core/contract/
ids.rs

1//! Basic ids and token accounting (§1.1).
2
3use serde::{Deserialize, Serialize};
4
5/// Run identifier — uuid v7 (time-ordered, sorts well on disk).
6pub type RunId = uuid::Uuid;
7/// Agent identifier — uuid v7.
8pub type AgentId = uuid::Uuid;
9/// Monotonic phase index (each top-level `parallel`/`converge` is one phase).
10pub type PhaseId = u32;
11
12/// Token usage, accumulated as a run progresses.
13#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(default)]
15pub struct TokenUsage {
16    pub input: u64,
17    pub output: u64,
18    pub cache_read: u64,
19    pub cache_write: u64,
20}
21
22impl std::ops::Add for TokenUsage {
23    type Output = Self;
24    fn add(self, o: Self) -> Self {
25        Self {
26            input: self.input + o.input,
27            output: self.output + o.output,
28            cache_read: self.cache_read + o.cache_read,
29            cache_write: self.cache_write + o.cache_write,
30        }
31    }
32}
33
34impl std::ops::AddAssign for TokenUsage {
35    fn add_assign(&mut self, o: Self) {
36        *self = *self + o;
37    }
38}
39
40impl TokenUsage {
41    /// Billable input + output (excludes cache counters).
42    pub fn total(&self) -> u64 {
43        self.input + self.output
44    }
45
46    /// Human-readable token count (e.g. "12.3k", "1.5M", "2.3B").
47    pub fn display_total(&self) -> String {
48        fmt_tokens(self.total())
49    }
50
51    /// Split display: "↑12.3k ↓5.6k" with optional cache annotation.
52    pub fn display_split(&self) -> String {
53        let mut parts = vec![
54            format!("↑{}", fmt_tokens(self.input)),
55            format!("↓{}", fmt_tokens(self.output)),
56        ];
57        if self.cache_read > 0 {
58            parts.push(format!("{} cached", fmt_tokens(self.cache_read)));
59        }
60        parts.join(" ")
61    }
62}
63
64/// Format a token count with k/M/B suffix.
65///
66/// - `< 1_000` → raw number (`832`)
67/// - `< 1_000_000` → `12.3k` (trailing `.0` stripped → `12k`)
68/// - `< 1_000_000_000` → `1.5M`
69/// - `≥ 1_000_000_000` → `1.5B`
70///
71/// Round-up edge cases (`999_999`, `999_999_999`) bump to the next magnitude
72/// instead of producing `"1000k"` / `"1000M"`.
73pub fn fmt_tokens(n: u64) -> String {
74    if n < 1_000 {
75        return n.to_string();
76    }
77    let (divisor, suffix) = if n < 1_000_000 {
78        (1_000_u64, "k")
79    } else if n < 1_000_000_000 {
80        (1_000_000_u64, "M")
81    } else {
82        (1_000_000_000_u64, "B")
83    };
84    let v = n as f64 / divisor as f64;
85    // If rounding would push v to ≥1000, bump to the next magnitude to avoid "1000k" / "1000M".
86    if v >= 999.95 {
87        let next_divisor = divisor * 1000;
88        let next_suffix = match suffix {
89            "k" => "M",
90            "M" => "B",
91            "B" => "T",
92            _ => unreachable!("unexpected suffix {suffix}"),
93        };
94        let v = n as f64 / next_divisor as f64;
95        fmt_suffix(v, next_suffix)
96    } else {
97        fmt_suffix(v, suffix)
98    }
99}
100
101fn fmt_suffix(v: f64, suffix: &str) -> String {
102    let s = format!("{:.1}", v);
103    let s = s.trim_end_matches(".0");
104    format!("{}{}", s, suffix)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_default() {
113        let t = TokenUsage::default();
114        assert_eq!(t.input, 0);
115        assert_eq!(t.output, 0);
116        assert_eq!(t.cache_read, 0);
117        assert_eq!(t.cache_write, 0);
118    }
119
120    #[test]
121    fn test_total_basic() {
122        let t = TokenUsage {
123            input: 10,
124            output: 20,
125            cache_read: 5,
126            cache_write: 3,
127        };
128        assert_eq!(t.total(), 30);
129    }
130
131    #[test]
132    fn test_total_zero() {
133        let t = TokenUsage::default();
134        assert_eq!(t.total(), 0);
135    }
136
137    #[test]
138    fn test_total_only_input() {
139        let t = TokenUsage {
140            input: 100,
141            output: 0,
142            cache_read: 0,
143            cache_write: 0,
144        };
145        assert_eq!(t.total(), 100);
146    }
147
148    #[test]
149    fn test_total_only_output() {
150        let t = TokenUsage {
151            input: 0,
152            output: 200,
153            cache_read: 0,
154            cache_write: 0,
155        };
156        assert_eq!(t.total(), 200);
157    }
158
159    #[test]
160    fn test_add() {
161        let a = TokenUsage {
162            input: 10,
163            output: 20,
164            cache_read: 5,
165            cache_write: 3,
166        };
167        let b = TokenUsage {
168            input: 3,
169            output: 7,
170            cache_read: 2,
171            cache_write: 1,
172        };
173        let result = a + b;
174        assert_eq!(result.input, 13);
175        assert_eq!(result.output, 27);
176        assert_eq!(result.cache_read, 7);
177        assert_eq!(result.cache_write, 4);
178    }
179
180    #[test]
181    fn test_add_zero() {
182        let a = TokenUsage {
183            input: 10,
184            output: 20,
185            cache_read: 5,
186            cache_write: 3,
187        };
188        let zero = TokenUsage::default();
189        let result = a + zero;
190        assert_eq!(result.input, 10);
191        assert_eq!(result.output, 20);
192        assert_eq!(result.cache_read, 5);
193        assert_eq!(result.cache_write, 3);
194    }
195
196    #[test]
197    fn test_add_large() {
198        let a = TokenUsage {
199            input: u64::MAX,
200            output: 0,
201            cache_read: 0,
202            cache_write: 0,
203        };
204        let b = TokenUsage {
205            input: 0,
206            output: u64::MAX,
207            cache_read: 0,
208            cache_write: 0,
209        };
210        let result = a + b;
211        assert_eq!(result.input, u64::MAX);
212        assert_eq!(result.output, u64::MAX);
213    }
214
215    #[test]
216    fn test_add_assign() {
217        let mut a = TokenUsage {
218            input: 10,
219            output: 20,
220            cache_read: 5,
221            cache_write: 3,
222        };
223        let b = TokenUsage {
224            input: 3,
225            output: 7,
226            cache_read: 2,
227            cache_write: 1,
228        };
229        a += b;
230        assert_eq!(a.input, 13);
231        assert_eq!(a.output, 27);
232        assert_eq!(a.cache_read, 7);
233        assert_eq!(a.cache_write, 4);
234    }
235
236    #[test]
237    fn test_add_assign_zero() {
238        let mut a = TokenUsage {
239            input: 10,
240            output: 20,
241            cache_read: 5,
242            cache_write: 3,
243        };
244        a += TokenUsage::default();
245        assert_eq!(a.input, 10);
246        assert_eq!(a.output, 20);
247        assert_eq!(a.cache_read, 5);
248        assert_eq!(a.cache_write, 3);
249    }
250
251    #[test]
252    fn test_add_assign_chained() {
253        let mut a = TokenUsage {
254            input: 1,
255            output: 2,
256            cache_read: 3,
257            cache_write: 4,
258        };
259        let b = TokenUsage {
260            input: 10,
261            output: 20,
262            cache_read: 30,
263            cache_write: 40,
264        };
265        let c = TokenUsage {
266            input: 100,
267            output: 200,
268            cache_read: 300,
269            cache_write: 400,
270        };
271        a += b;
272        a += c;
273        assert_eq!(a.input, 111);
274        assert_eq!(a.output, 222);
275        assert_eq!(a.cache_read, 333);
276        assert_eq!(a.cache_write, 444);
277    }
278
279    #[test]
280    fn test_serialize_roundtrip() {
281        let t = TokenUsage {
282            input: 1,
283            output: 2,
284            cache_read: 3,
285            cache_write: 4,
286        };
287        let json = serde_json::to_string(&t).unwrap();
288        let deserialized: TokenUsage = serde_json::from_str(&json).unwrap();
289        assert_eq!(deserialized, t);
290    }
291
292    #[test]
293    fn test_serialize_default() {
294        let t = TokenUsage::default();
295        let json = serde_json::to_string(&t).unwrap();
296        assert_eq!(
297            json,
298            r#"{"input":0,"output":0,"cache_read":0,"cache_write":0}"#
299        );
300        let deserialized: TokenUsage = serde_json::from_str(&json).unwrap();
301        assert_eq!(deserialized, t);
302    }
303
304    #[test]
305    fn test_debug_format() {
306        let t = TokenUsage {
307            input: 1,
308            output: 2,
309            cache_read: 3,
310            cache_write: 4,
311        };
312        let debug = format!("{:?}", t);
313        assert!(debug.contains("input: 1"));
314        assert!(debug.contains("output: 2"));
315        assert!(debug.contains("cache_read: 3"));
316        assert!(debug.contains("cache_write: 4"));
317    }
318
319    #[test]
320    fn test_clone() {
321        let t = TokenUsage {
322            input: 10,
323            output: 20,
324            cache_read: 5,
325            cache_write: 3,
326        };
327        let cloned = t;
328        assert_eq!(cloned, t);
329    }
330
331    #[test]
332    fn test_copy() {
333        let t = TokenUsage {
334            input: 10,
335            output: 20,
336            cache_read: 5,
337            cache_write: 3,
338        };
339        let copied = t;
340        let also_t = t; // should not move — Copy semantics
341        assert_eq!(copied, also_t);
342    }
343
344    #[test]
345    fn test_add_commutative() {
346        let a = TokenUsage {
347            input: 5,
348            output: 10,
349            cache_read: 2,
350            cache_write: 1,
351        };
352        let b = TokenUsage {
353            input: 3,
354            output: 7,
355            cache_read: 4,
356            cache_write: 6,
357        };
358        assert_eq!(a + b, b + a);
359    }
360
361    #[test]
362    fn test_total_excludes_cache() {
363        let t = TokenUsage {
364            input: 10,
365            output: 20,
366            cache_read: 100,
367            cache_write: 200,
368        };
369        assert_eq!(t.total(), 30);
370    }
371
372    #[test]
373    fn test_add_assign_identity() {
374        let mut a = TokenUsage {
375            input: 5,
376            output: 5,
377            cache_read: 5,
378            cache_write: 5,
379        };
380        a += TokenUsage::default();
381        assert_eq!(
382            a,
383            TokenUsage {
384                input: 5,
385                output: 5,
386                cache_read: 5,
387                cache_write: 5
388            }
389        );
390    }
391
392    #[test]
393    fn test_type_aliases() {
394        // Verify type aliases exist and can be constructed
395        let _run_id = RunId::nil();
396        let _agent_id = AgentId::nil();
397        let _phase_id: PhaseId = 42;
398    }
399
400    // ── fmt_tokens ───────────────────────────────────────────────
401
402    #[test]
403    fn fmt_tokens_zero() {
404        assert_eq!(fmt_tokens(0), "0");
405    }
406
407    #[test]
408    fn fmt_tokens_small() {
409        assert_eq!(fmt_tokens(1), "1");
410        assert_eq!(fmt_tokens(999), "999");
411    }
412
413    #[test]
414    fn fmt_tokens_exactly_1k() {
415        assert_eq!(fmt_tokens(1_000), "1k");
416    }
417
418    #[test]
419    fn fmt_tokens_k_with_decimal() {
420        assert_eq!(fmt_tokens(1_200), "1.2k");
421        assert_eq!(fmt_tokens(12_345), "12.3k");
422    }
423
424    #[test]
425    fn fmt_tokens_k_whole_no_decimal() {
426        assert_eq!(fmt_tokens(12_000), "12k");
427    }
428
429    #[test]
430    fn fmt_tokens_exactly_1m() {
431        assert_eq!(fmt_tokens(1_000_000), "1M");
432    }
433
434    #[test]
435    fn fmt_tokens_m_with_decimal() {
436        assert_eq!(fmt_tokens(1_500_000), "1.5M");
437        assert_eq!(fmt_tokens(2_300_000), "2.3M");
438    }
439
440    #[test]
441    fn fmt_tokens_m_whole_no_decimal() {
442        assert_eq!(fmt_tokens(10_000_000), "10M");
443    }
444
445    #[test]
446    fn fmt_tokens_border_999999() {
447        assert_eq!(fmt_tokens(999_999), "1M");
448    }
449
450    #[test]
451    fn fmt_tokens_border_999_999_999() {
452        assert_eq!(fmt_tokens(999_999_999), "1B");
453    }
454
455    #[test]
456    fn fmt_tokens_exactly_1b() {
457        assert_eq!(fmt_tokens(1_000_000_000), "1B");
458    }
459
460    #[test]
461    fn fmt_tokens_b_with_decimal() {
462        assert_eq!(fmt_tokens(1_500_000_000), "1.5B");
463        assert_eq!(fmt_tokens(2_300_000_000), "2.3B");
464    }
465
466    #[test]
467    fn fmt_tokens_b_whole_no_decimal() {
468        assert_eq!(fmt_tokens(10_000_000_000), "10B");
469    }
470
471    #[test]
472    fn display_total_matches_fmt_tokens() {
473        let t = TokenUsage {
474            input: 5_000,
475            output: 7_345,
476            cache_read: 0,
477            cache_write: 0,
478        };
479        assert_eq!(t.display_total(), fmt_tokens(12_345));
480        assert_eq!(t.display_total(), "12.3k");
481    }
482
483    #[test]
484    fn display_split_basic() {
485        let t = TokenUsage {
486            input: 5_000,
487            output: 7_345,
488            cache_read: 0,
489            cache_write: 0,
490        };
491        assert_eq!(t.display_split(), "↑5k ↓7.3k");
492    }
493
494    #[test]
495    fn display_split_with_cache() {
496        let t = TokenUsage {
497            input: 1_200,
498            output: 3_400,
499            cache_read: 800,
500            cache_write: 0,
501        };
502        assert_eq!(t.display_split(), "↑1.2k ↓3.4k 800 cached");
503    }
504
505    #[test]
506    fn display_split_zero() {
507        let t = TokenUsage::default();
508        assert_eq!(t.display_split(), "↑0 ↓0");
509    }
510}