Skip to main content

lean_ctx/core/context_kernel/
identity.rs

1//! Identity and cost-center attribution for context-kernel requests.
2
3use std::cmp::Reverse;
4use std::collections::HashSet;
5
6/// Identity metadata attached to a context-kernel caller.
7#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
8pub struct CallerIdentity {
9    /// Stable user identifier, when known.
10    pub user_id: Option<String>,
11    /// Stable team identifier, when known.
12    pub team_id: Option<String>,
13    /// Enterprise cost center charged for the request, when known.
14    pub cost_center: Option<String>,
15    /// Functional role of the caller.
16    pub role: CallerRole,
17    /// Session identifier used when no user identifier is available.
18    pub session_id: Option<String>,
19}
20
21/// Functional role associated with a caller.
22#[derive(
23    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
24)]
25pub enum CallerRole {
26    /// Software developer using context directly.
27    #[default]
28    Developer,
29    /// Human reviewer evaluating generated work.
30    Reviewer,
31    /// Autonomous or delegated software agent.
32    Agent,
33    /// Internal system process.
34    System,
35    /// Administrative caller.
36    Admin,
37}
38
39/// Accumulated token and outcome metrics for one caller identity.
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct IdentityAttribution {
42    /// Identity represented by this attribution entry.
43    pub identity: CallerIdentity,
44    /// Tokens delivered after context processing.
45    pub tokens_consumed: usize,
46    /// Tokens avoided through context processing.
47    pub tokens_saved: usize,
48    /// Number of requests attributed to the identity.
49    pub request_count: usize,
50    /// Number of accepted outcomes attributed to the identity.
51    pub accepted_outcomes: usize,
52}
53
54/// In-memory attribution ledger grouped by the strongest available identity key.
55#[derive(Debug, Clone, Default)]
56pub struct IdentityLedger {
57    entries: std::collections::HashMap<String, IdentityAttribution>,
58}
59
60impl IdentityLedger {
61    /// Creates an empty identity ledger.
62    #[must_use]
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Records token usage, savings, and outcome acceptance for a caller.
68    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    /// Returns attribution recorded under an identity key.
95    #[must_use]
96    pub fn attribution_for(&self, key: &str) -> Option<&IdentityAttribution> {
97        self.entries.get(key)
98    }
99
100    /// Returns up to `limit` entries ordered by descending token consumption.
101    #[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    /// Returns up to `limit` entries ordered by descending token savings.
110    #[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    /// Returns total tokens consumed across all identities.
119    #[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    /// Returns total tokens saved across all identities.
127    #[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    /// Summarizes distinct identities and aggregate token efficiency.
135    #[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/// Aggregate identity-ledger metrics.
187#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
188pub struct IdentityLedgerSummary {
189    /// Number of distinct known users.
190    pub total_users: usize,
191    /// Number of distinct known teams.
192    pub total_teams: usize,
193    /// Tokens consumed across all identities.
194    pub total_tokens: usize,
195    /// Tokens saved across all identities.
196    pub total_savings: usize,
197    /// Fraction of original tokens eliminated by context processing.
198    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}