Skip to main content

sqz_engine/
pipeline.rs

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
18/// Minimal session context passed to the pipeline. Just carries the session
19/// ID so stages can log or cache per-session.
20pub struct SessionContext {
21    pub session_id: String,
22}
23
24/// The compression pipeline orchestrator.
25///
26/// Runs content through a sequence of configurable stages (condense, strip
27/// nulls, collapse arrays, etc.), then applies post-processing: RLE
28/// compression, sliding-window dedup, entropy truncation, token pruning,
29/// dictionary compression, and TOON encoding for JSON.
30///
31/// Stages are sorted by priority — lower numbers run first. You can insert
32/// custom stages with [`insert_stage`](CompressionPipeline::insert_stage).
33pub 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    /// Construct the pipeline from a preset, creating all built-in stages
47    /// sorted by priority, plus the token pruner, dictionary compressor,
48    /// and entropy truncator.
49    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    /// Run content through all enabled stages, then apply:
77    /// 1. Dictionary compression + TOON encoding for JSON
78    /// 2. Token pruning for prose/plain text
79    /// 3. Entropy-weighted truncation for long content
80    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    /// Byte-faithful compression for file reads (`sqz_read_file`, `sqz_grep`,
110    /// `sqz_list_dir`). Returns the input verbatim apart from ANSI-escape
111    /// stripping: every content-altering stage is skipped — both the droppers
112    /// (entropy truncation, token pruning, JSON field projection, and the lossy
113    /// configured stages: condense, strip_fields, strip_nulls, flatten,
114    /// truncate_strings, collapse_arrays, git_diff_fold) and the
115    /// reversible-but-reformatting ones (RLE, sliding-window dedup, TOON/dict),
116    /// which would shift bytes or line numbers. No line, field, or segment is
117    /// ever dropped or moved. Token savings on this path come from the dedup
118    /// `§ref` cache on repeat reads, not from re-encoding.
119    /// See <https://github.com/ojuschugh1/sqz/issues/32>
120    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    /// Token count with the same tokenizer `compress` used for
132    /// `tokens_original`, so callers that post-process the output can report
133    /// comparable numbers.
134    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            // On the lossless (file-read) path, run only the reversible stages.
177            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        // Post-stage processing: apply advanced compression techniques
192
193        let is_json = ToonEncoder::is_json(&content.raw);
194        // Source code gets no line-dropping stages: an agent that cats a
195        // file is about to edit it, and imports or a class header score as
196        // "low information" to the truncator. Dedup still covers re-reads.
197        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        // JSON projection: strip internal/debug fields, empty collections,
203        // deep nesting, and redundant timestamps before other JSON processing
204        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        // RLE: collapse repeated patterns in non-JSON content (generalizes condense).
215        // Skipped on the lossless path — even reversible `[×N]` markers make a
216        // file read non-verbatim. Only apply when content is long enough.
217        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                    // Safety check: verify critical markers are preserved
227                    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        // Log-template compaction: collapse consecutive lines identical
238        // except for timestamps/durations/percentages — what exact RLE
239        // misses in real logs. Identifier-bearing lines never collapse.
240        // Skipped on the lossless path.
241        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        // Sliding window dedup: catch repeated substrings across non-adjacent lines.
255        // Skipped on the lossless path — its `[→Ln]` back-references have no
256        // agent-facing expand path and make a file read non-faithful (#32).
257        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                    // Safety check: verify critical markers are preserved
261                    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        // Aligned-column table compaction: collapse alignment padding in
272        // tabular CLI output (docker ps, kubectl get, ps aux, ...) to a
273        // two-space separator. Cells survive byte-exact; the detector
274        // refuses anything with leading indentation, so code, YAML,
275        // diffs, and pretty JSON are never touched. Runs before entropy
276        // truncation so the truncator scores the compact form.
277        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        // Entropy-weighted truncation for long non-JSON content.
287        // LOSSY — skipped on the lossless file-read path (issue #32).
288        // Benign-aware threshold: output with no error markers (clean
289        // builds, passing test runs) gets 2x headroom before truncation
290        // kicks in, since dropping detail from a success costs more than
291        // it saves.
292        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        // Token pruning for prose content (non-JSON, non-code).
306        // LOSSY — skipped on the lossless file-read path (issue #32).
307        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        // Apply dictionary compression + TOON encoding for JSON.
317        // Skipped on the lossless path so a JSON file read returns its bytes
318        // verbatim — TOON re-encoding is data-lossless but reformats and
319        // shifts line numbers, which breaks "read this file faithfully".
320        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            // Short codes for repeated keys. Tables already print each key
326            // once, so the dictionary is kept only when it beats plain TOON
327            // with its header counted.
328            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    /// Insert a plugin stage and re-sort by priority.
367    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    /// Rebuild stage list from a new preset (hot-reload support).
373    /// Built-in stages are recreated; plugin stages are dropped and must be
374    /// re-inserted by the caller.
375    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        // Reset session-level state in dict compressor
391        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
419/// Derive the `ModelFamily` from the preset's model configuration.
420fn 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
429/// Build a `StageConfig` for a named stage from the preset's compression config.
430fn 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                // Default: enabled with 2 context lines
479                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
543/// Whether a configured stage is safe to run on the lossless file-read path.
544/// Only stages that never drop or irreversibly alter content qualify; every
545/// other stage (condense, strip_fields, strip_nulls, flatten, truncate_strings,
546/// collapse_arrays, git_diff_fold, keep_fields) is skipped when `lossless`.
547fn stage_is_lossless(name: &str) -> bool {
548    matches!(name, "ansi_strip" | "custom_transforms")
549}
550
551/// Record lists at or under this size are never entropy-truncated.
552const RECORD_LIST_MAX_CHARS: usize = 16_000;
553
554/// One fact per line: grep matches, file lists, `ls -l`, commit lists.
555fn 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    // `path:12:text` (grep -n, compiler diagnostics)
574    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    // A bare path (find, ls -1, git ls-files, diff --name-only)
583    if !t.contains(char::is_whitespace) && pathish(t) {
584        return true;
585    }
586    // `ls -l`: drwxr-xr-x ...
587    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    // `git log --oneline`: abc1234 subject
595    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
599/// True when the output carries no error markers — a clean build, passing
600/// tests, an uneventful log. Benign output earns a higher truncation
601/// threshold.
602fn 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
610/// Heuristic: does this text look like prose (documentation, error messages,
611/// README content) rather than code or structured data?
612fn 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        // Code indicators
627        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    // Consider it prose if more than half the lines look like prose
648    prose_lines > code_lines
649}
650
651/// Heuristic: is this a source file rather than command output? Needs a
652/// couple of declaration lines and a clear majority of code-shaped lines.
653/// Timestamped lines are ignored so logs never qualify. False positives
654/// only cost compression; false negatives cost imports and class headers.
655fn 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// ---------------------------------------------------------------------------
695// Tests
696// ---------------------------------------------------------------------------
697
698#[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    /// Regression for https://github.com/ojuschugh1/sqz/issues/32 — the
770    /// file-read path (sqz_read_file / sqz_grep / sqz_list_dir) must never
771    /// silently drop content. `compress_lossless` keeps every segment; the
772    /// default `compress` truncates below-median-entropy segments.
773    #[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        // The lossless path must never table-compact either.
847        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        // 10 distinct, blank-line-separated segments with bimodal entropy,
861        // well over the 500-byte entropy-truncation threshold.
862        let mut segments = Vec::new();
863        // 24 segments so the fixture clears the benign-aware 1000-byte
864        // threshold (this content has no error markers).
865        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        // The default path is lossy — it drops below-median-entropy segments.
878        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        // The lossless path must preserve EVERY segment, byte-for-byte.
886        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    /// The lossless path returns JSON byte-for-byte — no stripped nulls,
946    /// collapsed arrays, truncated strings, projected-away fields, or TOON
947    /// re-encoding (which would shift line numbers).
948    #[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        // Sanity: the default path transforms the JSON (strips the null,
955        // TOON-encodes), so it differs from the input.
956        let lossy = pipeline.compress(json, &ctx(), &preset).unwrap();
957        assert_ne!(lossy.data, json, "default path should transform JSON");
958
959        // The lossless path returns the JSON verbatim.
960        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    /// Even highly repetitive content is returned verbatim on the lossless
965    /// path — RLE `[×N]` collapsing is skipped so a file read stays faithful.
966    #[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        // Default path collapses the repeated run; lossless returns it untouched.
978        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    /// Benign-aware threshold: the same sub-1000-byte shape truncates when
993    /// it carries an error marker and stays whole when it doesn't.
994    #[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        // Verify stages are sorted by priority
1031        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        // After strip_nulls, "b" is gone; TOON encodes the result
1063        assert!(result.data.starts_with("TOON:"));
1064        // Decode and verify null is gone
1065        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 // lower than all built-in stages
1121            }
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    // ---------------------------------------------------------------------------
1185    // Property tests
1186    // ---------------------------------------------------------------------------
1187
1188    use proptest::prelude::*;
1189
1190    /// Generate a significant line from a fixed set of meaningful tokens.
1191    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    /// Generate a noise line (repeated decorative content).
1204    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    /// Recursive strategy that generates arbitrary serde_json::Value instances.
1214    /// Mirrors the strategy in toon.rs tests.
1215    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        /// **Validates: Requirements 17.1, 17.2, 13.2**
1239        ///
1240        /// Property 22: no raw control characters.
1241        ///
1242        /// For any JSON input, the Compression_Pipeline SHALL produce output
1243        /// whose only control characters are standard whitespace (\t, \n,
1244        /// \r). Text outside the control range is written as-is, the same as
1245        /// non-JSON output.
1246        #[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        /// **Validates: Requirements 1.3**
1268        ///
1269        /// Property 1: Compression preserves semantically significant content.
1270        ///
1271        /// For any CLI output containing significant tokens (errors, warnings,
1272        /// status messages) mixed with noise (repeated identical lines), the
1273        /// Compression_Pipeline SHALL produce output that:
1274        ///   1. Contains all significant lines.
1275        ///   2. Contains each noise line at most `max_repeated_lines` times.
1276        #[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(); // condense enabled, max_repeated_lines=3
1283            let pipeline = CompressionPipeline::new(&preset);
1284
1285            // Build interleaved input: noise, significant, noise, significant, ...
1286            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            // Trailing noise block
1294            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            // 1. All significant lines must appear in the output.
1303            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            // 2. No consecutive run of the noise line exceeds max_repeated_lines (3).
1313            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}