1use std::collections::{HashMap, HashSet};
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::process::Command;
31use std::sync::LazyLock;
32
33use regex::Regex;
34use serde::{Deserialize, Serialize};
35use serde_json::{Value, json};
36
37use crate::conscience::{self, Challenge};
38use crate::outcome;
39use crate::trust;
40
41pub mod regret;
42
43static CORRECTION: LazyLock<Regex> = LazyLock::new(|| {
44 Regex::new(r"(?i)^\s*(no\b|nope|don'?t|do not|stop|wrong|that'?s not|not what|why did you|why do you|i said|instead|you should|never\b|always\b|please don'?t|double check|carefully check|check against|are you sure|hmm|but\b|just a second|still\b|can'?t we|don'?t we|isn'?t)").unwrap()
45});
46static REJECTION: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?is)want to proceed with this tool use.*?the user said:\s*(.*)").unwrap());
47static DENIAL: LazyLock<Regex> = LazyLock::new(|| {
50 Regex::new(r"(?i)^\s*(tool call denied by user: |tool call rejected by user \(|the user doesn'?t want to proceed with this tool use)").unwrap()
51});
52
53pub fn is_correction(prompt: &str) -> bool {
55 let t = prompt.trim();
56 t.len() > 3 && !t.starts_with('<') && CORRECTION.is_match(t)
57}
58
59pub fn rejection(error: &str) -> Option<String> {
61 let said = REJECTION.captures(error)?.get(1)?.as_str();
62 let said = said.split("Note: The user").next().unwrap_or(said).trim();
63 (said.len() > 3).then(|| said.chars().take(1500).collect())
64}
65
66pub fn denied(error: &str) -> bool {
68 rejection(error).is_none() && DENIAL.is_match(error)
69}
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct Episode {
73 pub id: String,
74 pub ms: i64,
75 pub repo: String,
76 pub session: String,
77 pub task: String,
78 pub agent: String,
79 pub rejected_tool: Option<String>,
80 pub correction: String,
81 #[serde(default)]
83 pub engineer: String,
84 #[serde(default, skip_serializing_if = "String::is_empty")]
86 pub kind: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub regret: Option<regret::Regret>,
89}
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct Lesson {
95 pub id: String,
96 pub origin_ms: i64,
97 pub repo: String,
98 pub question: String,
99 pub situations: Vec<String>,
100 pub consequence: String,
101 pub support: u32,
102 pub episodes: Vec<String>,
103 pub history: Vec<Value>,
104 pub version: u32,
105 #[serde(default)]
107 pub engineers: Vec<String>,
108 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
111 pub joined: std::collections::BTreeMap<String, i64>,
112 #[serde(default = "other_area")]
114 pub area: String,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub supersedes: Vec<String>,
118 #[serde(default, skip_serializing_if = "String::is_empty")]
121 pub source: String,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub scope: Vec<String>,
127 #[serde(default, skip_serializing_if = "String::is_empty")]
132 pub overrides: String,
133 #[serde(default, skip_serializing_if = "String::is_empty")]
135 pub origin: String,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub facets: Vec<String>,
141 #[serde(default, skip_serializing_if = "String::is_empty")]
144 pub reach: String,
145}
146
147fn other_area() -> String {
148 trust::OTHER.into()
149}
150
151pub fn home() -> PathBuf {
152 std::env::var("BORG_HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/share/borg"))
153}
154
155pub(crate) fn append_jsonl<T: Serialize>(path: &Path, v: &T) -> std::io::Result<()> {
156 append_line(path, &serde_json::to_string(v)?)
157}
158
159pub(crate) fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
163 if let Some(d) = path.parent() {
164 std::fs::create_dir_all(d)?;
165 }
166 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
167 f.write_all(format!("{line}\n").as_bytes())
168}
169
170fn read_jsonl<T: for<'de> Deserialize<'de>>(path: &Path) -> Vec<T> {
171 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
172}
173
174pub fn learning() -> bool {
176 std::env::var("BORG_LEARN").as_deref() != Ok("off")
177}
178
179pub fn engineer(cwd: &str) -> String {
181 if cwd.is_empty() {
182 return String::new();
183 }
184 Command::new("git")
185 .args(["-C", cwd, "config", "user.email"])
186 .stdin(std::process::Stdio::null())
187 .stderr(std::process::Stdio::null())
188 .output()
189 .ok()
190 .filter(|o| o.status.success())
191 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
192 .unwrap_or_default()
193}
194
195pub fn record_episode(e: &Episode) -> std::io::Result<()> {
196 record_episode_in(&home(), e)
197}
198
199pub fn record_episode_in(store: &Path, e: &Episode) -> std::io::Result<()> {
204 append_jsonl(&store.join("episodes.jsonl"), e)?;
205 count_correction(store, e);
206 Ok(())
207}
208
209pub fn count_correction(store: &Path, e: &Episode) {
211 if e.kind == crate::veto::KIND {
213 return;
214 }
215 let snippet = crate::stats::excerpt(&e.correction, 160);
216 let (why, text) = match &e.rejected_tool {
217 _ if e.kind == regret::KIND => ("regret", snippet),
218 Some(tool) => ("rejected_tool", format!("{tool}: {snippet}")),
219 None => ("pushback", snippet),
220 };
221 crate::stats::count(store, crate::stats::CORRECTION, &e.session, |ev| {
222 ev.ms = e.ms;
223 ev.engineer.clone_from(&e.engineer);
224 ev.repo.clone_from(&e.repo);
225 ev.why = why.into();
226 ev.text = text;
227 });
228}
229
230pub fn lessons(path: &Path) -> Vec<Lesson> {
232 let mut by: HashMap<String, Lesson> = HashMap::new();
233 for l in read_jsonl::<Lesson>(path) {
234 if by.get(&l.id).is_none_or(|o| o.version <= l.version) {
235 by.insert(l.id.clone(), l);
236 }
237 }
238 let mut v: Vec<Lesson> = by.into_values().collect();
239 v.sort_by_key(|l| l.origin_ms);
240 v
241}
242
243const OPENING: &str = "An engineer corrected a coding agent. Decide whether the correction expresses a reusable engineering \
245lesson: a critical concern about quality, design, correctness, reuse, spec conformance or root cause that this engineer would \
246plausibly raise again about similar future work. Operational remarks (\"still working?\", \"stop\", \"push\", \"restart\") are NOT lessons.";
247
248const REACH_RULES: &str = "Then decide its reach: who should hear it. Ask: would a teammate working on the same code get this correction too?
250- personal: how this one engineer likes the agent to work with them, not a property of the code: commit message style \
251or length, AI attribution or co-author trailers, whether and when to commit, push, branch or open a PR, what to stage, \
252how to report or ask, language and tone, their own tooling habits.
253- repo: a convention of the repo it happened in that other repos do not share (its branch or deploy workflow, a tool \
254or command it mandates).
255- team: everything else: an engineering concern about the code, design, correctness or verification.";
256
257const FIELDS: &str = "If it is a lesson, write:
258- question: ONE question the agent should ask itself next time, general enough to transfer, keeping the technical substance;
259- situations: 1-3 lines, each `<action> on/in <kind of state or component> [under <condition>]`, without project/class names;
260- consequence: one line, what goes wrong or what the engineer requires instead;";
261
262const AREA: &str = "- area: the one kind of concern: concurrency, spec-conformance, reuse-existing, architecture-boundary, verification, minimality, \
264protocol-correctness, data-source, security, dependency-version, scope, or other if none fits.";
265
266const MERGE_RULES: &str = "Existing lessons that may express the same concern are listed. If one of them IS the same concern (the same kind of mistake) \
268and agrees with this correction, set same_as to its id and write the merged, improved wording in question/situations/consequence. \
269Otherwise same_as is empty.
270
271If this correction contradicts or replaces a listed lesson (in the same situation it requires something different or the \
272opposite, e.g. the engineer reversed an earlier correction), list that lesson's id in supersedes; never the same_as lesson. \
273Otherwise supersedes is empty.";
274
275fn episode_prompt(e: &Episode, listed: &[String]) -> String {
277 let existing = if listed.is_empty() { "(none)".into() } else { listed.join("\n") };
278 if e.kind == crate::veto::KIND {
279 let agent = if e.agent.trim().is_empty() { "(not available)" } else { e.agent.as_str() };
280 return format!(
281 "{}\n\n{REACH_RULES}\n\n{FIELDS}\n{AREA}\n{}\n\n{MERGE_RULES}\n\n## Task the agent had\n{}\n\n## What the agent proposed (excerpt)\n{agent}\n\n## Engineer's veto\n{}\n\n## Existing lessons\n{existing}\n",
282 crate::veto::OPENING,
283 crate::veto::INVARIANT,
284 e.task.chars().take(1500).collect::<String>(),
285 e.correction,
286 );
287 }
288 if let Some(r) = e.regret.as_ref().filter(|_| e.kind == regret::KIND) {
289 let (head, sections) = (regret::head(r), regret::sections(r, &e.task));
290 let (reach, merge) = (regret::neutral(REACH_RULES), regret::neutral(MERGE_RULES));
291 return format!("{head}\n\n{reach}\n\n{}\n{AREA}\n\n{merge}\n\n{sections}## Existing lessons\n{existing}\n", regret::FIELDS);
292 }
293 format!(
294 "{OPENING}\n\n{REACH_RULES}\n\n{FIELDS}\n{AREA}\n\n{MERGE_RULES}\n\n## Task the agent had\n{}\n\n## What the agent proposed (excerpt)\n{}\n{}\n\n## Engineer's correction\n{}\n\n## Existing lessons\n{existing}\n",
295 e.task.chars().take(1500).collect::<String>(),
296 e.agent,
297 e.rejected_tool.as_deref().unwrap_or(""),
298 e.correction,
299 )
300}
301
302fn schema() -> Value {
303 json!({"type": "object", "additionalProperties": false, "properties": {
304 "is_lesson": {"type": "boolean"}, "question": {"type": "string"},
305 "situations": {"type": "array", "items": {"type": "string"}}, "consequence": {"type": "string"},
306 "same_as": {"type": "string"}, "area": {"type": "string", "enum": trust::AREAS},
307 "supersedes": {"type": "array", "items": {"type": "string"}},
308 "reach": {"type": "string", "enum": ["team", "repo", "personal"]}},
309 "required": ["is_lesson", "question", "situations", "consequence", "same_as", "area", "supersedes", "reach"]})
310}
311
312fn veto_schema() -> Value {
314 let mut s = schema();
315 s["properties"]["invariant"] = json!({"type": "string"});
316 if let Some(r) = s["required"].as_array_mut() {
317 r.push(json!("invariant"));
318 }
319 s
320}
321
322pub fn schema_for(prompt: &str) -> Value {
325 if prompt.starts_with(crate::veto::OPENING) { veto_schema() } else { schema() }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub struct AskFailed;
332
333const MAX_FAILURES: usize = 3;
336
337struct Answer {
339 question: String,
340 situations: Vec<String>,
341 consequence: String,
342 area: String,
343 reach: String,
344 same_as: String,
345 supersedes: Vec<String>,
346 invariant: String,
348}
349
350impl Answer {
351 fn parse(v: &Value) -> Result<Option<Answer>, AskFailed> {
353 match v["is_lesson"].as_bool() {
354 Some(true) => {}
355 Some(false) => return Ok(None),
356 None => return Err(AskFailed),
357 }
358 let strs = |k: &str| v[k].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect::<Vec<_>>();
359 let text = |k: &str| v[k].as_str().unwrap_or_default().to_string();
360 let question = v["question"].as_str().filter(|q| !q.trim().is_empty()).ok_or(AskFailed)?.to_string();
361 let reach = v["reach"].as_str().filter(|r| [crate::reach::REPO, crate::reach::PERSONAL].contains(r)).unwrap_or_default();
362 Ok(Some(Answer {
363 question,
364 situations: strs("situations"),
365 consequence: text("consequence"),
366 area: trust::area(v["area"].as_str().unwrap_or_default()).to_string(),
367 reach: reach.to_string(),
368 same_as: text("same_as"),
369 supersedes: strs("supersedes"),
370 invariant: text("invariant"),
371 }))
372 }
373}
374
375fn similar<'a>(e: &Episode, library: &'a [Lesson]) -> Vec<&'a Lesson> {
377 let as_challenges: Vec<Challenge> = library
378 .iter()
379 .map(|l| Challenge {
380 id: l.id.clone(),
381 origin_ms: l.origin_ms,
382 question: format!("{} {}", l.question, l.situations.join(" ")),
383 situations: l.situations.clone(),
384 consequence: l.consequence.clone(),
385 supersedes: l.supersedes.clone(),
388 engineers: l.engineers.clone(),
389 source: l.source.clone(),
390 ..Default::default() })
392 .collect();
393 let query = format!("{} {} {}", e.task, e.agent, e.correction);
394 conscience::select(&as_challenges, &query, 5).iter().filter_map(|c| library.iter().find(|l| l.id == c.id)).collect()
395}
396
397fn merged(old: &Lesson, e: &Episode, a: Answer, mut supersedes: Vec<String>) -> Lesson {
399 let mut history = old.history.clone();
400 history.push(json!({"question": old.question, "consequence": old.consequence, "version": old.version}));
401 let mut episodes = old.episodes.clone();
402 episodes.push(e.id.clone());
403 let (mut engineers, mut joined) = (old.engineers.clone(), old.joined.clone());
404 let same = |x: &String| x.trim().to_lowercase() == e.engineer.trim().to_lowercase();
407 if !e.engineer.trim().is_empty() && !engineers.iter().any(same) {
408 engineers.push(e.engineer.clone());
409 joined.insert(e.engineer.clone(), e.ms);
410 }
411 for id in old.supersedes.iter().rev() {
412 if !supersedes.contains(id) {
413 supersedes.insert(0, id.clone());
414 }
415 }
416 Lesson {
417 question: a.question,
418 situations: a.situations,
419 consequence: a.consequence,
420 support: old.support + 1,
421 episodes,
422 history,
423 version: old.version + 1,
424 engineers,
425 joined,
426 area: a.area,
427 supersedes,
428 facets: Vec::new(),
430 reach: crate::reach::narrower(&old.reach, &a.reach).to_string(),
431 ..old.clone()
432 }
433}
434
435fn fresh(e: &Episode, a: Answer, supersedes: Vec<String>) -> Lesson {
437 Lesson {
438 id: format!("L{}", e.id),
439 origin_ms: e.ms,
440 repo: e.repo.clone(),
441 question: a.question,
442 situations: a.situations,
443 consequence: a.consequence,
444 support: 1,
445 episodes: vec![e.id.clone()],
446 version: 1,
447 engineers: Some(e.engineer.clone()).filter(|x| !x.is_empty()).into_iter().collect(),
448 area: a.area,
449 supersedes,
450 reach: a.reach,
451 ..Lesson::default()
452 }
453}
454
455pub fn generalise(e: &Episode, library: &[Lesson], ask: &dyn Fn(&str) -> Option<Value>) -> Result<Option<Lesson>, AskFailed> {
462 generalise_with(e, library, ask).map(|o| o.map(|(l, _)| l))
463}
464
465pub fn generalise_with(e: &Episode, library: &[Lesson], ask: &dyn Fn(&str) -> Option<Value>) -> Result<Option<(Lesson, String)>, AskFailed> {
470 let library: Vec<Lesson> = library.iter().filter(|l| l.reach != crate::reach::PERSONAL).cloned().collect();
472 let similar = similar(e, &library);
473 let listed: Vec<String> = similar.iter().map(|l| format!("[{}] {} -> {}", l.id, l.question, l.consequence)).collect();
474 let Some(a) = Answer::parse(&ask(&episode_prompt(e, &listed)).ok_or(AskFailed)?)? else { return Ok(None) };
475 let same_as = similar.iter().find(|l| a.same_as == l.id);
476 let supersedes: Vec<String> =
478 a.supersedes.iter().filter(|id| similar.iter().any(|l| &l.id == *id) && same_as.is_none_or(|m| &m.id != *id)).cloned().collect();
479 let invariant = a.invariant.trim().to_string();
480 Ok(Some((
481 match same_as {
482 Some(old) => merged(old, e, a, supersedes),
483 None => fresh(e, a, supersedes),
484 },
485 invariant,
486 )))
487}
488
489pub fn ask_claude(prompt: &str) -> Option<Value> {
491 ask_model(prompt, &schema_for(prompt)).map(|(v, _)| v)
492}
493
494pub fn ask_model(prompt: &str, schema: &Value) -> Option<(Value, Option<f64>)> {
497 let started = std::time::Instant::now();
498 let answer = call_model(prompt, schema);
499 crate::stats::count(&home(), crate::stats::MODEL_CALL, "", |ev| {
500 ev.why = crate::stats::purpose().into();
501 ev.usd = answer.as_ref().and_then(|(_, c)| *c).unwrap_or(0.0);
502 ev.dur_ms = started.elapsed().as_millis() as u64;
503 if answer.is_none() {
504 ev.text = "no answer".into();
505 }
506 });
507 answer
508}
509
510fn call_model(prompt: &str, schema: &Value) -> Option<(Value, Option<f64>)> {
511 let candidates = crate::runner::choose(std::env::var("BORG_LEARN_MODEL").ok().as_deref());
512 crate::runner::structured(&candidates, prompt, schema, learn_timeout())
513}
514
515fn learn_timeout() -> std::time::Duration {
518 std::time::Duration::from_secs(std::env::var("BORG_LEARN_TIMEOUT_S").ok().and_then(|v| v.parse().ok()).unwrap_or(300))
519}
520
521pub fn now_ms() -> i64 {
522 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
523}
524
525pub fn learn_pending(session: Option<&str>, ask: &dyn Fn(&str) -> Option<Value>) -> (usize, usize, usize) {
530 learn_in(&home(), session, ask, learning(), now_ms())
531}
532
533pub fn learn_in(home: &Path, session: Option<&str>, ask: &dyn Fn(&str) -> Option<Value>, track: bool, now: i64) -> (usize, usize, usize) {
542 learn_in_reporting(home, session, ask, track, now, &mut |_| {})
543}
544
545pub fn print_step(st: Step) {
547 let left = (st.total - st.done) as f64 * st.secs / 60.0;
548 let mut v: String = st.verdict.chars().take(110).collect();
549 if v.len() < st.verdict.len() {
550 v.push('…');
551 }
552 eprintln!("[{}/{} · {:.0} s · ~{:.0} min left] {v}", st.done, st.total, st.secs, left);
553}
554
555pub struct Step<'a> {
558 pub done: usize,
559 pub total: usize,
560 pub verdict: &'a str,
561 pub secs: f64,
562}
563
564fn proposes(placed: &crate::reach::Placed, invariant: &str) -> bool {
567 matches!(placed, crate::reach::Placed::Here(_)) && !invariant.trim().is_empty()
568}
569
570pub fn regret_credit() -> bool {
573 std::env::var("BORG_REGRET_CREDIT").as_deref() != Ok("off")
574}
575
576pub fn test_credit() -> bool {
579 learning() && std::env::var("BORG_TEST_CREDIT").as_deref() != Ok("off")
580}
581
582fn credit_regrets(regrets: &[Episode], library: &[Lesson], outcomes: &mut Vec<outcome::Outcome>, path: &Path) {
594 let mut holder: HashMap<&str, &Lesson> = HashMap::new();
595 for l in library {
596 for id in &l.episodes {
597 holder.entry(id.as_str()).or_insert(l);
598 }
599 }
600 let deciding: HashSet<&str> = regrets.iter().filter_map(|e| e.regret.as_ref()).map(|r| r.decision_session.as_str()).collect();
601 let mut by_session: HashMap<String, Vec<outcome::Outcome>> = HashMap::new();
602 for o in outcomes.iter().filter(|o| deciding.contains(o.session.as_str())) {
603 by_session.entry(o.session.clone()).or_default().push(o.clone());
604 }
605 for e in regrets {
606 let Some(r) = &e.regret else { continue };
607 let Some(holder) = holder.get(e.id.as_str()) else { continue };
608 let target: Vec<String> = std::iter::once(holder.id.clone()).chain(holder.supersedes.iter().cloned()).collect();
609 let records = by_session.entry(r.decision_session.clone()).or_default();
610 for o in outcome::regretted(records, &r.decision_session, r.decision_ms, &e.id, &target, regret::strength(r), e.ms, &e.repo) {
611 let _ = outcome::append(path, &o);
612 records.push(o.clone());
613 outcomes.push(o);
614 }
615 }
616}
617
618const RETIRED: [&str; 2] = ["group", "rework"];
620
621pub fn learn_in_reporting(
628 home: &Path,
629 session: Option<&str>,
630 ask: &dyn Fn(&str) -> Option<Value>,
631 track: bool,
632 now: i64,
633 report: &mut dyn FnMut(Step),
634) -> (usize, usize, usize) {
635 crate::live::locked(&home.join("lessons.jsonl"), || learn_unlocked(home, session, ask, track, now, report))
636}
637
638fn learn_unlocked(
639 home: &Path,
640 session: Option<&str>,
641 ask: &dyn Fn(&str) -> Option<Value>,
642 track: bool,
643 now: i64,
644 report: &mut dyn FnMut(Step),
645) -> (usize, usize, usize) {
646 let (ep_path, lessons_path) = (home.join("episodes.jsonl"), home.join("lessons.jsonl"));
647 let outcomes_path = outcome::path_beside(&lessons_path);
648 let mut outcomes = if track { outcome::read(&outcomes_path) } else { Vec::new() };
649 let mut library = lessons(&lessons_path);
650 let done: std::collections::HashSet<String> = library.iter().flat_map(|l| l.episodes.clone()).collect();
651 let skipped_path = home.join("episodes-not-lessons.txt");
652 let skipped: std::collections::HashSet<String> = std::fs::read_to_string(&skipped_path).unwrap_or_default().lines().map(str::to_string).collect();
653 let (mut new, mut merged, mut failed, mut failures_in_row) = (0, 0, 0, 0);
654 let mut last_episode: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
656 let mut pending: std::collections::HashSet<String> = std::collections::HashSet::new();
657 let episodes: Vec<Episode> = read_jsonl::<Episode>(&ep_path).into_iter().filter(|e| !RETIRED.contains(&e.kind.as_str())).collect();
659 let regrets: Vec<Episode> = episodes.iter().filter(|e| e.kind == regret::KIND && e.regret.is_some()).cloned().collect();
660 let mine = |e: &Episode| session.is_none_or(|s| s == e.session) || e.kind == crate::veto::KIND;
662 let vetoes: HashSet<String> = episodes.iter().filter(|e| e.kind == crate::veto::KIND).map(|e| e.id.clone()).collect();
663 let total = episodes.iter().filter(|e| !done.contains(&e.id) && !skipped.contains(&e.id) && mine(e)).count();
664 let mut asked = 0;
665 for e in episodes {
666 let last = last_episode.entry(e.session.clone()).or_insert(e.ms);
667 *last = (*last).max(e.ms);
668 if done.contains(&e.id) || skipped.contains(&e.id) {
669 continue;
670 }
671 if !mine(&e) {
672 pending.insert(e.session.clone());
673 continue;
674 }
675 if failures_in_row >= MAX_FAILURES {
676 failed += 1;
677 pending.insert(e.session.clone());
678 continue;
679 }
680 let started = std::time::Instant::now();
681 let verdict = generalise_with(&e, &library, ask);
682 asked += 1;
683 let what = match &verdict {
684 Err(AskFailed) => "no answer from the model, left pending".to_string(),
685 Ok(Some((l, _))) if l.version > 1 => format!("merged into {}: {}", l.id, l.question),
686 Ok(Some((l, _))) => format!("new lesson {}: {}", l.id, l.question),
687 Ok(None) => "not a lesson".to_string(),
688 };
689 report(Step { done: asked, total, verdict: &what, secs: started.elapsed().as_secs_f64() });
690 failures_in_row = if verdict.is_err() { failures_in_row + 1 } else { 0 };
691 match verdict {
692 Err(AskFailed) => {
693 failed += 1;
694 pending.insert(e.session.clone());
695 }
696 Ok(Some((l, invariant))) => {
697 if l.version > 1 {
698 merged += 1
699 } else {
700 new += 1
701 }
702 let placed = crate::reach::place(home, &l);
704 let source = match &e.regret {
706 Some(r) if regret::is_review(r) => "review",
707 Some(_) => "regret",
708 None if e.kind == crate::veto::KIND => "veto",
709 None => "session",
710 };
711 let (kind, why, counted_in) = match &placed {
712 crate::reach::Placed::Here(l) => {
713 let _ = append_jsonl(&lessons_path, &**l);
714 (if l.version > 1 { crate::stats::LESSON_MERGED } else { crate::stats::LESSON_NEW }, source, home.to_path_buf())
715 }
716 crate::reach::Placed::Private(store) => (crate::stats::LESSON_NEW, "personal", store.clone()),
717 crate::reach::Placed::Dropped => (crate::stats::NOT_A_LESSON, "personal", home.to_path_buf()),
718 };
719 crate::stats::count(&counted_in, kind, &e.session, |ev| {
720 ev.engineer = e.engineer.clone();
721 ev.repo = e.repo.clone();
722 ev.lessons = vec![l.id.clone()];
723 ev.why = why.into();
724 });
725 if e.kind == crate::veto::KIND {
726 let earlier = library.iter().find(|x| x.id == l.id);
728 if l.version > 1 && earlier.is_some_and(|o| o.episodes.iter().any(|id| id != &e.id && vetoes.contains(id))) {
729 crate::stats::count(&counted_in, crate::stats::VETO_REPEAT, &e.session, |ev| {
732 ev.ms = if e.ms > 0 { e.ms } else { ev.ms };
733 ev.engineer = e.engineer.clone();
734 ev.repo = e.repo.clone();
735 ev.lessons = vec![l.id.clone()];
736 ev.text = crate::stats::excerpt(&e.correction, 160);
737 });
738 }
739 if proposes(&placed, &invariant) {
740 let _ = crate::veto::propose(home, &l, &invariant, &e, now);
741 }
742 }
743 let target = (l.version > 1).then_some(l.id.as_str());
744 let track_results = track && e.kind != crate::veto::KIND;
747 let contradicted = match track_results {
748 true => outcome::contradicted(&outcomes, &e.session, e.ms, target, &l.supersedes),
749 false => Vec::new(),
750 };
751 for id in contradicted {
752 let o = outcome::Outcome {
753 kind: outcome::CORRECTED.into(),
754 session: e.session.clone(),
755 lessons: vec![id],
756 ms: e.ms,
757 repo: e.repo.clone(),
758 ..Default::default()
759 };
760 let _ = outcome::append(&outcomes_path, &o);
761 outcomes.push(o);
762 }
763 let missed = match track_results {
764 true => outcome::missed(&outcomes, &e.session, e.ms, target, &l.supersedes),
765 false => Vec::new(),
766 };
767 for id in missed {
768 let o = outcome::Outcome {
769 kind: outcome::MISSED.into(),
770 session: e.session.clone(),
771 lessons: vec![id],
772 ms: e.ms,
773 repo: e.repo.clone(),
774 ..Default::default()
775 };
776 let _ = outcome::append(&outcomes_path, &o);
777 outcomes.push(o);
778 }
779 library.retain(|x| x.id != l.id);
780 library.push(l);
781 }
782 Ok(None) => {
783 let _ = append_line(&skipped_path, &e.id);
784 crate::stats::count(home, crate::stats::NOT_A_LESSON, &e.session, |ev| ev.engineer = e.engineer.clone());
785 }
786 }
787 }
788 if track {
789 if regret_credit() {
790 credit_regrets(®rets, &lessons(&lessons_path), &mut outcomes, &outcomes_path);
791 }
792 let mut ended: Vec<String> =
793 outcome::idle_sessions(&outcomes, &last_episode, now).into_iter().filter(|s| !pending.contains(*s)).map(str::to_string).collect();
794 if let Some(s) = session.filter(|s| !ended.iter().any(|x| x == s) && !pending.contains(*s)) {
795 ended.push(s.to_string());
796 }
797 let state = std::env::var_os("BORG_STATE").map_or_else(|| home.join("state"), std::path::PathBuf::from);
798 for s in &ended {
799 if test_credit() {
802 let tests = std::fs::read_to_string(state.join(format!("{}.tests", crate::notes::safe(s)))).unwrap_or_default();
803 for o in outcome::tested(&outcomes, s, &tests) {
804 let _ = outcome::append(&outcomes_path, &o);
805 outcomes.push(o);
806 }
807 }
808 for o in outcome::settle(&outcomes, s, now) {
809 let _ = outcome::append(&outcomes_path, &o);
810 outcomes.push(o);
811 }
812 }
813 }
814 (new, merged, failed)
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn concurrent_jsonl_appends_keep_every_line() {
823 let dir = std::env::temp_dir().join(format!("borg-learn-append-{}", std::process::id()));
824 let _ = std::fs::remove_dir_all(&dir);
825 let p = dir.join("episodes.jsonl");
826 let (writers, each) = (8, 2000);
827 std::thread::scope(|scope| {
828 for w in 0..writers {
829 let p = &p;
830 scope.spawn(move || (0..each).for_each(|i| append_jsonl(p, &serde_json::json!({"w": w, "i": i})).unwrap()));
831 }
832 });
833 assert_eq!(read_jsonl::<Value>(&p).len(), writers * each);
834 let _ = std::fs::remove_dir_all(dir);
835 }
836
837 #[test]
838 fn a_regret_is_asked_with_its_own_prompt_and_the_shared_rules() {
839 let r = regret::Regret {
840 decision_task: "read the flag in b".into(),
841 fix_subject: "fix: take the lock".into(),
842 signals: vec!["subject".into()],
843 age_days: 3,
844 ..regret::Regret::default()
845 };
846 let e = Episode {
847 kind: regret::KIND.into(),
848 regret: Some(r),
849 task: "make the flag safe".into(),
850 ..ep("Rdddd-ffff", "Fixed later (3d, subject): fix: take the lock")
851 };
852 let shown = std::cell::RefCell::new(String::new());
853 let l = generalise(&e, &[], &|p| {
854 *shown.borrow_mut() = p.to_string();
855 Some(json!({"is_lesson": true, "question": "Before reading a shared flag without a lock, has a concurrent writer been considered?",
856 "situations": ["reading a shared flag across threads"], "consequence": "a race", "same_as": "", "area": "concurrency", "supersedes": []}))
857 })
858 .unwrap()
859 .unwrap();
860 let p = shown.into_inner();
861 assert!(p.starts_with("A coding agent made a decision in an earlier session") && p.contains("3 days later"), "{p}");
862 for part in [
863 regret::neutral(REACH_RULES).as_str(),
864 regret::FIELDS,
865 AREA,
866 regret::neutral(MERGE_RULES).as_str(),
867 "## The decision (task)\nread the flag in b",
868 "## The fix (subject, task)\nfix: take the lock\nmake the flag safe",
869 ] {
870 assert!(p.contains(part), "{part}");
871 }
872 assert!(!p.contains("## Engineer's correction") && !p.contains(OPENING) && !p.contains("correction"), "{p}");
873 assert_eq!((l.id.as_str(), l.area.as_str(), l.episodes.as_slice()), ("LRdddd-ffff", "concurrency", &["Rdddd-ffff".to_string()][..]));
874 assert!(episode_prompt(&ep("e1", "no, the lock"), &[]).starts_with(OPENING));
876 assert!(episode_prompt(&Episode { kind: regret::KIND.into(), ..ep("e2", "no") }, &[]).starts_with(OPENING));
877 }
878
879 fn fake_veto_ask(p: &str) -> Option<Value> {
881 let reason = p.split("## Engineer's veto\n").nth(1)?.lines().next()?;
882 let after = |k: &str| reason.split(k).nth(1).and_then(|r| r.split_whitespace().next()).unwrap_or("").to_string();
883 let invariant = reason.split("invariant:").nth(1).unwrap_or("").trim().to_string();
884 Some(json!({"is_lesson": true, "question": format!("Q {reason}"), "situations": [], "consequence": "c",
885 "same_as": after("merge:"), "supersedes": [], "invariant": invariant}))
886 }
887
888 fn veto(id: &str, session: &str, ms: i64, reason: &str) -> Episode {
889 Episode { kind: crate::veto::KIND.into(), ..at(id, session, ms, "ann@x", reason) }
890 }
891
892 #[test]
893 fn a_veto_is_asked_with_its_own_prompt_and_schema() {
894 let p = episode_prompt(&veto("no-s-1", "s", 1, "never read the commit log for merges"), &[]);
895 assert!(p.starts_with(crate::veto::OPENING), "{p}");
896 for part in [
897 "## Engineer's veto\nnever read the commit log for merges",
898 crate::veto::INVARIANT,
899 REACH_RULES,
900 FIELDS,
901 MERGE_RULES,
902 "## Task the agent had\nplan the relay",
903 ] {
904 assert!(p.contains(part), "{part}");
905 }
906 assert!(!p.contains(OPENING) && !p.contains("## Engineer's correction"));
907 let without = episode_prompt(&Episode { agent: String::new(), ..veto("no-s-2", "s", 1, "no") }, &[]);
908 assert!(without.contains("## What the agent proposed (excerpt)\n(not available)"), "{without}");
909 assert!(schema_for(&p)["required"].as_array().unwrap().contains(&json!("invariant")));
910 assert!(schema_for(&p)["properties"]["invariant"].is_object());
911 let correction = episode_prompt(&ep("e1", "no, the lock"), &[]);
912 assert_eq!(schema_for(&correction), schema(), "a correction's call is unchanged");
913 let (l, inv) =
915 generalise_with(&veto("no-s-3", "s", 1, "merge lookups invariant: lookups go through the review API"), &[], &fake_veto_ask).unwrap().unwrap();
916 assert_eq!((l.id.as_str(), inv.as_str()), ("Lno-s-3", "lookups go through the review API"));
917 let (_, none) = generalise_with(&ep("e2", "no, the lock merge:x"), &[], &fake_ask).unwrap().unwrap();
918 assert!(none.is_empty());
919 }
920
921 #[test]
922 fn a_veto_is_learned_at_any_sessions_end_and_never_counted_as_a_correction() {
923 let home = temp_home("veto-any");
924 crate::learn::record_episode_in(&home, &veto("no-s1-1", "s1", 1, "never read the commit log for merges")).unwrap();
925 append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 2, "ann@x", "a correction of s1")).unwrap();
926 assert!(!crate::stats::events(&home).iter().any(|e| e.kind == crate::stats::CORRECTION), "count_correction skips a veto");
927 assert_eq!(learn_in(&home, Some("s2"), &fake_veto_ask, true, 3), (1, 0, 0));
929 let ls = lessons(&home.join("lessons.jsonl"));
930 assert_eq!(ls.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(), ["Lno-s1-1"]);
931 let news: Vec<crate::stats::Event> = crate::stats::events(&home).into_iter().filter(|e| e.kind == crate::stats::LESSON_NEW).collect();
932 assert_eq!(news[0].why, "veto");
933 let _ = std::fs::remove_dir_all(home);
934 }
935
936 #[test]
937 fn a_veto_merged_into_an_earlier_vetos_lesson_is_a_repeat() {
938 let home = temp_home("veto-repeat");
939 let repeats = |home: &Path| crate::stats::events(home).iter().filter(|e| e.kind == crate::stats::VETO_REPEAT).count();
940 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
942 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-2", "s1", 2, "the lock again merge:Le0")).unwrap();
943 let asked = outcome::Outcome { kind: outcome::ASKED.into(), session: "s1".into(), lessons: vec!["Le0".into()], ms: 1, ..Default::default() };
945 outcome::append(&home.join("outcomes.jsonl"), &asked).unwrap();
946 learn_in(&home, None, &|p| if p.starts_with(crate::veto::OPENING) { fake_veto_ask(p) } else { fake_ask(p) }, true, 3);
947 assert_eq!(repeats(&home), 0);
948 let results = outcome::read(&home.join("outcomes.jsonl"));
949 assert!(!results.iter().any(|o| o.kind == outcome::CORRECTED || o.kind == outcome::MISSED), "{results:?}");
950 assert!(!crate::stats::events(&home).iter().any(|e| e.kind == crate::stats::CORRECTED));
951 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s2-4", "s2", 4, "the lock, third time merge:Le0")).unwrap();
953 assert_eq!(learn_in(&home, Some("s2"), &fake_veto_ask, true, 5), (0, 1, 0));
954 assert_eq!(repeats(&home), 1);
955 let r = crate::stats::events(&home).into_iter().find(|e| e.kind == crate::stats::VETO_REPEAT).unwrap();
956 assert_eq!((r.session.as_str(), r.lessons.clone()), ("s2", vec!["Le0".to_string()]));
957 assert_eq!(r.ms, 4, "dated when the veto was said, the week of its own veto");
958 let _ = std::fs::remove_dir_all(home);
959 }
960
961 #[test]
962 fn an_invariant_of_a_veto_becomes_a_proposed_constraint_never_a_constraint() {
963 let home = temp_home("veto-invariant");
964 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-1", "s1", 1, "no commit log invariant: merge lookups use the review API")).unwrap();
965 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-2", "s1", 2, "not now, later")).unwrap();
966 learn_in(&home, Some("s1"), &fake_veto_ask, true, 3);
967 let queue = crate::queen::entries(&home);
968 assert_eq!(queue.len(), 1, "only the veto that named an invariant");
969 assert_eq!((queue[0].kind, queue[0].session.as_str()), (crate::queen::Kind::Constraint, "s1"));
970 assert!(
971 queue[0].evidence.contains(&"invariant: merge lookups use the review API".to_string())
972 && queue[0].evidence.contains(&"lesson Lno-s1-1".to_string())
973 );
974 assert!(!crate::veto::constraints_path(&home).exists());
975 let l = Lesson { id: "L".into(), ..Lesson::default() };
977 assert!(proposes(&crate::reach::Placed::Here(Box::new(l)), "x") && !proposes(&crate::reach::Placed::Dropped, "x"));
978 assert!(!proposes(&crate::reach::Placed::Private(home.clone()), "x") && !proposes(&crate::reach::Placed::Here(Box::default()), " "));
979 let _ = std::fs::remove_dir_all(home);
980 }
981
982 #[test]
983 fn detects_corrections_and_rejections() {
984 assert!(is_correction("double check against spec document"));
985 assert!(is_correction("No, let's not move this into the net"));
986 assert!(!is_correction("let's pick up the gpt live plan"));
987 let err = "The user doesn't want to proceed with this tool use. The tool use was rejected. the user said:\ncarefully check TOCTOU\nNote: The user's next message may contain a correction";
988 assert_eq!(rejection(err).as_deref(), Some("carefully check TOCTOU"));
989 assert_eq!(rejection("Exit code 1\nTraceback"), None);
990 assert!(denied("Tool call denied by user: bash"));
992 assert!(denied("Tool call rejected by user (edit)"));
993 assert!(denied(
994 "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."
995 ));
996 assert!(!denied(err), "a rejection with words is captured directly");
997 assert!(!denied("Exit code 1\nTool call denied by user: bash"), "only the harness's own message, not tool output");
998 }
999
1000 fn ep(id: &str, correction: &str) -> Episode {
1001 Episode {
1002 id: id.into(),
1003 ms: 1,
1004 repo: "r".into(),
1005 session: "s".into(),
1006 task: "plan the relay".into(),
1007 agent: "one ReentrantLock guards delivery".into(),
1008 rejected_tool: None,
1009 correction: correction.into(),
1010 ..Episode::default()
1011 }
1012 }
1013
1014 fn fake_ask(p: &str) -> Option<Value> {
1017 let correction = p.split("## Engineer's correction\n").nth(1)?.lines().next()?;
1018 let after = |k: &str| correction.split(k).nth(1).and_then(|r| r.split_whitespace().next()).unwrap_or("").to_string();
1019 let supersedes: Vec<String> = Some(after("supersede:")).filter(|x| !x.is_empty()).into_iter().collect();
1020 Some(json!({"is_lesson": true, "question": format!("Q {correction}"), "situations": [], "consequence": "c",
1021 "same_as": after("merge:"), "supersedes": supersedes}))
1022 }
1023
1024 fn results(home: &Path) -> Vec<(String, String, String)> {
1025 outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind != outcome::ASKED).map(|o| (o.kind, o.session, o.lessons.join(","))).collect()
1026 }
1027
1028 #[test]
1029 fn every_model_call_is_reported_with_its_place_and_verdict() {
1030 let home = temp_home("progress");
1031 for e in [at("e0", "s0", 1, "ann@x", "use the lock on the flag"), at("e1", "s0", 2, "bob@x", "the lock again merge:Le0")] {
1032 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1033 }
1034 let mut steps = Vec::new();
1035 learn_in_reporting(&home, None, &fake_ask, false, 3, &mut |st| steps.push((st.done, st.total, st.verdict.split(':').next().unwrap().to_string())));
1036 assert_eq!(steps, [(1, 2, "new lesson Le0".to_string()), (2, 2, "merged into Le0".to_string())]);
1037 steps.clear();
1039 learn_in_reporting(&home, None, &fake_ask, false, 4, &mut |st| steps.push((st.done, st.total, String::new())));
1040 assert!(steps.is_empty());
1041 let _ = std::fs::remove_dir_all(home);
1042 }
1043
1044 #[test]
1045 fn a_lesson_contradicted_after_asking_is_corrected_not_followed() {
1046 let home = temp_home("contradicted");
1047 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
1048 learn_in(&home, Some("s0"), &fake_ask, true, 2);
1049 asked(&home, "s1", 10, &["Le0"]);
1051 append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 20, "bob@x", "no, CAS on the flag supersede:Le0")).unwrap();
1052 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (1, 0, 0));
1053 assert_eq!(results(&home), [(outcome::CORRECTED.into(), "s1".into(), "Le0".into())]);
1054 let _ = std::fs::remove_dir_all(home);
1055 }
1056
1057 #[test]
1058 fn a_correction_of_a_lesson_that_applied_but_was_not_asked_is_a_miss() {
1059 let home = temp_home("missed");
1060 for e in [at("e0", "s0", 1, "ann@x", "use the lock on the flag"), at("e1", "s0", 2, "bob@x", "retry with jitter")] {
1061 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1062 }
1063 learn_in(&home, Some("s0"), &fake_ask, true, 3);
1064 let graded = outcome::Outcome {
1067 kind: outcome::GRADED.into(),
1068 session: "s1".into(),
1069 lessons: vec!["Le0".into(), "Le1".into()],
1070 ms: 10,
1071 repo: "r".into(),
1072 grades: vec![2, 3],
1073 ranks: vec![0, 1],
1074 applied: 2,
1075 chosen: vec!["Le1".into()],
1076 ..Default::default()
1077 };
1078 outcome::append(&home.join("outcomes.jsonl"), &graded).unwrap();
1079 asked(&home, "s1", 11, &["Le1"]);
1080 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "cy@x", "no, the lock on the flag merge:Le0")).unwrap();
1081 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (0, 1, 0));
1082 let r = results(&home);
1083 assert!(r.contains(&(outcome::MISSED.into(), "s1".into(), "Le0".into())), "{r:?}");
1084 assert!(!r.iter().any(|(k, _, l)| k == outcome::MISSED && l == "Le1"), "{r:?}");
1086 learn_in(&home, Some("s1"), &fake_ask, true, 40);
1087 assert_eq!(results(&home).iter().filter(|(k, ..)| k == outcome::MISSED).count(), 1);
1088 let _ = std::fs::remove_dir_all(home);
1089 }
1090
1091 #[test]
1092 fn siding_with_one_side_of_a_disagreement_corrects_the_other() {
1093 let home = temp_home("sides");
1094 for e in [at("e0", "s0", 1, "ann@x", "use the lock on the flag"), at("e1", "s0", 2, "bob@x", "use CAS on the flag")] {
1095 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1096 }
1097 learn_in(&home, Some("s0"), &fake_ask, true, 3);
1098 let o = outcome::Outcome {
1100 kind: outcome::ASKED.into(),
1101 session: "s1".into(),
1102 lessons: vec!["Le0".into(), "Le1".into()],
1103 ms: 10,
1104 repo: "r".into(),
1105 groups: vec![vec!["Le0".into(), "Le1".into()]],
1106 ..Default::default()
1107 };
1108 outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
1109 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "cy@x", "no, the lock on the flag merge:Le0")).unwrap();
1110 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (0, 1, 0));
1111 assert_eq!(results(&home), [(outcome::CORRECTED.into(), "s1".into(), "Le1".into()), (outcome::NOT_CORRECTED.into(), "s1".into(), "Le0".into())]);
1112 let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1114 assert_eq!(l0.joined.get("cy@x"), Some(&20));
1115 let _ = std::fs::remove_dir_all(home);
1116 }
1117
1118 #[test]
1119 fn a_superseded_lesson_is_not_a_merge_target_its_successor_is_shown() {
1120 let lesson = |id: &str, ms: i64, q: &str, sup: &[&str]| Lesson {
1121 id: id.into(),
1122 origin_ms: ms,
1123 repo: "r".into(),
1124 question: q.into(),
1125 situations: vec![],
1126 consequence: "c".into(),
1127 support: 1,
1128 episodes: vec![],
1129 history: vec![],
1130 version: 1,
1131 engineers: vec!["ann@x".into()],
1132 joined: Default::default(),
1133 area: "concurrency".into(),
1134 supersedes: sup.iter().map(|s| s.to_string()).collect(),
1135 source: String::new(),
1136 scope: vec![],
1137 overrides: String::new(),
1138 origin: String::new(),
1139 facets: vec![],
1140 reach: String::new(),
1141 };
1142 let lib = [lesson("L0", 1, "Is the closed flag guarded by the ReentrantLock?", &[]), lesson("L1", 2, "Would a CAS suffice?", &["L0"])];
1143 let shown = std::cell::RefCell::new(String::new());
1146 let back = generalise(&ep("e9", "no, guard the closed flag with the ReentrantLock"), &lib, &|p| {
1147 *shown.borrow_mut() = p.to_string();
1148 Some(json!({"is_lesson": true, "question": "Is the closed flag guarded by the ReentrantLock?", "situations": [],
1149 "consequence": "c", "same_as": "L0", "area": "concurrency", "supersedes": ["L1"]}))
1150 })
1151 .unwrap()
1152 .unwrap();
1153 let shown = shown.into_inner();
1154 assert!(shown.contains("[L1]") && !shown.contains("[L0]"), "{shown}");
1155 assert_eq!((back.version, back.supersedes.clone()), (1, vec!["L1".to_string()]), "same_as on a hidden lesson is no merge");
1156 let all: Vec<Challenge> = lib
1157 .iter()
1158 .chain([&back])
1159 .map(|l| Challenge {
1160 id: l.id.clone(),
1161 origin_ms: l.origin_ms,
1162 question: l.question.clone(),
1163 engineers: l.engineers.clone(),
1164 supersedes: l.supersedes.clone(),
1165 ..Default::default()
1166 })
1167 .collect();
1168 assert_eq!(trust::successors(&all).get("L0").map(String::as_str), Some(back.id.as_str()));
1169 }
1170
1171 fn temp_home(name: &str) -> PathBuf {
1172 let d = std::env::temp_dir().join(format!("borg-learn-{name}-{}", std::process::id()));
1173 let _ = std::fs::remove_dir_all(&d);
1174 std::fs::create_dir_all(&d).unwrap();
1175 d
1176 }
1177
1178 fn at(id: &str, session: &str, ms: i64, engineer: &str, correction: &str) -> Episode {
1179 Episode { ms, session: session.into(), engineer: engineer.into(), ..ep(id, correction) }
1180 }
1181
1182 fn asked(home: &Path, session: &str, ms: i64, ids: &[&str]) {
1183 let o = outcome::Outcome {
1184 kind: outcome::ASKED.into(),
1185 session: session.into(),
1186 lessons: ids.iter().map(|i| i.to_string()).collect(),
1187 ms,
1188 repo: "r".into(),
1189 ..Default::default()
1190 };
1191 outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
1192 }
1193
1194 fn regret_at(id: &str, decision_ms: i64) -> Episode {
1195 let r = regret::Regret { decision_session: "s-dec".into(), decision_ms, signals: vec!["subject".into()], ..regret::Regret::default() };
1196 Episode { kind: regret::KIND.into(), regret: Some(r), ..at(id, "s-fix", 100, "bob@x", "Fixed later (3d, subject): fix: take the lock") }
1197 }
1198
1199 fn regret_ask(lesson: bool) -> impl Fn(&str) -> Option<Value> {
1201 move |_| {
1202 Some(json!({"is_lesson": lesson, "question": "Before reading a shared flag, has a writer been considered?", "situations": [],
1203 "consequence": "a race", "same_as": "", "supersedes": []}))
1204 }
1205 }
1206
1207 #[test]
1208 fn a_regret_blames_lessons_asked_before_the_decision_once() {
1209 let regretted = |home: &Path| -> Vec<(String, String, f64, String)> {
1210 outcome::read(&home.join("outcomes.jsonl"))
1211 .into_iter()
1212 .filter(|o| o.kind == outcome::REGRETTED)
1213 .map(|o| (o.lessons.join(","), o.session, o.credit, o.cause))
1214 .collect()
1215 };
1216 let setup = |name: &str, e: Episode| {
1217 let home = temp_home(name);
1218 asked(&home, "s-dec", 10, &["Le0", "Le1"]);
1219 asked(&home, "s-dec", 50, &["Le2"]);
1220 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1221 home
1222 };
1223 let home = setup("regret", regret_at("R1", 20));
1224 assert_eq!(learn_in(&home, None, ®ret_ask(true), true, 200), (1, 0, 0));
1225 let got = regretted(&home);
1226 assert_eq!(
1227 got,
1228 [("Le0".into(), "s-dec".into(), 0.25, "R1".into()), ("Le1".into(), "s-dec".into(), 0.25, "R1".into())],
1229 "Le2 was asked after the decision"
1230 );
1231 let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1232 learn_in(&home, None, ®ret_ask(true), true, 300);
1233 assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before, "credited once");
1234 let _ = std::fs::remove_dir_all(home);
1235 let home = setup("regret-no", regret_at("R1", 20));
1237 learn_in(&home, None, ®ret_ask(false), true, 200);
1238 assert!(regretted(&home).is_empty(), "not a lesson");
1239 let _ = std::fs::remove_dir_all(home);
1240 let home = setup("regret-old", regret_at("R1", 0));
1241 learn_in(&home, None, ®ret_ask(true), true, 200);
1242 assert!(regretted(&home).is_empty(), "a regret from before decision times were kept");
1243 let _ = std::fs::remove_dir_all(home);
1244 let home = setup("regret-untracked", regret_at("R1", 20));
1245 learn_in(&home, None, ®ret_ask(true), false, 200);
1246 assert!(regretted(&home).is_empty(), "BORG_LEARN=off writes nothing");
1247 let _ = std::fs::remove_dir_all(home);
1248 }
1249
1250 #[test]
1251 fn records_outcomes_of_asked_lessons() {
1252 let home = temp_home("outcomes");
1253 for e in [at("e0", "s0", 1, "ann@x", "check TOCTOU on the flag"), at("e1", "s0", 2, "", "check against the spec")] {
1255 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1256 }
1257 assert_eq!(learn_in(&home, Some("s0"), &fake_ask, true, 3), (2, 0, 0));
1258 assert!(outcome::read(&home.join("outcomes.jsonl")).is_empty(), "nothing asked, nothing settled");
1259 asked(&home, "s1", 10, &["Le0", "Le1"]);
1262 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "bob@x", "flag still racy merge:Le0")).unwrap();
1263 asked(&home, "s1", 30, &["Le1"]);
1264 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 40), (0, 1, 0));
1265 let result: Vec<(String, String, i64)> =
1266 outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind != outcome::ASKED).map(|o| (o.kind, o.lessons.join(","), o.ms)).collect();
1267 assert_eq!(result, [(outcome::CORRECTED.into(), "Le0".into(), 20), (outcome::NOT_CORRECTED.into(), "Le1".into(), 40)]);
1268 let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1270 learn_in(&home, Some("s1"), &fake_ask, true, 50);
1271 assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
1272 let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1274 assert_eq!(l0.engineers, ["ann@x", "bob@x"]);
1275 append_jsonl(&home.join("episodes.jsonl"), &at("e3", "s2", 60, "Ann@X", "flag racy again merge:Le0")).unwrap();
1277 assert_eq!(learn_in(&home, Some("s2"), &fake_ask, true, 70), (0, 1, 0));
1278 let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1279 assert_eq!((l0.engineers.as_slice(), l0.joined.get("Ann@X")), (&["ann@x".to_string(), "bob@x".to_string()][..], None));
1280 let _ = std::fs::remove_dir_all(home);
1281 }
1282
1283 #[test]
1284 fn idle_sessions_settle_without_their_end_hook() {
1285 let home = temp_home("idle");
1286 for e in [at("e0", "s0", 1, "", "check TOCTOU on the flag"), at("e1", "s0", 2, "", "check against the spec")] {
1287 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1288 }
1289 learn_in(&home, Some("s0"), &fake_ask, true, 3);
1290 asked(&home, "s1", 10, &["Le0", "Le1"]);
1292 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "", "flag still racy merge:Le0")).unwrap();
1293 asked(&home, "s3", 5, &["Le0"]);
1294 let idle = 20 + outcome::IDLE_MS;
1295 let results = || -> Vec<(String, String, String)> {
1296 outcome::read(&home.join("outcomes.jsonl"))
1297 .into_iter()
1298 .filter(|o| o.kind != outcome::ASKED)
1299 .map(|o| (o.kind, o.session, o.lessons.join(",")))
1300 .collect()
1301 };
1302 let not = |s: &str, l: &str| (outcome::NOT_CORRECTED.to_string(), s.to_string(), l.to_string());
1303 asked(&home, "s2", idle - 1, &["Le1"]);
1305 learn_in(&home, Some("s2"), &fake_ask, true, idle);
1306 assert_eq!(results(), [not("s3", "Le0"), not("s2", "Le1")]);
1307 assert_eq!(learn_in(&home, None, &fake_ask, true, idle), (0, 1, 0));
1309 let corrected = (outcome::CORRECTED.to_string(), "s1".to_string(), "Le0".to_string());
1310 assert_eq!(results(), [not("s3", "Le0"), not("s2", "Le1"), corrected.clone(), not("s1", "Le1")]);
1311 asked(&home, "s4", idle, &["Le1"]);
1313 learn_in(&home, None, &fake_ask, true, idle + 1);
1314 assert_eq!(results().len(), 4);
1315 let _ = std::fs::remove_dir_all(home);
1316 }
1317
1318 #[test]
1319 fn no_outcomes_written_without_tracking() {
1320 let home = temp_home("off");
1321 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "", "check TOCTOU")).unwrap();
1322 learn_in(&home, Some("s0"), &fake_ask, true, 2);
1323 asked(&home, "s1", 10, &["Le0"]);
1324 let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1325 append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 20, "", "TOCTOU again merge:Le0")).unwrap();
1326 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, false, 30), (0, 1, 0));
1327 assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
1328 let _ = std::fs::remove_dir_all(home);
1329 }
1330
1331 #[test]
1332 fn failed_model_calls_leave_episodes_waiting_and_stop_early() {
1333 let home = temp_home("failed");
1334 for i in 0..5 {
1335 append_jsonl(&home.join("episodes.jsonl"), &at(&format!("e{i}"), "s0", i, "", &format!("check case {i}"))).unwrap();
1336 }
1337 asked(&home, "s0", 0, &["Lx"]);
1338 let calls = std::cell::Cell::new(0);
1339 let down = |_: &str| -> Option<Value> {
1340 calls.set(calls.get() + 1);
1341 None
1342 };
1343 assert_eq!(learn_in(&home, Some("s0"), &down, true, 1 + outcome::IDLE_MS), (0, 0, 5));
1346 assert_eq!(calls.get(), MAX_FAILURES);
1347 assert!(!home.join("episodes-not-lessons.txt").exists());
1348 assert!(outcome::read(&home.join("outcomes.jsonl")).iter().all(|o| o.kind == outcome::ASKED));
1349 assert_eq!(learn_in(&home, Some("s0"), &fake_ask, true, 2 + outcome::IDLE_MS), (5, 0, 0));
1351 let _ = std::fs::remove_dir_all(home);
1352 }
1353
1354 #[test]
1355 fn area_and_supersedes_come_from_the_same_call() {
1356 let lib = vec![
1357 generalise(&ep("e1", "use a lock here"), &[], &|_| {
1358 Some(json!({"is_lesson": true, "question": "Is the flag guarded by a lock?", "situations": ["flag across threads"],
1359 "consequence": "race", "same_as": "", "area": "Concurrency", "supersedes": ["Lnope"]}))
1360 })
1361 .unwrap()
1362 .unwrap(),
1363 ];
1364 assert_eq!(lib[0].area, "concurrency", "case-insensitive; normalised");
1365 assert!(lib[0].supersedes.is_empty(), "an id the model was not shown is dropped");
1366 let old: Lesson = serde_json::from_str(
1368 r#"{"id":"L","origin_ms":1,"repo":"r","question":"q","situations":[],"consequence":"c",
1369 "support":1,"episodes":[],"history":[],"version":1}"#,
1370 )
1371 .unwrap();
1372 assert_eq!((old.area.as_str(), old.supersedes.len()), ("other", 0));
1373 let id = lib[0].id.clone();
1374 let reversed = generalise(&ep("e2", "no, the lock is wrong here, use the atomic flag"), &lib, &move |_| {
1375 Some(json!({"is_lesson": true, "question": "Is a lock-free atomic enough for the flag?", "situations": ["flag across threads"],
1376 "consequence": "lock contention", "same_as": "", "area": "made-up", "supersedes": [id.clone()]}))
1377 })
1378 .unwrap()
1379 .unwrap();
1380 assert_eq!((reversed.area.as_str(), reversed.supersedes.clone()), ("other", vec![lib[0].id.clone()]));
1381 assert!(!serde_json::to_string(&lib[0]).unwrap().contains("supersedes"), "empty supersedes stays off the line");
1382 let (rid, lid) = (reversed.id.clone(), lib[0].id.clone());
1384 let merged = generalise(&ep("e3", "again: atomic, not a lock"), &[lib[0].clone(), reversed.clone()], &move |_| {
1385 Some(json!({"is_lesson": true, "question": "Is a lock-free atomic enough?", "situations": ["flag across threads"],
1386 "consequence": "lock contention", "same_as": rid.clone(), "area": "concurrency", "supersedes": [rid.clone(), lid.clone()]}))
1387 })
1388 .unwrap()
1389 .unwrap();
1390 assert_eq!((merged.id.as_str(), merged.version, merged.supersedes.clone()), (reversed.id.as_str(), 2, vec![lib[0].id.clone()]));
1391 }
1392
1393 #[test]
1394 fn new_lesson_then_merge_keeps_history() {
1395 let first = generalise(&ep("e1", "carefully check TOCTOU on the atomic flags"), &[], &|_| {
1396 Some(json!({"is_lesson": true, "question": "Is every check-then-act on a shared flag TOCTOU-safe?",
1397 "situations": ["check-then-act on a shared mutable flag across threads"], "consequence": "race", "same_as": ""}))
1398 })
1399 .unwrap()
1400 .unwrap();
1401 assert_eq!((first.version, first.support), (1, 1));
1402 let id = first.id.clone();
1403 let merged = generalise(&ep("e2", "again: the closed flag check is racy"), std::slice::from_ref(&first), &move |p| {
1404 assert!(p.contains(&id), "the similar lesson must be offered for merging");
1405 Some(json!({"is_lesson": true, "question": "Is each check-then-act on shared flags atomic or harmless on race?",
1406 "situations": ["check-then-act on a shared mutable flag across threads"], "consequence": "race", "same_as": id}))
1407 })
1408 .unwrap()
1409 .unwrap();
1410 assert_eq!((merged.id.as_str(), merged.version, merged.support, merged.history.len()), (first.id.as_str(), 2, 2, 1));
1411 let skipped =
1412 generalise(&ep("e3", "stop"), &[], &|_| Some(json!({"is_lesson": false, "question": "", "situations": [], "consequence": "", "same_as": ""})));
1413 assert!(matches!(skipped, Ok(None)), "a verdict: not a lesson");
1414 assert_eq!(generalise(&ep("e4", "no, the lock"), &[], &|_| None).map(|_| ()), Err(AskFailed), "no answer is no verdict");
1415 let malformed = generalise(&ep("e5", "no, the lock"), &[], &|_| Some(json!({"error": "rate limited"})));
1416 assert_eq!(malformed.map(|_| ()), Err(AskFailed));
1417 }
1418}