Skip to main content

borg/
facets.rs

1//! Facets: one lesson phrased several ways, so keyword search finds it from different angles
2//! (document expansion, "doc2query"; internal evaluation "facets", not published).
3//!
4//! A lesson is written once, in the words of the correction it came from. A drone meets the same
5//! concern in other words: as a symptom it observes, as the edit it is about to make, in German, or
6//! after a rename. One model call per lesson writes 6–8 short facets, each from a fixed angle (`ANGLES`):
7//! the concept without names, the symptom, the action, the consequence, German, and the identifiers
8//! as aliases. Names churn and concepts persist, so the identifiers are one facet among several,
9//! never the only key. The conscience indexes every facet as its own document of the lesson
10//! (conscience.rs `rank`); a drone never sees them, and the question and consequence never change.
11//!
12//! **When:** after `borg learn` and `borg assimilate --from-dir --apply`, for the lessons they wrote
13//! (`fill` with their ids), and by `borg facets [--apply]` for a store learned before facets existed
14//! (a dry run with a cost estimate by default). Every call's cost is recorded in `facets.jsonl` beside
15//! the store. Off by default (the pre-registered test failed, internal evaluation "facets", not published): `BORG_FACETS=on` turns the
16//! automatic step on; `borg facets --apply` works either way.
17
18use std::collections::HashSet;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21use std::sync::Mutex;
22use std::sync::atomic::{AtomicUsize, Ordering};
23
24use serde::{Deserialize, Serialize};
25use serde_json::{Value, json};
26
27use crate::learn::{self, Lesson};
28
29/// The model call: (prompt, JSON schema) -> (answer, cost in USD). `learn::ask_model` in production.
30pub type Ask<'a> = &'a (dyn Fn(&str, &Value) -> Option<(Value, Option<f64>)> + Sync);
31
32/// The angles, in the order facets are stored.
33pub const ANGLES: [&str; 6] = ["concept", "symptom", "action", "consequence", "german", "aliases"];
34
35/// At most this many facets per lesson, each at most `FACET_CHARS` characters. Guard: bounds what a
36/// model answer adds to a lesson (and to the search index) however much it writes; the prompt asks
37/// for 6 to 8, so a good answer is never cut.
38pub const MAX_FACETS: usize = 10;
39/// Guard: one facet is a phrase, not a paragraph; a longer one is cut.
40pub const FACET_CHARS: usize = 300;
41/// A model answer with fewer usable facets is no answer (retried by the next run). Guard: a refusal
42/// or a truncated answer is not stored as the lesson's facets.
43const MIN_FACETS: usize = 3;
44
45/// The cost estimate of a dry run, USD per lesson. Learned (measured once): the mean on the eval
46/// stores, $0.0112 (internal evaluation "facets", not published), rounded up. Display only: what
47/// `borg facets` without `--apply` says a run would cost; nothing is capped by it.
48pub const EST_USD_PER_LESSON: f64 = 0.012;
49
50const PROMPT: &str = "You index one lesson for keyword search (BM25). Coding agents will be matched against it with what \
51they are working on: the engineer's prompt, file paths, shell commands, their own plan and final message, in English or \
52German. The lesson's own wording is only one way to say it. Write facets: short alternative phrasings of the SAME lesson, \
53each from a different angle, so the search finds it whichever words the agent uses.
54
55Write 6 to 8 facets, each one line of at most 25 words:
56- concept: the underlying concern in plain words, WITHOUT any project, system, service, class, file or tool name;
57- symptom: what an agent would observe when this goes wrong (an error, a wrong number, a failing check, a review \
58remark), in the words of logs, tests or reviews;
59- action: what the agent is about to do when the lesson applies (the edit, task or plan step in progress), as an agent \
60would describe it in its plan;
61- consequence: what goes wrong, or what is required instead, in other words than the lesson's;
62- german: the question in natural German, as a German-speaking engineer would type it, keeping technical terms and names;
63- aliases: every identifier the lesson names (systems, services, repos, classes, files, metrics, flags), each with its \
64likely other spellings (camelCase, kebab-case, snake_case, abbreviation, full name), space-separated; skip this facet \
65if the lesson names none;
66- at most two more, of any angle, where the lesson covers a second distinct situation.
67
68Use natural words the lesson does not already use. Never add facts, names, numbers or advice that are not in the lesson.";
69
70fn schema() -> Value {
71    json!({"type": "object", "additionalProperties": false, "properties": {
72        "facets": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {
73            "angle": {"type": "string", "enum": ANGLES}, "text": {"type": "string"}}, "required": ["angle", "text"]}}},
74        "required": ["facets"]})
75}
76
77/// The prompt for one lesson: what it says, nothing about where it is stored.
78pub fn prompt(l: &Lesson) -> String {
79    let mut s = format!("{PROMPT}\n\n## Lesson\nquestion: {}\n", l.question.trim());
80    if !l.situations.is_empty() {
81        s.push_str(&format!("situations: {}\n", l.situations.join(" | ")));
82    }
83    if !l.consequence.trim().is_empty() {
84        s.push_str(&format!("consequence: {}\n", l.consequence.trim()));
85    }
86    if !l.scope.is_empty() {
87        s.push_str(&format!("applies to: {}\n", l.scope.join(", ")));
88    }
89    if !l.overrides.trim().is_empty() {
90        s.push_str(&format!("replaces the outside rule: {}\n", l.overrides.trim()));
91    }
92    s
93}
94
95/// The facets in a model answer: trimmed, clipped, unique, in `ANGLES` order (stable within an angle),
96/// at most `MAX_FACETS`. None when fewer than `MIN_FACETS` remain.
97pub fn parse(v: &Value) -> Option<Vec<String>> {
98    let mut rows: Vec<(usize, String)> = Vec::new();
99    for f in v["facets"].as_array()? {
100        let angle = f["angle"].as_str().unwrap_or_default();
101        let at = ANGLES.iter().position(|a| *a == angle).unwrap_or(ANGLES.len());
102        let text: String = f["text"].as_str().unwrap_or_default().split_whitespace().collect::<Vec<_>>().join(" ");
103        let text: String = text.chars().take(FACET_CHARS).collect();
104        if !text.is_empty() && !rows.iter().any(|(_, t)| t.eq_ignore_ascii_case(&text)) {
105            rows.push((at, text));
106        }
107    }
108    rows.sort_by_key(|(at, _)| *at);
109    let out: Vec<String> = rows.into_iter().map(|(_, t)| t).take(MAX_FACETS).collect();
110    (out.len() >= MIN_FACETS).then_some(out)
111}
112
113/// One model call: the lesson's facets and the call's cost. None: no usable answer.
114pub fn generate(l: &Lesson, ask: Ask) -> Option<(Vec<String>, Option<f64>)> {
115    let (v, cost) = ask(&prompt(l), &schema())?;
116    Some((parse(&v)?, cost))
117}
118
119/// One line of `facets.jsonl` beside the store: a lesson's facets were written, at what cost.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Generated {
122    pub id: String,
123    pub version: u32,
124    pub facets: usize,
125    pub cost_usd: Option<f64>,
126    pub ms: i64,
127}
128
129/// What `fill` did or (dry run) would do.
130#[derive(Debug, Default)]
131pub struct Report {
132    /// Lessons (latest versions) without facets, among those asked for.
133    pub missing: usize,
134    pub written: usize,
135    pub failed: Vec<String>,
136    /// Lessons that changed in the store while their facets were generated: not written, a later run retries.
137    pub changed: usize,
138    pub cost_usd: f64,
139}
140
141/// Ids of the lessons on lines appended to `path` after byte `from` (`len_of` before a learn run).
142pub fn appended_ids(path: &Path, from: u64) -> HashSet<String> {
143    let text = std::fs::read(path).unwrap_or_default();
144    let tail = text.get(from as usize..).unwrap_or_default();
145    String::from_utf8_lossy(tail).lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).filter_map(|v| v["id"].as_str().map(str::to_string)).collect()
146}
147
148/// The store's length in bytes (0 if absent).
149pub fn len_of(path: &Path) -> u64 {
150    std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
151}
152
153/// True only with BORG_FACETS=on: `borg learn` and `borg assimilate` then write facets (internal evaluation "facets", not published: off by default).
154pub fn enabled() -> bool {
155    std::env::var("BORG_FACETS").as_deref() == Ok("on")
156}
157
158/// Give facets to the lessons of the store at `path` that have none (latest version), limited to
159/// `only` when given. A dry run (`apply` false) asks nothing and writes nothing. With `apply`, `jobs`
160/// calls at a time; each lesson with facets is appended as a new line of the same id and version
161/// (readers keep the latest line), its question, consequence and every other field unchanged, unless the
162/// lesson changed in the store meanwhile. After 3 failures without a success the run stops asking.
163pub fn fill(path: &Path, only: Option<&HashSet<String>>, apply: bool, ask: Ask, jobs: usize, now: i64) -> Report {
164    let todo: Vec<Lesson> = learn::lessons(path).into_iter().filter(|l| l.facets.is_empty() && only.is_none_or(|o| o.contains(&l.id))).collect();
165    let mut report = Report { missing: todo.len(), ..Default::default() };
166    if !apply || todo.is_empty() {
167        return report;
168    }
169    let next = AtomicUsize::new(0);
170    let (failures, successes) = (AtomicUsize::new(0), AtomicUsize::new(0));
171    let out: Mutex<Report> = Mutex::new(report);
172    std::thread::scope(|s| {
173        for _ in 0..jobs.max(1).min(todo.len()) {
174            s.spawn(|| {
175                loop {
176                    let i = next.fetch_add(1, Ordering::SeqCst);
177                    let Some(l) = todo.get(i) else { break };
178                    if failures.load(Ordering::SeqCst) >= crate::runner::MAX_FAILURES && successes.load(Ordering::SeqCst) == 0 {
179                        out.lock().unwrap().failed.push(l.id.clone());
180                        continue;
181                    }
182                    let got = generate(l, ask);
183                    let mut r = out.lock().unwrap();
184                    let Some((facets, cost)) = got else {
185                        failures.fetch_add(1, Ordering::SeqCst);
186                        r.failed.push(l.id.clone());
187                        continue;
188                    };
189                    successes.fetch_add(1, Ordering::SeqCst);
190                    r.cost_usd += cost.unwrap_or(0.0);
191                    // Written under the lock, against the store as it is now: a lesson merged or replaced
192                    // meanwhile keeps its new version, and its facets wait for the next run.
193                    let now_latest = learn::lessons(path).into_iter().find(|x| x.id == l.id);
194                    if now_latest.as_ref().is_none_or(|x| x.version != l.version || x.question != l.question || !x.facets.is_empty()) {
195                        r.changed += 1;
196                        continue;
197                    }
198                    let with = Lesson { facets, ..l.clone() };
199                    let rec = Generated { id: l.id.clone(), version: l.version, facets: with.facets.len(), cost_usd: cost, ms: now };
200                    if append(path, &with).and_then(|_| append(&beside(path), &rec)).is_ok() {
201                        r.written += 1;
202                    } else {
203                        r.failed.push(l.id.clone());
204                    }
205                }
206            });
207        }
208    });
209    report = out.into_inner().unwrap();
210    report.cost_usd += 0.0; // an empty f64 sum is -0.0
211    report
212}
213
214/// `facets.jsonl` beside a lessons file.
215pub fn beside(lessons: &Path) -> PathBuf {
216    lessons.with_file_name("facets.jsonl")
217}
218
219fn append<T: Serialize>(path: &Path, v: &T) -> std::io::Result<()> {
220    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
221    writeln!(f, "{}", serde_json::to_string(v).map_err(std::io::Error::other)?)
222}
223
224pub const USAGE: &str = "usage: borg facets [--apply] [--store LESSONS.jsonl] [--jobs N]";
225
226/// `borg facets [--apply] [--store PATH] [--jobs N]`: backfill facets for a store (default: this
227/// collective's, `BORG_HOME`). The exit code.
228pub fn main(args: &[&str], ask: Ask) -> i32 {
229    let (mut store, mut jobs, mut apply) = (learn::home().join("lessons.jsonl"), crate::runner::JOBS, false);
230    let mut it = args.iter();
231    while let Some(a) = it.next() {
232        match *a {
233            "--apply" => apply = true,
234            "--store" => match it.next() {
235                Some(p) => store = PathBuf::from(p),
236                None => {
237                    eprintln!("{USAGE}");
238                    return 2;
239                }
240            },
241            "--jobs" => match it.next().and_then(|j| j.parse().ok()) {
242                Some(j) => jobs = j,
243                None => {
244                    eprintln!("{USAGE}");
245                    return 2;
246                }
247            },
248            other => {
249                eprintln!("borg facets: unknown argument {other}\n{USAGE}");
250                return 2;
251            }
252        }
253    }
254    if !store.is_file() {
255        eprintln!("borg facets: no lessons at {}", store.display());
256        return 2;
257    }
258    let total = learn::lessons(&store).len();
259    let r = fill(&store, None, apply, ask, jobs, learn::now_ms());
260    if !apply {
261        println!(
262            "{}: {total} lessons, {} without facets; generating them is {} model calls, about ${:.2} (~${EST_USD_PER_LESSON:.2} per lesson). Dry run: nothing written; --apply to generate.",
263            store.display(),
264            r.missing,
265            r.missing,
266            r.missing as f64 * EST_USD_PER_LESSON
267        );
268        return 0;
269    }
270    println!("{}: facets written for {} of {} lessons without them, ${:.4}", store.display(), r.written, r.missing, r.cost_usd);
271    if r.changed > 0 {
272        println!("{} lessons changed while their facets were generated: not written, re-run to retry", r.changed);
273    }
274    if !r.failed.is_empty() {
275        eprintln!("no usable answer from the model for {} lessons (re-run to retry): {}", r.failed.len(), r.failed.join(", "));
276        return 1;
277    }
278    0
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn store(name: &str, lines: &[Lesson]) -> PathBuf {
286        let d = std::env::temp_dir().join(format!("borg-facets-{name}-{}", std::process::id()));
287        let _ = std::fs::remove_dir_all(&d);
288        std::fs::create_dir_all(&d).unwrap();
289        let p = d.join("lessons.jsonl");
290        for l in lines {
291            append(&p, l).unwrap();
292        }
293        p
294    }
295
296    fn lesson(id: &str, q: &str) -> Lesson {
297        Lesson { id: id.into(), origin_ms: 1, question: q.into(), consequence: "c".into(), version: 1, area: "other".into(), ..Default::default() }
298    }
299
300    fn answer(n: usize) -> Value {
301        let angles = ["german", "concept", "aliases", "symptom", "action", "consequence", "concept", "symptom", "action", "concept", "symptom"];
302        json!({"facets": (0..n).map(|i| json!({"angle": angles[i], "text": format!("  facet   {i} {}", angles[i])})).collect::<Vec<_>>()})
303    }
304
305    #[test]
306    fn parse_orders_by_angle_cleans_and_needs_three() {
307        let f = parse(&answer(7)).unwrap();
308        assert_eq!(f[0], "facet 1 concept");
309        assert_eq!(f.iter().position(|x| x.ends_with("german")), Some(5));
310        assert_eq!(f.last().unwrap(), "facet 2 aliases");
311        assert_eq!(parse(&answer(11)).unwrap().len(), MAX_FACETS);
312        assert!(parse(&answer(2)).is_none());
313        assert!(parse(&json!({"error": "rate limited"})).is_none());
314        let dup = json!({"facets": [{"angle": "concept", "text": "same"}, {"angle": "symptom", "text": "SAME"}, {"angle": "action", "text": ""}, {"angle": "german", "text": "x"}]});
315        assert!(parse(&dup).is_none(), "duplicates and empties do not count");
316        let long = json!({"facets": [{"angle": "concept", "text": "w ".repeat(400)}, {"angle": "symptom", "text": "a"}, {"angle": "action", "text": "b"}]});
317        assert!(parse(&long).unwrap()[0].chars().count() <= FACET_CHARS);
318    }
319
320    #[test]
321    fn the_prompt_carries_the_lesson_not_its_store() {
322        let l = Lesson {
323            situations: vec!["on the Beacon replay".into()],
324            scope: vec!["Beacon".into()],
325            repo: "secret-repo".into(),
326            ..lesson("L1", "Is fill@8 reported?")
327        };
328        let p = prompt(&l);
329        assert!(p.contains("question: Is fill@8 reported?") && p.contains("situations: on the Beacon replay") && p.contains("applies to: Beacon"));
330        assert!(!p.contains("secret-repo") && !p.contains("L1"));
331    }
332
333    #[test]
334    fn dry_run_asks_nothing_apply_appends_facets_and_changes_nothing_else() {
335        let mut has = lesson("L2", "Already has facets?");
336        has.facets = vec!["x".into(), "y".into(), "z".into()];
337        let p = store("fill", &[lesson("L1", "Is the flag guarded?"), has.clone()]);
338        let calls = AtomicUsize::new(0);
339        let ask = |_: &str, _: &Value| -> Option<(Value, Option<f64>)> {
340            calls.fetch_add(1, Ordering::SeqCst);
341            Some((answer(6), Some(0.01)))
342        };
343        let before = std::fs::read_to_string(&p).unwrap();
344        let r = fill(&p, None, false, &ask, 2, 5);
345        assert_eq!((r.missing, r.written, calls.load(Ordering::SeqCst)), (1, 0, 0));
346        assert_eq!(std::fs::read_to_string(&p).unwrap(), before, "a dry run writes nothing");
347        let r = fill(&p, None, true, &ask, 2, 5);
348        assert_eq!((r.missing, r.written, calls.load(Ordering::SeqCst)), (1, 1, 1));
349        assert!((r.cost_usd - 0.01).abs() < 1e-12);
350        let all = learn::lessons(&p);
351        let l1 = all.iter().find(|l| l.id == "L1").unwrap();
352        assert_eq!(l1.facets.len(), 6);
353        let orig = lesson("L1", "Is the flag guarded?");
354        assert_eq!(serde_json::to_value(Lesson { facets: vec![], ..l1.clone() }).unwrap(), serde_json::to_value(&orig).unwrap(), "only facets change");
355        // The conscience reads the latest line, with its facets.
356        let cs = crate::conscience::load_unweighted(&p, None);
357        assert_eq!(cs.iter().find(|c| c.id == "L1").unwrap().facets.len(), 6);
358        let rec: Vec<Generated> = std::fs::read_to_string(beside(&p)).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
359        assert_eq!((rec.len(), rec[0].id.as_str(), rec[0].cost_usd), (1, "L1", Some(0.01)));
360        // Nothing left: a second run asks nothing.
361        let r = fill(&p, None, true, &ask, 2, 6);
362        assert_eq!((r.missing, calls.load(Ordering::SeqCst)), (0, 1));
363        let _ = std::fs::remove_dir_all(p.parent().unwrap());
364    }
365
366    #[test]
367    fn only_the_given_lessons_and_failures_stop_early() {
368        let ls: Vec<Lesson> = (0..6).map(|i| lesson(&format!("L{i}"), &format!("question {i}"))).collect();
369        let p = store("only", &ls);
370        let from = len_of(&p);
371        append(&p, &lesson("L9", "appended later")).unwrap();
372        assert_eq!(appended_ids(&p, from), HashSet::from(["L9".to_string()]));
373        let calls = AtomicUsize::new(0);
374        let down = |_: &str, _: &Value| -> Option<(Value, Option<f64>)> {
375            calls.fetch_add(1, Ordering::SeqCst);
376            None
377        };
378        let r = fill(&p, None, true, &down, 1, 1);
379        assert_eq!((r.written, r.failed.len(), calls.load(Ordering::SeqCst)), (0, 7, 3), "the model is down: stop after 3");
380        let ok = |_: &str, _: &Value| Some((answer(6), None));
381        let r = fill(&p, Some(&appended_ids(&p, from)), true, &ok, 1, 1);
382        assert_eq!((r.missing, r.written), (1, 1));
383        assert!(learn::lessons(&p).iter().filter(|l| !l.facets.is_empty()).map(|l| l.id.as_str()).eq(["L9"]));
384        let _ = std::fs::remove_dir_all(p.parent().unwrap());
385    }
386}