Skip to main content

chio_kernel/
receipt_analytics.rs

1use serde::{Deserialize, Serialize};
2
3/// Maximum number of grouped analytics rows to return per dimension.
4pub const MAX_ANALYTICS_GROUP_LIMIT: usize = 200;
5
6/// Supported time bucket widths for aggregated receipt analytics.
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum AnalyticsTimeBucket {
10    Hour,
11    Day,
12}
13
14impl AnalyticsTimeBucket {
15    #[must_use]
16    pub fn width_secs(self) -> u64 {
17        match self {
18            Self::Hour => 3_600,
19            Self::Day => 86_400,
20        }
21    }
22}
23
24/// Filters for aggregated receipt analytics.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct ReceiptAnalyticsQuery {
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub capability_id: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub agent_subject: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub tool_server: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub tool_name: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub since: Option<u64>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub until: Option<u64>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub group_limit: Option<usize>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub time_bucket: Option<AnalyticsTimeBucket>,
44}
45
46impl Default for ReceiptAnalyticsQuery {
47    fn default() -> Self {
48        Self {
49            capability_id: None,
50            agent_subject: None,
51            tool_server: None,
52            tool_name: None,
53            since: None,
54            until: None,
55            group_limit: Some(50),
56            time_bucket: Some(AnalyticsTimeBucket::Day),
57        }
58    }
59}
60
61/// Shared aggregated metrics derived from receipts.
62#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
63#[serde(rename_all = "camelCase")]
64pub struct ReceiptAnalyticsMetrics {
65    pub total_receipts: u64,
66    pub allow_count: u64,
67    pub deny_count: u64,
68    pub cancelled_count: u64,
69    pub incomplete_count: u64,
70    pub total_cost_charged: u64,
71    pub total_attempted_cost: u64,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub reliability_score: Option<f64>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub compliance_rate: Option<f64>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub budget_utilization_rate: Option<f64>,
78}
79
80impl ReceiptAnalyticsMetrics {
81    #[must_use]
82    pub fn from_raw(
83        total_receipts: u64,
84        allow_count: u64,
85        deny_count: u64,
86        cancelled_count: u64,
87        incomplete_count: u64,
88        total_cost_charged: u64,
89        total_attempted_cost: u64,
90    ) -> Self {
91        let terminal_total = allow_count
92            .saturating_add(cancelled_count)
93            .saturating_add(incomplete_count);
94        let attempted_total = total_cost_charged.saturating_add(total_attempted_cost);
95
96        Self {
97            total_receipts,
98            allow_count,
99            deny_count,
100            cancelled_count,
101            incomplete_count,
102            total_cost_charged,
103            total_attempted_cost,
104            reliability_score: ratio_option(allow_count, terminal_total),
105            compliance_rate: ratio_option(
106                total_receipts.saturating_sub(deny_count),
107                total_receipts,
108            ),
109            budget_utilization_rate: ratio_option(total_cost_charged, attempted_total),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115#[serde(rename_all = "camelCase")]
116pub struct AgentAnalyticsRow {
117    pub subject_key: String,
118    pub metrics: ReceiptAnalyticsMetrics,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122#[serde(rename_all = "camelCase")]
123pub struct ToolAnalyticsRow {
124    pub tool_server: String,
125    pub tool_name: String,
126    pub metrics: ReceiptAnalyticsMetrics,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130#[serde(rename_all = "camelCase")]
131pub struct TimeAnalyticsRow {
132    pub bucket_start: u64,
133    pub bucket_end: u64,
134    pub metrics: ReceiptAnalyticsMetrics,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[serde(rename_all = "camelCase")]
139pub struct ReceiptAnalyticsResponse {
140    pub summary: ReceiptAnalyticsMetrics,
141    pub by_agent: Vec<AgentAnalyticsRow>,
142    pub by_tool: Vec<ToolAnalyticsRow>,
143    pub by_time: Vec<TimeAnalyticsRow>,
144}
145
146fn ratio_option(numerator: u64, denominator: u64) -> Option<f64> {
147    if denominator == 0 {
148        None
149    } else {
150        Some(numerator as f64 / denominator as f64)
151    }
152}