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]
118 pub fn harness_notice(timestamp: u64, estimated_tokens: usize) -> Self {
119 Self::synthetic(timestamp, estimated_tokens)
120 }
121
122 #[must_use]
125 pub fn is_synthetic(&self) -> bool {
126 self.source.as_deref() == Some("synthetic")
127 }
128
129 #[must_use]
132 pub fn source(&self) -> Option<&str> {
133 self.source.as_deref()
134 }
135
136 #[must_use]
138 pub fn with_intent_id(mut self, intent_id: impl Into<String>) -> Self {
139 self.intent_id = Some(intent_id.into());
140 self
141 }
142
143 #[must_use]
145 pub fn intent_id(&self) -> Option<&str> {
146 self.intent_id.as_deref()
147 }
148
149 fn mark_compressed(&mut self, original_tokens: usize, compressed_tokens: usize) {
151 self.compression_status = CompressionStatus::Compressed {
152 original_token_count: original_tokens,
153 summary_token_count: compressed_tokens,
154 };
155 self.estimated_tokens = compressed_tokens;
156 }
157
158 fn mark_summarized(&mut self, original_tokens: usize, summary_tokens: usize) {
160 self.compression_status = CompressionStatus::Summarized {
161 original_token_count: original_tokens,
162 summary_token_count: summary_tokens,
163 };
164 self.estimated_tokens = summary_tokens;
165 }
166
167 fn set_importance(&mut self, score: f64) {
169 self.importance_score = score.clamp(0.0, 1.0);
170 }
171
172 fn original_token_count(&self) -> usize {
175 match self.compression_status {
176 CompressionStatus::Uncompressed => self.estimated_tokens,
177 CompressionStatus::Compressed { original_token_count, .. }
178 | CompressionStatus::Summarized { original_token_count, .. } => original_token_count,
179 CompressionStatus::Dropped => 0,
180 }
181 }
182
183 fn effective_token_count(&self) -> usize {
185 match self.compression_status {
186 CompressionStatus::Uncompressed => self.estimated_tokens,
187 CompressionStatus::Compressed { summary_token_count, .. }
188 | CompressionStatus::Summarized { summary_token_count, .. } => summary_token_count,
189 CompressionStatus::Dropped => 0,
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum CompressionStatus {
198 Uncompressed,
200 Compressed {
202 original_token_count: usize,
203 summary_token_count: usize,
204 },
205 Summarized {
207 original_token_count: usize,
208 summary_token_count: usize,
209 },
210 Dropped,
213}
214
215#[allow(
216 clippy::derivable_impls,
217 reason = "Intentional compatibility, platform, or test-only suppression."
218)]
219impl Default for CompressionStatus {
220 fn default() -> Self {
221 Self::Uncompressed
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_create_user_metadata() {
231 let meta = MessageMetadata::user_input(1000, 50);
232 assert_eq!(meta.timestamp, 1000);
233 assert!((meta.importance_score - 0.5).abs() < f64::EPSILON);
234 assert_eq!(meta.compression_status, CompressionStatus::Uncompressed);
235 assert_eq!(meta.estimated_tokens, 50);
236 assert_eq!(meta.source.as_deref(), Some("user_input"));
237 assert_eq!(meta.intent_id(), None);
238 }
239
240 #[test]
241 fn test_create_llm_response_metadata() {
242 let meta = MessageMetadata::llm_response(2000, 150);
243 assert!((meta.importance_score - 0.6).abs() < f64::EPSILON);
244 }
245
246 #[test]
247 fn test_mark_compressed() {
248 let mut meta = MessageMetadata::user_input(1000, 200);
249 meta.mark_compressed(200, 50);
250 assert_eq!(meta.estimated_tokens, 50);
251 assert_eq!(meta.effective_token_count(), 50);
252 assert_eq!(meta.original_token_count(), 200);
253 }
254
255 #[test]
256 fn test_mark_summarized() {
257 let mut meta = MessageMetadata::user_input(1000, 300);
258 meta.mark_summarized(300, 30);
259 assert_eq!(meta.effective_token_count(), 30);
260 assert_eq!(meta.original_token_count(), 300);
261 }
262
263 #[test]
264 fn test_set_importance_clamps() {
265 let mut meta = MessageMetadata::user_input(1000, 50);
266 meta.set_importance(1.5);
267 assert!((meta.importance_score - 1.0).abs() < f64::EPSILON);
268 meta.set_importance(-0.5);
269 assert!((meta.importance_score - 0.0).abs() < f64::EPSILON);
270 }
271
272 #[test]
273 fn test_compression_status_serde_roundtrip() {
274 let status = CompressionStatus::Compressed { original_token_count: 200, summary_token_count: 50 };
275 let json = serde_json::to_string(&status).unwrap();
276 let deserialized: CompressionStatus = serde_json::from_str(&json).unwrap();
277 assert_eq!(status, deserialized);
278 }
279
280 #[test]
281 fn test_intent_id_is_optional_and_serde_compatible() {
282 let meta = MessageMetadata::user_input(1000, 50).with_intent_id("intent-1");
283 let json = serde_json::to_string(&meta).unwrap();
284 let restored: MessageMetadata = serde_json::from_str(&json).unwrap();
285 assert_eq!(restored.intent_id(), Some("intent-1"));
286
287 let legacy = r#"{"timestamp":1000,"importance_score":0.5,"compression_status":"uncompressed","estimated_tokens":50,"source":"user_input"}"#;
288 let restored: MessageMetadata = serde_json::from_str(legacy).unwrap();
289 assert_eq!(restored.intent_id(), None);
290 }
291}