Skip to main content

ares_llm/
compact.rs

1//! History compaction behind any [`LLMClient`](crate::client::LLMClient).
2//!
3//! The doctrine is deliberate: **small context, frequent micro-calls**. Long
4//! conversations are not fed back wholesale; instead a [`Compactor`] keeps a
5//! bounded working set and spends tiny single-purpose calls to maintain it:
6//!
7//! 1. **Score** every recorded turn once, 1–5, with a fixed rubric prompt
8//!    ("score low when unsure").
9//! 2. **Audit** periodically: re-score anything unscored or weak in ONE
10//!    batched call, hoist 5-tier turns into a VERBATIM critical-facts list,
11//!    rebuild a short rolling memory from mid-tier facts, and evict
12//!    low-value turns — never the newest [`CompactConfig::grace_turns`].
13//! 3. **Build context**: callers get `[base, critical, memory, recent]`
14//!    message pairs ready for `generate_with_history`.
15//!
16//! Every prompt is a `const` fixed template so provider-side prompt caches
17//! see stable prefixes. Every LLM failure path degrades silently: state is
18//! kept as-is and a [`CompactEvent::Skipped`] is returned instead of an
19//! error — recording a turn must never fail because a scoring call did.
20
21use std::sync::Arc;
22
23use parking_lot::Mutex;
24
25use serde_json::Value;
26
27use crate::client::LLMClient;
28use crate::micro::salvage_json;
29
30/// Fixed rubric for the single-turn score call. Cache-stable by contract.
31const SCORE_SYSTEM: &str = "You rate one conversation turn for long-term value. \
32Reply with ONLY a JSON object {\"score\":N} where N is 1-5: \
335 = critical fact or decision worth keeping verbatim, \
344 = useful detail, \
353 = mild context value, \
362 = mostly filler, \
371 = worthless. \
38Score low when unsure.";
39
40/// Fixed rubric for the batched audit re-score call. Cache-stable by contract.
41const AUDIT_SYSTEM: &str = "You re-rate conversation turns for long-term value. \
42Each turn is prefixed with its sequence number [seq]. \
43Reply with ONLY a JSON array [{\"seq\":N,\"score\":N}] covering EVERY listed seq, \
44scores 1-5: \
455 = critical fact or decision worth keeping verbatim, \
464 = useful detail, \
473 = mild context value, \
482 = mostly filler, \
491 = worthless. \
50Score low when unsure.";
51
52/// Fixed instruction for the memory rebuild call. Output is plain text.
53const MEMORY_SYSTEM: &str = "You compress raw conversation notes into a dense rolling summary. \
54Keep only durable facts, decisions and preferences; drop filler. \
55Preserve concrete names, numbers and dates. \
56Output ONLY the summary text, nothing else.";
57
58/// Score at or above this tier is hoisted verbatim into the critical list.
59const S_TIER_SCORE: u8 = 5;
60/// Lowest mid-tier score feeding the memory rebuild.
61const MID_TIER_MIN: u8 = 3;
62/// Highest mid-tier score feeding the memory rebuild.
63const MID_TIER_MAX: u8 = 4;
64/// Scores at or below this mark a turn as an eviction candidate.
65const LOW_EVICT_SCORE: u8 = 2;
66
67/// Tuning knobs for a [`Compactor`].
68///
69/// Defaults suit interactive chat; tighten `trigger_turns` for bursty
70/// workloads, widen `grace_turns` when the newest exchanges must never be
71/// judged prematurely.
72#[derive(Debug, Clone)]
73pub struct CompactConfig {
74    /// Run an audit once this many NEW turns arrived since the last audit.
75    pub trigger_turns: usize,
76    /// Target size of the live (in-context) history; an audit also fires
77    /// when live turns exceed this capacity.
78    pub history_turns: usize,
79    /// The newest this many turns are NEVER evicted, regardless of score.
80    pub grace_turns: usize,
81    /// Hard character ceiling for the rebuilt memory string.
82    pub memory_max_chars: usize,
83    /// Maximum number of verbatim critical facts kept; overflow drops the
84    /// oldest admitted item (all admitted items scored 5, so age breaks ties).
85    pub critical_max_items: usize,
86    /// Turns scoring at or below this are re-scored during audits and count
87    /// as eviction candidates when they land at or below 2.
88    pub critical_reaudit: usize,
89}
90
91impl Default for CompactConfig {
92    fn default() -> Self {
93        Self {
94            trigger_turns: 6,
95            history_turns: 6,
96            grace_turns: 3,
97            memory_max_chars: 500,
98            critical_max_items: 8,
99            critical_reaudit: 6,
100        }
101    }
102}
103
104use serde::{Deserialize, Serialize};
105
106/// One recorded conversation turn with its audited importance score.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct TurnEntry {
109    /// Monotonic sequence number, starting at 1.
110    pub seq: u64,
111    /// The user side of the turn.
112    pub user: String,
113    /// The assistant side of the turn.
114    pub assistant: String,
115    /// Rubric score 1–5; `None` while unscored or when scoring failed.
116    pub score: Option<u8>,
117}
118
119/// Internal compaction state guarded by the [`Compactor`] mutex.
120#[derive(Debug, Default, Clone, Deserialize, Serialize)]
121pub struct CompactionState {
122    entries: Vec<TurnEntry>,
123    critical: Vec<String>,
124    memory: String,
125    last_audit_seq: u64,
126}
127
128impl CompactionState {
129    /// Builds a state from previously [`Compactor::export`]ed or persisted
130    /// parts (e.g. a DB snapshot row). The fields stay private so callers
131    /// cannot construct inconsistent internal states by hand.
132    pub fn from_parts(
133        entries: Vec<TurnEntry>,
134        critical: Vec<String>,
135        memory: String,
136        last_audit_seq: u64,
137    ) -> Self {
138        Self {
139            entries,
140            critical,
141            memory,
142            last_audit_seq,
143        }
144    }
145
146    /// Recorded turns, oldest first (for persistence).
147    pub fn entries(&self) -> &[TurnEntry] {
148        &self.entries
149    }
150
151    /// Verbatim critical facts (for persistence).
152    pub fn critical(&self) -> &[String] {
153        &self.critical
154    }
155
156    /// Rolling memory text (for persistence).
157    pub fn memory(&self) -> &str {
158        &self.memory
159    }
160
161    /// Sequence number the last completed audit covered (for persistence).
162    pub fn last_audit_seq(&self) -> u64 {
163        self.last_audit_seq
164    }
165
166    /// Next sequence number (last entry + 1, or 1 when empty).
167    fn next_seq(&self) -> u64 {
168        self.entries.last().map(|e| e.seq + 1).unwrap_or(1)
169    }
170
171    /// Number of NEW turns since the last audit finished.
172    fn turns_since_audit(&self) -> usize {
173        self.entries
174            .iter()
175            .filter(|e| e.seq > self.last_audit_seq)
176            .count()
177    }
178
179    fn apply_score(&mut self, seq: u64, score: u8) {
180        if let Some(entry) = self.entries.iter_mut().find(|e| e.seq == seq) {
181            entry.score = Some(score);
182        }
183    }
184
185    fn apply_scores(&mut self, updates: &[(u64, u8)]) {
186        for (seq, score) in updates {
187            self.apply_score(*seq, *score);
188        }
189    }
190}
191
192/// Counted state for tests and admin surfaces.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct CompactionSnapshot {
195    /// Live turns currently held in history.
196    pub turn_count: usize,
197    /// Live turns carrying a score.
198    pub scored_count: usize,
199    /// Verbatim critical facts kept.
200    pub critical_count: usize,
201    /// Character length of the rolling memory.
202    pub memory_chars: usize,
203    /// Sequence number the last completed audit covered up to.
204    pub last_audit_seq: u64,
205}
206
207/// Outcome of one compactor operation. Never an error: LLM trouble
208/// degrades to [`CompactEvent::Skipped`].
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum CompactEvent {
211    /// A recorded turn was scored.
212    Scored {
213        /// Sequence number of the scored turn.
214        seq: u64,
215        /// Clamped rubric score, 1–5.
216        score: u8,
217    },
218    /// An audit pass completed.
219    Audited {
220        /// Size of the critical list after the audit.
221        critical_kept: usize,
222        /// Character length of the memory string after the audit.
223        memory_chars: usize,
224        /// Sequence numbers evicted from live history.
225        dropped_seqs: Vec<u64>,
226    },
227    /// The operation degraded silently; prior state is untouched.
228    Skipped {
229        /// Machine-readable reason (`score-parse`, `score-call`,
230        /// `audit-call`, `audit-parse`, `not-due`).
231        reason: &'static str,
232    },
233}
234
235/// History compaction service running small frequent micro-calls over one
236/// shared client. See the [module docs](self) for the pipeline.
237pub struct Compactor {
238    config: CompactConfig,
239    client: Arc<dyn LLMClient>,
240    state: Mutex<CompactionState>,
241}
242
243impl Compactor {
244    /// Creates a compactor over `client` with `config`.
245    pub fn new(config: CompactConfig, client: Arc<dyn LLMClient>) -> Self {
246        Self {
247            config,
248            client,
249            state: Mutex::new(CompactionState::default()),
250        }
251    }
252
253    /// Creates a compactor over `client` with default [`CompactConfig`].
254    pub fn with_client(client: Arc<dyn LLMClient>) -> Self {
255        Self::new(CompactConfig::default(), client)
256    }
257
258    /// Records one user/assistant turn and scores THE PAIR with a single
259    /// micro-call.
260    ///
261    /// A transport failure yields [`CompactEvent::Skipped`] with reason
262    /// `score-call`; an unparseable reply yields `score-parse`. Either way
263    /// the turn is stored unscored and the caller never sees an error.
264    pub async fn record_turn(&self, user: String, assistant: String) -> CompactEvent {
265        let seq = {
266            let mut state = self.lock();
267            let seq = state.next_seq();
268            state.entries.push(TurnEntry {
269                seq,
270                user: user.clone(),
271                assistant: assistant.clone(),
272                score: None,
273            });
274            seq
275        };
276
277        let input = format!("user: {}\nassistant: {}", user, assistant);
278        let Ok(text) = self.client.generate_with_system(SCORE_SYSTEM, &input).await else {
279            return CompactEvent::Skipped {
280                reason: "score-call",
281            };
282        };
283        match parse_score(&text) {
284            Some(score) => {
285                self.lock().apply_score(seq, score);
286                CompactEvent::Scored { seq, score }
287            }
288            None => CompactEvent::Skipped {
289                reason: "score-parse",
290            },
291        }
292    }
293
294    /// Runs an audit pass when due: enough new turns arrived
295    /// ([`CompactConfig::trigger_turns`]) or live history exceeds
296    /// [`CompactConfig::history_turns`].
297    ///
298    /// One batched call re-scores every unscored or weak turn; then S-tier
299    /// (5) turns are hoisted VERBATIM into the deduplicated critical list,
300    /// the rolling memory is rebuilt from mid-tier facts in one more call
301    /// (skipped when there is nothing new to summarize), and low-value turns
302    /// outside the grace window are evicted. Returns a single-element
303    /// vector: [`CompactEvent::Audited`] on success or
304    /// [`CompactEvent::Skipped`] (`not-due`, `audit-call`, `audit-parse`).
305    pub async fn audit_if_due(&self) -> Vec<CompactEvent> {
306        let (due, candidates) = {
307            let state = self.lock();
308            let due = state.turns_since_audit() >= self.config.trigger_turns
309                || state.entries.len() > self.config.history_turns;
310            let candidates: Vec<TurnEntry> = state
311                .entries
312                .iter()
313                .filter(|e| {
314                    e.score.is_none() || e.score <= Some(self.config.critical_reaudit as u8)
315                })
316                .cloned()
317                .collect();
318            (due, candidates)
319        };
320        if !due {
321            return vec![CompactEvent::Skipped { reason: "not-due" }];
322        }
323
324        // One batched re-score call over all unscored/low turns.
325        let listing = candidates
326            .iter()
327            .map(|e| format!("[{}] user: {}\nassistant: {}", e.seq, e.user, e.assistant))
328            .collect::<Vec<_>>()
329            .join("\n---\n");
330        let Ok(text) = self
331            .client
332            .generate_with_system(AUDIT_SYSTEM, &listing)
333            .await
334        else {
335            return vec![CompactEvent::Skipped {
336                reason: "audit-call",
337            }];
338        };
339        let updates = parse_audit_scores(&text);
340        if updates.is_empty() {
341            return vec![CompactEvent::Skipped {
342                reason: "audit-parse",
343            }];
344        }
345
346        // Structural pass under one short-lived lock.
347        let (dropped_seqs, mid_facts, previous_memory) = {
348            let mut state = self.lock();
349            state.apply_scores(&updates);
350
351            // Newest grace_turns entries are untouchable, whatever they
352            // scored; older low-scorers leave live history.
353            let keep_from = state.entries.len().saturating_sub(self.config.grace_turns);
354            let mut dropped_seqs = Vec::new();
355            let mut kept: Vec<TurnEntry> = Vec::with_capacity(state.entries.len());
356            for (position, entry) in state.entries.drain(..).enumerate() {
357                let low = matches!(entry.score, Some(score) if score <= LOW_EVICT_SCORE);
358                let protected = position >= keep_from;
359                if protected || !low {
360                    kept.push(entry);
361                } else {
362                    dropped_seqs.push(entry.seq);
363                }
364            }
365            state.entries = kept;
366
367            // Hoist S-tier turns VERBATIM into the critical list, dedup by
368            // content. Scanning AFTER eviction is safe: a 5-scored entry is
369            // never evicted (only scores <= 2 leave), so nothing is missed.
370            let s_tier_items: Vec<String> = state
371                .entries
372                .iter()
373                .filter(|entry| entry.score == Some(S_TIER_SCORE))
374                .map(critical_item_text)
375                .collect();
376            for item in s_tier_items {
377                if !state.critical.contains(&item) {
378                    state.critical.push(item);
379                }
380            }
381            while state.critical.len() > self.config.critical_max_items {
382                state.critical.remove(0);
383            }
384
385            // Mid-tier facts inside the pre-grace window feed the memory
386            // rebuild; the previous summary rides along as context.
387            let window_end = state.entries.len().saturating_sub(self.config.grace_turns);
388            let mid_facts: Vec<String> = state
389                .entries
390                .iter()
391                .take(window_end)
392                .filter(|e| {
393                    matches!(e.score, Some(score) if (MID_TIER_MIN..=MID_TIER_MAX).contains(&score))
394                })
395                .map(mid_fact_text)
396                .collect();
397            let previous_memory = state.memory.clone();
398
399            state.last_audit_seq = state
400                .entries
401                .last()
402                .map(|e| e.seq)
403                .unwrap_or(state.last_audit_seq);
404            (dropped_seqs, mid_facts, previous_memory)
405        };
406
407        // Memory rebuild runs OUTSIDE the state lock; on failure or an empty
408        // reply the previous memory simply stays.
409        if !mid_facts.is_empty() {
410            let input = format!(
411                "Previous summary:\n{}\n\nNew notes:\n{}",
412                previous_memory,
413                mid_facts.join("\n")
414            );
415            if let Ok(summary) = self
416                .client
417                .generate_with_system(MEMORY_SYSTEM, &input)
418                .await
419            {
420                let trimmed = summary.trim();
421                if !trimmed.is_empty() {
422                    self.lock().memory = truncate_chars(trimmed, self.config.memory_max_chars);
423                }
424            }
425        }
426
427        let event = {
428            let state = self.lock();
429            CompactEvent::Audited {
430                critical_kept: state.critical.len(),
431                memory_chars: state.memory.chars().count(),
432                dropped_seqs,
433            }
434        };
435        vec![event]
436    }
437
438    /// Builds message pairs ready for `generate_with_history` callers:
439    /// `[base, critical, memory, recent turns]`, in that order. The critical
440    /// and memory slots appear only when non-empty; `recent_window` bounds
441    /// how many newest turns ride along.
442    pub fn build_context(&self, base: &str, recent_window: usize) -> Vec<(String, String)> {
443        let state = self.lock();
444        let mut messages = vec![("system".to_string(), base.to_string())];
445        if !state.critical.is_empty() {
446            messages.push((
447                "system".to_string(),
448                format!(
449                    "Critical facts to preserve verbatim:\n{}",
450                    state
451                        .critical
452                        .iter()
453                        .map(|item| format!("- {}", item))
454                        .collect::<Vec<_>>()
455                        .join("\n")
456                ),
457            ));
458        }
459        if !state.memory.is_empty() {
460            messages.push((
461                "system".to_string(),
462                format!("Conversation memory summary:\n{}", state.memory),
463            ));
464        }
465        let start = state.entries.len().saturating_sub(recent_window);
466        for entry in &state.entries[start..] {
467            messages.push(("user".to_string(), entry.user.clone()));
468            messages.push(("assistant".to_string(), entry.assistant.clone()));
469        }
470        messages
471    }
472
473    /// Exports the full compaction state for persistence (DB snapshot row).
474    pub fn export(&self) -> CompactionState {
475        self.lock().clone()
476    }
477
478    /// Rebuilds a compactor from previously [`Compactor::export`]ed state,
479    /// restoring entries, critical facts, memory and audit position.
480    pub fn hydrate(
481        config: CompactConfig,
482        client: Arc<dyn LLMClient>,
483        state: CompactionState,
484    ) -> Self {
485        Self {
486            config,
487            client,
488            state: Mutex::new(state),
489        }
490    }
491
492    /// Counted view of the current state for tests and admin surfaces.
493    pub fn state_snapshot(&self) -> CompactionSnapshot {
494        let state = self.lock();
495        CompactionSnapshot {
496            turn_count: state.entries.len(),
497            scored_count: state.entries.iter().filter(|e| e.score.is_some()).count(),
498            critical_count: state.critical.len(),
499            memory_chars: state.memory.chars().count(),
500            last_audit_seq: state.last_audit_seq,
501        }
502    }
503
504    /// Locks the state. `parking_lot` guards cannot poison, so a panic in
505    /// some other holder can never wedge turn recording.
506    fn lock(&self) -> parking_lot::MutexGuard<'_, CompactionState> {
507        self.state.lock()
508    }
509}
510
511/// Verbatim critical item text for a 5-tier turn.
512fn critical_item_text(entry: &TurnEntry) -> String {
513    format!("user: {}\nassistant: {}", entry.user, entry.assistant)
514}
515
516/// Compressed fact text feeding the memory rebuild for a mid-tier turn.
517fn mid_fact_text(entry: &TurnEntry) -> String {
518    format!(
519        "[{}] user: {}; assistant: {}",
520        entry.seq, entry.user, entry.assistant
521    )
522}
523
524/// Truncates to at most `max` chars without splitting a character.
525fn truncate_chars(text: &str, max: usize) -> String {
526    text.char_indices()
527        .nth(max)
528        .map_or_else(|| text.to_string(), |(idx, _)| text[..idx].to_string())
529}
530
531/// Parses a tolerant 1–5 score out of model output (any stage of salvage).
532fn parse_score(text: &str) -> Option<u8> {
533    let value = salvage_json(text)?;
534    let raw = value.get("score").and_then(|field| {
535        field.as_i64().or_else(|| {
536            field
537                .as_str()
538                .and_then(|string| string.trim().parse::<i64>().ok())
539        })
540    })?;
541    Some(raw.clamp(1, 5) as u8)
542}
543
544/// Parses `[{seq,score}]` audit replies; malformed rows are skipped, valid
545/// scores are clamped to 1–5. Empty result means the whole reply was unusable.
546fn parse_audit_scores(text: &str) -> Vec<(u64, u8)> {
547    let Some(value) = salvage_json(text) else {
548        return Vec::new();
549    };
550    let rows: Vec<Value> = match value {
551        Value::Array(rows) => rows,
552        Value::Object(_) => vec![value],
553        _ => return Vec::new(),
554    };
555    rows.iter()
556        .filter_map(|row| {
557            let seq = row.get("seq")?.as_u64()?;
558            let score = row.get("score")?.as_i64()?;
559            Some((seq, score.clamp(1, 5) as u8))
560        })
561        .collect()
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use ares_types::types::{AppError, Result};
568    use async_trait::async_trait;
569    use std::sync::atomic::{AtomicUsize, Ordering};
570
571    type Step = std::result::Result<String, AppError>;
572
573    /// Client scripting `generate_with_system` replies by call index and
574    /// counting calls; every other trait method fails as unused.
575    struct ScriptedClient {
576        replies: Box<dyn Fn(usize) -> Step + Send + Sync>,
577        calls: AtomicUsize,
578    }
579
580    impl ScriptedClient {
581        fn new<F>(replies: F) -> Self
582        where
583            F: Fn(usize) -> Step + Send + Sync + 'static,
584        {
585            Self {
586                replies: Box::new(replies),
587                calls: AtomicUsize::new(0),
588            }
589        }
590
591        fn call_index(&self) -> usize {
592            self.calls.fetch_add(1, Ordering::SeqCst)
593        }
594    }
595
596    #[async_trait]
597    impl LLMClient for ScriptedClient {
598        async fn generate(&self, _prompt: &str) -> Result<String> {
599            Err(AppError::Internal("unused".into()))
600        }
601
602        async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
603            (self.replies)(self.call_index())
604        }
605
606        async fn generate_with_history(
607            &self,
608            _messages: &[(String, String)],
609        ) -> Result<crate::client::LLMResponse> {
610            Err(AppError::Internal("unused".into()))
611        }
612
613        async fn generate_with_tools(
614            &self,
615            _prompt: &str,
616            _tools: &[ares_types::types::ToolDefinition],
617        ) -> Result<crate::client::LLMResponse> {
618            Err(AppError::Internal("unused".into()))
619        }
620
621        async fn generate_with_tools_and_history(
622            &self,
623            _messages: &[crate::coordinator::ConversationMessage],
624            _tools: &[ares_types::types::ToolDefinition],
625        ) -> Result<crate::client::LLMResponse> {
626            Err(AppError::Internal("unused".into()))
627        }
628
629        async fn stream(
630            &self,
631            _prompt: &str,
632        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
633            Err(AppError::Internal("unused".into()))
634        }
635
636        async fn stream_with_system(
637            &self,
638            _system: &str,
639            _prompt: &str,
640        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
641            Err(AppError::Internal("unused".into()))
642        }
643
644        async fn stream_with_history(
645            &self,
646            _messages: &[(String, String)],
647        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
648            Err(AppError::Internal("unused".into()))
649        }
650
651        fn model_name(&self) -> &str {
652            "compact-scripted-mock"
653        }
654    }
655
656    fn config() -> CompactConfig {
657        CompactConfig {
658            trigger_turns: 1,
659            history_turns: 16,
660            grace_turns: 1,
661            memory_max_chars: 500,
662            critical_max_items: 8,
663            critical_reaudit: 6,
664        }
665    }
666
667    #[tokio::test]
668    async fn record_turn_scores_the_pair() {
669        let client = Arc::new(ScriptedClient::new(|_| Ok("{\"score\": 4}".into())));
670        let compactor = Compactor::with_client(client);
671
672        let event = compactor
673            .record_turn("what is ares?".to_string(), "an api gateway".to_string())
674            .await;
675
676        assert_eq!(event, CompactEvent::Scored { seq: 1, score: 4 });
677        let snapshot = compactor.state_snapshot();
678        assert_eq!(snapshot.turn_count, 1);
679        assert_eq!(snapshot.scored_count, 1);
680        assert_eq!(snapshot.last_audit_seq, 0, "scoring alone is not an audit");
681    }
682
683    #[tokio::test]
684    async fn record_turn_parse_failure_is_skipped_not_err() {
685        let client = Arc::new(ScriptedClient::new(|_| Ok("no json at all".into())));
686        let compactor = Compactor::with_client(client);
687
688        let event = compactor
689            .record_turn("u".to_string(), "a".to_string())
690            .await;
691
692        assert_eq!(
693            event,
694            CompactEvent::Skipped {
695                reason: "score-parse"
696            }
697        );
698        let snapshot = compactor.state_snapshot();
699        assert_eq!(snapshot.turn_count, 1, "turn is kept unscored");
700        assert_eq!(snapshot.scored_count, 0);
701    }
702
703    #[tokio::test]
704    async fn record_turn_transport_failure_is_skipped_not_err() {
705        let client = Arc::new(ScriptedClient::new(|_| {
706            Err(AppError::External("down".into()))
707        }));
708        let compactor = Compactor::with_client(client);
709
710        let event = compactor
711            .record_turn("u".to_string(), "a".to_string())
712            .await;
713
714        assert_eq!(
715            event,
716            CompactEvent::Skipped {
717                reason: "score-call"
718            }
719        );
720        assert_eq!(compactor.state_snapshot().turn_count, 1);
721    }
722
723    #[tokio::test]
724    async fn audit_hoists_s_tier_verbatim_and_evicts_low_outside_grace() {
725        // Calls 0..3 score four turns; call 4 is the batched audit re-score;
726        // call 5 is the memory rebuild (one mid-tier survivor exists).
727        let client = Arc::new(ScriptedClient::new(|call| {
728            match call {
729            0..=3 => Ok(format!("{{\"score\":{}}}", [5, 1, 4, 1][call])),
730            4 => Ok("[{\"seq\":1,\"score\":5},{\"seq\":2,\"score\":1},{\"seq\":3,\"score\":4},{\"seq\":4,\"score\":1}]".into()),
731            5 => Ok("likes rust; dislikes yaml".into()),
732            _ => Err(AppError::Internal("unexpected call".into())),
733        }
734        }));
735        let compactor = Compactor::new(config(), client);
736
737        for index in 0..4u64 {
738            let event = compactor
739                .record_turn(format!("u{}", index), format!("a{}", index))
740                .await;
741            assert!(
742                matches!(event, CompactEvent::Scored { .. }),
743                "seed turn {} should score",
744                index
745            );
746        }
747
748        let events = compactor.audit_if_due().await;
749        assert_eq!(events.len(), 1);
750        // Seq 2 (score 1) is evicted. Seq 4 ALSO scored 1 but sits inside the
751        // newest-entry grace window, so it survives despite its score.
752        assert_eq!(
753            events[0],
754            CompactEvent::Audited {
755                critical_kept: 1,
756                memory_chars: "likes rust; dislikes yaml".chars().count(),
757                dropped_seqs: vec![2],
758            }
759        );
760
761        let snapshot = compactor.state_snapshot();
762        assert_eq!(snapshot.turn_count, 3, "seq 2 evicted; 1, 3, 4 stay");
763        assert_eq!(snapshot.critical_count, 1);
764        assert_eq!(snapshot.last_audit_seq, 4);
765
766        // Grace window, isolated: a single low-scored turn is the newest
767        // entry, so an audit must NOT evict it.
768        let client = Arc::new(ScriptedClient::new(|call| match call {
769            0 => Ok("{\"score\":1}".into()),
770            1 => Ok("[{\"seq\":5,\"score\":1}]".into()),
771            _ => Err(AppError::Internal("unexpected call".into())),
772        }));
773        let compactor = Compactor::new(config(), client);
774        compactor
775            .record_turn("fresh".to_string(), "low value".to_string())
776            .await;
777        let events = compactor.audit_if_due().await;
778        assert_eq!(
779            events[0],
780            CompactEvent::Audited {
781                critical_kept: 0,
782                memory_chars: 0,
783                dropped_seqs: vec![],
784            },
785            "newest grace_turns entry survives despite score 1"
786        );
787    }
788
789    #[tokio::test]
790    async fn audit_memory_failure_keeps_previous_memory() {
791        // Two seeded turns put a mid-tier fact inside the pre-grace window so
792        // the first audit rebuilds memory; the SECOND audit's memory call
793        // fails, and the previous summary must survive untouched.
794        let client = Arc::new(ScriptedClient::new(|call| match call {
795            0..=1 => Ok("{\"score\":3}".into()),
796            2 => Ok("[{\"seq\":1,\"score\":3},{\"seq\":2,\"score\":3}]".into()),
797            3 => Ok("first summary".into()),
798            4 => Ok("{\"score\":3}".into()),
799            5 => Ok("[{\"seq\":3,\"score\":3}]".into()),
800            _ => Err(AppError::External("memory call down".into())),
801        }));
802        let compactor = Compactor::new(config(), client);
803
804        compactor.record_turn("u1".into(), "a1".into()).await;
805        compactor.record_turn("u2".into(), "a2".into()).await;
806        let first = compactor.audit_if_due().await;
807        assert_eq!(
808            first[0],
809            CompactEvent::Audited {
810                critical_kept: 0,
811                memory_chars: "first summary".chars().count(),
812                dropped_seqs: vec![],
813            }
814        );
815
816        compactor.record_turn("u3".into(), "a3".into()).await;
817        let second = compactor.audit_if_due().await;
818        assert!(matches!(second[0], CompactEvent::Audited { .. }));
819        let snapshot = compactor.state_snapshot();
820        assert_eq!(
821            snapshot.memory_chars,
822            "first summary".chars().count(),
823            "failed rebuild falls back to previous memory"
824        );
825    }
826
827    #[tokio::test]
828    async fn audit_call_failures_degrade_to_skipped() {
829        // Audit re-score transport failure...
830        let failing = Arc::new(ScriptedClient::new(|call| match call {
831            0 => Ok("{\"score\":1}".into()),
832            _ => Err(AppError::External("audit down".into())),
833        }));
834        let compactor = Compactor::new(config(), failing);
835        compactor.record_turn("u".into(), "a".into()).await;
836        assert_eq!(
837            compactor.audit_if_due().await,
838            vec![CompactEvent::Skipped {
839                reason: "audit-call"
840            }]
841        );
842
843        // ...and unparseable audit reply.
844        let garbage = Arc::new(ScriptedClient::new(|call| match call {
845            0 => Ok("{\"score\":1}".into()),
846            1 => Ok("total gibberish".into()),
847            _ => Err(AppError::Internal("unexpected call".into())),
848        }));
849        let compactor = Compactor::new(config(), garbage);
850        compactor.record_turn("u".into(), "a".into()).await;
851        assert_eq!(
852            compactor.audit_if_due().await,
853            vec![CompactEvent::Skipped {
854                reason: "audit-parse"
855            }]
856        );
857        let snapshot = compactor.state_snapshot();
858        assert_eq!(snapshot.turn_count, 1, "skipped audits keep state intact");
859        assert_eq!(snapshot.last_audit_seq, 0);
860    }
861
862    #[tokio::test]
863    async fn audit_skips_when_not_due() {
864        let client = Arc::new(ScriptedClient::new(|_| {
865            Err(AppError::Internal("no calls expected".into()))
866        }));
867        let quiet = CompactConfig {
868            trigger_turns: 100,
869            history_turns: 100,
870            ..config()
871        };
872        let compactor = Compactor::new(quiet, client);
873
874        assert_eq!(
875            compactor.audit_if_due().await,
876            vec![CompactEvent::Skipped { reason: "not-due" }]
877        );
878    }
879
880    #[tokio::test]
881    async fn build_context_orders_base_critical_memory_recent() {
882        // Three turns: seq1 mid-tier (feeds memory), seq2 S-tier (verbatim
883        // critical), seq3 low but inside the newest-entry grace window.
884        let client = Arc::new(ScriptedClient::new(|call| match call {
885            0..=2 => Ok(format!("{{\"score\":{}}}", [4, 5, 2][call])),
886            3 => Ok(
887                "[{\"seq\":1,\"score\":4},{\"seq\":2,\"score\":5},{\"seq\":3,\"score\":2}]".into(),
888            ),
889            4 => Ok("she prefers dark mode".into()),
890            _ => Err(AppError::Internal("unexpected call".into())),
891        }));
892        let compactor = Compactor::new(config(), client);
893        compactor
894            .record_turn("theme?".into(), "dark mode".into())
895            .await;
896        compactor.record_turn("stack?".into(), "rust".into()).await;
897        compactor.record_turn("tabs?".into(), "spaces".into()).await;
898        compactor.audit_if_due().await;
899
900        let messages = compactor.build_context("You are helpful.", 8);
901
902        assert_eq!(messages.len(), 9, "base + critical + memory + 3 turns x2");
903        assert_eq!(
904            messages[0],
905            ("system".to_string(), "You are helpful.".to_string())
906        );
907        assert_eq!(messages[1].0, "system");
908        assert!(
909            messages[1].1.contains("Critical facts") && messages[1].1.contains("user: stack?"),
910            "critical slot comes right after base and holds the S-tier pair"
911        );
912        assert_eq!(messages[2].0, "system");
913        assert!(
914            messages[2].1.contains("Conversation memory summary")
915                && messages[2].1.contains("she prefers dark mode"),
916            "memory slot follows critical"
917        );
918        assert_eq!(messages[3], ("user".to_string(), "theme?".to_string()));
919        assert_eq!(
920            messages[4],
921            ("assistant".to_string(), "dark mode".to_string())
922        );
923        assert_eq!(messages[5], ("user".to_string(), "stack?".to_string()));
924        assert_eq!(messages[6], ("assistant".to_string(), "rust".to_string()));
925        assert_eq!(messages[7], ("user".to_string(), "tabs?".to_string()));
926        assert_eq!(messages[8], ("assistant".to_string(), "spaces".to_string()));
927
928        // A tight recent window trims oldest turns but keeps the prefix.
929        let trimmed = compactor.build_context("base", 1);
930        assert_eq!(trimmed.len(), 5, "base + critical + memory + 1 turn x2");
931        assert_eq!(trimmed[3], ("user".to_string(), "tabs?".to_string()));
932        assert_eq!(trimmed[4], ("assistant".to_string(), "spaces".to_string()));
933
934        // Empty-state compactor emits just the base message.
935        let bare = Compactor::with_client(Arc::new(ScriptedClient::new(|_| {
936            Err(AppError::Internal("unused".into()))
937        })));
938        assert_eq!(
939            bare.build_context("only", 4),
940            vec![("system".to_string(), "only".to_string())]
941        );
942    }
943
944    #[test]
945    fn parse_score_clamps_and_tolerates_strings() {
946        assert_eq!(parse_score("{\"score\":4}"), Some(4));
947        assert_eq!(parse_score("Sure! {\"score\":\"9\"}"), Some(5));
948        assert_eq!(parse_score("{\"score\":0}"), Some(1));
949        assert_eq!(parse_score("garbage"), None);
950    }
951
952    #[test]
953    fn truncate_chars_respects_boundaries() {
954        assert_eq!(truncate_chars("hello", 50), "hello");
955        assert_eq!(truncate_chars("héllo", 2), "hé");
956    }
957
958    /// State must survive a serde round trip unchanged: this is the contract
959    /// behind DB snapshot persistence (`export` → JSONB row → `hydrate`).
960    #[tokio::test]
961    async fn state_serde_round_trip_preserves_entries_critical_memory_audit_seq() {
962        let client = Arc::new(ScriptedClient::new(|_| {
963            Err(AppError::Internal("unused".into()))
964        }));
965        let compactor = Compactor::with_client(client);
966        {
967            let mut state = compactor.lock();
968            state.entries.push(TurnEntry {
969                seq: 1,
970                user: "theme?".to_string(),
971                assistant: "dark mode".to_string(),
972                score: Some(5),
973            });
974            state.entries.push(TurnEntry {
975                seq: 2,
976                user: "stack?".to_string(),
977                assistant: "rust".to_string(),
978                score: None,
979            });
980            state
981                .critical
982                .push("user: theme?\nassistant: dark mode".to_string());
983            state.memory = "User prefers dark mode.".to_string();
984            state.last_audit_seq = 1;
985        }
986
987        // export → JSON → JSON (store row) → hydrate.
988        let exported = compactor.export();
989        let json = serde_json::to_string(&exported).expect("serialize");
990        let restored: CompactionState = serde_json::from_str(&json).expect("deserialize");
991        let revived = Compactor::hydrate(
992            CompactConfig::default(),
993            Arc::new(ScriptedClient::new(|_| {
994                Err(AppError::Internal("unused".into()))
995            })),
996            restored,
997        );
998
999        assert_eq!(revived.export().entries, exported.entries);
1000        assert_eq!(revived.export().critical, exported.critical);
1001        assert_eq!(revived.export().memory, exported.memory);
1002        assert_eq!(revived.export().last_audit_seq, 1);
1003        // Hydrated audit position survives: no new turns means no audit due.
1004        assert_eq!(
1005            revived.audit_if_due().await.first(),
1006            Some(&CompactEvent::Skipped { reason: "not-due" })
1007        );
1008    }
1009}