1use sha2::{Digest, Sha256};
2
3use crate::ansi_strip::AnsiStripper;
4use crate::dict_compressor::DictCompressor;
5use crate::entropy_truncator::EntropyTruncator;
6use crate::error::{Result, SqzError};
7use crate::preset::Preset;
8use crate::prompt_cache::PromptCacheDetector;
9use crate::stages::{
10 CollapseArraysStage, CondenseStage, CustomTransformsStage, FlattenStage, GitDiffFoldStage,
11 KeepFieldsStage, StripFieldsStage, StripNullsStage, TruncateStringsStage,
12};
13use crate::token_counter::TokenCounter;
14use crate::token_pruner::TokenPruner;
15use crate::toon::ToonEncoder;
16use crate::types::{CompressedContent, Content, ContentType, ModelFamily, StageConfig};
17
18pub struct SessionContext {
21 pub session_id: String,
22}
23
24pub struct CompressionPipeline {
34 stages: Vec<Box<dyn crate::stages::CompressionStage>>,
35 toon_encoder: ToonEncoder,
36 token_counter: TokenCounter,
37 token_counts: std::cell::RefCell<CountCache>,
38 token_pruner: TokenPruner,
39 dict_compressor: std::cell::RefCell<DictCompressor>,
40 entropy_truncator: EntropyTruncator,
41 #[allow(dead_code)]
42 prompt_cache_detector: PromptCacheDetector,
43}
44
45impl CompressionPipeline {
46 pub fn new(_preset: &Preset) -> Self {
50 let mut stages: Vec<Box<dyn crate::stages::CompressionStage>> = vec![
51 Box::new(AnsiStripper),
52 Box::new(KeepFieldsStage),
53 Box::new(StripFieldsStage),
54 Box::new(CondenseStage),
55 Box::new(GitDiffFoldStage),
56 Box::new(StripNullsStage),
57 Box::new(FlattenStage),
58 Box::new(TruncateStringsStage),
59 Box::new(CollapseArraysStage),
60 Box::new(CustomTransformsStage),
61 ];
62 stages.sort_by_key(|s| s.priority());
63
64 Self {
65 stages,
66 toon_encoder: ToonEncoder,
67 token_counter: TokenCounter::new(),
68 token_counts: std::cell::RefCell::new(CountCache::default()),
69 token_pruner: TokenPruner::new(),
70 dict_compressor: std::cell::RefCell::new(DictCompressor::new()),
71 entropy_truncator: EntropyTruncator::new(),
72 prompt_cache_detector: PromptCacheDetector,
73 }
74 }
75
76 pub fn compress(
81 &self,
82 input: &str,
83 ctx: &SessionContext,
84 preset: &Preset,
85 ) -> Result<CompressedContent> {
86 let mut result = self.compress_inner(input, ctx, preset, false)?;
87 self.count_output(&mut result, preset);
88 Ok(result)
89 }
90
91 pub(crate) fn compress_uncounted(
92 &self,
93 input: &str,
94 ctx: &SessionContext,
95 preset: &Preset,
96 ) -> Result<CompressedContent> {
97 self.compress_inner(input, ctx, preset, false)
98 }
99
100 pub(crate) fn count_output(&self, result: &mut CompressedContent, preset: &Preset) {
101 result.tokens_compressed = self.count_tokens(&result.data, preset);
102 result.compression_ratio = if result.tokens_original == 0 {
103 1.0
104 } else {
105 result.tokens_compressed as f64 / result.tokens_original as f64
106 };
107 }
108
109 pub fn compress_lossless(
121 &self,
122 input: &str,
123 ctx: &SessionContext,
124 preset: &Preset,
125 ) -> Result<CompressedContent> {
126 let mut result = self.compress_inner(input, ctx, preset, true)?;
127 self.count_output(&mut result, preset);
128 Ok(result)
129 }
130
131 pub fn count_tokens(&self, text: &str, preset: &Preset) -> u32 {
135 let family = model_family_from_preset(preset);
136 if text.len() < COUNT_CACHE_MIN_BYTES || matches!(family, ModelFamily::Local(_)) {
137 return self.token_counter.count(text, &family);
138 }
139 let key: CountKey = (
140 std::mem::discriminant(&family),
141 text.len(),
142 Sha256::digest(text.as_bytes()).into(),
143 );
144 if let Some(n) = self.token_counts.borrow().get(&key) {
145 return n;
146 }
147 let n = self.token_counter.count(text, &family);
148 self.token_counts.borrow_mut().insert(key, n);
149 n
150 }
151
152 fn compress_inner(
153 &self,
154 input: &str,
155 _ctx: &SessionContext,
156 preset: &Preset,
157 lossless: bool,
158 ) -> Result<CompressedContent> {
159 let tokens_original = self.count_tokens(input, preset);
160
161 let mut content = Content {
162 raw: input.to_owned(),
163 content_type: ContentType::PlainText,
164 metadata: crate::types::ContentMetadata {
165 source: None,
166 path: None,
167 language: None,
168 },
169 tokens_original,
170 };
171
172 let mut stages_applied: Vec<String> = Vec::new();
173 let mut condensed: Option<(String, String)> = None;
174
175 for stage in &self.stages {
176 if lossless && !stage_is_lossless(stage.name()) {
178 continue;
179 }
180 let config = stage_config_from_preset(stage.name(), preset);
181 if config.enabled {
182 let before = (stage.name() == "condense").then(|| content.raw.clone());
183 stage.process(&mut content, &config)?;
184 if let Some(before) = before.filter(|before| *before != content.raw) {
185 condensed = Some((before, content.raw.clone()));
186 }
187 stages_applied.push(stage.name().to_owned());
188 }
189 }
190
191 let is_json = ToonEncoder::is_json(&content.raw);
194 let is_code = !is_json && looks_like_code(&content.raw);
198 if is_code {
199 stages_applied.push("source_code".to_owned());
200 }
201
202 if !lossless && is_json && content.raw.len() > 100 {
205 let proj_config = crate::json_projection::ProjectionConfig::default();
206 if let Ok(proj_result) = crate::json_projection::project_json(&content.raw, &proj_config) {
207 if proj_result.fields_removed > 0 {
208 content.raw = proj_result.data;
209 stages_applied.push("json_projection".to_owned());
210 }
211 }
212 }
213
214 if !lossless && !is_json && !is_code && content.raw.len() > 200 {
218 let original = condensed
219 .as_ref()
220 .filter(|(_, after)| *after == content.raw)
221 .map(|(before, _)| before.as_str());
222 if let Ok(rle_result) =
223 crate::rle_compressor::rle_compress_with_original(&content.raw, original, 3)
224 {
225 if rle_result.runs_collapsed > 0 {
226 let has_error = content.raw.contains("ERROR") || content.raw.contains("error:");
228 let rle_has_error = rle_result.text.contains("ERROR") || rle_result.text.contains("error:");
229 if !has_error || rle_has_error {
230 content.raw = rle_result.text;
231 stages_applied.push("rle".to_owned());
232 }
233 }
234 }
235 }
236
237 if !lossless && !is_json && !is_code && content.raw.len() > 200 {
242 if let Ok(lt_result) = crate::rle_compressor::log_template_collapse(&content.raw, 3) {
243 if lt_result.runs_collapsed > 0 {
244 let has_error = content.raw.contains("ERROR") || content.raw.contains("error:");
245 let lt_has_error = lt_result.text.contains("ERROR") || lt_result.text.contains("error:");
246 if !has_error || lt_has_error {
247 content.raw = lt_result.text;
248 stages_applied.push("log_template".to_owned());
249 }
250 }
251 }
252 }
253
254 if !lossless && !is_json && !is_code && content.raw.len() > 300 {
258 if let Ok(sw_result) = crate::rle_compressor::sliding_window_dedup(&content.raw, 4) {
259 if sw_result.dedup_count > 0 {
260 let has_error = content.raw.contains("ERROR") || content.raw.contains("error:");
262 let sw_has_error = sw_result.text.contains("ERROR") || sw_result.text.contains("error:");
263 if !has_error || sw_has_error {
264 content.raw = sw_result.text;
265 stages_applied.push("sliding_window_dedup".to_owned());
266 }
267 }
268 }
269 }
270
271 if !lossless && !is_json && !is_code && content.raw.len() > 200 {
278 if let Some(table_result) =
279 crate::table_compactor::compact_aligned_table(&content.raw)
280 {
281 content.raw = table_result.text;
282 stages_applied.push("table_compact".to_owned());
283 }
284 }
285
286 let entropy_threshold = if looks_benign(&content.raw) { 1000 } else { 500 };
293 let record_list = content.raw.len() <= RECORD_LIST_MAX_CHARS
294 && (stages_applied.iter().any(|s| s == "table_compact")
295 || looks_like_record_list(&content.raw));
296 if !lossless && !is_json && !is_code && !record_list && content.raw.len() > entropy_threshold {
297 if let Ok(trunc_result) = self.entropy_truncator.truncate_string(&content.raw) {
298 if trunc_result.segments_dropped > 0 {
299 content.raw = trunc_result.text;
300 stages_applied.push("entropy_truncate".to_owned());
301 }
302 }
303 }
304
305 if !lossless && !is_json && content.raw.len() > 100 && looks_like_prose(&content.raw) {
308 if let Ok(prune_result) = self.token_pruner.prune(&content.raw) {
309 if prune_result.tokens_removed > 0 {
310 content.raw = prune_result.text;
311 stages_applied.push("token_prune".to_owned());
312 }
313 }
314 }
315
316 let data = if !lossless && ToonEncoder::is_json(&content.raw) {
321 let json: serde_json::Value = serde_json::from_str(&content.raw)
322 .map_err(|e| SqzError::Other(format!("pipeline: JSON parse error: {e}")))?;
323 let encoded = self.toon_encoder.encode(&json)?;
324
325 self.dict_compressor.borrow_mut().observe(&content.raw);
329 let dict_result = self.dict_compressor.borrow().compress(&content.raw)?;
330 let with_dict = if dict_result.substitutions > 0 {
331 dict_result.data.find("\n{").and_then(|pos| {
332 let (header, body) = dict_result.data.split_at(pos + 1);
333 let json = serde_json::from_str::<serde_json::Value>(body).ok()?;
334 let body = self.toon_encoder.encode(&json).ok()?;
335 Some(format!("{header}{body}"))
336 })
337 } else {
338 None
339 };
340 match with_dict {
341 Some(dict) if dict.len() < encoded.len() => {
342 stages_applied.push("dict_compress".to_owned());
343 stages_applied.push("toon_encode".to_owned());
344 dict
345 }
346 _ => {
347 stages_applied.push("toon_encode".to_owned());
348 encoded
349 }
350 }
351 } else {
352 content.raw
353 };
354
355 Ok(CompressedContent {
356 data,
357 tokens_compressed: 0,
358 tokens_original,
359 stages_applied,
360 compression_ratio: 1.0,
361 provenance: crate::types::Provenance::default(),
362 verify: None,
363 })
364 }
365
366 pub fn insert_stage(&mut self, stage: Box<dyn crate::stages::CompressionStage>) {
368 self.stages.push(stage);
369 self.stages.sort_by_key(|s| s.priority());
370 }
371
372 pub fn reload_preset(&mut self, _preset: &Preset) -> Result<()> {
376 let mut stages: Vec<Box<dyn crate::stages::CompressionStage>> = vec![
377 Box::new(AnsiStripper),
378 Box::new(KeepFieldsStage),
379 Box::new(StripFieldsStage),
380 Box::new(CondenseStage),
381 Box::new(GitDiffFoldStage),
382 Box::new(StripNullsStage),
383 Box::new(FlattenStage),
384 Box::new(TruncateStringsStage),
385 Box::new(CollapseArraysStage),
386 Box::new(CustomTransformsStage),
387 ];
388 stages.sort_by_key(|s| s.priority());
389 self.stages = stages;
390 self.dict_compressor.borrow_mut().reset();
392 Ok(())
393 }
394}
395
396const COUNT_CACHE_MIN_BYTES: usize = 4 * 1024;
397const COUNT_CACHE_MAX_ENTRIES: usize = 64;
398
399type CountKey = (std::mem::Discriminant<ModelFamily>, usize, [u8; 32]);
400
401#[derive(Default)]
402struct CountCache {
403 entries: std::collections::VecDeque<(CountKey, u32)>,
404}
405
406impl CountCache {
407 fn get(&self, key: &CountKey) -> Option<u32> {
408 self.entries.iter().find(|(k, _)| k == key).map(|(_, n)| *n)
409 }
410
411 fn insert(&mut self, key: CountKey, tokens: u32) {
412 if self.entries.len() == COUNT_CACHE_MAX_ENTRIES {
413 self.entries.pop_front();
414 }
415 self.entries.push_back((key, tokens));
416 }
417}
418
419fn model_family_from_preset(preset: &Preset) -> ModelFamily {
421 match preset.model.family.to_lowercase().as_str() {
422 "anthropic" | "claude" => ModelFamily::AnthropicClaude,
423 "openai" | "gpt" => ModelFamily::OpenAiGpt,
424 "google" | "gemini" => ModelFamily::GoogleGemini,
425 other => ModelFamily::Local(other.to_string()),
426 }
427}
428
429fn stage_config_from_preset(name: &str, preset: &Preset) -> StageConfig {
431 let c = &preset.compression;
432 match name {
433 "ansi_strip" => StageConfig {
434 enabled: true,
435 options: serde_json::Value::Object(Default::default()),
436 },
437 "keep_fields" => {
438 if let Some(cfg) = &c.keep_fields {
439 StageConfig {
440 enabled: cfg.enabled,
441 options: serde_json::json!({ "fields": cfg.fields }),
442 }
443 } else {
444 StageConfig::default()
445 }
446 }
447 "strip_fields" => {
448 if let Some(cfg) = &c.strip_fields {
449 StageConfig {
450 enabled: cfg.enabled,
451 options: serde_json::json!({ "fields": cfg.fields }),
452 }
453 } else {
454 StageConfig::default()
455 }
456 }
457 "condense" => {
458 if let Some(cfg) = &c.condense {
459 StageConfig {
460 enabled: cfg.enabled,
461 options: serde_json::json!({
462 "max_repeated_lines": cfg.max_repeated_lines
463 }),
464 }
465 } else {
466 StageConfig::default()
467 }
468 }
469 "git_diff_fold" => {
470 if let Some(cfg) = &c.git_diff_fold {
471 StageConfig {
472 enabled: cfg.enabled,
473 options: serde_json::json!({
474 "max_context_lines": cfg.max_context_lines
475 }),
476 }
477 } else {
478 StageConfig {
480 enabled: true,
481 options: serde_json::json!({ "max_context_lines": 2 }),
482 }
483 }
484 }
485 "strip_nulls" => {
486 if let Some(cfg) = &c.strip_nulls {
487 StageConfig {
488 enabled: cfg.enabled,
489 options: serde_json::Value::Object(Default::default()),
490 }
491 } else {
492 StageConfig::default()
493 }
494 }
495 "flatten" => {
496 if let Some(cfg) = &c.flatten {
497 StageConfig {
498 enabled: cfg.enabled,
499 options: serde_json::json!({ "max_depth": cfg.max_depth }),
500 }
501 } else {
502 StageConfig::default()
503 }
504 }
505 "truncate_strings" => {
506 if let Some(cfg) = &c.truncate_strings {
507 StageConfig {
508 enabled: cfg.enabled,
509 options: serde_json::json!({ "max_length": cfg.max_length }),
510 }
511 } else {
512 StageConfig::default()
513 }
514 }
515 "collapse_arrays" => {
516 if let Some(cfg) = &c.collapse_arrays {
517 StageConfig {
518 enabled: cfg.enabled,
519 options: serde_json::json!({
520 "max_items": cfg.max_items,
521 "max_chars": cfg.max_chars,
522 "summary_template": cfg.summary_template
523 }),
524 }
525 } else {
526 StageConfig::default()
527 }
528 }
529 "custom_transforms" => {
530 if let Some(cfg) = &c.custom_transforms {
531 StageConfig {
532 enabled: cfg.enabled,
533 options: serde_json::Value::Object(Default::default()),
534 }
535 } else {
536 StageConfig::default()
537 }
538 }
539 _ => StageConfig::default(),
540 }
541}
542
543fn stage_is_lossless(name: &str) -> bool {
548 matches!(name, "ansi_strip" | "custom_transforms")
549}
550
551const RECORD_LIST_MAX_CHARS: usize = 16_000;
553
554fn looks_like_record_list(text: &str) -> bool {
556 let lines: Vec<&str> = text.lines().map(str::trim_end).filter(|l| !l.is_empty()).collect();
557 if lines.len() < 8 {
558 return false;
559 }
560 let unique: std::collections::HashSet<&str> = lines.iter().copied().collect();
561 if unique.len() * 10 < lines.len() * 9 {
562 return false;
563 }
564 let records = lines.iter().filter(|l| is_record_line(l)).count();
565 records * 10 >= lines.len() * 8
566}
567
568fn is_record_line(line: &str) -> bool {
569 let t = line.trim_start();
570 let pathish = |s: &str| {
571 (s.contains('/') || s.contains('.')) && s.bytes().any(|b| b.is_ascii_alphanumeric())
572 };
573 if let Some((path, rest)) = t.split_once(':') {
575 let digits = rest.bytes().take_while(|b| b.is_ascii_digit()).count();
576 if digits > 0 && rest.as_bytes().get(digits) == Some(&b':')
577 && !path.contains(char::is_whitespace) && pathish(path)
578 {
579 return true;
580 }
581 }
582 if !t.contains(char::is_whitespace) && pathish(t) {
584 return true;
585 }
586 let b = t.as_bytes();
588 if b.len() > 10
589 && b"-dlcbps".contains(&b[0])
590 && b[1..10].iter().all(|c| b"rwxsStT-".contains(c))
591 {
592 return true;
593 }
594 let sha = t.bytes().take_while(|b| b.is_ascii_hexdigit()).count();
596 sha >= 7 && sha <= 40 && t.as_bytes().get(sha).map_or(true, |b| *b == b' ')
597}
598
599fn looks_benign(text: &str) -> bool {
603 let lower = text.to_lowercase();
604 const MARKERS: &[&str] = &[
605 "error", "panic", "traceback", "failed", "failure", "exception", "fatal",
606 ];
607 !MARKERS.iter().any(|m| lower.contains(m))
608}
609
610fn looks_like_prose(text: &str) -> bool {
613 let lines: Vec<&str> = text.lines().take(20).collect();
614 if lines.is_empty() {
615 return false;
616 }
617
618 let mut prose_lines = 0;
619 let mut code_lines = 0;
620
621 for line in &lines {
622 let trimmed = line.trim();
623 if trimmed.is_empty() {
624 continue;
625 }
626 if trimmed.ends_with('{')
628 || trimmed.ends_with('}')
629 || trimmed.ends_with(';')
630 || trimmed.starts_with("fn ")
631 || trimmed.starts_with("pub ")
632 || trimmed.starts_with("let ")
633 || trimmed.starts_with("const ")
634 || trimmed.starts_with("import ")
635 || trimmed.starts_with("def ")
636 || trimmed.starts_with("class ")
637 || trimmed.contains("::")
638 || trimmed.contains("->")
639 || trimmed.contains("=>")
640 {
641 code_lines += 1;
642 } else {
643 prose_lines += 1;
644 }
645 }
646
647 prose_lines > code_lines
649}
650
651fn looks_like_code(text: &str) -> bool {
656 const DECL: &[&str] = &[
657 "def ", "class ", "fn ", "pub ", "func ", "function ", "async ", "import ", "from ",
658 "use ", "package ", "struct ", "impl ", "enum ", "trait ", "interface ", "type ",
659 "export ", "const ", "let ", "var ", "static ", "public ", "private ", "protected ",
660 "return ", "#include", "@",
661 ];
662 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).take(80).collect();
663 if lines.len() < 3 {
664 return false;
665 }
666 let mut decl = 0usize;
667 let mut codeish = 0usize;
668 for line in &lines {
669 let t = line.trim_start();
670 if t.starts_with(|c: char| c.is_ascii_digit()) {
671 continue;
672 }
673 let is_decl = DECL.iter().any(|k| t.starts_with(k));
674 if is_decl {
675 decl += 1;
676 }
677 let ends_like_code = t.trim_end().ends_with(['{', '}', ';', ':', ')', ',']);
678 if is_decl
679 || ends_like_code
680 || t.contains(" = ")
681 || t.starts_with("//")
682 || t.starts_with('#')
683 || t.starts_with("/*")
684 || t.starts_with('*')
685 || line.starts_with(" ")
686 || line.starts_with('\t')
687 {
688 codeish += 1;
689 }
690 }
691 decl >= 2 && codeish * 10 >= lines.len() * 7
692}
693
694#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::preset::{
702 BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
703 CustomTransformsConfig, ModelConfig, PresetMeta,
704 StripNullsConfig, ToolSelectionConfig, TruncateStringsConfig,
705 TerseModeConfig,
706 };
707
708 fn default_preset() -> Preset {
709 Preset {
710 preset: PresetMeta {
711 name: "test".into(),
712 version: "1.0".into(),
713 description: String::new(),
714 },
715 compression: CompressionConfig {
716 stages: vec![],
717 keep_fields: None,
718 strip_fields: None,
719 condense: Some(CondenseConfig {
720 enabled: true,
721 max_repeated_lines: 3,
722 }),
723 git_diff_fold: None,
724 strip_nulls: Some(StripNullsConfig { enabled: true }),
725 flatten: None,
726 truncate_strings: Some(TruncateStringsConfig {
727 enabled: true,
728 max_length: 500,
729 }),
730 collapse_arrays: Some(CollapseArraysConfig {
731 enabled: true,
732 max_items: 5,
733 max_chars: 32_000,
734 summary_template: "... and {remaining} more items".into(),
735 }),
736 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
737 },
738 tool_selection: ToolSelectionConfig {
739 max_tools: 5,
740 similarity_threshold: 0.7,
741 default_tools: vec![],
742 },
743 budget: BudgetConfig {
744 warning_threshold: 0.70,
745 ceiling_threshold: 0.85,
746 default_window_size: 200_000,
747 agents: Default::default(),
748 },
749 terse_mode: TerseModeConfig {
750 enabled: false,
751 level: crate::preset::TerseLevel::Moderate,
752 },
753 model: ModelConfig {
754 family: "anthropic".into(),
755 primary: "claude-sonnet-4-20250514".into(),
756 local: String::new(),
757 complexity_threshold: 0.4,
758 pricing: None,
759 },
760 }
761 }
762
763 fn ctx() -> SessionContext {
764 SessionContext {
765 session_id: "test-session".into(),
766 }
767 }
768
769 #[test]
774 fn source_code_skips_line_dropping_stages() {
775 let pipeline = CompressionPipeline::new(&default_preset());
776 let preset = default_preset();
777 let mut code = String::from(
778 "from dataclasses import dataclass\n\nfrom .errors import RefundError\nfrom .gateway import Gateway\n\n\n@dataclass\nclass RefundService:\n gateway: Gateway\n\n",
779 );
780 for i in 0..12 {
781 code.push_str(&format!(
782 " def method_{i}(self, charge: Charge, amount_cents: int) -> Refund:\n if amount_cents <= 0:\n raise RefundError(\"invalid refund amount\")\n refund = self.gateway.refund(charge.id, amount_cents)\n self.ledger.record(refund)\n return refund\n\n"
783 ));
784 }
785 assert!(code.len() > 1000);
786 assert!(looks_like_code(&code));
787 let result = pipeline.compress(&code, &ctx(), &preset).unwrap();
788 for stage in ["entropy_truncate", "sliding_window_dedup", "rle", "log_template", "table_compact"] {
789 assert!(!result.stages_applied.iter().any(|s| s == stage), "{stage} ran on source code: {:?}", result.stages_applied);
790 }
791 assert!(result.stages_applied.iter().any(|s| s == "source_code"), "{:?}", result.stages_applied);
792 assert!(result.data.contains("from .errors import RefundError"));
793 assert!(result.data.contains("class RefundService:"));
794
795 let mut log = String::new();
796 for i in 0..60 {
797 log.push_str(&format!("2026-09-14T10:22:{:02}Z INFO GET /healthz 200 1ms\n", i % 60));
798 }
799 log.push_str("2026-09-14T10:23:41Z ERROR refund failed order=ord_8812\n");
800 assert!(!looks_like_code(&log));
801 let result = pipeline.compress(&log, &ctx(), &preset).unwrap();
802 assert!(result.data.len() < log.len() / 2, "log should still collapse: {}", result.data);
803 assert!(result.data.contains("ERROR refund failed order=ord_8812"));
804
805 let status = "On branch main\n\nChanges not staged for commit:\n\tmodified: README.md\n\tmodified: src/lib.rs\n\nUntracked files:\n\tnotes.txt\n";
806 assert!(!looks_like_code(status));
807 }
808
809 #[test]
810 fn table_compact_stage_fires_on_tabular_output_only() {
811 let pipeline = CompressionPipeline::new(&default_preset());
812
813 let table = "\
814NAME READY STATUS RESTARTS AGE
815api-6d4cf56db6-2m5xk 1/1 Running 0 4d20h
816api-6d4cf56db6-9lq2v 1/1 Running 0 4d20h
817worker-7c9f8b5d44-abcde 1/1 Running 2 12h
818worker-7c9f8b5d44-fghij 0/1 Pending 0 3m
819ingress-nginx-controller-x 1/1 Running 0 30d
820";
821 let preset = default_preset();
822 let result = pipeline.compress(table, &ctx(), &preset).unwrap();
823 assert!(
824 result.stages_applied.iter().any(|s| s == "table_compact"),
825 "stages: {:?}",
826 result.stages_applied
827 );
828 assert!(result.data.contains("api-6d4cf56db6-2m5xk"), "{}", result.data);
829
830 let code = "\
831fn main() {
832 let alignment_one = 1;
833 let alignment_two = 2;
834 let alignment_three = 3;
835 let alignment_four = 4;
836 println!(\"{}\", alignment_one + alignment_two);
837}
838";
839 let result = pipeline.compress(code, &ctx(), &preset).unwrap();
840 assert!(
841 !result.stages_applied.iter().any(|s| s == "table_compact"),
842 "code must never hit the table compactor: {:?}",
843 result.stages_applied
844 );
845
846 let result = pipeline.compress_lossless(table, &ctx(), &preset).unwrap();
848 assert!(
849 !result.stages_applied.iter().any(|s| s == "table_compact"),
850 "lossless path must stay byte-faithful: {:?}",
851 result.stages_applied
852 );
853 }
854
855 #[test]
856 fn compress_lossless_never_drops_segments() {
857 let preset = default_preset();
858 let pipeline = CompressionPipeline::new(&preset);
859
860 let mut segments = Vec::new();
863 for i in 0..24 {
866 if i % 2 == 0 {
867 segments.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
868 } else {
869 segments.push(format!(
870 "SEG{i} the quick brown fox jumps over {i} lazy dogs by silver rivers"
871 ));
872 }
873 }
874 let input = segments.join("\n\n");
875 assert!(input.len() > 1000, "input must exceed the benign truncation threshold");
876
877 let lossy = pipeline.compress(&input, &ctx(), &preset).unwrap();
879 assert!(
880 lossy.data.contains("low-information segments omitted"),
881 "expected the default path to truncate; got: {}",
882 lossy.data
883 );
884
885 let lossless = pipeline.compress_lossless(&input, &ctx(), &preset).unwrap();
887 assert!(
888 !lossless.data.contains("omitted"),
889 "lossless path must not truncate; got: {}",
890 lossless.data
891 );
892 assert_eq!(
893 lossless.data, input,
894 "lossless path must return content unchanged"
895 );
896 assert!(!lossless.stages_applied.contains(&"entropy_truncate".to_owned()));
897 assert!(!lossless.stages_applied.contains(&"token_prune".to_owned()));
898 }
899
900 #[test]
901 fn repeated_lines_report_the_real_count() {
902 let preset = default_preset();
903 let pipeline = CompressionPipeline::new(&preset);
904 let head = "2024-01-01 09:59:59 [INFO] Server started on 0.0.0.0:8080\n";
905 let line = "2024-01-01 10:00:00 [INFO] Connected to database";
906 let tail = "2024-01-01 10:00:11 [ERROR] Connection timeout after 30s\n";
907 for n in [2, 3, 4, 10, 100] {
908 let input = format!("{head}{}{tail}", format!("{line}\n").repeat(n));
909 assert!(input.len() > 200, "n={n}");
910 let result = pipeline.compress(&input, &ctx(), &preset).unwrap();
911 let expected = if n < 3 { input.clone() } else { format!("{head}{line} [×{n}]\n{tail}") };
912 assert_eq!(result.data, expected, "n={n}");
913 }
914 }
915
916 #[test]
917 fn folded_and_collapsed_counts_are_exact() {
918 let preset = default_preset();
919 let pipeline = CompressionPipeline::new(&preset);
920 let context: String = (0..6).map(|i| format!(" context_{i:02}()\n")).collect();
921 let diff = format!(
922 "diff --git a/a.py b/a.py\nindex 1111111..2222222 100644\n--- a/a.py\n+++ b/a.py\n@@ -1,30 +1,30 @@\n-old_call()\n+new_call()\n{context}{}{context}-old_tail()\n+new_tail()\n",
923 " \n".repeat(10)
924 );
925 let result = pipeline.compress(&diff, &ctx(), &preset).unwrap();
926 assert!(result.data.contains("[18 unchanged lines]"), "{}", result.data);
927
928 let mut samples = vec![0; 20_000];
929 samples.push(1);
930 let pretty = serde_json::to_string_pretty(&serde_json::json!({ "samples": samples })).unwrap();
931 let result = pipeline.compress(&pretty, &ctx(), &preset).unwrap();
932 let decoded = ToonEncoder.decode(&result.data).unwrap();
933 let arr = decoded["samples"].as_array().unwrap();
934 let (summary, kept) = arr.split_last().unwrap();
935 let remaining: usize = summary
936 .as_str()
937 .and_then(|s| s.strip_prefix("... and "))
938 .and_then(|s| s.strip_suffix(" more items"))
939 .and_then(|n| n.parse().ok())
940 .unwrap();
941 assert_eq!(kept.len() + remaining, 20_001);
942 assert!(kept.iter().all(|v| v == 0));
943 }
944
945 #[test]
949 fn compress_lossless_preserves_json_fields() {
950 let preset = default_preset();
951 let pipeline = CompressionPipeline::new(&preset);
952 let json = r#"{"keep":1,"gone":null,"items":[1,2,3,4,5,6,7,8],"note":"x"}"#;
953
954 let lossy = pipeline.compress(json, &ctx(), &preset).unwrap();
957 assert_ne!(lossy.data, json, "default path should transform JSON");
958
959 let result = pipeline.compress_lossless(json, &ctx(), &preset).unwrap();
961 assert_eq!(result.data, json, "lossless must return JSON byte-for-byte");
962 }
963
964 #[test]
967 fn compress_lossless_returns_repeated_lines_verbatim() {
968 let preset = default_preset();
969 let pipeline = CompressionPipeline::new(&preset);
970 let line = "this is a repeated diagnostic line that is quite long\n";
971 let input = format!(
972 "{}a unique trailing line of sufficient length here\n",
973 line.repeat(6)
974 );
975 assert!(input.len() > 300);
976
977 let lossy = pipeline.compress(&input, &ctx(), &preset).unwrap();
979 assert!(
980 lossy.data.len() < input.len(),
981 "default path should collapse repeats"
982 );
983
984 let result = pipeline.compress_lossless(&input, &ctx(), &preset).unwrap();
985 assert_eq!(
986 result.data, input,
987 "lossless must return repeated content verbatim"
988 );
989 assert!(!result.stages_applied.contains(&"rle".to_owned()));
990 }
991
992 #[test]
995 fn benign_output_gets_double_truncation_headroom() {
996 let preset = default_preset();
997 let pipeline = CompressionPipeline::new(&preset);
998
999 let mut segments = Vec::new();
1000 for i in 0..12 {
1001 if i % 2 == 0 {
1002 segments.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
1003 } else {
1004 segments.push(format!(
1005 "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
1006 ));
1007 }
1008 }
1009 let benign = segments.join("\n\n");
1010 assert!(benign.len() > 500 && benign.len() < 1000, "fixture must sit between thresholds: {}", benign.len());
1011
1012 let result = pipeline.compress(&benign, &ctx(), &preset).unwrap();
1013 assert!(
1014 !result.stages_applied.contains(&"entropy_truncate".to_owned()),
1015 "benign content under 1000 bytes must not be truncated"
1016 );
1017
1018 let with_error = format!("error: something broke\n\n{benign}");
1019 let result = pipeline.compress(&with_error, &ctx(), &preset).unwrap();
1020 assert!(
1021 result.stages_applied.contains(&"entropy_truncate".to_owned()),
1022 "error-bearing content over 500 bytes should truncate"
1023 );
1024 }
1025
1026 #[test]
1027 fn new_creates_pipeline_with_sorted_stages() {
1028 let preset = default_preset();
1029 let pipeline = CompressionPipeline::new(&preset);
1030 let priorities: Vec<u32> = pipeline.stages.iter().map(|s| s.priority()).collect();
1032 let mut sorted = priorities.clone();
1033 sorted.sort();
1034 assert_eq!(priorities, sorted);
1035 }
1036
1037 #[test]
1038 fn compress_plain_text_passthrough() {
1039 let preset = default_preset();
1040 let pipeline = CompressionPipeline::new(&preset);
1041 let result = pipeline.compress("hello world", &ctx(), &preset).unwrap();
1042 assert_eq!(result.data, "hello world");
1043 assert!(!result.stages_applied.contains(&"toon_encode".to_owned()));
1044 }
1045
1046 #[test]
1047 fn compress_json_applies_toon() {
1048 let preset = default_preset();
1049 let pipeline = CompressionPipeline::new(&preset);
1050 let json = r#"{"name":"Alice","age":30}"#;
1051 let result = pipeline.compress(json, &ctx(), &preset).unwrap();
1052 assert!(result.data.starts_with("TOON:"), "data: {}", result.data);
1053 assert!(result.stages_applied.contains(&"toon_encode".to_owned()));
1054 }
1055
1056 #[test]
1057 fn compress_strips_nulls_from_json() {
1058 let preset = default_preset();
1059 let pipeline = CompressionPipeline::new(&preset);
1060 let json = r#"{"a":1,"b":null}"#;
1061 let result = pipeline.compress(json, &ctx(), &preset).unwrap();
1062 assert!(result.data.starts_with("TOON:"));
1064 let decoded = ToonEncoder.decode(&result.data).unwrap();
1066 assert!(decoded.get("b").is_none());
1067 assert_eq!(decoded["a"], serde_json::json!(1));
1068 }
1069
1070 #[test]
1071 fn compress_returns_token_counts() {
1072 let preset = default_preset();
1073 let pipeline = CompressionPipeline::new(&preset);
1074 let input = "a".repeat(100);
1075 let result = pipeline.compress(&input, &ctx(), &preset).unwrap();
1076 assert!(result.tokens_original > 0);
1077 assert!(result.tokens_compressed > 0);
1078 }
1079
1080 #[test]
1081 fn count_cache_holds_no_text() {
1082 let preset = default_preset();
1083 let pipeline = CompressionPipeline::new(&preset);
1084 let texts: Vec<String> = (0..COUNT_CACHE_MAX_ENTRIES + 8)
1085 .map(|i| format!("token=secret-{i:03} in a tool result\n").repeat(150))
1086 .collect();
1087 let family = model_family_from_preset(&preset);
1088 for (i, text) in texts.iter().enumerate() {
1089 let n = pipeline.token_counter.count(text, &family);
1090 assert_eq!(pipeline.count_tokens(text, &preset), n);
1091 assert_eq!(pipeline.count_tokens(text, &preset), n);
1092 let held = pipeline.token_counts.borrow().entries.len();
1093 assert_eq!(held, (i + 1).min(COUNT_CACHE_MAX_ENTRIES));
1094 }
1095 assert!(!std::mem::needs_drop::<(CountKey, u32)>());
1096 let cache = pipeline.token_counts.borrow();
1097 let bytes = cache.entries.capacity() * std::mem::size_of::<(CountKey, u32)>();
1098 assert!(bytes < texts[0].len(), "cache holds {bytes} bytes");
1099 }
1100
1101 #[test]
1102 fn compress_ratio_is_reasonable() {
1103 let preset = default_preset();
1104 let pipeline = CompressionPipeline::new(&preset);
1105 let result = pipeline.compress("hello", &ctx(), &preset).unwrap();
1106 assert!(result.compression_ratio > 0.0);
1107 }
1108
1109 #[test]
1110 fn insert_stage_re_sorts_by_priority() {
1111 use crate::stages::CompressionStage;
1112 use crate::types::StageConfig;
1113
1114 struct LowPriorityStage;
1115 impl CompressionStage for LowPriorityStage {
1116 fn name(&self) -> &str {
1117 "low_priority"
1118 }
1119 fn priority(&self) -> u32 {
1120 5 }
1122 fn process(
1123 &self,
1124 _content: &mut Content,
1125 _config: &StageConfig,
1126 ) -> crate::error::Result<()> {
1127 Ok(())
1128 }
1129 }
1130
1131 let preset = default_preset();
1132 let mut pipeline = CompressionPipeline::new(&preset);
1133 pipeline.insert_stage(Box::new(LowPriorityStage));
1134
1135 let priorities: Vec<u32> = pipeline.stages.iter().map(|s| s.priority()).collect();
1136 let mut sorted = priorities.clone();
1137 sorted.sort();
1138 assert_eq!(priorities, sorted);
1139 assert_eq!(pipeline.stages[0].name(), "ansi_strip");
1140 assert_eq!(pipeline.stages[1].name(), "low_priority");
1141 }
1142
1143 #[test]
1144 fn reload_preset_rebuilds_stages() {
1145 let preset = default_preset();
1146 let mut pipeline = CompressionPipeline::new(&preset);
1147 let original_count = pipeline.stages.len();
1148 pipeline.reload_preset(&preset).unwrap();
1149 assert_eq!(pipeline.stages.len(), original_count);
1150 }
1151
1152 #[test]
1153 fn compress_keep_fields_filters_json() {
1154 use crate::preset::KeepFieldsConfig;
1155 let mut preset = default_preset();
1156 preset.compression.keep_fields = Some(KeepFieldsConfig {
1157 enabled: true,
1158 fields: vec!["id".into(), "name".into()],
1159 });
1160 let pipeline = CompressionPipeline::new(&preset);
1161 let json = r#"{"id":1,"name":"Bob","debug":"x"}"#;
1162 let result = pipeline.compress(json, &ctx(), &preset).unwrap();
1163 let decoded = ToonEncoder.decode(&result.data).unwrap();
1164 assert!(decoded.get("debug").is_none());
1165 assert_eq!(decoded["id"], serde_json::json!(1));
1166 }
1167
1168 #[test]
1169 fn compress_empty_string() {
1170 let preset = default_preset();
1171 let pipeline = CompressionPipeline::new(&preset);
1172 let result = pipeline.compress("", &ctx(), &preset).unwrap();
1173 assert_eq!(result.data, "");
1174 assert_eq!(result.tokens_original, 0);
1175 }
1176
1177 #[test]
1178 fn stage_config_from_preset_unknown_stage() {
1179 let preset = default_preset();
1180 let config = stage_config_from_preset("nonexistent", &preset);
1181 assert!(!config.enabled);
1182 }
1183
1184 use proptest::prelude::*;
1189
1190 fn significant_line_strategy() -> impl Strategy<Value = String> {
1192 prop_oneof![
1193 Just("error: connection refused".to_owned()),
1194 Just("warning: deprecated API usage".to_owned()),
1195 Just("failed: build step exited with code 1".to_owned()),
1196 Just("success: deployment complete".to_owned()),
1197 Just("status: all checks passed".to_owned()),
1198 Just("error: file not found".to_owned()),
1199 Just("warning: unused variable detected".to_owned()),
1200 ]
1201 }
1202
1203 fn noise_line_strategy() -> impl Strategy<Value = String> {
1205 prop_oneof![
1206 Just("---".to_owned()),
1207 Just("Loading...".to_owned()),
1208 Just("================".to_owned()),
1209 Just("...".to_owned()),
1210 ]
1211 }
1212
1213 fn arb_json_value() -> impl Strategy<Value = serde_json::Value> {
1216 let leaf = prop_oneof![
1217 Just(serde_json::Value::Null),
1218 any::<bool>().prop_map(serde_json::Value::Bool),
1219 any::<i64>().prop_map(|n| serde_json::json!(n)),
1220 any::<f64>()
1221 .prop_filter("must be finite", |f| f.is_finite())
1222 .prop_map(|f| serde_json::json!(f)),
1223 ".*".prop_map(serde_json::Value::String),
1224 ];
1225
1226 leaf.prop_recursive(4, 64, 8, |inner| {
1227 prop_oneof![
1228 prop::collection::vec(inner.clone(), 0..8)
1229 .prop_map(serde_json::Value::Array),
1230 prop::collection::hash_map(".*", inner, 0..8).prop_map(|m| {
1231 serde_json::Value::Object(m.into_iter().collect())
1232 }),
1233 ]
1234 })
1235 }
1236
1237 proptest! {
1238 #[test]
1247 fn prop_pipeline_ascii_safe_output(v in arb_json_value()) {
1248 let preset = default_preset();
1249 let pipeline = CompressionPipeline::new(&preset);
1250
1251 let json_input = serde_json::to_string(&v).expect("serialize should not fail");
1252 let result = pipeline.compress(&json_input, &ctx(), &preset)
1253 .expect("compress should not fail");
1254
1255 for ch in result.data.chars() {
1256 let is_standard_whitespace = matches!(ch, '\t' | '\n' | '\r');
1257 prop_assert!(
1258 !ch.is_control() || is_standard_whitespace,
1259 "raw control character in output: U+{:04X}\noutput: {:?}",
1260 ch as u32, result.data
1261 );
1262 }
1263 }
1264 }
1265
1266 proptest! {
1267 #[test]
1277 fn prop_compression_preserves_significant_content(
1278 significant_lines in prop::collection::vec(significant_line_strategy(), 1..=5),
1279 noise_line in noise_line_strategy(),
1280 noise_repeat in 5u32..=10u32,
1281 ) {
1282 let preset = default_preset(); let pipeline = CompressionPipeline::new(&preset);
1284
1285 let mut lines: Vec<String> = Vec::new();
1287 for sig in &significant_lines {
1288 for _ in 0..noise_repeat {
1289 lines.push(noise_line.clone());
1290 }
1291 lines.push(sig.clone());
1292 }
1293 for _ in 0..noise_repeat {
1295 lines.push(noise_line.clone());
1296 }
1297
1298 let input = lines.join("\n");
1299 let result = pipeline.compress(&input, &ctx(), &preset).unwrap();
1300 let output = &result.data;
1301
1302 for sig in &significant_lines {
1304 prop_assert!(
1305 output.contains(sig.as_str()),
1306 "significant line missing from output: {:?}\noutput: {:?}",
1307 sig,
1308 output
1309 );
1310 }
1311
1312 let mut max_run = 0usize;
1314 let mut current_run = 0usize;
1315 for line in output.lines() {
1316 if line == noise_line.as_str() {
1317 current_run += 1;
1318 max_run = max_run.max(current_run);
1319 } else {
1320 current_run = 0;
1321 }
1322 }
1323 prop_assert!(
1324 max_run <= 3,
1325 "noise line {:?} has a consecutive run of {} (max 3)\noutput: {:?}",
1326 noise_line,
1327 max_run,
1328 output
1329 );
1330 }
1331 }
1332}