Skip to main content

lean_ctx/core/session/
playbook.rs

1//! Incremental delta playbook for checkpoints (#541, EFF-4 — ACE principle).
2//!
3//! ACE (Agentic Context Engineering, 2510.04618) showed that monolithic
4//! checkpoint rewrites cause *brevity bias* (repeated summarization loses
5//! detail) and *context collapse* (an observed 18k -> 122 token implosion,
6//! −29% accuracy). The cure: contexts grow as structured, itemized delta
7//! entries with stable IDs that are never rewritten — only appended, bumped
8//! (dedup-confirm), voted on, and locally evicted.
9//!
10//! lean-ctx wires this into `ctx_compress`: every checkpoint distills the
11//! session into playbook deltas instead of re-summarizing prior summaries.
12//! Renders are ordered by stable ID, so unchanged prefixes stay prefix-cache
13//! friendly across checkpoints.
14
15use serde::{Deserialize, Serialize};
16
17use crate::core::memory_consolidation::token_jaccard;
18
19/// Entries with this token-Jaccard similarity to an existing entry are
20/// duplicates: the existing entry gets confirmed instead of inserting.
21const DEDUP_JACCARD: f64 = 0.7;
22/// Entries unconfirmed for this many turns are evicted (locally).
23const STALE_TURNS: u32 = 50;
24/// Hard cap on entry content length (chars).
25const MAX_CONTENT_CHARS: usize = 200;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EntryKind {
30    /// An approach that worked (decisions, successful tactics).
31    Strategy,
32    /// Something that bit us (gotchas, bounces, failed edits).
33    Pitfall,
34    /// A stable observation about the codebase or domain.
35    Fact,
36    /// A file worth remembering, with why.
37    FileRef,
38}
39
40impl EntryKind {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            EntryKind::Strategy => "Strategy",
44            EntryKind::Pitfall => "Pitfall",
45            EntryKind::Fact => "Fact",
46            EntryKind::FileRef => "FileRef",
47        }
48    }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct PlaybookEntry {
53    /// Stable, monotonically assigned — never reused, never rewritten.
54    pub id: u32,
55    pub kind: EntryKind,
56    pub content: String,
57    pub created_turn: u32,
58    pub last_confirmed_turn: u32,
59    pub helpful_votes: u32,
60    pub harmful_votes: u32,
61}
62
63impl PlaybookEntry {
64    fn salience(&self) -> i64 {
65        i64::from(self.helpful_votes) - i64::from(self.harmful_votes)
66    }
67}
68
69/// Outcome of a delta insert.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DeltaOutcome {
72    Added(u32),
73    Confirmed(u32),
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct Playbook {
78    pub entries: Vec<PlaybookEntry>,
79    pub next_id: u32,
80}
81
82impl Playbook {
83    /// Grow-and-refine insert: near-duplicates confirm the existing entry
84    /// (bump + helpful vote) instead of creating drift. Existing entries are
85    /// NEVER rewritten — that is the ACE anti-collapse invariant.
86    pub fn add_delta(&mut self, kind: EntryKind, content: &str, turn: u32) -> DeltaOutcome {
87        let content: String = content.trim().chars().take(MAX_CONTENT_CHARS).collect();
88        if let Some(existing) = self
89            .entries
90            .iter_mut()
91            .find(|e| e.kind == kind && token_jaccard(&e.content, &content) >= DEDUP_JACCARD)
92        {
93            existing.last_confirmed_turn = turn;
94            existing.helpful_votes += 1;
95            return DeltaOutcome::Confirmed(existing.id);
96        }
97        self.next_id += 1;
98        let id = self.next_id;
99        self.entries.push(PlaybookEntry {
100            id,
101            kind,
102            content,
103            created_turn: turn,
104            last_confirmed_turn: turn,
105            helpful_votes: 0,
106            harmful_votes: 0,
107        });
108        DeltaOutcome::Added(id)
109    }
110
111    /// Vote on an entry by stable ID (agent feedback via ctx_session).
112    pub fn vote(&mut self, id: u32, helpful: bool) -> bool {
113        match self.entries.iter_mut().find(|e| e.id == id) {
114            Some(e) => {
115                if helpful {
116                    e.helpful_votes += 1;
117                } else {
118                    e.harmful_votes += 1;
119                }
120                true
121            }
122            None => false,
123        }
124    }
125
126    /// Local eviction only: net-harmful entries and entries unconfirmed for
127    /// STALE_TURNS die. No global re-summarization ever happens.
128    pub fn evict(&mut self, current_turn: u32) -> usize {
129        let before = self.entries.len();
130        self.entries.retain(|e| {
131            let net_harmful = e.harmful_votes > e.helpful_votes;
132            let stale = current_turn.saturating_sub(e.last_confirmed_turn) > STALE_TURNS;
133            !(net_harmful || stale)
134        });
135        before - self.entries.len()
136    }
137
138    /// Render ordered by stable ID (prefix-cache friendly: old entries keep
139    /// their byte positions). When `top_k` is exceeded, the lowest-salience
140    /// entries are elided — never rewritten.
141    pub fn render(&self, top_k: usize) -> String {
142        if self.entries.is_empty() {
143            return String::new();
144        }
145        let mut selected: Vec<&PlaybookEntry> = self.entries.iter().collect();
146        let elided = if selected.len() > top_k {
147            selected.sort_by_key(|e| std::cmp::Reverse((e.salience(), e.last_confirmed_turn)));
148            let n = selected.len() - top_k;
149            selected.truncate(top_k);
150            n
151        } else {
152            0
153        };
154        selected.sort_by_key(|e| e.id);
155
156        let mut out = String::from("PLAYBOOK (delta log, stable IDs):\n");
157        for e in selected {
158            let votes = if e.helpful_votes > 0 || e.harmful_votes > 0 {
159                format!(" (+{}/-{})", e.helpful_votes, e.harmful_votes)
160            } else {
161                String::new()
162            };
163            out.push_str(&format!(
164                "[P{}] {}: {}{votes}\n",
165                e.id,
166                e.kind.as_str(),
167                e.content
168            ));
169        }
170        if elided > 0 {
171            out.push_str(&format!(
172                "… {elided} low-salience entries elided (recall via ctx_session)\n"
173            ));
174        }
175        out
176    }
177
178    /// Total content volume (entries × chars) — used by the brevity-bias
179    /// regression test: repeated checkpoints must never shrink this.
180    pub fn information_volume(&self) -> usize {
181        self.entries.iter().map(|e| e.content.len()).sum()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn add_assigns_stable_monotonic_ids() {
191        let mut p = Playbook::default();
192        let a = p.add_delta(EntryKind::Fact, "billing service uses separate database", 1);
193        let b = p.add_delta(EntryKind::Strategy, "deploy via rsync then script", 1);
194        assert_eq!(a, DeltaOutcome::Added(1));
195        assert_eq!(b, DeltaOutcome::Added(2));
196    }
197
198    #[test]
199    fn near_duplicate_confirms_instead_of_inserting() {
200        let mut p = Playbook::default();
201        p.add_delta(
202            EntryKind::Fact,
203            "the webhook parses cancel_at from stripe",
204            1,
205        );
206        let out = p.add_delta(EntryKind::Fact, "webhook parses cancel_at from stripe", 5);
207        assert_eq!(out, DeltaOutcome::Confirmed(1));
208        assert_eq!(p.entries.len(), 1);
209        assert_eq!(p.entries[0].last_confirmed_turn, 5);
210        assert_eq!(p.entries[0].helpful_votes, 1);
211    }
212
213    #[test]
214    fn eviction_is_local_only() {
215        let mut p = Playbook::default();
216        p.add_delta(EntryKind::Strategy, "good strategy that keeps working", 1);
217        p.add_delta(EntryKind::Pitfall, "bad advice that hurt us twice", 1);
218        p.vote(2, false);
219        let evicted = p.evict(2);
220        assert_eq!(evicted, 1);
221        assert_eq!(p.entries.len(), 1);
222        assert_eq!(p.entries[0].id, 1, "untouched entry survives verbatim");
223    }
224
225    #[test]
226    fn stale_entries_evicted_after_50_turns() {
227        let mut p = Playbook::default();
228        p.add_delta(EntryKind::Fact, "old fact from early in the session", 1);
229        p.add_delta(EntryKind::Fact, "recent fact still being confirmed", 60);
230        let evicted = p.evict(60);
231        assert_eq!(evicted, 1);
232        assert_eq!(p.entries[0].created_turn, 60);
233    }
234
235    #[test]
236    fn render_is_stable_across_checkpoints() {
237        let mut p = Playbook::default();
238        p.add_delta(EntryKind::Fact, "fact one about the billing database", 1);
239        p.add_delta(EntryKind::Strategy, "strategy two for safe deploys", 1);
240        let r1 = p.render(10);
241        let r2 = p.render(10);
242        assert_eq!(r1, r2, "no drift without new deltas");
243        // A new delta only appends — existing lines keep their bytes.
244        p.add_delta(EntryKind::Pitfall, "pitfall three with the file lock", 2);
245        let r3 = p.render(10);
246        assert!(r3.contains("[P1]") && r3.contains("[P2]") && r3.contains("[P3]"));
247        for line in r1.lines().filter(|l| l.starts_with("[P")) {
248            assert!(r3.contains(line), "old line rewritten: {line}");
249        }
250    }
251
252    #[test]
253    fn brevity_bias_regression_volume_never_shrinks() {
254        let mut p = Playbook::default();
255        let mut last_volume = 0;
256        for turn in 1..=10 {
257            p.add_delta(
258                EntryKind::Fact,
259                &format!("distinct finding number {turn} about module {turn}"),
260                turn,
261            );
262            p.evict(turn);
263            let vol = p.information_volume();
264            assert!(
265                vol >= last_volume,
266                "checkpoint {turn} shrank information volume: {last_volume} -> {vol}"
267            );
268            last_volume = vol;
269        }
270    }
271
272    #[test]
273    fn render_caps_at_top_k_by_salience() {
274        let mut p = Playbook::default();
275        let topics = [
276            "webhook parses stripe cancellation timestamps",
277            "dashboard heatmap aggregates bounce counters",
278            "scent field decays claims exponentially",
279            "playbook entries keep stable identifiers",
280            "thresholds learn from edit failures",
281            "litm calibration shifts begin share",
282            "billing purge runs inside one transaction",
283            "goodbye email sends after account deletion",
284            "entropy mode rescues task keywords",
285            "bm25 index rebuilds on provider sync",
286        ];
287        for (i, t) in topics.iter().enumerate() {
288            p.add_delta(EntryKind::Fact, t, i as u32 + 1);
289        }
290        assert_eq!(p.entries.len(), topics.len(), "fixtures must not dedup");
291        p.vote(7, true);
292        p.vote(7, true);
293        let r = p.render(5);
294        let entry_lines = r.lines().filter(|l| l.starts_with("[P")).count();
295        assert_eq!(entry_lines, 5);
296        assert!(r.contains("[P7]"), "high-salience entry survives the cut");
297        assert!(r.contains("elided"));
298    }
299
300    #[test]
301    fn content_capped_at_200_chars() {
302        let mut p = Playbook::default();
303        let long = "x".repeat(500);
304        p.add_delta(EntryKind::Fact, &long, 1);
305        assert_eq!(p.entries[0].content.len(), MAX_CONTENT_CHARS);
306    }
307}