Skip to main content

oxicode_snapcompact/
lib.rs

1//! Snapcompact — bitmap-frame context compression for vision-capable LLMs.
2//!
3//! Ported from [omp](https://github.com/can1357/oh-my-pi) (MIT) —
4//! `packages/snapcompact/src/snapcompact.ts`. See
5//! `docs/ref-porter/xai-org-grok-build.md` section E2 for design rationale.
6//!
7//! ## How it works
8//!
9//! Instead of asking an LLM to summarize discarded history, snapcompact
10//! renders discarded text as dense bitmap frames that vision-capable
11//! models read back directly. Local and deterministic — no LLM call,
12//! no API key, no latency beyond rendering.
13//!
14//! ## Scope of this crate
15//!
16//! Shape system, conversation serialization, shape selection, and
17//! the full text→PNG rasterizer (ported from omp's
18//! `pi-natives/src/snapcompact.rs` — see the [`renderer`] submodule).
19//! The renderer uses bundled BDF/HEX bitmap fonts plus the bundled
20//! Silver TrueType font for non-Latin glyphs, all under permissive
21//! licenses (see `NOTICE.md`).
22//!
23//! [`compact()`] always returns real PNG-encoded bytes — there is no
24//! "no-op renderer" path. When a frame fails to render, the error is
25//! logged via `tracing` and that frame's bytes are empty; the caller
26//! can decide whether to drop or surface partial results.
27use serde::{Deserialize, Serialize};
28pub mod renderer;
29
30// ── Shape system ──────────────────────────────────────────────────────
31
32/// One eval-validated frame shape.
33///
34/// `name` is a stable identifier matching omp's `SHAPE_VARIANTS` table.
35/// The MVP carries a hard-coded name on each shape entry; future
36/// extension can compute names from fields, but the table is the source
37/// of truth for now (matches omp SHAPE_VARIANTS exactly).
38#[derive(Debug, Clone, PartialEq)]
39pub struct Shape {
40    /// Stable name (e.g. "11on16-bw"). Used as lookup key.
41    pub name: &'static str,
42    /// Bundled font.
43    pub font: Font,
44    pub cell_width: u32,
45    pub cell_height: u32,
46    pub stretch: bool,
47    pub variant: Variant,
48    pub stopword_dim: bool,
49    pub columns: u32,
50    pub line_repeat: u32,
51    pub frame_size: u32,
52}
53
54/// Bundled font name.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub enum Font {
57    FiveByEight,
58    EightByEight,
59    SixByTwelve,
60    EightByThirteen,
61    Silver,
62}
63
64impl Font {
65    /// String identifier matching omp's shape table.
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            Font::FiveByEight => "5x8",
69            Font::EightByEight => "8x8",
70            Font::SixByTwelve => "6x12",
71            Font::EightByThirteen => "8x13",
72            Font::Silver => "silver",
73        }
74    }
75}
76
77/// Ink variant.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Variant {
80    /// Cycle six hues at sentence boundaries.
81    Sent,
82    /// Plain black ink — best for Anthropic vision readers.
83    Bw,
84}
85
86impl Variant {
87    pub fn as_str(&self) -> &'static str {
88        match self {
89            Variant::Sent => "sent",
90            Variant::Bw => "bw",
91        }
92    }
93}
94
95/// Eval-validated shape table. Names match omp's `SHAPE_VARIANTS` keys
96/// exactly so the Rust and TS registries stay in sync.
97pub const SHAPES: &[Shape] = &[
98    // Redundancy-coded double-print (line_repeat=2).
99    Shape {
100        name: "8x8r-bw",
101        font: Font::EightByEight,
102        cell_width: 8,
103        cell_height: 8,
104        stretch: false,
105        variant: Variant::Bw,
106        stopword_dim: false,
107        columns: 1,
108        line_repeat: 2,
109        frame_size: 1568,
110    },
111    Shape {
112        name: "8x8r-sent",
113        font: Font::EightByEight,
114        cell_width: 8,
115        cell_height: 8,
116        stretch: false,
117        variant: Variant::Sent,
118        stopword_dim: false,
119        columns: 1,
120        line_repeat: 2,
121        frame_size: 1568,
122    },
123    // Standard 8x8 dense shapes. Note: `u` suffix is part of the name
124    // even though cell==natural; it flags the variant family.
125    Shape {
126        name: "8x8u-bw",
127        font: Font::EightByEight,
128        cell_width: 8,
129        cell_height: 8,
130        stretch: false,
131        variant: Variant::Bw,
132        stopword_dim: false,
133        columns: 1,
134        line_repeat: 1,
135        frame_size: 1568,
136    },
137    Shape {
138        name: "8x8u-sent",
139        font: Font::EightByEight,
140        cell_width: 8,
141        cell_height: 8,
142        stretch: false,
143        variant: Variant::Sent,
144        stopword_dim: false,
145        columns: 1,
146        line_repeat: 1,
147        frame_size: 1568,
148    },
149    // Squeezed 8x8 (Lanczos-scaled to 6x6 cell).
150    Shape {
151        name: "6x6u-bw",
152        font: Font::EightByEight,
153        cell_width: 6,
154        cell_height: 6,
155        stretch: true,
156        variant: Variant::Bw,
157        stopword_dim: false,
158        columns: 1,
159        line_repeat: 1,
160        frame_size: 1568,
161    },
162    Shape {
163        name: "6x6u-sent",
164        font: Font::EightByEight,
165        cell_width: 6,
166        cell_height: 6,
167        stretch: true,
168        variant: Variant::Sent,
169        stopword_dim: false,
170        columns: 1,
171        line_repeat: 1,
172        frame_size: 1568,
173    },
174    // 5x8 at 2576px frame edge.
175    Shape {
176        name: "5x8-bw",
177        font: Font::FiveByEight,
178        cell_width: 5,
179        cell_height: 8,
180        stretch: false,
181        variant: Variant::Bw,
182        stopword_dim: false,
183        columns: 1,
184        line_repeat: 1,
185        frame_size: 2576,
186    },
187    Shape {
188        name: "5x8-sent",
189        font: Font::FiveByEight,
190        cell_width: 5,
191        cell_height: 8,
192        stretch: false,
193        variant: Variant::Sent,
194        stopword_dim: false,
195        columns: 1,
196        line_repeat: 1,
197        frame_size: 2576,
198    },
199    // 6x12-dim: dim stopwords in gray ink.
200    Shape {
201        name: "6x12-dim",
202        font: Font::SixByTwelve,
203        cell_width: 6,
204        cell_height: 12,
205        stretch: false,
206        variant: Variant::Bw,
207        stopword_dim: true,
208        columns: 1,
209        line_repeat: 1,
210        frame_size: 1568,
211    },
212    // 8x13 natural-cell shape.
213    Shape {
214        name: "8x13-bw",
215        font: Font::EightByThirteen,
216        cell_width: 8,
217        cell_height: 13,
218        stretch: false,
219        variant: Variant::Bw,
220        stopword_dim: false,
221        columns: 1,
222        line_repeat: 1,
223        frame_size: 1568,
224    },
225    // 8x13 on extra-leading pitches.
226    Shape {
227        name: "8on16-bw",
228        font: Font::EightByThirteen,
229        cell_width: 8,
230        cell_height: 16,
231        stretch: false,
232        variant: Variant::Bw,
233        stopword_dim: false,
234        columns: 1,
235        line_repeat: 1,
236        frame_size: 1568,
237    },
238    Shape {
239        name: "8on22-bw",
240        font: Font::EightByThirteen,
241        cell_width: 8,
242        cell_height: 22,
243        stretch: false,
244        variant: Variant::Bw,
245        stopword_dim: false,
246        columns: 1,
247        line_repeat: 1,
248        frame_size: 1568,
249    },
250    // 11on16-bw: extra tracking (11px advance) on 8x13.
251    Shape {
252        name: "11on16-bw",
253        font: Font::EightByThirteen,
254        cell_width: 11,
255        cell_height: 16,
256        stretch: false,
257        variant: Variant::Bw,
258        stopword_dim: false,
259        columns: 1,
260        line_repeat: 1,
261        frame_size: 1568,
262    },
263    // Silver TTF (16x16 grid) — CJK.
264    Shape {
265        name: "silver16-bw",
266        font: Font::Silver,
267        cell_width: 16,
268        cell_height: 16,
269        stretch: false,
270        variant: Variant::Bw,
271        stopword_dim: false,
272        columns: 1,
273        line_repeat: 1,
274        frame_size: 1568,
275    },
276    // Newspaper column layouts (doc shapes).
277    Shape {
278        name: "doc-8on16-bw",
279        font: Font::EightByThirteen,
280        cell_width: 8,
281        cell_height: 16,
282        stretch: false,
283        variant: Variant::Bw,
284        stopword_dim: false,
285        columns: 2,
286        line_repeat: 1,
287        frame_size: 1568,
288    },
289    Shape {
290        name: "doc-8on16-sent",
291        font: Font::EightByThirteen,
292        cell_width: 8,
293        cell_height: 16,
294        stretch: false,
295        variant: Variant::Sent,
296        stopword_dim: false,
297        columns: 2,
298        line_repeat: 1,
299        frame_size: 1568,
300    },
301    Shape {
302        name: "doc-8on16-sent-dim",
303        font: Font::EightByThirteen,
304        cell_width: 8,
305        cell_height: 16,
306        stretch: false,
307        variant: Variant::Sent,
308        stopword_dim: true,
309        columns: 2,
310        line_repeat: 1,
311        frame_size: 1568,
312    },
313];
314
315impl Shape {
316    /// The stable name (matches omp SHAPE_VARIANTS key).
317    pub fn name(&self) -> &'static str {
318        self.name
319    }
320
321    /// Approximate characters per frame.
322    pub fn chars_per_frame(&self) -> u32 {
323        self.frame_size / self.cell_width.max(1)
324    }
325
326    /// Approximate rows per frame.
327    pub fn rows_per_frame(&self) -> u32 {
328        self.frame_size / self.cell_height.max(1)
329    }
330}
331
332/// Pick the eval-validated shape for `model_id`.
333pub fn resolve_shape(model_id: &str) -> Shape {
334    let id = model_id.to_ascii_lowercase();
335    if id.contains("claude") || id.contains("anthropic") {
336        return lookup_owned("11on16-bw").unwrap_or_else(|| SHAPES[0].clone());
337    }
338    if id.contains("gpt-5") || id.contains("gpt-4.1") || id.contains("o3") || id.contains("o4") {
339        return lookup_owned("8on22-bw").unwrap_or_else(|| SHAPES[0].clone());
340    }
341    if id.contains("gemini") {
342        return lookup_owned("8on22-bw").unwrap_or_else(|| SHAPES[0].clone());
343    }
344    // Unknown provider: Anthropic shape (safest default for vision).
345    lookup_owned("11on16-bw").unwrap_or_else(|| SHAPES[0].clone())
346}
347
348fn lookup_owned(name: &str) -> Option<Shape> {
349    SHAPES.iter().find(|s| s.name == name).cloned()
350}
351
352// ── Text normalization ────────────────────────────────────────────────
353
354/// Normalize conversation text for rasterization.
355pub fn normalize(input: &str) -> String {
356    let bytes = input.as_bytes();
357    let mut out = String::with_capacity(input.len());
358    let mut i = 0;
359    while i < bytes.len() {
360        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
361            i += 2;
362            while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
363                i += 1;
364            }
365            i += 1;
366            continue;
367        }
368        let c = input[i..].chars().next().unwrap_or(' ');
369        match c {
370            '─' | '━' | '│' | '┃' | '┌' | '┍' | '┎' | '┏' | '┐' | '┑' | '┒' | '┓' | '└' | '┕'
371            | '┖' | '┗' | '┘' | '┙' | '┚' | '┛' | '├' | '┝' | '┞' | '┟' | '┠' | '┡' | '┢' | '┣'
372            | '┤' | '┥' | '┦' | '┧' | '┨' | '┩' | '┪' | '┫' | '┬' | '┭' | '┮' | '┯' | '┰' | '┱'
373            | '┲' | '┳' | '┴' | '┵' | '┶' | '┷' | '┸' | '┹' | '┺' | '┻' | '┼' | '┽' | '┾' | '┿' =>
374            {
375                out.push('-');
376                i += c.len_utf8();
377            }
378            '═' => {
379                out.push('=');
380                i += c.len_utf8();
381            }
382            '║' => {
383                out.push('|');
384                i += c.len_utf8();
385            }
386            '\n' => {
387                out.push('\u{2588}');
388                i += 1;
389                while i < bytes.len() && bytes[i] == b'\n' {
390                    i += 1;
391                }
392            }
393            ' ' | '\t' => {
394                if !out.ends_with(' ') {
395                    out.push(' ');
396                }
397                i += c.len_utf8();
398            }
399            _ => {
400                out.push(c);
401                i += c.len_utf8();
402            }
403        }
404    }
405    out
406}
407
408// ── Preparation ──────────────────────────────────────────────────────
409
410/// Inputs to a compaction pass.
411#[derive(Debug, Clone)]
412pub struct CompactPreparation {
413    pub text: String,
414    pub bounded_text: String,
415    pub remaining_text: String,
416}
417
418/// Render the conversation text into a serializable compact envelope.
419pub fn prepare(input: &str, tool_result_max_chars: usize, text_limit: usize) -> CompactPreparation {
420    let text = serialize_conversation(input, tool_result_max_chars);
421    let bounded = bounded_slice(&text, text_limit);
422    let consumed = bounded.chars().count();
423    let remaining = if consumed >= text.chars().count() {
424        String::new()
425    } else {
426        text.chars().skip(consumed).collect()
427    };
428    CompactPreparation {
429        text,
430        bounded_text: bounded,
431        remaining_text: remaining,
432    }
433}
434
435/// Compact conversation text to one line per turn.
436pub fn serialize_conversation(input: &str, tool_result_max_chars: usize) -> String {
437    let mut out = String::new();
438    for line in input.lines() {
439        let trimmed = line.trim();
440        if trimmed.is_empty() {
441            continue;
442        }
443        if line.starts_with("Tool:") {
444            let truncated = truncate_tool_output(trimmed, tool_result_max_chars);
445            out.push_str(&truncated);
446            out.push('\n');
447        } else {
448            out.push_str(trimmed);
449            out.push('\n');
450        }
451    }
452    out
453}
454
455fn truncate_tool_output(line: &str, max_chars: usize) -> String {
456    if line.chars().count() <= max_chars {
457        return line.to_string();
458    }
459    let head_ratio = 0.6;
460    let head = (max_chars as f64 * head_ratio) as usize;
461    let tail = max_chars.saturating_sub(head);
462    let chars: Vec<char> = line.chars().collect();
463    let head_str: String = chars.iter().take(head).collect();
464    let tail_str: String = chars
465        .iter()
466        .skip(chars.len().saturating_sub(tail))
467        .collect();
468    format!("{head_str}…[truncated]…{tail_str}")
469}
470
471fn bounded_slice(s: &str, max_chars: usize) -> String {
472    if s.chars().count() <= max_chars {
473        return s.to_string();
474    }
475    s.chars().take(max_chars).collect()
476}
477
478// ── Compaction envelope ──────────────────────────────────────────────
479
480/// Options for [`compact`].
481#[derive(Debug, Clone)]
482pub struct CompactOptions {
483    pub model_id: String,
484    pub max_frames: u32,
485    pub shape: Option<Shape>,
486}
487
488impl Default for CompactOptions {
489    fn default() -> Self {
490        Self {
491            model_id: String::new(),
492            max_frames: 80,
493            shape: None,
494        }
495    }
496}
497
498/// Outcome of a compaction pass.
499#[derive(Debug, Clone)]
500pub struct CompactResult {
501    pub summary: String,
502    pub source_text: String,
503    pub frames: Vec<FrameRef>,
504    pub shape: Shape,
505}
506
507/// A reference to a rendered frame.
508#[derive(Debug, Clone)]
509pub struct FrameRef {
510    pub index: u32,
511    pub source_start: usize,
512    pub source_end: usize,
513    pub bytes: Vec<u8>,
514}
515
516/// The minimal envelope returned by [`compact`] when no renderer is
517/// available — shape chosen, text chunked into per-frame source ranges,
518/// but frame bytes are empty.
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct BoundedSource {
521    pub lead_in: String,
522    pub text: String,
523    pub frames: Vec<FrameRange>,
524}
525
526/// Per-frame source range.
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
528pub struct FrameRange {
529    pub index: u32,
530    pub source_start: usize,
531    pub source_end: usize,
532}
533
534impl CompactResult {
535    /// Drop the frame bytes; the bounded source + ranges remain.
536    pub fn into_bounded_source(&self) -> BoundedSource {
537        BoundedSource {
538            lead_in: self.summary.clone(),
539            text: self.source_text.clone(),
540            frames: self
541                .frames
542                .iter()
543                .map(|f| FrameRange {
544                    index: f.index,
545                    source_start: f.source_start,
546                    source_end: f.source_end,
547                })
548                .collect(),
549        }
550    }
551}
552
553/// Convert a [`Shape`] into the renderer's option struct.
554///
555/// The shape's `font` / `cell_width` / `cell_height` / `variant` /
556/// `line_repeat` / `stretch` / `columns` fields map directly onto
557/// [`crate::renderer::SnapcompactRenderOptions`].
558fn shape_to_render_options(shape: &Shape) -> crate::renderer::SnapcompactRenderOptions {
559    crate::renderer::SnapcompactRenderOptions {
560        size: shape.frame_size,
561        font: Some(shape.font.as_str().to_string()),
562        cell_width: Some(shape.cell_width),
563        cell_height: Some(shape.cell_height),
564        variant: Some(shape.variant.as_str().to_string()),
565        line_repeat: Some(shape.line_repeat),
566        stretch: Some(shape.stretch),
567        columns: Some(shape.columns),
568    }
569}
570
571/// Run a compaction pass — render each frame slice through the
572/// pi-natives ported renderer (`render_snapcompact_png`) so the
573/// returned bytes are real PNG-encoded image data.
574///
575/// Errors propagate as `Vec::new()` for the offending frame (so the
576/// caller still gets partial results) and the failure is logged via
577/// `tracing`. This is the only [`compact`] surface — the old
578/// `NoopRenderer` / `FrameRenderer` / `compact_with` indirection has
579/// been removed: there is one renderer, and it always returns real
580/// bytes.
581pub fn compact(prep: &CompactPreparation, options: &CompactOptions) -> CompactResult {
582    let shape: Shape = options
583        .shape
584        .clone()
585        .unwrap_or_else(|| resolve_shape(&options.model_id));
586    let chars_per_frame = shape.chars_per_frame() as usize;
587    let frames = chunk_into_frames(
588        &prep.bounded_text,
589        chars_per_frame,
590        options.max_frames as usize,
591    );
592    let render_opts = shape_to_render_options(&shape);
593    let rendered: Vec<FrameRef> = frames
594        .iter()
595        .map(|range| {
596            let slice = slice_char_range(&prep.bounded_text, range.source_start, range.source_end);
597            let bytes = match crate::renderer::render_snapcompact_png(slice, render_opts.clone()) {
598                Ok(b) => b,
599                Err(e) => {
600                    tracing::warn!(
601                        frame = range.index,
602                        shape = %shape.name,
603                        error = %e,
604                        "snapcompact render failed; emitting empty frame"
605                    );
606                    Vec::new()
607                }
608            };
609            FrameRef {
610                index: range.index,
611                source_start: range.source_start,
612                source_end: range.source_end,
613                bytes,
614            }
615        })
616        .collect();
617    CompactResult {
618        summary: render_lead_in(&prep.text),
619        source_text: prep.bounded_text.clone(),
620        frames: rendered,
621        shape,
622    }
623}
624
625fn chunk_into_frames(text: &str, chars_per_frame: usize, max_frames: usize) -> Vec<FrameRange> {
626    let chars: Vec<char> = text.chars().collect();
627    let mut out = Vec::new();
628    if chars_per_frame == 0 || chars.is_empty() || max_frames == 0 {
629        return out;
630    }
631    let mut start = 0usize;
632    let mut idx: u32 = 0;
633    while start < chars.len() && idx < max_frames as u32 {
634        let end = (start + chars_per_frame).min(chars.len());
635        out.push(FrameRange {
636            index: idx,
637            source_start: start,
638            source_end: end,
639        });
640        start = end;
641        idx += 1;
642    }
643    out
644}
645
646fn slice_char_range(s: &str, start: usize, end: usize) -> String {
647    s.chars()
648        .skip(start)
649        .take(end.saturating_sub(start))
650        .collect()
651}
652
653fn render_lead_in(text: &str) -> String {
654    let chars: Vec<char> = text.chars().collect();
655    let head: String = chars.iter().take(120).collect();
656    format!("Resume prior conversation. {head}…")
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn shape_table_includes_all_omp_variants() {
665        for name in &[
666            "8x8r-bw",
667            "8x8r-sent",
668            "8x8u-bw",
669            "8x8u-sent",
670            "6x6u-bw",
671            "6x6u-sent",
672            "5x8-bw",
673            "5x8-sent",
674            "6x12-dim",
675            "8x13-bw",
676            "8on16-bw",
677            "8on22-bw",
678            "11on16-bw",
679            "silver16-bw",
680            "doc-8on16-bw",
681            "doc-8on16-sent",
682            "doc-8on16-sent-dim",
683        ] {
684            assert!(
685                lookup_owned(name).is_some(),
686                "missing shape `{name}` from registry"
687            );
688        }
689    }
690
691    #[test]
692    fn shape_name_round_trip() {
693        for s in SHAPES {
694            assert_eq!(lookup_owned(s.name).map(|x| x.name), Some(s.name));
695        }
696    }
697
698    #[test]
699    fn resolve_shape_anthropic_picks_11on16() {
700        let s = resolve_shape("claude-3-5-sonnet-20241022");
701        assert_eq!(s.name, "11on16-bw");
702    }
703
704    #[test]
705    fn resolve_shape_openai_picks_8on22() {
706        let s = resolve_shape("gpt-5.5");
707        assert_eq!(s.name, "8on22-bw");
708    }
709
710    #[test]
711    fn resolve_shape_google_picks_8on22() {
712        let s = resolve_shape("gemini-3-flash");
713        assert_eq!(s.name, "8on22-bw");
714    }
715
716    #[test]
717    fn resolve_shape_unknown_defaults_to_anthropic() {
718        let s = resolve_shape("unknown-provider-model-xyz");
719        assert_eq!(s.name, "11on16-bw");
720    }
721
722    #[test]
723    fn chars_per_frame_scales_with_frame_size_and_cell() {
724        // 11on16-bw: 1568px / 11 cell_width.
725        let s = lookup_owned("11on16-bw").unwrap();
726        assert_eq!(s.chars_per_frame(), 1568 / 11);
727    }
728
729    #[test]
730    fn normalize_strips_ansi() {
731        let input = "\u{1b}[31mhello\u{1b}[0m world";
732        let out = normalize(input);
733        assert!(!out.contains('\u{1b}'));
734        assert!(out.contains("hello"));
735        assert!(out.contains("world"));
736    }
737
738    #[test]
739    fn normalize_folds_newlines_to_full_block() {
740        let input = "line1\n\n\nline2";
741        let out = normalize(input);
742        assert!(out.contains('\u{2588}'));
743        assert!(!out.contains("\n\n\n"));
744    }
745
746    #[test]
747    fn normalize_replaces_box_drawing_with_ascii() {
748        let input = "┌──┐\n│hi│\n└──┘";
749        let out = normalize(input);
750        assert!(!out.contains('┌'));
751        assert!(out.contains('-'));
752    }
753
754    #[test]
755    fn normalize_collapses_whitespace_runs() {
756        let input = "a    b\t\tc";
757        let out = normalize(input);
758        assert!(!out.contains("    "));
759        assert!(!out.contains('\t'));
760        assert_eq!(out, "a b c");
761    }
762
763    #[test]
764    fn prepare_respects_text_limit() {
765        let long = "x".repeat(10_000);
766        let prep = prepare(&long, 2000, 200);
767        assert!(prep.bounded_text.chars().count() <= 200);
768        assert!(prep.remaining_text.chars().count() >= 10_000 - 200);
769    }
770
771    #[test]
772    fn prepare_short_input_has_no_remaining() {
773        let prep = prepare("hello world", 2000, 200);
774        assert_eq!(prep.bounded_text, "hello world\n");
775        assert!(prep.remaining_text.is_empty());
776    }
777
778    #[test]
779    fn serialize_conversation_truncates_tool_results() {
780        let input = "Tool: a very long output that goes on and on and on";
781        let text = serialize_conversation(input, 20);
782        assert!(text.contains("[truncated]"));
783        assert!(text.chars().count() < 60);
784    }
785
786    #[test]
787    fn compact_emits_one_frame_per_chunk_with_png_bytes() {
788        // Pre-port this asserted `f.bytes.is_empty()` (the old
789        // NoopRenderer path). compact() now renders real PNGs, so
790        // each frame should carry a non-empty payload with the
791        // PNG magic header. The `frames.len() <= max_frames`
792        // invariant is preserved.
793        let long: String = "a".repeat(800);
794        let prep = prepare(&long, 2000, 800);
795        let opts = CompactOptions {
796            model_id: "claude-3-5-sonnet".into(),
797            ..Default::default()
798        };
799        let result = compact(&prep, &opts);
800        assert!(!result.frames.is_empty());
801        assert!(result.frames.len() <= opts.max_frames as usize);
802        for f in &result.frames {
803            assert!(
804                !f.bytes.is_empty(),
805                "frame {} should have real PNG bytes",
806                f.index
807            );
808            assert_eq!(
809                &f.bytes[..8],
810                &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
811                "frame {} missing PNG magic",
812                f.index
813            );
814        }
815    }
816
817    #[test]
818    fn compact_summary_includes_lead_in_and_head() {
819        let text = "User: hello\nAssistant: hi";
820        let prep = prepare(text, 2000, 2000);
821        let opts = CompactOptions {
822            model_id: "claude".into(),
823            ..Default::default()
824        };
825        let result = compact(&prep, &opts);
826        assert!(result.summary.starts_with("Resume prior conversation."));
827    }
828    #[test]
829    fn compact_frame_ranges_are_disjoint_and_cover_source() {
830        // Each frame holds `chars_per_frame` characters; with max_frames=5
831        // and an unbounded text the frames cover exactly the first
832        // `max_frames * chars_per_frame` characters.
833        let long: String = (0..200).map(|i| format!("x{i}\n")).collect();
834        let prep = prepare(&long, 5000, 5000);
835        let opts = CompactOptions {
836            model_id: "claude".into(),
837            max_frames: 5,
838            ..Default::default()
839        };
840        let result = compact(&prep, &opts);
841        assert!(!result.frames.is_empty());
842        // Frame 0 starts at 0; frames are contiguous and disjoint.
843        let mut prev_end = 0;
844        for (i, f) in result.frames.iter().enumerate() {
845            assert_eq!(f.index, i as u32);
846            assert_eq!(f.source_start, prev_end);
847            assert!(f.source_end > f.source_start);
848            prev_end = f.source_end;
849        }
850        // The frames cover the first `max_frames * chars_per_frame`
851        // characters of the bounded text — less than the full length
852        // when `max_frames * chars_per_frame` is small.
853        let chars_per_frame = result.shape.chars_per_frame() as usize;
854        assert_eq!(prev_end, (opts.max_frames as usize) * chars_per_frame);
855    }
856
857    #[test]
858    fn bounded_source_drop_is_lossless() {
859        let text = "x".repeat(2000);
860        let prep = prepare(&text, 5000, 2000);
861        let opts = CompactOptions {
862            model_id: "claude".into(),
863            ..Default::default()
864        };
865        let result = compact(&prep, &opts);
866        let bounded = result.into_bounded_source();
867        assert_eq!(bounded.text, prep.bounded_text);
868    }
869
870    #[test]
871    fn compact_renders_real_png_bytes_per_frame() {
872        // The compact() path used to go through a NoopRenderer that
873        // emitted empty bytes; this guards against regressions to
874        // that fake-success path by asserting each frame carries a
875        // non-empty PNG payload.
876        let long: String = "a".repeat(500);
877        let prep = prepare(&long, 2000, 500);
878        let opts = CompactOptions {
879            model_id: "claude".into(),
880            max_frames: 3,
881            ..Default::default()
882        };
883        let result = compact(&prep, &opts);
884        assert!(!result.frames.is_empty(), "compact must produce ≥1 frame");
885        for f in &result.frames {
886            assert!(!f.bytes.is_empty(), "frame {} has empty bytes", f.index);
887            // PNG magic header.
888            assert_eq!(
889                &f.bytes[..8],
890                &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
891                "frame {} is not a PNG",
892                f.index
893            );
894        }
895    }
896}