use serde::{Deserialize, Serialize};
use crate::core::memory_consolidation::token_jaccard;
const DEDUP_JACCARD: f64 = 0.7;
const STALE_TURNS: u32 = 50;
const MAX_CONTENT_CHARS: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntryKind {
Strategy,
Pitfall,
Fact,
FileRef,
}
impl EntryKind {
pub fn as_str(self) -> &'static str {
match self {
EntryKind::Strategy => "Strategy",
EntryKind::Pitfall => "Pitfall",
EntryKind::Fact => "Fact",
EntryKind::FileRef => "FileRef",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookEntry {
pub id: u32,
pub kind: EntryKind,
pub content: String,
pub created_turn: u32,
pub last_confirmed_turn: u32,
pub helpful_votes: u32,
pub harmful_votes: u32,
}
impl PlaybookEntry {
fn salience(&self) -> i64 {
i64::from(self.helpful_votes) - i64::from(self.harmful_votes)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeltaOutcome {
Added(u32),
Confirmed(u32),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Playbook {
pub entries: Vec<PlaybookEntry>,
pub next_id: u32,
}
impl Playbook {
pub fn add_delta(&mut self, kind: EntryKind, content: &str, turn: u32) -> DeltaOutcome {
let content: String = content.trim().chars().take(MAX_CONTENT_CHARS).collect();
if let Some(existing) = self
.entries
.iter_mut()
.find(|e| e.kind == kind && token_jaccard(&e.content, &content) >= DEDUP_JACCARD)
{
existing.last_confirmed_turn = turn;
existing.helpful_votes += 1;
return DeltaOutcome::Confirmed(existing.id);
}
self.next_id += 1;
let id = self.next_id;
self.entries.push(PlaybookEntry {
id,
kind,
content,
created_turn: turn,
last_confirmed_turn: turn,
helpful_votes: 0,
harmful_votes: 0,
});
DeltaOutcome::Added(id)
}
pub fn vote(&mut self, id: u32, helpful: bool) -> bool {
match self.entries.iter_mut().find(|e| e.id == id) {
Some(e) => {
if helpful {
e.helpful_votes += 1;
} else {
e.harmful_votes += 1;
}
true
}
None => false,
}
}
pub fn evict(&mut self, current_turn: u32) -> usize {
let before = self.entries.len();
self.entries.retain(|e| {
let net_harmful = e.harmful_votes > e.helpful_votes;
let stale = current_turn.saturating_sub(e.last_confirmed_turn) > STALE_TURNS;
!(net_harmful || stale)
});
before - self.entries.len()
}
pub fn render(&self, top_k: usize) -> String {
if self.entries.is_empty() {
return String::new();
}
let mut selected: Vec<&PlaybookEntry> = self.entries.iter().collect();
let elided = if selected.len() > top_k {
selected.sort_by_key(|e| std::cmp::Reverse((e.salience(), e.last_confirmed_turn)));
let n = selected.len() - top_k;
selected.truncate(top_k);
n
} else {
0
};
selected.sort_by_key(|e| e.id);
let mut out = String::from("PLAYBOOK (delta log, stable IDs):\n");
for e in selected {
let votes = if e.helpful_votes > 0 || e.harmful_votes > 0 {
format!(" (+{}/-{})", e.helpful_votes, e.harmful_votes)
} else {
String::new()
};
out.push_str(&format!(
"[P{}] {}: {}{votes}\n",
e.id,
e.kind.as_str(),
e.content
));
}
if elided > 0 {
out.push_str(&format!(
"… {elided} low-salience entries elided (recall via ctx_session)\n"
));
}
out
}
pub fn information_volume(&self) -> usize {
self.entries.iter().map(|e| e.content.len()).sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_assigns_stable_monotonic_ids() {
let mut p = Playbook::default();
let a = p.add_delta(EntryKind::Fact, "billing service uses separate database", 1);
let b = p.add_delta(EntryKind::Strategy, "deploy via rsync then script", 1);
assert_eq!(a, DeltaOutcome::Added(1));
assert_eq!(b, DeltaOutcome::Added(2));
}
#[test]
fn near_duplicate_confirms_instead_of_inserting() {
let mut p = Playbook::default();
p.add_delta(
EntryKind::Fact,
"the webhook parses cancel_at from stripe",
1,
);
let out = p.add_delta(EntryKind::Fact, "webhook parses cancel_at from stripe", 5);
assert_eq!(out, DeltaOutcome::Confirmed(1));
assert_eq!(p.entries.len(), 1);
assert_eq!(p.entries[0].last_confirmed_turn, 5);
assert_eq!(p.entries[0].helpful_votes, 1);
}
#[test]
fn eviction_is_local_only() {
let mut p = Playbook::default();
p.add_delta(EntryKind::Strategy, "good strategy that keeps working", 1);
p.add_delta(EntryKind::Pitfall, "bad advice that hurt us twice", 1);
p.vote(2, false);
let evicted = p.evict(2);
assert_eq!(evicted, 1);
assert_eq!(p.entries.len(), 1);
assert_eq!(p.entries[0].id, 1, "untouched entry survives verbatim");
}
#[test]
fn stale_entries_evicted_after_50_turns() {
let mut p = Playbook::default();
p.add_delta(EntryKind::Fact, "old fact from early in the session", 1);
p.add_delta(EntryKind::Fact, "recent fact still being confirmed", 60);
let evicted = p.evict(60);
assert_eq!(evicted, 1);
assert_eq!(p.entries[0].created_turn, 60);
}
#[test]
fn render_is_stable_across_checkpoints() {
let mut p = Playbook::default();
p.add_delta(EntryKind::Fact, "fact one about the billing database", 1);
p.add_delta(EntryKind::Strategy, "strategy two for safe deploys", 1);
let r1 = p.render(10);
let r2 = p.render(10);
assert_eq!(r1, r2, "no drift without new deltas");
p.add_delta(EntryKind::Pitfall, "pitfall three with the file lock", 2);
let r3 = p.render(10);
assert!(r3.contains("[P1]") && r3.contains("[P2]") && r3.contains("[P3]"));
for line in r1.lines().filter(|l| l.starts_with("[P")) {
assert!(r3.contains(line), "old line rewritten: {line}");
}
}
#[test]
fn brevity_bias_regression_volume_never_shrinks() {
let mut p = Playbook::default();
let mut last_volume = 0;
for turn in 1..=10 {
p.add_delta(
EntryKind::Fact,
&format!("distinct finding number {turn} about module {turn}"),
turn,
);
p.evict(turn);
let vol = p.information_volume();
assert!(
vol >= last_volume,
"checkpoint {turn} shrank information volume: {last_volume} -> {vol}"
);
last_volume = vol;
}
}
#[test]
fn render_caps_at_top_k_by_salience() {
let mut p = Playbook::default();
let topics = [
"webhook parses stripe cancellation timestamps",
"dashboard heatmap aggregates bounce counters",
"scent field decays claims exponentially",
"playbook entries keep stable identifiers",
"thresholds learn from edit failures",
"litm calibration shifts begin share",
"billing purge runs inside one transaction",
"goodbye email sends after account deletion",
"entropy mode rescues task keywords",
"bm25 index rebuilds on provider sync",
];
for (i, t) in topics.iter().enumerate() {
p.add_delta(EntryKind::Fact, t, i as u32 + 1);
}
assert_eq!(p.entries.len(), topics.len(), "fixtures must not dedup");
p.vote(7, true);
p.vote(7, true);
let r = p.render(5);
let entry_lines = r.lines().filter(|l| l.starts_with("[P")).count();
assert_eq!(entry_lines, 5);
assert!(r.contains("[P7]"), "high-salience entry survives the cut");
assert!(r.contains("elided"));
}
#[test]
fn content_capped_at_200_chars() {
let mut p = Playbook::default();
let long = "x".repeat(500);
p.add_delta(EntryKind::Fact, &long, 1);
assert_eq!(p.entries[0].content.len(), MAX_CONTENT_CHARS);
}
}