1use std::path::PathBuf;
2
3use crate::errors::TokenFoldError;
4use crate::input::{CompressionInput, InputFormat};
5use crate::token_estimator::TokenEstimator;
6
7const MIN_LOSSY_TTL_SECONDS: u64 = 86_400;
12
13#[derive(Debug, Clone, PartialEq)]
15pub struct CompressionPolicy {
16 pub target_tokens: Option<usize>,
17 pub reserve_output_tokens: usize,
18 pub mode: CompressionMode,
19 pub task_scope: TaskScope,
20 pub cache_boundary: Option<CacheBoundary>,
21 pub preserve_latest_user_message: bool,
22 pub disabled: Vec<String>,
23 pub unsafe_disable_redaction: bool,
24 pub experimental: bool,
28 pub enable: Vec<String>,
32 pub store_originals: bool,
36 pub retrieval_namespace: String,
39 pub retrieval_ttl_seconds: Option<u64>,
43 pub retrieval_backend: String,
47 pub retrieval_store_path: Option<PathBuf>,
50 pub lossy: Option<LossyPath>,
57 pub lossy_ratio: f64,
67 pub lossy_preserve: Vec<String>,
70 pub(crate) preview: bool,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum CompressionMode {
86 Conservative,
87 Balanced,
88 Aggressive,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum LossyPath {
97 Heuristic,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum TaskScope {
102 All,
103 General,
104 CodeReview,
105 ChangeSummary,
106 Debugging,
107 Generation,
108 ApiOverview,
109 RetrievalQa,
110 AgentHistory,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum CacheBoundary {
115 ByteOffset(usize),
116 TurnIndex(usize),
117}
118
119impl CompressionPolicy {
120 pub fn builder() -> CompressionPolicyBuilder {
121 CompressionPolicyBuilder::default()
122 }
123
124 pub fn validate(&self) -> Result<(), TokenFoldError> {
131 if self.disabled.iter().any(|id| id == "secret_redaction") {
132 return Err(TokenFoldError::ConfigError(
133 "secret_redaction cannot be disabled via CompressionPolicy.disabled".to_string(),
134 ));
135 }
136 if !(0.0..=1.0).contains(&self.lossy_ratio) {
137 return Err(TokenFoldError::ConfigError(format!(
138 "lossy_ratio must be between 0.0 and 1.0, got {}",
139 self.lossy_ratio
140 )));
141 }
142 if self.lossy.is_some() {
143 if self.retrieval_backend != "filesystem" {
147 return Err(TokenFoldError::ConfigError(format!(
148 "lossy pruning requires a durable retrieval backend (\"filesystem\"); \
149 {:?} would make dropped items unrecoverable",
150 self.retrieval_backend
151 )));
152 }
153 let effective_ttl = self
154 .retrieval_ttl_seconds
155 .unwrap_or(crate::retrieval_store::DEFAULT_TTL_SECONDS);
156 if effective_ttl < MIN_LOSSY_TTL_SECONDS {
157 return Err(TokenFoldError::ConfigError(format!(
158 "lossy pruning requires retrieval_ttl_seconds >= {MIN_LOSSY_TTL_SECONDS} \
159 (got {effective_ttl}); a near-immediate expiry has no real recoverability"
160 )));
161 }
162 }
163 Ok(())
164 }
165}
166
167#[derive(Debug, Clone, Default)]
168pub struct CompressionPolicyBuilder {
169 target_tokens: Option<usize>,
170 reserve_output_tokens: Option<usize>,
171 mode: Option<CompressionMode>,
172 task_scope: Option<TaskScope>,
173 cache_boundary: Option<CacheBoundary>,
174 preserve_latest_user_message: Option<bool>,
175 disabled: Vec<String>,
176 unsafe_disable_redaction: bool,
177 experimental: bool,
178 enable: Vec<String>,
179 store_originals: bool,
180 retrieval_namespace: Option<String>,
181 retrieval_ttl_seconds: Option<u64>,
182 retrieval_backend: Option<String>,
183 retrieval_store_path: Option<PathBuf>,
184 lossy: Option<LossyPath>,
185 lossy_ratio: Option<f64>,
186 lossy_preserve: Vec<String>,
187 preview: bool,
188}
189
190impl CompressionPolicyBuilder {
191 pub fn target_tokens(mut self, target_tokens: usize) -> Self {
192 self.target_tokens = Some(target_tokens);
193 self
194 }
195
196 pub fn reserve_output_tokens(mut self, reserve_output_tokens: usize) -> Self {
197 self.reserve_output_tokens = Some(reserve_output_tokens);
198 self
199 }
200
201 pub fn mode(mut self, mode: CompressionMode) -> Self {
202 self.mode = Some(mode);
203 self
204 }
205
206 pub fn task_scope(mut self, task_scope: TaskScope) -> Self {
207 self.task_scope = Some(task_scope);
208 self
209 }
210
211 pub fn cache_boundary(mut self, cache_boundary: CacheBoundary) -> Self {
212 self.cache_boundary = Some(cache_boundary);
213 self
214 }
215
216 pub fn preserve_latest_user_message(mut self, preserve: bool) -> Self {
217 self.preserve_latest_user_message = Some(preserve);
218 self
219 }
220
221 pub fn disable(mut self, transform_id: impl Into<String>) -> Self {
222 self.disabled.push(transform_id.into());
223 self
224 }
225
226 pub fn unsafe_disable_redaction(mut self, unsafe_disable: bool) -> Self {
227 self.unsafe_disable_redaction = unsafe_disable;
228 self
229 }
230
231 pub fn experimental(mut self, experimental: bool) -> Self {
232 self.experimental = experimental;
233 self
234 }
235
236 pub fn enable(mut self, transform_id: impl Into<String>) -> Self {
237 self.enable.push(transform_id.into());
238 self
239 }
240
241 pub fn store_originals(mut self, store_originals: bool) -> Self {
242 self.store_originals = store_originals;
243 self
244 }
245
246 pub fn retrieval_namespace(mut self, namespace: impl Into<String>) -> Self {
247 self.retrieval_namespace = Some(namespace.into());
248 self
249 }
250
251 pub fn retrieval_ttl_seconds(mut self, ttl_seconds: Option<u64>) -> Self {
252 self.retrieval_ttl_seconds = ttl_seconds;
253 self
254 }
255
256 pub fn retrieval_backend(mut self, backend: impl Into<String>) -> Self {
257 self.retrieval_backend = Some(backend.into());
258 self
259 }
260
261 pub fn retrieval_store_path(mut self, store_path: Option<PathBuf>) -> Self {
262 self.retrieval_store_path = store_path;
263 self
264 }
265
266 pub fn lossy(mut self, lossy: LossyPath) -> Self {
267 self.lossy = Some(lossy);
268 self
269 }
270
271 pub fn lossy_ratio(mut self, ratio: f64) -> Self {
272 self.lossy_ratio = Some(ratio);
273 self
274 }
275
276 pub fn lossy_preserve(mut self, path: impl Into<String>) -> Self {
277 self.lossy_preserve.push(path.into());
278 self
279 }
280
281 pub fn preview(mut self, preview: bool) -> Self {
287 self.preview = preview;
288 self
289 }
290
291 pub fn build(self) -> Result<CompressionPolicy, TokenFoldError> {
292 let policy = CompressionPolicy {
293 target_tokens: self.target_tokens,
294 reserve_output_tokens: self.reserve_output_tokens.unwrap_or(0),
295 mode: self.mode.unwrap_or(CompressionMode::Balanced),
296 task_scope: self.task_scope.unwrap_or(TaskScope::All),
297 cache_boundary: self.cache_boundary,
298 preserve_latest_user_message: self.preserve_latest_user_message.unwrap_or(true),
299 disabled: self.disabled,
300 unsafe_disable_redaction: self.unsafe_disable_redaction,
301 experimental: self.experimental,
302 enable: self.enable,
303 store_originals: self.store_originals,
304 retrieval_namespace: self
305 .retrieval_namespace
306 .unwrap_or_else(|| "default".to_string()),
307 retrieval_ttl_seconds: self.retrieval_ttl_seconds,
308 retrieval_backend: self
309 .retrieval_backend
310 .unwrap_or_else(|| "filesystem".to_string()),
311 retrieval_store_path: self.retrieval_store_path,
312 lossy: self.lossy,
313 lossy_ratio: self.lossy_ratio.unwrap_or(0.3),
314 lossy_preserve: self.lossy_preserve,
315 preview: self.preview,
316 };
317 policy.validate()?;
318 Ok(policy)
319 }
320}
321
322pub fn protected_floor(
324 input: &CompressionInput,
325 policy: &CompressionPolicy,
326 estimator: &dyn TokenEstimator,
327) -> usize {
328 estimator.count_bytes(&protected_segments(input, policy).concat())
329}
330
331pub fn protected_segments(input: &CompressionInput, policy: &CompressionPolicy) -> Vec<Vec<u8>> {
337 match input.format {
338 InputFormat::OpenAiJson => extract_openai_protected(&input.bytes, policy),
339 InputFormat::AnthropicJson => extract_anthropic_protected(&input.bytes, policy),
340 InputFormat::GitDiff => extract_diff_protected(&input.bytes),
341 InputFormat::PlainText
346 | InputFormat::CommandOutput
347 | InputFormat::Json
348 | InputFormat::Auto => Vec::new(),
349 }
350}
351
352fn extract_openai_protected(bytes: &[u8], policy: &CompressionPolicy) -> Vec<Vec<u8>> {
353 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
354 return Vec::new();
355 };
356 let Some(messages) = value.get("messages").and_then(|m| m.as_array()) else {
357 return Vec::new();
358 };
359
360 let mut segments = Vec::new();
361 for message in messages {
362 if message.get("role").and_then(|r| r.as_str()) == Some("system")
363 && let Some(bytes) = message_content_bytes(message)
364 {
365 segments.push(bytes);
366 }
367 }
368 if policy.preserve_latest_user_message
369 && let Some(last_user) = messages
370 .iter()
371 .rev()
372 .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
373 && let Some(bytes) = message_content_bytes(last_user)
374 {
375 segments.push(bytes);
376 }
377 segments
378}
379
380fn extract_anthropic_protected(bytes: &[u8], policy: &CompressionPolicy) -> Vec<Vec<u8>> {
381 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
382 return Vec::new();
383 };
384
385 let mut segments = Vec::new();
386 match value.get("system") {
391 Some(serde_json::Value::String(text)) => segments.push(text.as_bytes().to_vec()),
392 Some(structured @ serde_json::Value::Array(_)) => {
393 if let Ok(bytes) = serde_json::to_vec(structured) {
394 segments.push(bytes);
395 }
396 }
397 _ => {}
398 }
399 if policy.preserve_latest_user_message
400 && let Some(last_user) =
401 value
402 .get("messages")
403 .and_then(|m| m.as_array())
404 .and_then(|messages| {
405 messages
406 .iter()
407 .rev()
408 .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
409 })
410 && let Some(bytes) = message_content_bytes(last_user)
411 {
412 segments.push(bytes);
413 }
414 segments
415}
416
417fn message_content_bytes(message: &serde_json::Value) -> Option<Vec<u8>> {
418 match message.get("content") {
419 Some(serde_json::Value::String(text)) => Some(text.as_bytes().to_vec()),
420 Some(structured) => serde_json::to_vec(structured).ok(),
421 None => None,
422 }
423}
424
425fn extract_diff_protected(bytes: &[u8]) -> Vec<Vec<u8>> {
428 let text = String::from_utf8_lossy(bytes);
429 let mut segments = Vec::new();
430 for line in text.lines() {
431 if line.starts_with("diff --git")
432 || line.starts_with("--- ")
433 || line.starts_with("+++ ")
434 || line.starts_with("@@")
435 {
436 let mut segment = line.as_bytes().to_vec();
437 segment.push(b'\n');
438 segments.push(segment);
439 }
440 }
441 segments
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::token_estimator::ByteHeuristicEstimator;
448
449 #[test]
450 fn default_mode_is_balanced() {
451 let policy = CompressionPolicy::builder().build().unwrap();
452 assert_eq!(policy.mode, CompressionMode::Balanced);
453 }
454
455 #[test]
456 fn store_originals_defaults_to_false_with_a_default_namespace() {
457 let policy = CompressionPolicy::builder().build().unwrap();
458 assert!(!policy.store_originals);
459 assert_eq!(policy.retrieval_namespace, "default");
460 }
461
462 #[test]
463 fn store_originals_and_namespace_are_settable_via_the_builder() {
464 let policy = CompressionPolicy::builder()
465 .store_originals(true)
466 .retrieval_namespace("project-x")
467 .retrieval_ttl_seconds(Some(60))
468 .retrieval_backend("memory")
469 .retrieval_store_path(Some(std::path::PathBuf::from("/tmp/custom")))
470 .build()
471 .unwrap();
472 assert!(policy.store_originals);
473 assert_eq!(policy.retrieval_namespace, "project-x");
474 assert_eq!(policy.retrieval_ttl_seconds, Some(60));
475 assert_eq!(policy.retrieval_backend, "memory");
476 assert_eq!(
477 policy.retrieval_store_path,
478 Some(std::path::PathBuf::from("/tmp/custom"))
479 );
480 }
481
482 #[test]
483 fn retrieval_defaults_are_none_ttl_and_filesystem_backend() {
484 let policy = CompressionPolicy::builder().build().unwrap();
485 assert_eq!(policy.retrieval_ttl_seconds, None);
486 assert_eq!(policy.retrieval_backend, "filesystem");
487 assert_eq!(policy.retrieval_store_path, None);
488 }
489
490 #[test]
491 fn secret_redaction_cannot_be_disabled_through_policy() {
492 let err = CompressionPolicy::builder()
493 .disable("secret_redaction")
494 .build()
495 .unwrap_err();
496 assert!(matches!(err, TokenFoldError::ConfigError(_)));
497 }
498
499 #[test]
500 fn disabling_other_transforms_is_allowed() {
501 let policy = CompressionPolicy::builder()
502 .disable("json_minify")
503 .build()
504 .unwrap();
505 assert_eq!(policy.disabled, vec!["json_minify".to_string()]);
506 }
507
508 #[test]
509 fn floor_is_zero_for_plain_text() {
510 let input = CompressionInput::plain_text(b"just some plain text".to_vec());
511 let policy = CompressionPolicy::builder().build().unwrap();
512 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
513 assert_eq!(floor, 0);
514 }
515
516 #[test]
517 fn floor_covers_system_and_latest_user_message_for_openai_json() {
518 let payload = serde_json::json!({
519 "model": "gpt-4",
520 "messages": [
521 {"role": "system", "content": "You are a helpful assistant."},
522 {"role": "user", "content": "first question"},
523 {"role": "assistant", "content": "first answer"},
524 {"role": "user", "content": "second question"},
525 ]
526 });
527 let input = CompressionInput::openai_json(serde_json::to_vec(&payload).unwrap());
528 let policy = CompressionPolicy::builder().build().unwrap();
529 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
530
531 let expected_bytes = "You are a helpful assistant.".len() + "second question".len();
532 assert_eq!(
533 floor,
534 ByteHeuristicEstimator.count_bytes(&vec![0u8; expected_bytes])
535 );
536 assert!(floor < ByteHeuristicEstimator.count_bytes(input.bytes.as_slice()));
538 }
539
540 #[test]
541 fn floor_excludes_latest_user_message_when_policy_disables_preservation() {
542 let payload = serde_json::json!({
543 "messages": [
544 {"role": "system", "content": "system prompt"},
545 {"role": "user", "content": "question"},
546 ]
547 });
548 let input = CompressionInput::openai_json(serde_json::to_vec(&payload).unwrap());
549 let policy = CompressionPolicy::builder()
550 .preserve_latest_user_message(false)
551 .build()
552 .unwrap();
553 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
554 assert_eq!(floor, ByteHeuristicEstimator.count_bytes(b"system prompt"));
555 }
556
557 #[test]
558 fn floor_covers_system_and_latest_user_message_for_anthropic_json() {
559 let payload = serde_json::json!({
560 "system": "system prompt",
561 "messages": [
562 {"role": "user", "content": "first"},
563 {"role": "assistant", "content": "reply"},
564 {"role": "user", "content": "second"},
565 ]
566 });
567 let input = CompressionInput::anthropic_json(serde_json::to_vec(&payload).unwrap());
568 let policy = CompressionPolicy::builder().build().unwrap();
569 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
570 let expected_bytes = "system prompt".len() + "second".len();
571 assert_eq!(
572 floor,
573 ByteHeuristicEstimator.count_bytes(&vec![0u8; expected_bytes])
574 );
575 }
576
577 #[test]
578 fn floor_covers_structured_anthropic_system_content_not_just_a_plain_string() {
579 let payload = serde_json::json!({
584 "system": [{"type": "text", "text": "structured system prompt"}],
585 "messages": [
586 {"role": "user", "content": "first"},
587 ]
588 });
589 let input = CompressionInput::anthropic_json(serde_json::to_vec(&payload).unwrap());
590 let policy = CompressionPolicy::builder()
591 .preserve_latest_user_message(false)
592 .build()
593 .unwrap();
594 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
595 assert!(
596 floor > 0,
597 "a structured Anthropic `system` array must contribute to the protected floor"
598 );
599 let segments = protected_segments(&input, &policy);
600 let system_bytes = serde_json::to_vec(
601 &serde_json::json!([{"type": "text", "text": "structured system prompt"}]),
602 )
603 .unwrap();
604 assert!(
605 segments.contains(&system_bytes),
606 "the structured system content must be a protected segment, byte-for-byte"
607 );
608 }
609
610 #[test]
611 fn floor_keeps_diff_headers_and_hunk_markers_only() {
612 let diff =
613 b"diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,2 +1,2 @@\n-old\n+new\n";
614 let input = CompressionInput::git_diff(diff.to_vec());
615 let policy = CompressionPolicy::builder().build().unwrap();
616 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
617 assert!(floor > 0);
618 assert!(floor < ByteHeuristicEstimator.count_bytes(diff));
619 }
620
621 #[test]
622 fn lossy_defaults_to_disabled_with_a_default_ratio() {
623 let policy = CompressionPolicy::builder().build().unwrap();
624 assert_eq!(policy.lossy, None);
625 assert_eq!(policy.lossy_ratio, 0.3);
626 assert!(policy.lossy_preserve.is_empty());
627 }
628
629 #[test]
630 fn lossy_is_settable_via_the_builder() {
631 let policy = CompressionPolicy::builder()
632 .lossy(LossyPath::Heuristic)
633 .lossy_ratio(0.5)
634 .lossy_preserve("items")
635 .lossy_preserve("data.results")
636 .build()
637 .unwrap();
638 assert_eq!(policy.lossy, Some(LossyPath::Heuristic));
639 assert_eq!(policy.lossy_ratio, 0.5);
640 assert_eq!(policy.lossy_preserve, vec!["items", "data.results"]);
641 }
642
643 #[test]
644 fn lossy_refuses_memory_retrieval_backend() {
645 let err = CompressionPolicy::builder()
646 .lossy(LossyPath::Heuristic)
647 .retrieval_backend("memory")
648 .build()
649 .unwrap_err();
650 assert!(matches!(err, TokenFoldError::ConfigError(_)));
651 }
652
653 #[test]
654 fn lossy_refuses_a_ttl_below_the_floor() {
655 let err = CompressionPolicy::builder()
656 .lossy(LossyPath::Heuristic)
657 .retrieval_ttl_seconds(Some(60))
658 .build()
659 .unwrap_err();
660 assert!(matches!(err, TokenFoldError::ConfigError(_)));
661 }
662
663 #[test]
664 fn lossy_with_default_retrieval_settings_is_accepted() {
665 let policy = CompressionPolicy::builder()
668 .lossy(LossyPath::Heuristic)
669 .build()
670 .unwrap();
671 assert_eq!(policy.lossy, Some(LossyPath::Heuristic));
672 }
673
674 #[test]
675 fn lossy_ratio_outside_unit_interval_is_rejected() {
676 let err = CompressionPolicy::builder()
677 .lossy_ratio(1.5)
678 .build()
679 .unwrap_err();
680 assert!(matches!(err, TokenFoldError::ConfigError(_)));
681 let err = CompressionPolicy::builder()
682 .lossy_ratio(-0.1)
683 .build()
684 .unwrap_err();
685 assert!(matches!(err, TokenFoldError::ConfigError(_)));
686 }
687
688 #[test]
689 fn malformed_json_never_panics_and_yields_zero_floor() {
690 let input = CompressionInput::openai_json(b"{not json".to_vec());
691 let policy = CompressionPolicy::builder().build().unwrap();
692 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
693 assert_eq!(floor, 0);
694 }
695}