1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct MessageMetadata {
20 timestamp: u64,
22
23 importance_score: f64,
29
30 compression_status: CompressionStatus,
32
33 estimated_tokens: usize,
36
37 source: Option<String>,
40
41 #[serde(default, skip_serializing_if = "Option::is_none")]
47 intent_id: Option<String>,
48}
49
50impl MessageMetadata {
51 pub fn user_input(timestamp: u64, estimated_tokens: usize) -> Self {
53 Self {
54 timestamp,
55 importance_score: 0.5,
56 compression_status: CompressionStatus::Uncompressed,
57 estimated_tokens,
58 source: Some("user_input".into()),
59 intent_id: None,
60 }
61 }
62
63 pub fn llm_response(timestamp: u64, estimated_tokens: usize) -> Self {
65 Self {
66 timestamp,
67 importance_score: 0.6,
68 compression_status: CompressionStatus::Uncompressed,
69 estimated_tokens,
70 source: Some("llm_response".into()),
71 intent_id: None,
72 }
73 }
74
75 pub fn tool_result(timestamp: u64, estimated_tokens: usize) -> Self {
77 Self {
78 timestamp,
79 importance_score: 0.4,
80 compression_status: CompressionStatus::Uncompressed,
81 estimated_tokens,
82 source: Some("tool_result".into()),
83 intent_id: None,
84 }
85 }
86
87 pub fn system(timestamp: u64, estimated_tokens: usize) -> Self {
89 Self {
90 timestamp,
91 importance_score: 1.0,
92 compression_status: CompressionStatus::Uncompressed,
93 estimated_tokens,
94 source: Some("system".into()),
95 intent_id: None,
96 }
97 }
98
99 pub fn synthetic(timestamp: u64, estimated_tokens: usize) -> Self {
101 Self {
102 timestamp,
103 importance_score: 0.3,
104 compression_status: CompressionStatus::Uncompressed,
105 estimated_tokens,
106 source: Some("synthetic".into()),
107 intent_id: None,
108 }
109 }
110
111 #[must_use]
113 pub fn with_intent_id(mut self, intent_id: impl Into<String>) -> Self {
114 self.intent_id = Some(intent_id.into());
115 self
116 }
117
118 #[must_use]
120 pub fn intent_id(&self) -> Option<&str> {
121 self.intent_id.as_deref()
122 }
123
124 fn mark_compressed(&mut self, original_tokens: usize, compressed_tokens: usize) {
126 self.compression_status = CompressionStatus::Compressed {
127 original_token_count: original_tokens,
128 summary_token_count: compressed_tokens,
129 };
130 self.estimated_tokens = compressed_tokens;
131 }
132
133 fn mark_summarized(&mut self, original_tokens: usize, summary_tokens: usize) {
135 self.compression_status = CompressionStatus::Summarized {
136 original_token_count: original_tokens,
137 summary_token_count: summary_tokens,
138 };
139 self.estimated_tokens = summary_tokens;
140 }
141
142 fn set_importance(&mut self, score: f64) {
144 self.importance_score = score.clamp(0.0, 1.0);
145 }
146
147 fn original_token_count(&self) -> usize {
150 match self.compression_status {
151 CompressionStatus::Uncompressed => self.estimated_tokens,
152 CompressionStatus::Compressed { original_token_count, .. }
153 | CompressionStatus::Summarized { original_token_count, .. } => original_token_count,
154 CompressionStatus::Dropped => 0,
155 }
156 }
157
158 fn effective_token_count(&self) -> usize {
160 match self.compression_status {
161 CompressionStatus::Uncompressed => self.estimated_tokens,
162 CompressionStatus::Compressed { summary_token_count, .. }
163 | CompressionStatus::Summarized { summary_token_count, .. } => summary_token_count,
164 CompressionStatus::Dropped => 0,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum CompressionStatus {
173 Uncompressed,
175 Compressed {
177 original_token_count: usize,
178 summary_token_count: usize,
179 },
180 Summarized {
182 original_token_count: usize,
183 summary_token_count: usize,
184 },
185 Dropped,
188}
189
190#[allow(
191 clippy::derivable_impls,
192 reason = "Intentional compatibility, platform, or test-only suppression."
193)]
194impl Default for CompressionStatus {
195 fn default() -> Self {
196 Self::Uncompressed
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_create_user_metadata() {
206 let meta = MessageMetadata::user_input(1000, 50);
207 assert_eq!(meta.timestamp, 1000);
208 assert!((meta.importance_score - 0.5).abs() < f64::EPSILON);
209 assert_eq!(meta.compression_status, CompressionStatus::Uncompressed);
210 assert_eq!(meta.estimated_tokens, 50);
211 assert_eq!(meta.source.as_deref(), Some("user_input"));
212 assert_eq!(meta.intent_id(), None);
213 }
214
215 #[test]
216 fn test_create_llm_response_metadata() {
217 let meta = MessageMetadata::llm_response(2000, 150);
218 assert!((meta.importance_score - 0.6).abs() < f64::EPSILON);
219 }
220
221 #[test]
222 fn test_mark_compressed() {
223 let mut meta = MessageMetadata::user_input(1000, 200);
224 meta.mark_compressed(200, 50);
225 assert_eq!(meta.estimated_tokens, 50);
226 assert_eq!(meta.effective_token_count(), 50);
227 assert_eq!(meta.original_token_count(), 200);
228 }
229
230 #[test]
231 fn test_mark_summarized() {
232 let mut meta = MessageMetadata::user_input(1000, 300);
233 meta.mark_summarized(300, 30);
234 assert_eq!(meta.effective_token_count(), 30);
235 assert_eq!(meta.original_token_count(), 300);
236 }
237
238 #[test]
239 fn test_set_importance_clamps() {
240 let mut meta = MessageMetadata::user_input(1000, 50);
241 meta.set_importance(1.5);
242 assert!((meta.importance_score - 1.0).abs() < f64::EPSILON);
243 meta.set_importance(-0.5);
244 assert!((meta.importance_score - 0.0).abs() < f64::EPSILON);
245 }
246
247 #[test]
248 fn test_compression_status_serde_roundtrip() {
249 let status = CompressionStatus::Compressed { original_token_count: 200, summary_token_count: 50 };
250 let json = serde_json::to_string(&status).unwrap();
251 let deserialized: CompressionStatus = serde_json::from_str(&json).unwrap();
252 assert_eq!(status, deserialized);
253 }
254
255 #[test]
256 fn test_intent_id_is_optional_and_serde_compatible() {
257 let meta = MessageMetadata::user_input(1000, 50).with_intent_id("intent-1");
258 let json = serde_json::to_string(&meta).unwrap();
259 let restored: MessageMetadata = serde_json::from_str(&json).unwrap();
260 assert_eq!(restored.intent_id(), Some("intent-1"));
261
262 let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
263 let restored: MessageMetadata = serde_json::from_str(legacy).unwrap();
264 assert_eq!(restored.intent_id(), None);
265 }
266}