Skip to main content

borg/
escalation.rs

1//! The queen's attention reward: did
2//! an escalation spend the engineer's minutes well?
3//!
4//! ```text
5//! R(queen) = no_after_escalation − α·rubber_stamp − β·missed_escalation
6//! ```
7//!
8//! Three stat lines (stats.rs), each logged once, where its event happens:
9//! - `queen_no` (+1): an escalation the engineer answered by changing course: holding the move,
10//!   making it reversible, keeping the old contract, leaving the other side to its owners, or own words
11//!   (queen.rs `answer`).
12//! - `queen_stamp` (−α): an escalation answered "yes, fine": the option that lets the plan go ahead
13//!   (`Kind::proceeds`), or a bare yes.
14//! - `queen_missed` (−β): a "no" that came after the work merged, from a session with no escalation:
15//!   a veto (veto.rs `record`, any of its three ways in) of a session whose work landed (a commit of its
16//!   own still on HEAD, notes.rs `landed_by`), or a strong regress link (notes/regress_learn.rs: a later
17//!   fix of a committed decision) of a deciding session.
18//!
19//! Escalations are the questions the queen chooses to raise: the four checkpoint kinds
20//! (`Kind::escalates`). The other queue kinds are proposals or gates an engineer must answer whatever
21//! the queen thinks (constraints, reach, open decisions), so they earn nothing.
22//! "No escalation" is judged against this node's queue: the queue is local, and so are the sessions it
23//! names.
24//!
25//! The lines roll up into `stats.jsonl` like every count and sync with it, so `of` folds the team's
26//! yield from the buckets; borg-web's queen view shows it. α = 1 (a wasted answer costs what a useful
27//! one earns) and β = memory.rs `VETOED_W` (a missed escalation weighs what the veto it missed weighs
28//! in a lesson's belief); both design-chosen, neither tuned. Nothing reads the reward but monitoring:
29//! the design keeps the queen's predictor a router, and optimising a proxy of the engineer is the
30//! Goodhart trap it warns about. Preparation: no value metric yet; it is what the value metric of a
31//! learned escalation policy will be read from.
32
33use std::collections::BTreeMap;
34use std::path::Path;
35
36use serde::Serialize;
37
38use crate::queen::{self, Entry};
39use crate::{notes, stats};
40
41/// α: what a rubber stamp costs. Baseline: defines the reward (a wasted answer costs what a useful
42/// one earns); design-chosen, not tuned, read by monitoring only.
43pub const STAMP_W: f64 = 1.0;
44/// β: what a missed escalation costs. Derived: memory.rs `VETOED_W`, what the missed veto weighs in a
45/// lesson's belief.
46pub const MISSED_W: f64 = crate::memory::VETOED_W as f64;
47/// Display: the characters of the engineer's answer a feed line shows after the entry's id, so the
48/// line stays within a feed excerpt (stats.rs `FEED_CHARS`); `borg queen show` prints it whole.
49const ANSWER_CHARS: usize = 120;
50
51/// Words an answer that only says "yes, fine" is made of.
52const 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
58/// Whether `answer` to escalation `e` is "yes, fine": it is (or starts with) the option that lets the
59/// plan go ahead, or every word of it is a plain yes.
60pub 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
66/// Whether `answer` says only yes: every word of it is a plain yes.
67pub fn yes(answer: &str) -> bool {
68    let a = norm(answer);
69    !a.is_empty() && a.split(' ').all(|w| YES.contains(&w))
70}
71
72/// Logs the reward of an answer to `e` (queen.rs `answer`, after it was recorded): `queen_no` or
73/// `queen_stamp` for an escalation, nothing for any other kind. Returns the kind logged.
74pub 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
89/// Whether the queen escalated anything from `session` (answered, open or dismissed alike: it was
90/// raised).
91pub 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
95/// Whether `session`'s work merged: it landed a commit of its own that is still on HEAD (notes.rs
96/// `landed_by`, from the hook's state in `state`).
97pub fn merged(state: &Path, session: &str) -> bool {
98    !notes::landed_by(state, session).is_empty()
99}
100
101/// Logs a missed escalation once per `id` (a veto episode, a regress link): `cause` `veto` or
102/// `regret`, of `session` in `repo`. Nothing when the queen escalated from that session, or for an
103/// unknown session. Returns whether it logged.
104pub 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/// The queen's attention reward over some counts (a bucket's, or the team's summed).
121#[derive(Debug, Clone, Default, PartialEq, Serialize)]
122pub struct Yield {
123    /// Escalations answered: `no` + `stamps`.
124    pub answered: u64,
125    pub no: u64,
126    pub stamps: u64,
127    pub missed: u64,
128    /// The share of answered escalations that ended in a "no"; None before any answer.
129    pub share: Option<f64>,
130    /// R(queen) = no − α·stamps − β·missed.
131    pub reward: f64,
132    pub alpha: f64,
133    pub beta: f64,
134}
135
136/// The yield of `counts` (stats.rs `Bucket::counts`).
137pub 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
153/// The team's yield: every bucket of the store (this node's events and the synced rollups), summed.
154pub 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        // An external mutation's default holds it: the engineer letting it run is the yes.
209        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        // The queen escalated from s2: a no there is the escalation working, not a miss.
247        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        // A gate question from s3 is no escalation.
253        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}