use std::pin::Pin;
use std::sync::Arc;
use oxi_ai::Message;
use oxi_ai::compaction::{CompactedContext, CompactionError, CompactionMetadata, Compactor};
use oxi_snapcompact::Shape;
pub struct SnapcompactCompactor {
shape: Option<Shape>,
frame_chars: usize,
max_frames: u32,
}
impl Default for SnapcompactCompactor {
fn default() -> Self {
Self {
shape: None,
frame_chars: 4000,
max_frames: 8,
}
}
}
impl SnapcompactCompactor {
pub fn new() -> Self {
Self::default()
}
pub fn with_shape(mut self, shape: Shape) -> Self {
self.shape = Some(shape);
self
}
pub fn with_frame_chars(mut self, frame_chars: usize) -> Self {
self.frame_chars = frame_chars.max(64);
self
}
pub fn with_max_frames(mut self, max_frames: u32) -> Self {
self.max_frames = max_frames;
self
}
pub fn shape(&self) -> Option<&Shape> {
self.shape.as_ref()
}
}
impl Compactor for SnapcompactCompactor {
fn estimate_tokens(&self, messages: &[Message]) -> usize {
messages
.iter()
.map(|m| m.text_content().map(|t| t.len() / 4).unwrap_or(0))
.sum()
}
fn compact<'a>(
&'a self,
messages: &'a [Message],
instruction: Option<&'a str>,
) -> Pin<
Box<
dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
>,
> {
Box::pin(async move {
if messages.is_empty() {
return Err(CompactionError::NoMessagesToCompact);
}
let mut text = String::new();
if let Some(instr) = instruction {
text.push_str(&format!("[instruction] {instr}\n\n"));
}
text.push_str(&oxi_snapcompact::serialize_conversation(
&flatten_messages(messages),
self.frame_chars,
));
let frame_count = max_frame_count(text.chars().count(), self.frame_chars);
let frames_to_render = frame_count.min(self.max_frames as usize).max(1);
let original_tokens = self.estimate_tokens(messages);
let mut frames: Vec<(u32, Vec<u8>)> = Vec::with_capacity(frames_to_render);
let mut idx: u32 = 0;
'outer: for chunk in chunks(&text, self.frame_chars).take(frames_to_render) {
let prep = oxi_snapcompact::prepare(&chunk, 200_000, self.frame_chars);
let opts = oxi_snapcompact::CompactOptions {
shape: self.shape.clone(),
model_id: String::new(),
max_frames: 1,
};
let result = oxi_snapcompact::compact(&prep, &opts);
if result.frames.is_empty() {
return Err(CompactionError::LlmError(format!(
"snapcompact produced no frames for chunk {idx}"
)));
}
for f in result.frames {
if f.bytes.is_empty() {
return Err(CompactionError::LlmError(format!(
"snapcompact frame {} returned empty bytes (render failure)",
f.index
)));
}
frames.push((idx, f.bytes));
idx += 1;
if idx as usize == self.max_frames as usize {
break 'outer;
}
}
}
let metadata = CompactionMetadata::new(
original_tokens,
estimate_frame_tokens(frames.len()),
frames.len(),
0,
0.0, );
let mut context = CompactedContext::new(
format!("[snapcompact] {} frames", frames.len()),
Vec::new(),
frames.len(),
metadata,
);
context.frames = Some(Arc::new(frames));
Ok(context)
})
}
}
fn estimate_frame_tokens(frames: usize) -> usize {
frames.saturating_mul(1500)
}
fn max_frame_count(chars: usize, frame_chars: usize) -> usize {
if frame_chars == 0 {
return 0;
}
chars.div_ceil(frame_chars)
}
fn chunks(text: &str, frame_chars: usize) -> impl Iterator<Item = String> + '_ {
let mut remaining = text;
std::iter::from_fn(move || {
if remaining.is_empty() {
return None;
}
let n = remaining.chars().count().min(frame_chars);
let cut = remaining
.char_indices()
.nth(n)
.map(|(idx, _)| idx)
.unwrap_or(remaining.len());
let (head, tail) = remaining.split_at(cut);
remaining = tail;
Some(head.to_string())
})
}
fn flatten_messages(messages: &[Message]) -> String {
let mut out = String::new();
for m in messages {
let role = match m {
Message::User(_) => "user",
Message::Assistant(_) => "assistant",
Message::ToolResult(_) => "tool",
};
let text = m.text_content().unwrap_or_default();
if !text.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(role);
out.push_str(": ");
out.push_str(&text);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_construction() {
let c = SnapcompactCompactor::new();
assert!(c.shape().is_none());
assert_eq!(c.frame_chars, 4000);
assert_eq!(c.max_frames, 8);
}
#[test]
fn shape_pinning_works() {
let shape = oxi_snapcompact::SHAPES[0].clone();
let c = SnapcompactCompactor::new().with_shape(shape.clone());
assert!(c.shape().is_some());
assert_eq!(c.shape().unwrap().name, shape.name);
}
#[test]
fn frame_chars_floor() {
let c = SnapcompactCompactor::new().with_frame_chars(0);
assert_eq!(c.frame_chars, 64);
}
#[test]
fn empty_messages_returns_error() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let compactor = SnapcompactCompactor::new();
let err = rt
.block_on(async { compactor.compact(&[], None).await })
.unwrap_err();
assert!(matches!(err, CompactionError::NoMessagesToCompact));
}
#[test]
fn compact_real_messages_produces_png_frames() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let messages = vec![Message::user(
"Hello world. This is a test message that should be rendered as a PNG frame by snapcompact.",
)];
let compactor = SnapcompactCompactor::new();
let result = rt
.block_on(async { compactor.compact(&messages, None).await })
.expect("compaction should succeed");
let frames = result.frames.as_ref().expect("frames should be attached");
assert!(!frames.is_empty(), "should produce ≥1 frame");
for (idx, bytes) in frames.iter() {
assert!(!bytes.is_empty(), "frame {idx} must be non-empty");
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
"frame {idx} must be PNG"
);
}
}
}