1use serde::{Deserialize, Serialize};
16
17use crate::core::memory_consolidation::token_jaccard;
18
19const DEDUP_JACCARD: f64 = 0.7;
22const STALE_TURNS: u32 = 50;
24const 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 Strategy,
32 Pitfall,
34 Fact,
36 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 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#[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 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 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 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 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 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 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}