Skip to main content

borg/
prefilter.rs

1//! The free gate before the Stop model call (internal evaluation "stop-prefilter", not published).
2//!
3//! The consequence model (predict.rs) costs one model call (~6 s, ~$0.03-0.07) per turn end, and
4//! most turn ends touch no lesson at all. So word matching first scores this turn alone: the best
5//! BM25 match of its prompt, touches and final message against the lessons, divided by the largest
6//! idf a word can have in this store (scores grow with the number of lessons; this keeps one
7//! threshold valid for a store of 10 or of 100). Below the threshold the hook asks nothing and
8//! costs 0 tokens; at or above it, predict decides as before. Calibrated on the graded preempt
9//! cases and the engineer's real turn ends (eval/preempt/stop_prefilter.py).
10
11use crate::conscience::{self, Challenge};
12
13/// State-file markers that close a turn (hook.rs appends one at every Stop that reaches the conscience;
14/// a detached check's stop `#borg-checking`, hook.rs `CHECKING`). Never a selection line (`#borg-select`,
15/// `#borg-pf`, `#borg-placebo`, `#borg-routed`) nor a detached check's later lines (`#borg-graded`,
16/// `#borg-predict-cost`): those come mid-turn too and close nothing. Protocol.
17pub const MARKERS: [&str; 3] = ["#borg-asked", "#borg-skip", "#borg-checking"];
18
19/// Learned (evaluation-decided): pre-registered in the internal evaluation "stop-prefilter" (not
20/// published): 0.9 × the lowest score of any labelled checkpoint with a relevant lesson in its shortlist
21/// (9.28), rounded down. Kept by dated decision (2026-10-31): 9 corrections followed a turn it skipped in a
22/// team store, none about an existing lesson.
23pub const THRESHOLD: f64 = 8.0;
24
25/// What the state file gained since the last Stop: this turn's prompt and touches (plus the drone's
26/// final message, appended by the caller). A live check's ask (`#borg-asked` followed by `#borg-live`:
27/// before 0.7.6, or a queued ask handed over at a prompt or tool call, hook.rs `ASK_NEXT`) came during the
28/// turn and does not end it.
29pub fn turn(context: &str) -> &str {
30    let mut start = 0;
31    let mut at = 0;
32    let mut lines = context.split_inclusive('\n').peekable();
33    while let Some(line) = lines.next() {
34        at += line.len();
35        let live = lines.peek().is_some_and(|next| next.starts_with("#borg-live "));
36        if MARKERS.iter().any(|m| line.starts_with(m)) && !live {
37            start = at;
38        }
39    }
40    &context[start..]
41}
42
43/// The gate's score of the state file + final message: this turn's best lesson match over the
44/// store's largest idf. 0 for an empty store or a turn that matches nothing.
45pub fn score(challenges: &[Challenge], context: &str) -> f64 {
46    let n = challenges.len() as f64;
47    let top = conscience::rank(challenges, &conscience::work_text(turn(context))).first().map(|(s, _)| *s).unwrap_or(0.0);
48    let idf_max = (1.0 + (n - 0.5) / 1.5).ln();
49    if idf_max > 0.0 { top / idf_max } else { 0.0 }
50}
51
52/// The threshold in force: `BORG_PREFILTER` = "off" (None: always ask the model), a number, or unset
53/// (`THRESHOLD`). An unparsable value keeps the default.
54pub fn threshold() -> Option<f64> {
55    match std::env::var("BORG_PREFILTER") {
56        Ok(v) if v.trim().eq_ignore_ascii_case("off") => None,
57        Ok(v) => Some(v.trim().parse().unwrap_or(THRESHOLD)),
58        Err(_) => Some(THRESHOLD),
59    }
60}
61
62/// Some(score) when the Stop hook should skip the model call for this state file + final message.
63pub fn skip(challenges: &[Challenge], context: &str) -> Option<f64> {
64    let threshold = threshold()?;
65    let s = score(challenges, context);
66    (s < threshold).then_some(s)
67}
68
69/// `BORG_STOP_GATE=edits` (internal evaluation "whole-store", not published): instead of the lexical score, the model is asked
70/// whenever this turn changed something (an edit tool, or a shell command that is not read-only), and a
71/// read-only or talk-only turn skips. Default (unset or anything else): the lexical gate above.
72pub fn edits_gate() -> bool {
73    std::env::var("BORG_STOP_GATE").is_ok_and(|v| v.trim().eq_ignore_ascii_case("edits"))
74}
75
76/// Programs that only read (with the exceptions in `changes`). Anything else counts as a change.
77const READ_ONLY: &[&str] = &[
78    "ls", "cat", "head", "tail", "less", "more", "grep", "egrep", "rg", "ag", "find", "fd", "tree", "wc", "file", "stat", "du", "df", "pwd", "echo", "printf",
79    "which", "type", "whereis", "env", "printenv", "date", "whoami", "id", "uname", "ps", "jq", "yq", "sort", "uniq", "cut", "tr", "diff", "cmp", "column",
80    "nl", "realpath", "dirname", "basename", "sed", "awk", "cd", "true",
81];
82
83/// git subcommands that only read.
84const GIT_READ: &[&str] = &["status", "log", "diff", "show", "blame", "branch", "rev-parse", "ls-files", "grep", "remote", "describe", "shortlog"];
85
86/// Whether a tool call changes state: an edit tool (Edit, Write, MultiEdit, NotebookEdit), or a Bash
87/// command any part of which is not read-only (`READ_ONLY`, read-only git), redirects output into a
88/// file, or edits in place (`sed -i`, `find -delete/-exec`). Other tools (Read, Grep, Glob, ...) read.
89pub fn changes(tool: &str, input: &serde_json::Value) -> bool {
90    let tool = tool.to_ascii_lowercase();
91    if ["edit", "write", "multiedit", "notebookedit"].contains(&tool.as_str()) {
92        return true;
93    }
94    if tool != "bash" {
95        return false;
96    }
97    let cmd = input.get("command").and_then(serde_json::Value::as_str).unwrap_or_default();
98    let cmd = ["2>&1", "&>/dev/null", "2>/dev/null", ">/dev/null", "> /dev/null"].iter().fold(cmd.to_string(), |c, r| c.replace(r, " "));
99    if cmd.contains('>') {
100        return true;
101    }
102    cmd.split(['\n', ';', '|', '&', '(', ')', '`']).map(str::trim).filter(|p| !p.is_empty()).any(|part| {
103        let words: Vec<&str> = part.split_whitespace().skip_while(|w| w.contains('=') && !w.starts_with('-')).collect();
104        let Some(&prog) = words.first() else { return false };
105        let prog = prog.rsplit('/').next().unwrap_or(prog);
106        match prog {
107            "git" => {
108                // The subcommand: the first word after `git` that is not an option (or `-C`/`-c`'s value).
109                let mut rest = words[1..].iter();
110                let sub = loop {
111                    match rest.next() {
112                        Some(&("-C" | "-c")) => {
113                            rest.next();
114                        }
115                        Some(w) if w.starts_with('-') => {}
116                        other => break other.copied(),
117                    }
118                };
119                let mutating_flag = rest.any(|w| ["-d", "-D", "-m", "-M", "--delete", "add", "remove", "rename", "set-url"].contains(w));
120                !sub.is_some_and(|s| GIT_READ.contains(&s)) || mutating_flag
121            }
122            "sed" => words.iter().any(|w| w.starts_with("-i") || *w == "--in-place"),
123            "find" => words.iter().any(|w| ["-delete", "-exec", "-execdir", "-ok"].contains(w)),
124            p => !READ_ONLY.contains(&p),
125        }
126    })
127}
128
129/// What the gate did in a store's session state (`borg status`): turn ends skipped at 0 tokens, turn
130/// ends that asked the model, and the model's cost as logged by `#borg-predict-cost` (USD for Claude,
131/// tokens for `codex:<model>`). Live checks (live.rs) log `#borg-predict-cost` too, and a `live` cost
132/// line each: they are counted apart (`live_checks`, `live_cost`), not as turn ends.
133#[derive(Debug, Default, PartialEq)]
134pub struct Tally {
135    pub skipped: usize,
136    pub asked: usize,
137    pub cost: f64,
138    /// R3b (main.rs `log_cost`): Stop blocks (extra rounds), the questions in them and their size;
139    /// free deliveries (context on a hook that forces no round), their lessons and size.
140    pub rounds: usize,
141    pub questions: usize,
142    pub block_chars: usize,
143    pub free: usize,
144    pub free_lessons: usize,
145    pub free_chars: usize,
146    /// R1 (main.rs `record_verdicts`): model calls that also judged pending lessons, the lessons judged,
147    /// those that got a final verdict, and the characters the judging added to those prompts.
148    pub judge_calls: usize,
149    pub judged: usize,
150    pub judge_resolved: usize,
151    pub judge_chars: usize,
152    /// A detached check's questions delivered after its turn (hook.rs `mark_late`, `late` lines): the
153    /// deliveries, their questions and size, and the questions a wake carried (`event=wake`).
154    pub late: usize,
155    pub late_questions: usize,
156    pub late_chars: usize,
157    pub late_woken: usize,
158    /// Live mode's model calls during the turn (before 0.7.6, history only), their cost and lessons shown.
159    pub live_checks: usize,
160    pub live_cost: f64,
161    pub live_lessons: usize,
162}
163
164pub fn tally(state: &std::path::Path) -> Tally {
165    let mut t = Tally::default();
166    for e in std::fs::read_dir(state).into_iter().flatten().flatten() {
167        if e.path().extension().is_some_and(|x| x == "cost") {
168            for line in std::fs::read_to_string(e.path()).unwrap_or_default().lines() {
169                let field = |k: &str| line.split_whitespace().find_map(|w| w.strip_prefix(k)).and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
170                if line.starts_with("block ") {
171                    t.rounds += 1;
172                    t.questions += field("questions=");
173                    t.block_chars += field("chars=");
174                } else if line.starts_with("free ") {
175                    t.free += 1;
176                    t.free_lessons += field("lessons=");
177                    t.free_chars += field("chars=");
178                } else if line.starts_with("late ") {
179                    t.late += 1;
180                    t.late_questions += field("questions=");
181                    t.late_chars += field("chars=");
182                    if line.split_whitespace().any(|w| w == "event=wake") {
183                        t.late_woken += field("questions=");
184                    }
185                } else if line.starts_with("live ") {
186                    t.live_checks += 1;
187                    t.live_lessons += field("lessons=");
188                    t.live_cost += line.split_whitespace().find_map(|w| w.strip_prefix("usd=")).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
189                } else if line.starts_with("judge ") {
190                    t.judge_calls += 1;
191                    t.judged += field("pending=");
192                    t.judge_resolved += field("resolved=");
193                    t.judge_chars += field("chars=");
194                }
195            }
196            continue;
197        }
198        if e.path().extension().is_none_or(|x| x != "ctx") {
199            continue;
200        }
201        for line in std::fs::read_to_string(e.path()).unwrap_or_default().lines() {
202            if line.starts_with("#borg-skip") {
203                t.skipped += 1;
204            } else if let Some(c) = line.strip_prefix("#borg-predict-cost ") {
205                t.asked += 1;
206                t.cost += c.trim().parse::<f64>().unwrap_or(0.0);
207            }
208        }
209    }
210    t.asked = t.asked.saturating_sub(t.live_checks);
211    t.cost = (t.cost - t.live_cost).max(0.0);
212    t
213}
214
215/// The gate's score and what an eval needs beside it (eval/preempt/stop_prefilter.py).
216pub fn features(challenges: &[Challenge], context: &str) -> serde_json::Value {
217    let t = conscience::work_text(turn(context));
218    let t = t.as_ref();
219    let ranked = conscience::rank(challenges, t);
220    let shortlist = conscience::select(challenges, &conscience::work_text(context), 20);
221    let q: std::collections::HashSet<String> = conscience::tokens(t).into_iter().collect();
222    serde_json::json!({
223        "score": score(challenges, context),
224        "turn_scores": ranked.iter().take(20).map(|(s, _)| *s).collect::<Vec<_>>(),
225        "turn_ids": ranked.iter().take(20).map(|(_, c)| c.id.clone()).collect::<Vec<_>>(),
226        "qtokens": q.len(),
227        "turn_chars": t.len(),
228        "n": challenges.len(),
229        "shortlist": shortlist.iter().map(|c| c.id.clone()).collect::<Vec<_>>(),
230    })
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn lesson(id: &str, q: &str) -> Challenge {
238        Challenge { id: id.into(), question: q.into(), ..Default::default() }
239    }
240
241    #[test]
242    fn a_turn_starts_after_the_last_stop_marker() {
243        let ctx = "first prompt\nsrc/a.rs\n#borg-predict-cost 0.05\n#borg-asked L1\nsecond prompt\n#borg-skip 1.20\nthird\nsrc/b.rs\n";
244        assert_eq!(turn(ctx), "third\nsrc/b.rs\n");
245        assert_eq!(turn("only\n"), "only\n");
246        assert_eq!(turn("x\n#borg-asked \n"), "");
247        // A live check's ask is part of the turn, not its end.
248        assert_eq!(turn("a\n#borg-asked L1\nb\n#borg-asked L2\n#borg-live L2 ms=5\nc\n"), "b\n#borg-asked L2\n#borg-live L2 ms=5\nc\n");
249    }
250
251    /// `borg status` counts a detached check's questions (hook.rs `mark_late`'s `late` cost lines), a
252    /// wake's apart.
253    #[test]
254    fn the_tally_counts_questions_asked_after_their_turn() {
255        let d = std::env::temp_dir().join(format!("borg-prefilter-late-{}", std::process::id()));
256        let _ = std::fs::remove_dir_all(&d);
257        std::fs::create_dir_all(&d).unwrap();
258        std::fs::write(d.join("s.cost"), "late event=prompt questions=2 chars=400\nlate event=wake questions=1 chars=100\nblock questions=1 chars=50\n")
259            .unwrap();
260        let t = tally(&d);
261        assert_eq!((t.late, t.late_questions, t.late_chars, t.late_woken, t.rounds), (2, 3, 500, 1, 1));
262        let _ = std::fs::remove_dir_all(d);
263    }
264
265    #[test]
266    fn a_detached_checks_stop_ends_the_turn_and_its_late_lines_do_not() {
267        let ctx = "first prompt\nsrc/a.rs\n#borg-checking @0123456789ab\nsecond prompt\n";
268        assert_eq!(turn(ctx), "second prompt\n", "the stop's marker ends the turn");
269        // The check writes its lines once the model answers, during the next turn: they end nothing.
270        let late = format!("{ctx}#borg-predict-cost 0.0500\n#borg-graded n=1 L1:3:0\n#borg-select policy=fixed @0123456789ab\nsrc/b.rs\n");
271        assert_eq!(turn(&late), "second prompt\n#borg-predict-cost 0.0500\n#borg-graded n=1 L1:3:0\n#borg-select policy=fixed @0123456789ab\nsrc/b.rs\n");
272    }
273
274    #[test]
275    fn scores_this_turn_only_and_normalises_by_store_size() {
276        let ls: Vec<Challenge> = (0..30)
277            .map(|i| lesson(&format!("L{i}"), &format!("unrelated filler words number{i} about gardening")))
278            .chain([lesson("lock", "Did I hold the websocket send lock across the await on the delivery queue?")])
279            .collect();
280        let hit = "plan: hold the websocket send lock while awaiting the delivery queue";
281        assert!(score(&ls, hit) > score(&ls, "ok, thanks"), "a turn about a lesson scores above chat");
282        let earlier = format!("{hit}\n#borg-asked lock\nok, thanks\n");
283        assert_eq!(score(&ls, &earlier), score(&ls, "ok, thanks"), "earlier turns do not count");
284        assert_eq!(score(&[], hit), 0.0);
285        assert_eq!(score(&ls[..1], "nothing matches here"), 0.0, "a store of one has idf 0 or no match");
286    }
287
288    /// A bookkeeping line in the turn (`#borg-answer` carries the drone's last proposal, `#borg-p` lesson
289    /// ids) is not the turn's work: it scores nothing.
290    #[test]
291    fn bookkeeping_lines_score_nothing() {
292        let ls: Vec<Challenge> = (0..30)
293            .map(|i| lesson(&format!("L{i}"), &format!("unrelated filler words number{i} about gardening")))
294            .chain([lesson("lock", "Did I hold the websocket send lock across the await on the delivery queue?")])
295            .collect();
296        let marked = "ok, thanks\n#borg-answer hold the websocket send lock while awaiting the delivery queue\n#borg-p lock=0.9\n";
297        assert_eq!(score(&ls, marked), score(&ls, "ok, thanks\n"));
298        assert!(features(&ls, marked)["turn_ids"].as_array().unwrap().is_empty(), "{}", features(&ls, marked));
299    }
300
301    /// Why the held-out result was fixed in advance (internal evaluation "stop-prefilter", not published): query words are a set
302    /// and every BM25 term is positive, so adding words to a turn never lowers its score. A checkpoint
303    /// whose brief alone reaches the threshold passes whatever the drone adds to it.
304    #[test]
305    fn adding_words_to_a_turn_never_lowers_its_score() {
306        let ls: Vec<Challenge> = (0..20)
307            .map(|i| lesson(&format!("L{i}"), &format!("question {i} about retries, timeouts and module{i} wiring")))
308            .chain([lesson("lock", "Did I hold the websocket send lock across the await on the delivery queue?")])
309            .collect();
310        let brief = "plan: hold the websocket send lock while awaiting the delivery queue";
311        let base = score(&ls, brief);
312        assert!(base > 0.0);
313        let mut turn = brief.to_string();
314        for extra in ["ok", "retries retries retries", "module3 wiring timeouts", "lock lock lock lock", "unrelated gardening words"] {
315            turn.push('\n');
316            turn.push_str(extra);
317            let s = score(&ls, &turn);
318            assert!(s >= base - 1e-12, "{extra:?} lowered the score: {s} < {base}");
319        }
320    }
321
322    #[test]
323    fn edit_tools_and_state_changing_commands_count_as_changes() {
324        let bash = |c: &str| changes("Bash", &serde_json::json!({"command": c}));
325        assert!(changes("Edit", &serde_json::json!({})) && changes("write", &serde_json::json!({})) && changes("MultiEdit", &serde_json::json!({})));
326        assert!(!changes("Read", &serde_json::json!({"file_path": "/x"})) && !changes("Grep", &serde_json::json!({})));
327        for read in [
328            "ls -la src",
329            "cat a.rs | head -20 2>&1",
330            "rg -n foo crates/ > /dev/null",
331            "git status && git log --oneline -5",
332            "git -C /repo diff HEAD~1",
333            "sed -n 1,80p main.rs",
334            "find . -name '*.rs' | wc -l",
335            "cd /x && grep -r foo .",
336            "LANG=C sort a | uniq -c",
337        ] {
338            assert!(!bash(read), "{read}");
339        }
340        for change in [
341            "cargo build",
342            "rm -rf target",
343            "echo x > notes.md",
344            "git commit -m x",
345            "git -C /repo checkout -b y",
346            "git branch -D old",
347            "sed -i s/a/b/ f.rs",
348            "find . -name '*.tmp' -delete",
349            "ls && mv a b",
350            "python3 script.py",
351        ] {
352            assert!(bash(change), "{change}");
353        }
354        assert!(!bash(""));
355    }
356
357    #[test]
358    fn tallies_skips_and_model_calls_across_sessions() {
359        let d = std::env::temp_dir().join(format!("borg-prefilter-tally-{}", std::process::id()));
360        let _ = std::fs::remove_dir_all(&d);
361        std::fs::create_dir_all(&d).unwrap();
362        std::fs::write(d.join("a.ctx"), "p\n#borg-skip 1.50\nq\n#borg-predict-cost 0.0500\n#borg-asked L1\n").unwrap();
363        std::fs::write(d.join("b.ctx"), "#borg-skip 0.00\n#borg-predict-cost 0.0250\n#borg-asked \n").unwrap();
364        std::fs::write(d.join("b.prompts"), "#borg-skip in a prompt file does not count\n").unwrap();
365        std::fs::write(d.join("a.cost"), "free event=UserPromptSubmit lessons=3 chars=900\nblock questions=7 chars=2100\nblock questions=2 chars=600\n")
366            .unwrap();
367        std::fs::write(d.join("b.cost"), "free event=PostToolUse lessons=1 chars=300\njudge call=2 pending=3 resolved=2 chars=4100\n").unwrap();
368        let t = tally(&d);
369        assert_eq!((t.skipped, t.asked), (2, 2));
370        assert_eq!((t.live_checks, t.live_lessons), (0, 0));
371        assert_eq!((t.rounds, t.questions, t.block_chars), (2, 9, 2700));
372        assert_eq!((t.free, t.free_lessons, t.free_chars), (2, 4, 1200));
373        assert_eq!((t.judge_calls, t.judged, t.judge_resolved, t.judge_chars), (1, 3, 2, 4100));
374        assert!((t.cost - 0.075).abs() < 1e-9);
375        // A live check logs its call like a turn end's, and a `live` cost line: it is counted apart.
376        std::fs::write(d.join("c.ctx"), "p\n#borg-predict-cost 0.0400\n#borg-live-skip 1.00\n").unwrap();
377        std::fs::write(d.join("c.cost"), "live usd=0.0400 lessons=8\n").unwrap();
378        let t = tally(&d);
379        assert_eq!((t.skipped, t.asked, t.live_checks, t.live_lessons), (2, 2, 1, 8));
380        assert!((t.cost - 0.075).abs() < 1e-9 && (t.live_cost - 0.04).abs() < 1e-9);
381        assert_eq!(tally(&d.join("absent")), Tally::default());
382    }
383}