Skip to main content

borg/
cues.rs

1//! Retrieval cues (0.7.7): per lesson, ten short texts in the words of the work where it matters again,
2//! written once when the lesson is learned, and logged as a shadow score only (pools.rs `cue`, `mv`).
3//!
4//! A lesson is written in the words of its correction; the work that needs it again is written in the
5//! words of prompts, paths, commands, proposals and identifiers. One model call per new or merged lesson
6//! writes cues in that vocabulary (`PROMPT`), with the incident the lesson came from. The offline
7//! measure found them the strongest single word channel of the candidate orders, alone and fused
8//! (internal evaluation "learn-retrieval", not published); whether they rank the pool better is the
9//! pre-registered switch test's to say, so until it passes they change nothing a drone sees.
10//!
11//! **Where:** `cues.jsonl` beside the store (`Cued`), local, never synced, never a facet: facets are
12//! BM25 documents of the lesson (conscience.rs `documents`) and travel over the mesh, so cues stored
13//! there would change the pool before the test. A cue line is keyed by the lesson's text (`key`): a
14//! merge that rewrites the lesson asks again, a version that keeps the text keeps its cues.
15//!
16//! **When:** after `borg learn` for the lessons it wrote (main.rs `learn_store`), on by default
17//! (`BORG_CUES=off` turns it off), and by `borg cues [--apply]` for lessons learned before 0.7.7 (a dry run
18//! with its cost by default). Each call's cost is on its line and on the `model_call` event (purpose
19//! `cues`).
20
21use std::collections::{HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::sync::Mutex;
24use std::sync::atomic::{AtomicUsize, Ordering};
25
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28
29use crate::facets::Ask;
30use crate::learn::{self, Lesson};
31
32/// The kinds a model answer's cues are kept under, in the order they are stored.
33pub const KINDS: [&str; 5] = ["prompt", "path_or_command", "proposal", "identifier", "move"];
34
35/// Guard: at most this many cues per lesson, each at most `CUE_CHARS` characters; the prompt asks for
36/// exactly 10, so a good answer is never cut.
37pub const MAX_CUES: usize = 10;
38pub const CUE_CHARS: usize = 300;
39/// Guard: a model answer with fewer usable cues is no answer (a later run retries).
40const MIN_CUES: usize = 3;
41
42/// The incident's excerpts in the prompt, each at most this many characters and ` …` (learn.rs
43/// `EXCERPT_CHARS`: what an episode keeps of a correction).
44pub const INCIDENT_CHARS: usize = learn::EXCERPT_CHARS;
45
46/// The dry run's estimate per lesson before any call measured a cost, in tokens: the offline measure,
47/// 1,415,298 tokens for 678 lessons, about 2.1k each (internal evaluation "learn-retrieval", 2026-11-02).
48/// Display only; once `cues.jsonl` holds a measured cost, its mean is shown instead.
49pub const EST_TOKENS_PER_LESSON: usize = 2_100;
50
51/// The cue prompt, fixed with the offline measure it was evaluated in.
52pub const PROMPT: &str = "You write retrieval cues for one lesson in an engineering team's memory. The lesson was learned when \
53an engineer corrected a coding agent. Later, a word-matching search (BM25) compares the lesson's cues against the text of a \
54coding agent's ongoing work: the engineer's prompts, the agent's messages and plans, the file paths, commands and code it \
55touches. The cues must make the lesson come up at the moments in such work where it matters again.
56
57Write 10 short cues, each at most 25 words, in the vocabulary of that WORK, not paraphrases of the lesson. Write exactly:
58- 2 \"prompt\": user prompts an engineer might type when asking for work where this lesson matters;
59- 2 \"path_or_command\": file paths, globs or shell commands an agent would touch or run in such work;
60- 2 \"proposal\": sentences an agent might write when proposing the flawed move the lesson warns against;
61- 2 \"identifier\": code identifiers or API calls typical of the situation;
62- 2 \"move\": bare abstract design moves in the grammar \"<action> on/in <kind of state> [under <condition>]\", without project or class names.
63At least one cue must come from a different domain or repository than the incident below, where the same kind of mistake \
64could happen. Do not reuse the lesson's own sentences; use the words people and tools write while doing the work.";
65
66/// On unless `BORG_CUES=off`.
67#[must_use]
68pub fn enabled() -> bool {
69    !std::env::var("BORG_CUES").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
70}
71
72fn schema() -> Value {
73    json!({"type": "object", "additionalProperties": false, "properties": {
74        "cues": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {
75            "kind": {"type": "string", "enum": KINDS}, "text": {"type": "string"}}, "required": ["kind", "text"]}}},
76        "required": ["cues"]})
77}
78
79/// A lesson text's key: the first 8 bytes of the BLAKE3 hash of `[question, situations, consequence]`
80/// as JSON, in hex. Its cues and the pool log's `ver` name the wording by it.
81#[must_use]
82pub fn key(question: &str, situations: &[String], consequence: &str) -> String {
83    let text = serde_json::to_string(&(question, situations, consequence)).unwrap_or_default();
84    blake3::hash(text.as_bytes()).as_bytes()[..8].iter().map(|b| format!("{b:02x}")).collect()
85}
86
87/// `s` trimmed, at most `INCIDENT_CHARS` characters, then ` …` when cut.
88fn clip(s: &str) -> String {
89    let s = s.trim();
90    if s.chars().count() <= INCIDENT_CHARS { s.to_string() } else { format!("{} …", s.chars().take(INCIDENT_CHARS).collect::<String>()) }
91}
92
93/// The incident a lesson came from: the first of its episodes in `episodes` with a correction, as the
94/// agent's proposal and the engineer's correction; `(no incident recorded)` without one (a synced peer's
95/// lesson: its episodes stay on the peer).
96#[must_use]
97pub fn incident(l: &Lesson, episodes: &HashMap<String, learn::Episode>) -> String {
98    match l.episodes.iter().find_map(|e| episodes.get(e).filter(|e| !e.correction.trim().is_empty())) {
99        Some(e) => format!("agent's proposal: {}\nengineer's correction: {}", clip(&e.agent), clip(&e.correction)),
100        None => "(no incident recorded)".into(),
101    }
102}
103
104/// The prompt for one lesson: what it says and its incident, nothing about where it is stored.
105#[must_use]
106pub fn prompt(l: &Lesson, incident: &str) -> String {
107    let situations: Vec<String> = l.situations.iter().map(|s| format!("- {s}")).collect();
108    format!(
109        "{PROMPT}\n\n## Lesson\nquestion: {}\nsituations:\n{}\nconsequence: {}\n\n## Incident the lesson was learned from\n{incident}",
110        l.question,
111        situations.join("\n"),
112        l.consequence
113    )
114}
115
116/// One cue.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct Cue {
119    pub kind: String,
120    pub text: String,
121}
122
123/// The cues in a model answer: flattened, clipped, unique, in `KINDS` order (stable within a kind), at
124/// most `MAX_CUES`. None when fewer than `MIN_CUES` remain.
125#[must_use]
126pub fn parse(v: &Value) -> Option<Vec<Cue>> {
127    let mut rows: Vec<(usize, Cue)> = Vec::new();
128    for c in v["cues"].as_array()? {
129        let kind = c["kind"].as_str().unwrap_or_default();
130        let at = KINDS.iter().position(|k| *k == kind).unwrap_or(KINDS.len());
131        let text: String = c["text"].as_str().unwrap_or_default().split_whitespace().collect::<Vec<_>>().join(" ");
132        let text: String = text.chars().take(CUE_CHARS).collect();
133        if !text.is_empty() && !rows.iter().any(|(_, x)| x.text.eq_ignore_ascii_case(&text)) {
134            rows.push((at, Cue { kind: kind.into(), text }));
135        }
136    }
137    rows.sort_by_key(|(at, _)| *at);
138    let out: Vec<Cue> = rows.into_iter().map(|(_, c)| c).take(MAX_CUES).collect();
139    (out.len() >= MIN_CUES).then_some(out)
140}
141
142/// One line of `cues.jsonl`: a lesson's cues for the text `key` names, at what cost.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Cued {
145    pub id: String,
146    pub version: u32,
147    pub key: String,
148    pub cues: Vec<Cue>,
149    pub cost_usd: Option<f64>,
150    pub ms: i64,
151}
152
153/// `cues.jsonl` beside a lessons file.
154#[must_use]
155pub fn beside(lessons: &Path) -> PathBuf {
156    lessons.with_file_name("cues.jsonl")
157}
158
159/// Every lesson text's cues in the store of `lessons`, by `key` (the latest line of a key wins), read
160/// line by line.
161#[must_use]
162pub fn load(lessons: &Path) -> HashMap<String, Vec<String>> {
163    load_before(lessons, None)
164}
165
166/// `load` of the cues written before `before_ms` (a check's stop: what the store held then).
167#[must_use]
168pub fn load_before(lessons: &Path, before_ms: Option<i64>) -> HashMap<String, Vec<String>> {
169    use std::io::BufRead;
170    let mut out = HashMap::new();
171    let Ok(f) = std::fs::File::open(beside(lessons)) else { return out };
172    for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
173        if let Ok(c) = serde_json::from_str::<Cued>(&line)
174            && before_ms.is_none_or(|b| c.ms < b)
175        {
176            out.insert(c.key, c.cues.into_iter().map(|c| c.text).collect());
177        }
178    }
179    out
180}
181
182/// What `fill` did or (dry run) would do.
183#[derive(Debug, Default)]
184pub struct Report {
185    /// Lessons (latest versions) whose text has no cues, among those asked for.
186    pub missing: usize,
187    pub written: usize,
188    pub failed: Vec<String>,
189    pub cost_usd: f64,
190}
191
192/// Give cues to the lessons of the store at `path` whose text has none (`key`), limited to `only` when
193/// given. A dry run (`apply` false) asks nothing and writes nothing. With `apply`, `jobs` calls at a time;
194/// each answer is appended to `cues.jsonl` as one line. After `runner::MAX_FAILURES` failures without
195/// a success the run stops asking.
196pub fn fill(path: &Path, only: Option<&HashSet<String>>, apply: bool, ask: Ask, jobs: usize, now: i64) -> Report {
197    let have = load(path);
198    let todo: Vec<Lesson> = learn::lessons(path)
199        .into_iter()
200        .filter(|l| !l.question.trim().is_empty() && l.question != crate::reach::MOVED)
201        .filter(|l| only.is_none_or(|o| o.contains(&l.id)) && !have.contains_key(&key(&l.question, &l.situations, &l.consequence)))
202        .collect();
203    let report = Report { missing: todo.len(), ..Report::default() };
204    if !apply || todo.is_empty() {
205        return report;
206    }
207    let episodes: HashMap<String, learn::Episode> = learn::episodes_of(&path.with_file_name("episodes.jsonl"));
208    let next = AtomicUsize::new(0);
209    let (failures, successes) = (AtomicUsize::new(0), AtomicUsize::new(0));
210    let out: Mutex<Report> = Mutex::new(report);
211    std::thread::scope(|s| {
212        for _ in 0..jobs.max(1).min(todo.len()) {
213            s.spawn(|| {
214                loop {
215                    let i = next.fetch_add(1, Ordering::SeqCst);
216                    let Some(l) = todo.get(i) else { break };
217                    let lock = || out.lock().unwrap_or_else(|e| e.into_inner());
218                    if failures.load(Ordering::SeqCst) >= crate::runner::MAX_FAILURES && successes.load(Ordering::SeqCst) == 0 {
219                        lock().failed.push(l.id.clone());
220                        continue;
221                    }
222                    let got = ask(&prompt(l, &incident(l, &episodes)), &schema()).and_then(|(v, cost)| Some((parse(&v)?, cost)));
223                    let mut r = lock();
224                    let Some((cues, cost)) = got else {
225                        failures.fetch_add(1, Ordering::SeqCst);
226                        r.failed.push(l.id.clone());
227                        continue;
228                    };
229                    successes.fetch_add(1, Ordering::SeqCst);
230                    r.cost_usd += cost.unwrap_or(0.0);
231                    let rec =
232                        Cued { id: l.id.clone(), version: l.version, key: key(&l.question, &l.situations, &l.consequence), cues, cost_usd: cost, ms: now };
233                    match serde_json::to_string(&rec).map_err(std::io::Error::other).and_then(|line| learn::append_line(&beside(path), &line)) {
234                        Ok(()) => r.written += 1,
235                        Err(_) => r.failed.push(l.id.clone()),
236                    }
237                }
238            });
239        }
240    });
241    let mut report = out.into_inner().unwrap_or_else(|e| e.into_inner());
242    report.cost_usd += 0.0; // an empty f64 sum is -0.0
243    report
244}
245
246/// The measured mean cost of a lesson's cues in the store of `lessons` (USD), None before any call
247/// reported one.
248#[must_use]
249pub fn mean_cost(lessons: &Path) -> Option<f64> {
250    use std::io::BufRead;
251    let f = std::fs::File::open(beside(lessons)).ok()?;
252    let costs: Vec<f64> = std::io::BufReader::new(f).lines().map_while(Result::ok).filter_map(|l| serde_json::from_str::<Cued>(&l).ok()?.cost_usd).collect();
253    (!costs.is_empty()).then(|| costs.iter().sum::<f64>() / costs.len() as f64)
254}
255
256pub const USAGE: &str = "usage: borg cues [--apply] [--store FILE] [--jobs N]";
257
258/// `borg cues [--apply] [--store PATH] [--jobs N]`: backfill cues for a store (default: this
259/// collective's, `BORG_HOME`). The exit code.
260pub fn main(args: &[&str], ask: Ask) -> i32 {
261    let (mut store, mut jobs, mut apply) = (learn::home().join("lessons.jsonl"), crate::runner::JOBS, false);
262    let mut it = args.iter();
263    while let Some(a) = it.next() {
264        match *a {
265            "--apply" => apply = true,
266            "--store" => match it.next() {
267                Some(p) => store = PathBuf::from(p),
268                None => {
269                    eprintln!("{USAGE}");
270                    return 2;
271                }
272            },
273            "--jobs" => match it.next().and_then(|j| j.parse().ok()) {
274                Some(j) => jobs = j,
275                None => {
276                    eprintln!("{USAGE}");
277                    return 2;
278                }
279            },
280            other => {
281                eprintln!("borg cues: unknown argument {other}\n{USAGE}");
282                return 2;
283            }
284        }
285    }
286    if !store.is_file() {
287        eprintln!("borg cues: no lessons at {}", store.display());
288        return 2;
289    }
290    let total = learn::lessons(&store).len();
291    let r = fill(&store, None, apply, ask, jobs, learn::now_ms());
292    if !apply {
293        let cost = match mean_cost(&store) {
294            Some(usd) => format!("about ${:.2} (${usd:.4} per lesson, measured)", r.missing as f64 * usd),
295            None => format!("about {} tokens (~{EST_TOKENS_PER_LESSON} per lesson, the offline measure)", r.missing * EST_TOKENS_PER_LESSON),
296        };
297        println!(
298            "{}: {total} lessons, {} without cues; generating them is {} model calls, {cost}. Dry run: nothing written; --apply to generate.",
299            store.display(),
300            r.missing,
301            r.missing
302        );
303        return 0;
304    }
305    println!("{}: cues written for {} of {} lessons without them, ${:.4}", store.display(), r.written, r.missing, r.cost_usd);
306    if !r.failed.is_empty() {
307        eprintln!("no usable answer from the model for {} lessons (re-run to retry): {}", r.failed.len(), r.failed.join(", "));
308        return 1;
309    }
310    0
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn lesson(id: &str, q: &str) -> Lesson {
318        Lesson { id: id.into(), origin_ms: 1, question: q.into(), consequence: "c".into(), version: 1, area: "other".into(), ..Default::default() }
319    }
320
321    fn answer(n: usize) -> Value {
322        json!({"cues": (0..n).map(|i| json!({"kind": KINDS[KINDS.len() - 1 - i % KINDS.len()], "text": format!("  cue   {i}  ")})).collect::<Vec<_>>()})
323    }
324
325    /// A check reads the cues the store held at its stop: one written after (a `borg learn` meanwhile)
326    /// is not in its pool log's scores.
327    #[test]
328    fn cues_are_read_as_of_a_time() {
329        let d = std::env::temp_dir().join(format!("borg-cues-before-{}", std::process::id()));
330        let _ = std::fs::remove_dir_all(&d);
331        std::fs::create_dir_all(&d).unwrap();
332        let lessons = d.join("lessons.jsonl");
333        let line = |key: &str, text: &str, ms: i64| {
334            let c = Cued { id: key.into(), version: 1, key: key.into(), cues: vec![Cue { kind: "move".into(), text: text.into() }], cost_usd: None, ms };
335            format!("{}\n", serde_json::to_string(&c).unwrap())
336        };
337        std::fs::write(beside(&lessons), line("k1", "old", 10) + &line("k2", "later", 30) + &line("k1", "newer", 40)).unwrap();
338        let at = load_before(&lessons, Some(30));
339        assert_eq!((at.len(), at["k1"].clone()), (1, vec!["old".to_string()]));
340        assert_eq!(load(&lessons)["k1"], ["newer"], "all of them, the latest of a key winning");
341        let _ = std::fs::remove_dir_all(d);
342    }
343
344    #[test]
345    fn parse_orders_by_kind_cleans_and_needs_three() {
346        let c = parse(&answer(10)).unwrap();
347        assert_eq!(c.len(), 10);
348        assert_eq!((c[0].kind.as_str(), c[0].text.as_str()), ("prompt", "cue 4"));
349        assert_eq!(c.last().unwrap().kind, "move");
350        assert_eq!(parse(&answer(14)).unwrap().len(), MAX_CUES);
351        assert!(parse(&answer(2)).is_none());
352        assert!(parse(&json!({"error": "rate limited"})).is_none());
353    }
354
355    #[test]
356    fn the_prompt_carries_the_lesson_and_its_incident_not_its_store() {
357        let l = Lesson {
358            situations: vec!["on a replay".into()],
359            repo: "secret-repo".into(),
360            episodes: vec!["E1".into(), "E2".into()],
361            ..lesson("L1", "Is the budget drained?")
362        };
363        let mut eps = HashMap::new();
364        eps.insert(
365            "E2".to_string(),
366            learn::Episode { id: "E2".into(), agent: "I reset it".into(), correction: format!("no, drain it {}", "x".repeat(2000)), ..Default::default() },
367        );
368        let inc = incident(&l, &eps);
369        assert!(inc.starts_with("agent's proposal: I reset it\nengineer's correction: no, drain it x") && inc.ends_with(" …"), "{inc}");
370        let p = prompt(&l, &inc);
371        assert!(p.contains("question: Is the budget drained?\nsituations:\n- on a replay\nconsequence: c") && p.contains(&inc));
372        assert!(!p.contains("secret-repo") && !p.contains("L1"));
373        assert_eq!(incident(&lesson("L2", "q"), &eps), "(no incident recorded)");
374    }
375
376    /// A dry run asks nothing; `--apply` writes one line per lesson, keyed by its text; a lesson whose
377    /// text has cues is not asked again; a merge that rewrites it is.
378    #[test]
379    fn fill_writes_cues_by_text_and_skips_those_with_them() {
380        let d = std::env::temp_dir().join(format!("borg-cues-{}", std::process::id()));
381        let _ = std::fs::remove_dir_all(&d);
382        std::fs::create_dir_all(&d).unwrap();
383        let p = d.join("lessons.jsonl");
384        for l in [lesson("A", "first?"), lesson("B", "second?")] {
385            learn::append_line(&p, &serde_json::to_string(&l).unwrap()).unwrap();
386        }
387        let calls = AtomicUsize::new(0);
388        let ask = |_: &str, _: &Value| {
389            calls.fetch_add(1, Ordering::SeqCst);
390            Some((answer(10), Some(0.002)))
391        };
392        let r = fill(&p, None, false, &ask, 2, 5);
393        assert_eq!((r.missing, r.written, calls.load(Ordering::SeqCst)), (2, 0, 0), "a dry run asks nothing");
394        let r = fill(&p, None, true, &ask, 2, 5);
395        assert_eq!((r.written, calls.load(Ordering::SeqCst)), (2, 2));
396        let have = load(&p);
397        assert_eq!(have.get(&key("first?", &[], "c")).map(Vec::len), Some(10));
398        assert!((mean_cost(&p).unwrap() - 0.002).abs() < 1e-12);
399        assert_eq!(fill(&p, None, true, &ask, 2, 5).missing, 0, "nothing left");
400        let merged = Lesson { version: 2, question: "first, reworded?".into(), ..lesson("A", "") };
401        learn::append_line(&p, &serde_json::to_string(&merged).unwrap()).unwrap();
402        let r = fill(&p, None, true, &ask, 2, 5);
403        assert_eq!((r.missing, r.written), (1, 1), "a new wording is asked again");
404        let _ = std::fs::remove_dir_all(&d);
405    }
406}