1use crate::ansi_strip::AnsiStripper;
2use crate::dict_compressor::DictCompressor;
3use crate::entropy_truncator::EntropyTruncator;
4use crate::error::{Result, SqzError};
5use crate::preset::Preset;
6use crate::prompt_cache::PromptCacheDetector;
7use crate::stages::{
8 CollapseArraysStage, CondenseStage, CustomTransformsStage, FlattenStage, GitDiffFoldStage,
9 KeepFieldsStage, StripFieldsStage, StripNullsStage, TruncateStringsStage,
10};
11use crate::token_counter::TokenCounter;
12use crate::token_pruner::TokenPruner;
13use crate::toon::ToonEncoder;
14use crate::types::{CompressedContent, Content, ContentType, ModelFamily, StageConfig};
15
16pub struct SessionContext {
19 pub session_id: String,
20}
21
22pub struct CompressionPipeline {
32 stages: Vec<Box<dyn crate::stages::CompressionStage>>,
33 toon_encoder: ToonEncoder,
34 token_counter: TokenCounter,
35 token_pruner: TokenPruner,
36 dict_compressor: std::cell::RefCell<DictCompressor>,
37 entropy_truncator: EntropyTruncator,
38 #[allow(dead_code)]
39 prompt_cache_detector: PromptCacheDetector,
40}
41
42impl CompressionPipeline {
43 pub fn new(_preset: &Preset) -> Self {
47 let mut stages: Vec<Box<dyn crate::stages::CompressionStage>> = vec![
48 Box::new(AnsiStripper),
49 Box::new(KeepFieldsStage),
50 Box::new(StripFieldsStage),
51 Box::new(CondenseStage),
52 Box::new(GitDiffFoldStage),
53 Box::new(StripNullsStage),
54 Box::new(FlattenStage),
55 Box::new(TruncateStringsStage),
56 Box::new(CollapseArraysStage),
57 Box::new(CustomTransformsStage),
58 ];
59 stages.sort_by_key(|s| s.priority());
60
61 Self {
62 stages,
63 toon_encoder: ToonEncoder,
64 token_counter: TokenCounter::new(),
65 token_pruner: TokenPruner::new(),
66 dict_compressor: std::cell::RefCell::new(DictCompressor::new()),
67 entropy_truncator: EntropyTruncator::new(),
68 prompt_cache_detector: PromptCacheDetector,
69 }
70 }
71
72 pub fn compress(
77 &self,
78 input: &str,
79 ctx: &SessionContext,
80 preset: &Preset,
81 ) -> Result<CompressedContent> {
82 self.compress_inner(input, ctx, preset, false)
83 }
84
85 pub fn compress_lossless(
97 &self,
98 input: &str,
99 ctx: &SessionContext,
100 preset: &Preset,
101 ) -> Result<CompressedContent> {
102 self.compress_inner(input, ctx, preset, true)
103 }
104
105 fn compress_inner(
106 &self,
107 input: &str,
108 _ctx: &SessionContext,
109 preset: &Preset,
110 lossless: bool,
111 ) -> Result<CompressedContent> {
112 let model_family = model_family_from_preset(preset);
113 let tokens_original = self.token_counter.count(input, &model_family);
114
115 let mut content = Content {
116 raw: input.to_owned(),
117 content_type: ContentType::PlainText,
118 metadata: crate::types::ContentMetadata {
119 source: None,
120 path: None,
121 language: None,
122 },
123 tokens_original,
124 };
125
126 let mut stages_applied: Vec<String> = Vec::new();
127
128 for stage in &self.stages {
129 if lossless && !stage_is_lossless(stage.name()) {
131 continue;
132 }
133 let config = stage_config_from_preset(stage.name(), preset);
134 if config.enabled {
135 stage.process(&mut content, &config)?;
136 stages_applied.push(stage.name().to_owned());
137 }
138 }
139
140 let is_json = ToonEncoder::is_json(&content.raw);
143
144 if !lossless && is_json && content.raw.len() > 100 {
147 let proj_config = crate::json_projection::ProjectionConfig::default();
148 if let Ok(proj_result) = crate::json_projection::project_json(&content.raw, &proj_config) {
149 if proj_result.fields_removed > 0 {
150 content.raw = proj_result.data;
151 stages_applied.push("json_projection".to_owned());
152 }
153 }
154 }
155
156 if !lossless && !is_json && content.raw.len() > 200 {
160 if let Ok(rle_result) = crate::rle_compressor::rle_compress(&content.raw, 3) {
161 if rle_result.runs_collapsed > 0 {
162 let has_error = content.raw.contains("ERROR") || content.raw.contains("error:");
164 let rle_has_error = rle_result.text.contains("ERROR") || rle_result.text.contains("error:");
165 if !has_error || rle_has_error {
166 content.raw = rle_result.text;
167 stages_applied.push("rle".to_owned());
168 }
169 }
170 }
171 }
172
173 if !lossless && !is_json && content.raw.len() > 300 {
177 if let Ok(sw_result) = crate::rle_compressor::sliding_window_dedup(&content.raw, 4) {
178 if sw_result.dedup_count > 0 {
179 let has_error = content.raw.contains("ERROR") || content.raw.contains("error:");
181 let sw_has_error = sw_result.text.contains("ERROR") || sw_result.text.contains("error:");
182 if !has_error || sw_has_error {
183 content.raw = sw_result.text;
184 stages_applied.push("sliding_window_dedup".to_owned());
185 }
186 }
187 }
188 }
189
190 if !lossless && !is_json && content.raw.len() > 500 {
193 if let Ok(trunc_result) = self.entropy_truncator.truncate_string(&content.raw) {
194 if trunc_result.segments_dropped > 0 {
195 content.raw = trunc_result.text;
196 stages_applied.push("entropy_truncate".to_owned());
197 }
198 }
199 }
200
201 if !lossless && !is_json && content.raw.len() > 100 && looks_like_prose(&content.raw) {
204 if let Ok(prune_result) = self.token_pruner.prune(&content.raw) {
205 if prune_result.tokens_removed > 0 {
206 content.raw = prune_result.text;
207 stages_applied.push("token_prune".to_owned());
208 }
209 }
210 }
211
212 let data = if !lossless && ToonEncoder::is_json(&content.raw) {
217 self.dict_compressor.borrow_mut().observe(&content.raw);
221 let dict_result = self.dict_compressor.borrow().compress(&content.raw)?;
222
223 if dict_result.substitutions > 0 && dict_result.bytes_saved > 50 {
224 stages_applied.push("dict_compress".to_owned());
226 if let Some(newline_pos) = dict_result.data.find("\n{") {
227 let header = &dict_result.data[..newline_pos + 1];
228 let json_body = &dict_result.data[newline_pos + 1..];
229 if let Ok(json) = serde_json::from_str::<serde_json::Value>(json_body) {
230 let encoded = self.toon_encoder.encode(&json)?;
231 stages_applied.push("toon_encode".to_owned());
232 format!("{header}{encoded}")
233 } else {
234 dict_result.data
235 }
236 } else {
237 dict_result.data
238 }
239 } else {
240 let json: serde_json::Value = serde_json::from_str(&content.raw)
242 .map_err(|e| SqzError::Other(format!("pipeline: JSON parse error: {e}")))?;
243 let encoded = self.toon_encoder.encode(&json)?;
244 stages_applied.push("toon_encode".to_owned());
245 encoded
246 }
247 } else {
248 content.raw
249 };
250
251 let tokens_compressed = self.token_counter.count(&data, &model_family);
252 let compression_ratio = if tokens_original == 0 {
253 1.0
254 } else {
255 tokens_compressed as f64 / tokens_original as f64
256 };
257
258 Ok(CompressedContent {
259 data,
260 tokens_compressed,
261 tokens_original,
262 stages_applied,
263 compression_ratio,
264 provenance: crate::types::Provenance::default(),
265 verify: None,
266 })
267 }
268
269 pub fn insert_stage(&mut self, stage: Box<dyn crate::stages::CompressionStage>) {
271 self.stages.push(stage);
272 self.stages.sort_by_key(|s| s.priority());
273 }
274
275 pub fn reload_preset(&mut self, _preset: &Preset) -> Result<()> {
279 let mut stages: Vec<Box<dyn crate::stages::CompressionStage>> = vec![
280 Box::new(AnsiStripper),
281 Box::new(KeepFieldsStage),
282 Box::new(StripFieldsStage),
283 Box::new(CondenseStage),
284 Box::new(GitDiffFoldStage),
285 Box::new(StripNullsStage),
286 Box::new(FlattenStage),
287 Box::new(TruncateStringsStage),
288 Box::new(CollapseArraysStage),
289 Box::new(CustomTransformsStage),
290 ];
291 stages.sort_by_key(|s| s.priority());
292 self.stages = stages;
293 self.dict_compressor.borrow_mut().reset();
295 Ok(())
296 }
297}
298
299fn model_family_from_preset(preset: &Preset) -> ModelFamily {
301 match preset.model.family.to_lowercase().as_str() {
302 "anthropic" | "claude" => ModelFamily::AnthropicClaude,
303 "openai" | "gpt" => ModelFamily::OpenAiGpt,
304 "google" | "gemini" => ModelFamily::GoogleGemini,
305 other => ModelFamily::Local(other.to_string()),
306 }
307}
308
309fn stage_config_from_preset(name: &str, preset: &Preset) -> StageConfig {
311 let c = &preset.compression;
312 match name {
313 "ansi_strip" => StageConfig {
314 enabled: true,
315 options: serde_json::Value::Object(Default::default()),
316 },
317 "keep_fields" => {
318 if let Some(cfg) = &c.keep_fields {
319 StageConfig {
320 enabled: cfg.enabled,
321 options: serde_json::json!({ "fields": cfg.fields }),
322 }
323 } else {
324 StageConfig::default()
325 }
326 }
327 "strip_fields" => {
328 if let Some(cfg) = &c.strip_fields {
329 StageConfig {
330 enabled: cfg.enabled,
331 options: serde_json::json!({ "fields": cfg.fields }),
332 }
333 } else {
334 StageConfig::default()
335 }
336 }
337 "condense" => {
338 if let Some(cfg) = &c.condense {
339 StageConfig {
340 enabled: cfg.enabled,
341 options: serde_json::json!({
342 "max_repeated_lines": cfg.max_repeated_lines
343 }),
344 }
345 } else {
346 StageConfig::default()
347 }
348 }
349 "git_diff_fold" => {
350 if let Some(cfg) = &c.git_diff_fold {
351 StageConfig {
352 enabled: cfg.enabled,
353 options: serde_json::json!({
354 "max_context_lines": cfg.max_context_lines
355 }),
356 }
357 } else {
358 StageConfig {
360 enabled: true,
361 options: serde_json::json!({ "max_context_lines": 2 }),
362 }
363 }
364 }
365 "strip_nulls" => {
366 if let Some(cfg) = &c.strip_nulls {
367 StageConfig {
368 enabled: cfg.enabled,
369 options: serde_json::Value::Object(Default::default()),
370 }
371 } else {
372 StageConfig::default()
373 }
374 }
375 "flatten" => {
376 if let Some(cfg) = &c.flatten {
377 StageConfig {
378 enabled: cfg.enabled,
379 options: serde_json::json!({ "max_depth": cfg.max_depth }),
380 }
381 } else {
382 StageConfig::default()
383 }
384 }
385 "truncate_strings" => {
386 if let Some(cfg) = &c.truncate_strings {
387 StageConfig {
388 enabled: cfg.enabled,
389 options: serde_json::json!({ "max_length": cfg.max_length }),
390 }
391 } else {
392 StageConfig::default()
393 }
394 }
395 "collapse_arrays" => {
396 if let Some(cfg) = &c.collapse_arrays {
397 StageConfig {
398 enabled: cfg.enabled,
399 options: serde_json::json!({
400 "max_items": cfg.max_items,
401 "summary_template": cfg.summary_template
402 }),
403 }
404 } else {
405 StageConfig::default()
406 }
407 }
408 "custom_transforms" => {
409 if let Some(cfg) = &c.custom_transforms {
410 StageConfig {
411 enabled: cfg.enabled,
412 options: serde_json::Value::Object(Default::default()),
413 }
414 } else {
415 StageConfig::default()
416 }
417 }
418 _ => StageConfig::default(),
419 }
420}
421
422fn stage_is_lossless(name: &str) -> bool {
427 matches!(name, "ansi_strip" | "custom_transforms")
428}
429
430fn looks_like_prose(text: &str) -> bool {
433 let lines: Vec<&str> = text.lines().take(20).collect();
434 if lines.is_empty() {
435 return false;
436 }
437
438 let mut prose_lines = 0;
439 let mut code_lines = 0;
440
441 for line in &lines {
442 let trimmed = line.trim();
443 if trimmed.is_empty() {
444 continue;
445 }
446 if trimmed.ends_with('{')
448 || trimmed.ends_with('}')
449 || trimmed.ends_with(';')
450 || trimmed.starts_with("fn ")
451 || trimmed.starts_with("pub ")
452 || trimmed.starts_with("let ")
453 || trimmed.starts_with("const ")
454 || trimmed.starts_with("import ")
455 || trimmed.starts_with("def ")
456 || trimmed.starts_with("class ")
457 || trimmed.contains("::")
458 || trimmed.contains("->")
459 || trimmed.contains("=>")
460 {
461 code_lines += 1;
462 } else {
463 prose_lines += 1;
464 }
465 }
466
467 prose_lines > code_lines
469}
470
471#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::preset::{
479 BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
480 CustomTransformsConfig, ModelConfig, PresetMeta,
481 StripNullsConfig, ToolSelectionConfig, TruncateStringsConfig,
482 TerseModeConfig,
483 };
484
485 fn default_preset() -> Preset {
486 Preset {
487 preset: PresetMeta {
488 name: "test".into(),
489 version: "1.0".into(),
490 description: String::new(),
491 },
492 compression: CompressionConfig {
493 stages: vec![],
494 keep_fields: None,
495 strip_fields: None,
496 condense: Some(CondenseConfig {
497 enabled: true,
498 max_repeated_lines: 3,
499 }),
500 git_diff_fold: None,
501 strip_nulls: Some(StripNullsConfig { enabled: true }),
502 flatten: None,
503 truncate_strings: Some(TruncateStringsConfig {
504 enabled: true,
505 max_length: 500,
506 }),
507 collapse_arrays: Some(CollapseArraysConfig {
508 enabled: true,
509 max_items: 5,
510 summary_template: "... and {remaining} more items".into(),
511 }),
512 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
513 },
514 tool_selection: ToolSelectionConfig {
515 max_tools: 5,
516 similarity_threshold: 0.7,
517 default_tools: vec![],
518 },
519 budget: BudgetConfig {
520 warning_threshold: 0.70,
521 ceiling_threshold: 0.85,
522 default_window_size: 200_000,
523 agents: Default::default(),
524 },
525 terse_mode: TerseModeConfig {
526 enabled: false,
527 level: crate::preset::TerseLevel::Moderate,
528 },
529 model: ModelConfig {
530 family: "anthropic".into(),
531 primary: "claude-sonnet-4-20250514".into(),
532 local: String::new(),
533 complexity_threshold: 0.4,
534 pricing: None,
535 },
536 }
537 }
538
539 fn ctx() -> SessionContext {
540 SessionContext {
541 session_id: "test-session".into(),
542 }
543 }
544
545 #[test]
550 fn compress_lossless_never_drops_segments() {
551 let preset = default_preset();
552 let pipeline = CompressionPipeline::new(&preset);
553
554 let mut segments = Vec::new();
557 for i in 0..10 {
558 if i % 2 == 0 {
559 segments.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
560 } else {
561 segments.push(format!(
562 "SEG{i} the quick brown fox jumps over {i} lazy dogs by silver rivers"
563 ));
564 }
565 }
566 let input = segments.join("\n\n");
567 assert!(input.len() > 500, "input must exceed truncation threshold");
568
569 let lossy = pipeline.compress(&input, &ctx(), &preset).unwrap();
571 assert!(
572 lossy.data.contains("low-information segments omitted"),
573 "expected the default path to truncate; got: {}",
574 lossy.data
575 );
576
577 let lossless = pipeline.compress_lossless(&input, &ctx(), &preset).unwrap();
579 assert!(
580 !lossless.data.contains("omitted"),
581 "lossless path must not truncate; got: {}",
582 lossless.data
583 );
584 assert_eq!(
585 lossless.data, input,
586 "lossless path must return content unchanged"
587 );
588 assert!(!lossless.stages_applied.contains(&"entropy_truncate".to_owned()));
589 assert!(!lossless.stages_applied.contains(&"token_prune".to_owned()));
590 }
591
592 #[test]
596 fn compress_lossless_preserves_json_fields() {
597 let preset = default_preset();
598 let pipeline = CompressionPipeline::new(&preset);
599 let json = r#"{"keep":1,"gone":null,"items":[1,2,3,4,5,6,7,8],"note":"x"}"#;
600
601 let lossy = pipeline.compress(json, &ctx(), &preset).unwrap();
604 assert_ne!(lossy.data, json, "default path should transform JSON");
605
606 let result = pipeline.compress_lossless(json, &ctx(), &preset).unwrap();
608 assert_eq!(result.data, json, "lossless must return JSON byte-for-byte");
609 }
610
611 #[test]
614 fn compress_lossless_returns_repeated_lines_verbatim() {
615 let preset = default_preset();
616 let pipeline = CompressionPipeline::new(&preset);
617 let line = "this is a repeated diagnostic line that is quite long\n";
618 let input = format!(
619 "{}a unique trailing line of sufficient length here\n",
620 line.repeat(6)
621 );
622 assert!(input.len() > 300);
623
624 let lossy = pipeline.compress(&input, &ctx(), &preset).unwrap();
626 assert!(
627 lossy.data.len() < input.len(),
628 "default path should collapse repeats"
629 );
630
631 let result = pipeline.compress_lossless(&input, &ctx(), &preset).unwrap();
632 assert_eq!(
633 result.data, input,
634 "lossless must return repeated content verbatim"
635 );
636 assert!(!result.stages_applied.contains(&"rle".to_owned()));
637 }
638
639 #[test]
640 fn new_creates_pipeline_with_sorted_stages() {
641 let preset = default_preset();
642 let pipeline = CompressionPipeline::new(&preset);
643 let priorities: Vec<u32> = pipeline.stages.iter().map(|s| s.priority()).collect();
645 let mut sorted = priorities.clone();
646 sorted.sort();
647 assert_eq!(priorities, sorted);
648 }
649
650 #[test]
651 fn compress_plain_text_passthrough() {
652 let preset = default_preset();
653 let pipeline = CompressionPipeline::new(&preset);
654 let result = pipeline.compress("hello world", &ctx(), &preset).unwrap();
655 assert_eq!(result.data, "hello world");
656 assert!(!result.stages_applied.contains(&"toon_encode".to_owned()));
657 }
658
659 #[test]
660 fn compress_json_applies_toon() {
661 let preset = default_preset();
662 let pipeline = CompressionPipeline::new(&preset);
663 let json = r#"{"name":"Alice","age":30}"#;
664 let result = pipeline.compress(json, &ctx(), &preset).unwrap();
665 assert!(result.data.starts_with("TOON:"), "data: {}", result.data);
666 assert!(result.stages_applied.contains(&"toon_encode".to_owned()));
667 }
668
669 #[test]
670 fn compress_strips_nulls_from_json() {
671 let preset = default_preset();
672 let pipeline = CompressionPipeline::new(&preset);
673 let json = r#"{"a":1,"b":null}"#;
674 let result = pipeline.compress(json, &ctx(), &preset).unwrap();
675 assert!(result.data.starts_with("TOON:"));
677 let decoded = ToonEncoder.decode(&result.data).unwrap();
679 assert!(decoded.get("b").is_none());
680 assert_eq!(decoded["a"], serde_json::json!(1));
681 }
682
683 #[test]
684 fn compress_returns_token_counts() {
685 let preset = default_preset();
686 let pipeline = CompressionPipeline::new(&preset);
687 let input = "a".repeat(100);
688 let result = pipeline.compress(&input, &ctx(), &preset).unwrap();
689 assert!(result.tokens_original > 0);
690 assert!(result.tokens_compressed > 0);
691 }
692
693 #[test]
694 fn compress_ratio_is_reasonable() {
695 let preset = default_preset();
696 let pipeline = CompressionPipeline::new(&preset);
697 let result = pipeline.compress("hello", &ctx(), &preset).unwrap();
698 assert!(result.compression_ratio > 0.0);
699 }
700
701 #[test]
702 fn insert_stage_re_sorts_by_priority() {
703 use crate::stages::CompressionStage;
704 use crate::types::StageConfig;
705
706 struct LowPriorityStage;
707 impl CompressionStage for LowPriorityStage {
708 fn name(&self) -> &str {
709 "low_priority"
710 }
711 fn priority(&self) -> u32 {
712 5 }
714 fn process(
715 &self,
716 _content: &mut Content,
717 _config: &StageConfig,
718 ) -> crate::error::Result<()> {
719 Ok(())
720 }
721 }
722
723 let preset = default_preset();
724 let mut pipeline = CompressionPipeline::new(&preset);
725 pipeline.insert_stage(Box::new(LowPriorityStage));
726
727 let priorities: Vec<u32> = pipeline.stages.iter().map(|s| s.priority()).collect();
728 let mut sorted = priorities.clone();
729 sorted.sort();
730 assert_eq!(priorities, sorted);
731 assert_eq!(pipeline.stages[0].name(), "ansi_strip");
732 assert_eq!(pipeline.stages[1].name(), "low_priority");
733 }
734
735 #[test]
736 fn reload_preset_rebuilds_stages() {
737 let preset = default_preset();
738 let mut pipeline = CompressionPipeline::new(&preset);
739 let original_count = pipeline.stages.len();
740 pipeline.reload_preset(&preset).unwrap();
741 assert_eq!(pipeline.stages.len(), original_count);
742 }
743
744 #[test]
745 fn compress_keep_fields_filters_json() {
746 use crate::preset::KeepFieldsConfig;
747 let mut preset = default_preset();
748 preset.compression.keep_fields = Some(KeepFieldsConfig {
749 enabled: true,
750 fields: vec!["id".into(), "name".into()],
751 });
752 let pipeline = CompressionPipeline::new(&preset);
753 let json = r#"{"id":1,"name":"Bob","debug":"x"}"#;
754 let result = pipeline.compress(json, &ctx(), &preset).unwrap();
755 let decoded = ToonEncoder.decode(&result.data).unwrap();
756 assert!(decoded.get("debug").is_none());
757 assert_eq!(decoded["id"], serde_json::json!(1));
758 }
759
760 #[test]
761 fn compress_empty_string() {
762 let preset = default_preset();
763 let pipeline = CompressionPipeline::new(&preset);
764 let result = pipeline.compress("", &ctx(), &preset).unwrap();
765 assert_eq!(result.data, "");
766 assert_eq!(result.tokens_original, 0);
767 }
768
769 #[test]
770 fn stage_config_from_preset_unknown_stage() {
771 let preset = default_preset();
772 let config = stage_config_from_preset("nonexistent", &preset);
773 assert!(!config.enabled);
774 }
775
776 use proptest::prelude::*;
781
782 fn significant_line_strategy() -> impl Strategy<Value = String> {
784 prop_oneof![
785 Just("error: connection refused".to_owned()),
786 Just("warning: deprecated API usage".to_owned()),
787 Just("failed: build step exited with code 1".to_owned()),
788 Just("success: deployment complete".to_owned()),
789 Just("status: all checks passed".to_owned()),
790 Just("error: file not found".to_owned()),
791 Just("warning: unused variable detected".to_owned()),
792 ]
793 }
794
795 fn noise_line_strategy() -> impl Strategy<Value = String> {
797 prop_oneof![
798 Just("---".to_owned()),
799 Just("Loading...".to_owned()),
800 Just("================".to_owned()),
801 Just("...".to_owned()),
802 ]
803 }
804
805 fn arb_json_value() -> impl Strategy<Value = serde_json::Value> {
808 let leaf = prop_oneof![
809 Just(serde_json::Value::Null),
810 any::<bool>().prop_map(serde_json::Value::Bool),
811 any::<i64>().prop_map(|n| serde_json::json!(n)),
812 any::<f64>()
813 .prop_filter("must be finite", |f| f.is_finite())
814 .prop_map(|f| serde_json::json!(f)),
815 ".*".prop_map(serde_json::Value::String),
816 ];
817
818 leaf.prop_recursive(4, 64, 8, |inner| {
819 prop_oneof![
820 prop::collection::vec(inner.clone(), 0..8)
821 .prop_map(serde_json::Value::Array),
822 prop::collection::hash_map(".*", inner, 0..8).prop_map(|m| {
823 serde_json::Value::Object(m.into_iter().collect())
824 }),
825 ]
826 })
827 }
828
829 proptest! {
830 #[test]
838 fn prop_pipeline_ascii_safe_output(v in arb_json_value()) {
839 let preset = default_preset();
840 let pipeline = CompressionPipeline::new(&preset);
841
842 let json_input = serde_json::to_string(&v).expect("serialize should not fail");
843 let result = pipeline.compress(&json_input, &ctx(), &preset)
844 .expect("compress should not fail");
845
846 for ch in result.data.chars() {
847 let cp = ch as u32;
848 let is_printable_ascii = cp >= 0x20 && cp <= 0x7E;
849 let is_standard_whitespace = cp == 0x09 || cp == 0x0A || cp == 0x0D;
850 prop_assert!(
851 is_printable_ascii || is_standard_whitespace,
852 "non-ASCII-safe character in output: U+{:04X} ({:?})\noutput: {:?}",
853 cp, ch, result.data
854 );
855 }
856 }
857 }
858
859 proptest! {
860 #[test]
870 fn prop_compression_preserves_significant_content(
871 significant_lines in prop::collection::vec(significant_line_strategy(), 1..=5),
872 noise_line in noise_line_strategy(),
873 noise_repeat in 5u32..=10u32,
874 ) {
875 let preset = default_preset(); let pipeline = CompressionPipeline::new(&preset);
877
878 let mut lines: Vec<String> = Vec::new();
880 for sig in &significant_lines {
881 for _ in 0..noise_repeat {
882 lines.push(noise_line.clone());
883 }
884 lines.push(sig.clone());
885 }
886 for _ in 0..noise_repeat {
888 lines.push(noise_line.clone());
889 }
890
891 let input = lines.join("\n");
892 let result = pipeline.compress(&input, &ctx(), &preset).unwrap();
893 let output = &result.data;
894
895 for sig in &significant_lines {
897 prop_assert!(
898 output.contains(sig.as_str()),
899 "significant line missing from output: {:?}\noutput: {:?}",
900 sig,
901 output
902 );
903 }
904
905 let mut max_run = 0usize;
907 let mut current_run = 0usize;
908 for line in output.lines() {
909 if line == noise_line.as_str() {
910 current_run += 1;
911 max_run = max_run.max(current_run);
912 } else {
913 current_run = 0;
914 }
915 }
916 prop_assert!(
917 max_run <= 3,
918 "noise line {:?} has a consecutive run of {} (max 3)\noutput: {:?}",
919 noise_line,
920 max_run,
921 output
922 );
923 }
924 }
925}