1use 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
29pub type Ask<'a> = &'a (dyn Fn(&str, &Value) -> Option<(Value, Option<f64>)> + Sync);
31
32pub const ANGLES: [&str; 6] = ["concept", "symptom", "action", "consequence", "german", "aliases"];
34
35pub const MAX_FACETS: usize = 10;
37pub const FACET_CHARS: usize = 300;
38const MIN_FACETS: usize = 3;
40
41pub const EST_USD_PER_LESSON: f64 = 0.012;
43
44const PROMPT: &str = "You index one lesson for keyword search (BM25). Coding agents will be matched against it with what \
45they are working on: the engineer's prompt, file paths, shell commands, their own plan and final message, in English or \
46German. The lesson's own wording is only one way to say it. Write facets: short alternative phrasings of the SAME lesson, \
47each from a different angle, so the search finds it whichever words the agent uses.
48
49Write 6 to 8 facets, each one line of at most 25 words:
50- concept: the underlying concern in plain words, WITHOUT any project, system, service, class, file or tool name;
51- symptom: what an agent would observe when this goes wrong (an error, a wrong number, a failing check, a review \
52remark), in the words of logs, tests or reviews;
53- action: what the agent is about to do when the lesson applies (the edit, task or plan step in progress), as an agent \
54would describe it in its plan;
55- consequence: what goes wrong, or what is required instead, in other words than the lesson's;
56- german: the question in natural German, as a German-speaking engineer would type it, keeping technical terms and names;
57- aliases: every identifier the lesson names (systems, services, repos, classes, files, metrics, flags), each with its \
58likely other spellings (camelCase, kebab-case, snake_case, abbreviation, full name), space-separated; skip this facet \
59if the lesson names none;
60- at most two more, of any angle, where the lesson covers a second distinct situation.
61
62Use natural words the lesson does not already use. Never add facts, names, numbers or advice that are not in the lesson.";
63
64fn schema() -> Value {
65 json!({"type": "object", "additionalProperties": false, "properties": {
66 "facets": {"type": "array", "items": {"type": "object", "additionalProperties": false, "properties": {
67 "angle": {"type": "string", "enum": ANGLES}, "text": {"type": "string"}}, "required": ["angle", "text"]}}},
68 "required": ["facets"]})
69}
70
71pub fn prompt(l: &Lesson) -> String {
73 let mut s = format!("{PROMPT}\n\n## Lesson\nquestion: {}\n", l.question.trim());
74 if !l.situations.is_empty() {
75 s.push_str(&format!("situations: {}\n", l.situations.join(" | ")));
76 }
77 if !l.consequence.trim().is_empty() {
78 s.push_str(&format!("consequence: {}\n", l.consequence.trim()));
79 }
80 if !l.scope.is_empty() {
81 s.push_str(&format!("applies to: {}\n", l.scope.join(", ")));
82 }
83 if !l.overrides.trim().is_empty() {
84 s.push_str(&format!("replaces the outside rule: {}\n", l.overrides.trim()));
85 }
86 s
87}
88
89pub fn parse(v: &Value) -> Option<Vec<String>> {
92 let mut rows: Vec<(usize, String)> = Vec::new();
93 for f in v["facets"].as_array()? {
94 let angle = f["angle"].as_str().unwrap_or_default();
95 let at = ANGLES.iter().position(|a| *a == angle).unwrap_or(ANGLES.len());
96 let text: String = f["text"].as_str().unwrap_or_default().split_whitespace().collect::<Vec<_>>().join(" ");
97 let text: String = text.chars().take(FACET_CHARS).collect();
98 if !text.is_empty() && !rows.iter().any(|(_, t)| t.eq_ignore_ascii_case(&text)) {
99 rows.push((at, text));
100 }
101 }
102 rows.sort_by_key(|(at, _)| *at);
103 let out: Vec<String> = rows.into_iter().map(|(_, t)| t).take(MAX_FACETS).collect();
104 (out.len() >= MIN_FACETS).then_some(out)
105}
106
107pub fn generate(l: &Lesson, ask: Ask) -> Option<(Vec<String>, Option<f64>)> {
109 let (v, cost) = ask(&prompt(l), &schema())?;
110 Some((parse(&v)?, cost))
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct Generated {
116 pub id: String,
117 pub version: u32,
118 pub facets: usize,
119 pub cost_usd: Option<f64>,
120 pub ms: i64,
121}
122
123#[derive(Debug, Default)]
125pub struct Report {
126 pub missing: usize,
128 pub written: usize,
129 pub failed: Vec<String>,
130 pub changed: usize,
132 pub cost_usd: f64,
133}
134
135pub fn appended_ids(path: &Path, from: u64) -> HashSet<String> {
137 let text = std::fs::read(path).unwrap_or_default();
138 let tail = text.get(from as usize..).unwrap_or_default();
139 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()
140}
141
142pub fn len_of(path: &Path) -> u64 {
144 std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
145}
146
147pub fn enabled() -> bool {
149 std::env::var("BORG_FACETS").as_deref() == Ok("on")
150}
151
152pub fn fill(path: &Path, only: Option<&HashSet<String>>, apply: bool, ask: Ask, jobs: usize, now: i64) -> Report {
158 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();
159 let mut report = Report { missing: todo.len(), ..Default::default() };
160 if !apply || todo.is_empty() {
161 return report;
162 }
163 let next = AtomicUsize::new(0);
164 let (failures, successes) = (AtomicUsize::new(0), AtomicUsize::new(0));
165 let out: Mutex<Report> = Mutex::new(report);
166 std::thread::scope(|s| {
167 for _ in 0..jobs.max(1).min(todo.len()) {
168 s.spawn(|| {
169 loop {
170 let i = next.fetch_add(1, Ordering::SeqCst);
171 let Some(l) = todo.get(i) else { break };
172 if failures.load(Ordering::SeqCst) >= 3 && successes.load(Ordering::SeqCst) == 0 {
173 out.lock().unwrap().failed.push(l.id.clone());
174 continue;
175 }
176 let got = generate(l, ask);
177 let mut r = out.lock().unwrap();
178 let Some((facets, cost)) = got else {
179 failures.fetch_add(1, Ordering::SeqCst);
180 r.failed.push(l.id.clone());
181 continue;
182 };
183 successes.fetch_add(1, Ordering::SeqCst);
184 r.cost_usd += cost.unwrap_or(0.0);
185 let now_latest = learn::lessons(path).into_iter().find(|x| x.id == l.id);
188 if now_latest.as_ref().is_none_or(|x| x.version != l.version || x.question != l.question || !x.facets.is_empty()) {
189 r.changed += 1;
190 continue;
191 }
192 let with = Lesson { facets, ..l.clone() };
193 let rec = Generated { id: l.id.clone(), version: l.version, facets: with.facets.len(), cost_usd: cost, ms: now };
194 if append(path, &with).and_then(|_| append(&beside(path), &rec)).is_ok() {
195 r.written += 1;
196 } else {
197 r.failed.push(l.id.clone());
198 }
199 }
200 });
201 }
202 });
203 report = out.into_inner().unwrap();
204 report.cost_usd += 0.0; report
206}
207
208pub fn beside(lessons: &Path) -> PathBuf {
210 lessons.with_file_name("facets.jsonl")
211}
212
213fn append<T: Serialize>(path: &Path, v: &T) -> std::io::Result<()> {
214 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
215 writeln!(f, "{}", serde_json::to_string(v).map_err(std::io::Error::other)?)
216}
217
218pub const USAGE: &str = "usage: borg facets [--apply] [--store LESSONS.jsonl] [--jobs N]";
219
220pub fn main(args: &[&str], ask: Ask) -> i32 {
223 let (mut store, mut jobs, mut apply) = (learn::home().join("lessons.jsonl"), 4usize, false);
224 let mut it = args.iter();
225 while let Some(a) = it.next() {
226 match *a {
227 "--apply" => apply = true,
228 "--store" => match it.next() {
229 Some(p) => store = PathBuf::from(p),
230 None => {
231 eprintln!("{USAGE}");
232 return 2;
233 }
234 },
235 "--jobs" => match it.next().and_then(|j| j.parse().ok()) {
236 Some(j) => jobs = j,
237 None => {
238 eprintln!("{USAGE}");
239 return 2;
240 }
241 },
242 other => {
243 eprintln!("borg facets: unknown argument {other}\n{USAGE}");
244 return 2;
245 }
246 }
247 }
248 if !store.is_file() {
249 eprintln!("borg facets: no lessons at {}", store.display());
250 return 2;
251 }
252 let total = learn::lessons(&store).len();
253 let r = fill(&store, None, apply, ask, jobs, learn::now_ms());
254 if !apply {
255 println!(
256 "{}: {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.",
257 store.display(),
258 r.missing,
259 r.missing,
260 r.missing as f64 * EST_USD_PER_LESSON
261 );
262 return 0;
263 }
264 println!("{}: facets written for {} of {} lessons without them, ${:.4}", store.display(), r.written, r.missing, r.cost_usd);
265 if r.changed > 0 {
266 println!("{} lessons changed while their facets were generated: not written, re-run to retry", r.changed);
267 }
268 if !r.failed.is_empty() {
269 eprintln!("no usable answer from the model for {} lessons (re-run to retry): {}", r.failed.len(), r.failed.join(", "));
270 return 1;
271 }
272 0
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 fn store(name: &str, lines: &[Lesson]) -> PathBuf {
280 let d = std::env::temp_dir().join(format!("borg-facets-{name}-{}", std::process::id()));
281 let _ = std::fs::remove_dir_all(&d);
282 std::fs::create_dir_all(&d).unwrap();
283 let p = d.join("lessons.jsonl");
284 for l in lines {
285 append(&p, l).unwrap();
286 }
287 p
288 }
289
290 fn lesson(id: &str, q: &str) -> Lesson {
291 Lesson { id: id.into(), origin_ms: 1, question: q.into(), consequence: "c".into(), version: 1, area: "other".into(), ..Default::default() }
292 }
293
294 fn answer(n: usize) -> Value {
295 let angles = ["german", "concept", "aliases", "symptom", "action", "consequence", "concept", "symptom", "action", "concept", "symptom"];
296 json!({"facets": (0..n).map(|i| json!({"angle": angles[i], "text": format!(" facet {i} {}", angles[i])})).collect::<Vec<_>>()})
297 }
298
299 #[test]
300 fn parse_orders_by_angle_cleans_and_needs_three() {
301 let f = parse(&answer(7)).unwrap();
302 assert_eq!(f[0], "facet 1 concept");
303 assert_eq!(f.iter().position(|x| x.ends_with("german")), Some(5));
304 assert_eq!(f.last().unwrap(), "facet 2 aliases");
305 assert_eq!(parse(&answer(11)).unwrap().len(), MAX_FACETS);
306 assert!(parse(&answer(2)).is_none());
307 assert!(parse(&json!({"error": "rate limited"})).is_none());
308 let dup = json!({"facets": [{"angle": "concept", "text": "same"}, {"angle": "symptom", "text": "SAME"}, {"angle": "action", "text": ""}, {"angle": "german", "text": "x"}]});
309 assert!(parse(&dup).is_none(), "duplicates and empties do not count");
310 let long = json!({"facets": [{"angle": "concept", "text": "w ".repeat(400)}, {"angle": "symptom", "text": "a"}, {"angle": "action", "text": "b"}]});
311 assert!(parse(&long).unwrap()[0].chars().count() <= FACET_CHARS);
312 }
313
314 #[test]
315 fn the_prompt_carries_the_lesson_not_its_store() {
316 let l = Lesson {
317 situations: vec!["on the Beacon replay".into()],
318 scope: vec!["Beacon".into()],
319 repo: "secret-repo".into(),
320 ..lesson("L1", "Is fill@8 reported?")
321 };
322 let p = prompt(&l);
323 assert!(p.contains("question: Is fill@8 reported?") && p.contains("situations: on the Beacon replay") && p.contains("applies to: Beacon"));
324 assert!(!p.contains("secret-repo") && !p.contains("L1"));
325 }
326
327 #[test]
328 fn dry_run_asks_nothing_apply_appends_facets_and_changes_nothing_else() {
329 let mut has = lesson("L2", "Already has facets?");
330 has.facets = vec!["x".into(), "y".into(), "z".into()];
331 let p = store("fill", &[lesson("L1", "Is the flag guarded?"), has.clone()]);
332 let calls = AtomicUsize::new(0);
333 let ask = |_: &str, _: &Value| -> Option<(Value, Option<f64>)> {
334 calls.fetch_add(1, Ordering::SeqCst);
335 Some((answer(6), Some(0.01)))
336 };
337 let before = std::fs::read_to_string(&p).unwrap();
338 let r = fill(&p, None, false, &ask, 2, 5);
339 assert_eq!((r.missing, r.written, calls.load(Ordering::SeqCst)), (1, 0, 0));
340 assert_eq!(std::fs::read_to_string(&p).unwrap(), before, "a dry run writes nothing");
341 let r = fill(&p, None, true, &ask, 2, 5);
342 assert_eq!((r.missing, r.written, calls.load(Ordering::SeqCst)), (1, 1, 1));
343 assert!((r.cost_usd - 0.01).abs() < 1e-12);
344 let all = learn::lessons(&p);
345 let l1 = all.iter().find(|l| l.id == "L1").unwrap();
346 assert_eq!(l1.facets.len(), 6);
347 let orig = lesson("L1", "Is the flag guarded?");
348 assert_eq!(serde_json::to_value(Lesson { facets: vec![], ..l1.clone() }).unwrap(), serde_json::to_value(&orig).unwrap(), "only facets change");
349 let cs = crate::conscience::load_unweighted(&p, None);
351 assert_eq!(cs.iter().find(|c| c.id == "L1").unwrap().facets.len(), 6);
352 let rec: Vec<Generated> = std::fs::read_to_string(beside(&p)).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
353 assert_eq!((rec.len(), rec[0].id.as_str(), rec[0].cost_usd), (1, "L1", Some(0.01)));
354 let r = fill(&p, None, true, &ask, 2, 6);
356 assert_eq!((r.missing, calls.load(Ordering::SeqCst)), (0, 1));
357 let _ = std::fs::remove_dir_all(p.parent().unwrap());
358 }
359
360 #[test]
361 fn only_the_given_lessons_and_failures_stop_early() {
362 let ls: Vec<Lesson> = (0..6).map(|i| lesson(&format!("L{i}"), &format!("question {i}"))).collect();
363 let p = store("only", &ls);
364 let from = len_of(&p);
365 append(&p, &lesson("L9", "appended later")).unwrap();
366 assert_eq!(appended_ids(&p, from), HashSet::from(["L9".to_string()]));
367 let calls = AtomicUsize::new(0);
368 let down = |_: &str, _: &Value| -> Option<(Value, Option<f64>)> {
369 calls.fetch_add(1, Ordering::SeqCst);
370 None
371 };
372 let r = fill(&p, None, true, &down, 1, 1);
373 assert_eq!((r.written, r.failed.len(), calls.load(Ordering::SeqCst)), (0, 7, 3), "the model is down: stop after 3");
374 let ok = |_: &str, _: &Value| Some((answer(6), None));
375 let r = fill(&p, Some(&appended_ids(&p, from)), true, &ok, 1, 1);
376 assert_eq!((r.missing, r.written), (1, 1));
377 assert!(learn::lessons(&p).iter().filter(|l| !l.facets.is_empty()).map(|l| l.id.as_str()).eq(["L9"]));
378 let _ = std::fs::remove_dir_all(p.parent().unwrap());
379 }
380}