lean_ctx/core/context_kernel/
identity.rs1use std::cmp::Reverse;
4use std::collections::HashSet;
5
6#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
8pub struct CallerIdentity {
9 pub user_id: Option<String>,
11 pub team_id: Option<String>,
13 pub cost_center: Option<String>,
15 pub role: CallerRole,
17 pub session_id: Option<String>,
19}
20
21#[derive(
23 Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
24)]
25pub enum CallerRole {
26 #[default]
28 Developer,
29 Reviewer,
31 Agent,
33 System,
35 Admin,
37}
38
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct IdentityAttribution {
42 pub identity: CallerIdentity,
44 pub tokens_consumed: usize,
46 pub tokens_saved: usize,
48 pub request_count: usize,
50 pub accepted_outcomes: usize,
52}
53
54#[derive(Debug, Clone, Default)]
56pub struct IdentityLedger {
57 entries: std::collections::HashMap<String, IdentityAttribution>,
58}
59
60impl IdentityLedger {
61 #[must_use]
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn record(
69 &mut self,
70 identity: &CallerIdentity,
71 consumed: usize,
72 saved: usize,
73 accepted: bool,
74 ) {
75 let key = Self::ledger_key(identity);
76 let attribution = self
77 .entries
78 .entry(key)
79 .or_insert_with(|| IdentityAttribution {
80 identity: identity.clone(),
81 tokens_consumed: 0,
82 tokens_saved: 0,
83 request_count: 0,
84 accepted_outcomes: 0,
85 });
86 attribution.tokens_consumed = attribution.tokens_consumed.saturating_add(consumed);
87 attribution.tokens_saved = attribution.tokens_saved.saturating_add(saved);
88 attribution.request_count = attribution.request_count.saturating_add(1);
89 if accepted {
90 attribution.accepted_outcomes = attribution.accepted_outcomes.saturating_add(1);
91 }
92 }
93
94 #[must_use]
96 pub fn attribution_for(&self, key: &str) -> Option<&IdentityAttribution> {
97 self.entries.get(key)
98 }
99
100 #[must_use]
102 pub fn top_consumers(&self, limit: usize) -> Vec<&IdentityAttribution> {
103 let mut entries: Vec<_> = self.entries.values().collect();
104 entries.sort_unstable_by_key(|entry| Reverse(entry.tokens_consumed));
105 entries.truncate(limit);
106 entries
107 }
108
109 #[must_use]
111 pub fn top_savers(&self, limit: usize) -> Vec<&IdentityAttribution> {
112 let mut entries: Vec<_> = self.entries.values().collect();
113 entries.sort_unstable_by_key(|entry| Reverse(entry.tokens_saved));
114 entries.truncate(limit);
115 entries
116 }
117
118 #[must_use]
120 pub fn total_tokens(&self) -> usize {
121 self.entries.values().fold(0, |total, entry| {
122 total.saturating_add(entry.tokens_consumed)
123 })
124 }
125
126 #[must_use]
128 pub fn total_savings(&self) -> usize {
129 self.entries
130 .values()
131 .fold(0, |total, entry| total.saturating_add(entry.tokens_saved))
132 }
133
134 #[must_use]
136 pub fn summary(&self) -> IdentityLedgerSummary {
137 let users: HashSet<_> = self
138 .entries
139 .values()
140 .filter_map(|entry| entry.identity.user_id.as_deref())
141 .collect();
142 let teams: HashSet<_> = self
143 .entries
144 .values()
145 .filter_map(|entry| entry.identity.team_id.as_deref())
146 .collect();
147 let total_tokens = self.total_tokens();
148 let total_savings = self.total_savings();
149 let original_tokens = total_tokens.saturating_add(total_savings);
150 let savings_rate = if original_tokens == 0 {
151 0.0
152 } else {
153 total_savings as f64 / original_tokens as f64
154 };
155
156 IdentityLedgerSummary {
157 total_users: users.len(),
158 total_teams: teams.len(),
159 total_tokens,
160 total_savings,
161 savings_rate,
162 }
163 }
164
165 fn ledger_key(identity: &CallerIdentity) -> String {
166 identity
167 .user_id
168 .as_ref()
169 .or(identity.session_id.as_ref())
170 .or(identity.team_id.as_ref())
171 .or(identity.cost_center.as_ref())
172 .cloned()
173 .unwrap_or_else(|| {
174 match identity.role {
175 CallerRole::Developer => "developer",
176 CallerRole::Reviewer => "reviewer",
177 CallerRole::Agent => "agent",
178 CallerRole::System => "system",
179 CallerRole::Admin => "admin",
180 }
181 .to_owned()
182 })
183 }
184}
185
186#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
188pub struct IdentityLedgerSummary {
189 pub total_users: usize,
191 pub total_teams: usize,
193 pub total_tokens: usize,
195 pub total_savings: usize,
197 pub savings_rate: f64,
199}
200
201#[cfg(test)]
202mod tests {
203 use super::{CallerIdentity, CallerRole, IdentityLedger};
204
205 fn identity(user: &str, team: &str) -> CallerIdentity {
206 CallerIdentity {
207 user_id: Some(user.to_owned()),
208 team_id: Some(team.to_owned()),
209 ..CallerIdentity::default()
210 }
211 }
212
213 #[test]
214 fn identity_default() {
215 assert_eq!(CallerIdentity::default().role, CallerRole::Developer);
216 }
217
218 #[test]
219 fn ledger_record_single() {
220 let mut ledger = IdentityLedger::new();
221 ledger.record(&identity("alice", "platform"), 80, 20, true);
222
223 let entry = ledger
224 .attribution_for("alice")
225 .expect("alice must have attribution");
226 assert_eq!(entry.tokens_consumed, 80);
227 assert_eq!(entry.tokens_saved, 20);
228 assert_eq!(entry.request_count, 1);
229 assert_eq!(entry.accepted_outcomes, 1);
230 }
231
232 #[test]
233 fn ledger_record_multiple_same_user() {
234 let mut ledger = IdentityLedger::new();
235 let caller = identity("alice", "platform");
236 ledger.record(&caller, 80, 20, true);
237 ledger.record(&caller, 40, 10, false);
238
239 let entry = ledger
240 .attribution_for("alice")
241 .expect("alice must have attribution");
242 assert_eq!(entry.tokens_consumed, 120);
243 assert_eq!(entry.tokens_saved, 30);
244 assert_eq!(entry.request_count, 2);
245 assert_eq!(entry.accepted_outcomes, 1);
246 }
247
248 #[test]
249 fn top_consumers_sorted() {
250 let mut ledger = IdentityLedger::new();
251 ledger.record(&identity("small", "one"), 10, 20, false);
252 ledger.record(&identity("large", "two"), 90, 5, false);
253
254 let consumers = ledger.top_consumers(2);
255 assert_eq!(consumers[0].identity.user_id.as_deref(), Some("large"));
256 assert_eq!(consumers[1].identity.user_id.as_deref(), Some("small"));
257 }
258
259 #[test]
260 fn top_savers_sorted() {
261 let mut ledger = IdentityLedger::new();
262 ledger.record(&identity("small", "one"), 90, 5, false);
263 ledger.record(&identity("large", "two"), 10, 20, false);
264
265 let savers = ledger.top_savers(2);
266 assert_eq!(savers[0].identity.user_id.as_deref(), Some("large"));
267 assert_eq!(savers[1].identity.user_id.as_deref(), Some("small"));
268 }
269
270 #[test]
271 fn summary_counts_unique() {
272 let mut ledger = IdentityLedger::new();
273 ledger.record(&identity("alice", "platform"), 80, 20, true);
274 ledger.record(&identity("bob", "platform"), 70, 30, false);
275
276 let summary = ledger.summary();
277 assert_eq!(summary.total_users, 2);
278 assert_eq!(summary.total_teams, 1);
279 assert_eq!(summary.total_tokens, 150);
280 assert_eq!(summary.total_savings, 50);
281 assert!((summary.savings_rate - 0.25).abs() < f64::EPSILON);
282 }
283
284 #[test]
285 fn serde_roundtrip() {
286 let original = CallerIdentity {
287 user_id: Some("alice".to_owned()),
288 team_id: Some("platform".to_owned()),
289 cost_center: Some("engineering".to_owned()),
290 role: CallerRole::Reviewer,
291 session_id: Some("session-1".to_owned()),
292 };
293 let json = serde_json::to_string(&original).expect("identity must serialize");
294 let decoded = serde_json::from_str(&json).expect("identity must deserialize");
295
296 assert_eq!(original, decoded);
297 }
298}