1use std::collections::BTreeMap;
34use std::path::Path;
35
36use serde::Serialize;
37
38use crate::queen::{self, Entry};
39use crate::{notes, stats};
40
41pub const STAMP_W: f64 = 1.0;
44pub const MISSED_W: f64 = crate::memory::VETOED_W as f64;
47const ANSWER_CHARS: usize = 120;
50
51const YES: [&str; 11] = ["yes", "y", "ok", "okay", "fine", "sure", "go", "ahead", "proceed", "approved", "lgtm"];
53
54fn norm(text: &str) -> String {
55 text.to_lowercase().split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ")
56}
57
58pub fn stamp(e: &Entry, answer: &str) -> bool {
61 let a = norm(answer);
62 let plan = norm(e.kind.proceeds());
63 (!plan.is_empty() && a.starts_with(&plan)) || yes(answer)
64}
65
66pub fn yes(answer: &str) -> bool {
68 let a = norm(answer);
69 !a.is_empty() && a.split(' ').all(|w| YES.contains(&w))
70}
71
72pub fn answered(store: &Path, e: &Entry, answer: &str, author: &str, now: i64) -> Option<&'static str> {
75 if !e.kind.escalates() {
76 return None;
77 }
78 let kind = if stamp(e, answer) { stats::QUEEN_STAMP } else { stats::QUEEN_NO };
79 stats::count(store, kind, &e.session, |ev| {
80 ev.ms = now;
81 ev.engineer = author.into();
82 ev.repo = e.repo.clone();
83 ev.why = format!("{:?}", e.kind);
84 ev.text = format!("{} {}", e.id, stats::excerpt(answer, ANSWER_CHARS));
85 });
86 Some(kind)
87}
88
89pub fn escalated(store: &Path, session: &str) -> bool {
92 !session.is_empty() && queen::entries(store).iter().any(|e| e.session == session && e.kind.escalates())
93}
94
95pub fn merged(state: &Path, session: &str) -> bool {
98 !notes::landed_by(state, session).is_empty()
99}
100
101pub fn missed(store: &Path, session: &str, cause: &str, id: &str, repo: &str, now: i64) -> bool {
105 if session.is_empty() || escalated(store, session) {
106 return false;
107 }
108 if stats::events(store).iter().any(|e| e.kind == stats::QUEEN_MISSED && e.text.split(' ').next() == Some(id)) {
109 return false;
110 }
111 stats::count(store, stats::QUEEN_MISSED, session, |ev| {
112 ev.ms = now;
113 ev.repo = repo.into();
114 ev.why = cause.into();
115 ev.text = id.into();
116 });
117 true
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Serialize)]
122pub struct Yield {
123 pub answered: u64,
125 pub no: u64,
126 pub stamps: u64,
127 pub missed: u64,
128 pub share: Option<f64>,
130 pub reward: f64,
132 pub alpha: f64,
133 pub beta: f64,
134}
135
136pub fn of(counts: &BTreeMap<String, u64>) -> Yield {
138 let n = |k: &str| counts.get(k).copied().unwrap_or(0);
139 let (no, stamps, missed) = (n(stats::QUEEN_NO), n(stats::QUEEN_STAMP), n(stats::QUEEN_MISSED));
140 let answered = no + stamps;
141 Yield {
142 answered,
143 no,
144 stamps,
145 missed,
146 share: (answered > 0).then(|| no as f64 / answered as f64),
147 reward: no as f64 - STAMP_W * stamps as f64 - MISSED_W * missed as f64,
148 alpha: STAMP_W,
149 beta: MISSED_W,
150 }
151}
152
153pub fn team(home: &Path) -> Yield {
155 let mut all = stats::Bucket::default();
156 for b in stats::current(home) {
157 all.add(&b);
158 }
159 of(&all.counts)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::queen::Kind;
166
167 fn tmp(tag: &str) -> std::path::PathBuf {
168 let d = std::env::temp_dir().join(format!("borg-escalation-{tag}-{}", std::process::id()));
169 let _ = std::fs::remove_dir_all(&d);
170 std::fs::create_dir_all(&d).unwrap();
171 d
172 }
173
174 fn entry(id: &str, kind: Kind, session: &str) -> Entry {
175 let (a, b) = kind.options();
176 Entry {
177 id: id.into(),
178 decision_key: format!("k{id}"),
179 kind,
180 question: format!("q {id}"),
181 options: vec![a.into(), b.into()],
182 default: a.into(),
183 evidence: vec![],
184 session: session.into(),
185 cwd: String::new(),
186 repo: "app".into(),
187 engineer: "ann@x".into(),
188 ms: 1,
189 answer: None,
190 answered_by: None,
191 answered_ms: None,
192 disagreeing: vec![],
193 dismissed: None,
194 dismissed_ms: None,
195 payload: None,
196 }
197 }
198
199 #[test]
200 fn letting_the_plan_go_ahead_is_a_stamp_and_changing_course_is_a_no() {
201 let irreversible = entry("q1", Kind::Irreversible, "s");
202 assert!(stamp(&irreversible, "proceed as planned"));
203 assert!(stamp(&irreversible, "Yes, fine."));
204 assert!(stamp(&irreversible, "ok go ahead"));
205 assert!(!stamp(&irreversible, "make it reversible first (backup, soft delete, a revert path)"));
206 assert!(!stamp(&irreversible, "yes but keep a backup"), "a yes with a condition changes the course");
207 assert!(!stamp(&irreversible, "no"));
208 let push = entry("q2", Kind::ExternalMutation, "s");
210 assert!(stamp(&push, "run it as planned"));
211 assert!(!stamp(&push, &push.default), "holding it is the no");
212 }
213
214 #[test]
215 fn the_yield_folds_counts_with_the_design_weights() {
216 let counts: BTreeMap<String, u64> =
217 [(stats::QUEEN_NO, 3), (stats::QUEEN_STAMP, 1), (stats::QUEEN_MISSED, 1)].into_iter().map(|(k, v)| (k.to_string(), v)).collect();
218 let y = of(&counts);
219 assert_eq!((y.answered, y.no, y.stamps, y.missed), (4, 3, 1, 1));
220 assert_eq!(y.share, Some(0.75));
221 assert_eq!(y.reward, 3.0 - 1.0 - 3.0, "a missed escalation weighs a veto");
222 assert_eq!(of(&BTreeMap::new()), Yield { alpha: 1.0, beta: 3.0, ..Yield::default() }, "no answer, no share");
223 }
224
225 #[test]
226 fn only_the_queens_own_escalations_earn_and_each_answer_logs_once() {
227 let store = tmp("answered");
228 let mut e = entry("q1", Kind::PublicApi, "s1");
229 assert_eq!(answered(&store, &e, "keep the old contract working", "ann@x", 10), Some(stats::QUEEN_NO));
230 assert_eq!(answered(&store, &e, "proceed as planned", "ann@x", 11), Some(stats::QUEEN_STAMP));
231 for k in [Kind::Constraint, Kind::Reach, Kind::OpenDecision] {
232 e.kind = k;
233 assert_eq!(answered(&store, &e, "anything", "ann@x", 12), None, "{k:?} is a gate, not the queen's choice");
234 }
235 let y = team(&store);
236 assert_eq!((y.no, y.stamps), (1, 1));
237 let _ = std::fs::remove_dir_all(&store);
238 }
239
240 #[test]
241 fn a_no_without_an_escalation_is_missed_once_and_never_after_one() {
242 let store = tmp("missed");
243 assert!(missed(&store, "s1", "veto", "no-s1-5", "app", 5));
244 assert!(!missed(&store, "s1", "veto", "no-s1-5", "app", 6), "once per id");
245 assert!(!missed(&store, "", "regret", "r1", "app", 6), "an unknown session is nobody's miss");
246 let line = serde_json::to_string(&entry("q9", Kind::CrossRepo, "s2")).unwrap();
248 std::fs::create_dir_all(queen::dir(&store)).unwrap();
249 std::fs::write(queen::dir(&store).join("queue.jsonl"), format!("{line}\n")).unwrap();
250 assert!(escalated(&store, "s2"));
251 assert!(!missed(&store, "s2", "regret", "r2", "app", 7));
252 let line = serde_json::to_string(&entry("q10", Kind::Constraint, "s3")).unwrap();
254 std::fs::write(queen::dir(&store).join("queue.jsonl"), format!("{line}\n")).unwrap();
255 assert!(missed(&store, "s3", "regret", "r3", "app", 8));
256 let y = team(&store);
257 assert_eq!((y.missed, y.reward), (2, -6.0));
258 let _ = std::fs::remove_dir_all(&store);
259 }
260}