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::runner::MAX_FAILURES;
42use crate::trust;
43
44pub mod regret;
45
46static CORRECTION: LazyLock<Regex> = LazyLock::new(|| {
47 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()
48});
49static REJECTION: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?is)want to proceed with this tool use.*?the user said:\s*(.*)").unwrap());
50static DENIAL: LazyLock<Regex> = LazyLock::new(|| {
53 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()
54});
55
56pub const EXCERPT_CHARS: usize = 1500;
60pub const TASK_CHARS: usize = 3000;
64pub const PROPOSAL_CHARS: usize = 2000;
67
68pub fn is_correction(prompt: &str) -> bool {
70 let t = prompt.trim();
71 t.len() > 3 && !t.starts_with('<') && CORRECTION.is_match(t)
72}
73
74pub fn rejection(error: &str) -> Option<String> {
76 let said = REJECTION.captures(error)?.get(1)?.as_str();
77 let said = said.split("Note: The user").next().unwrap_or(said).trim();
78 (said.len() > 3).then(|| said.chars().take(EXCERPT_CHARS).collect())
79}
80
81pub fn denied(error: &str) -> bool {
83 rejection(error).is_none() && DENIAL.is_match(error)
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct Episode {
88 pub id: String,
89 pub ms: i64,
90 pub repo: String,
91 pub session: String,
92 pub task: String,
93 pub agent: String,
94 pub rejected_tool: Option<String>,
95 pub correction: String,
96 #[serde(default)]
98 pub engineer: String,
99 #[serde(default, skip_serializing_if = "String::is_empty")]
101 pub kind: String,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub regret: Option<regret::Regret>,
104 #[serde(default, skip_serializing_if = "String::is_empty")]
107 pub via: String,
108 #[serde(default, skip_serializing_if = "String::is_empty")]
111 pub harness: String,
112 #[serde(default, skip_serializing_if = "String::is_empty")]
115 pub harness_version: String,
116}
117
118pub const ANSWER: &str = "answer";
120
121pub const SAME_MS: i64 = 5_000;
126
127pub fn transcript_id(session: &str, call: &str, ms: i64) -> String {
132 let s8: String = session.chars().take(8).collect();
133 if call.is_empty() { format!("{s8}-{ms}") } else { format!("{s8}-T{call}") }
134}
135
136fn words(t: &str) -> String {
139 t.split_whitespace().collect::<Vec<_>>().join(" ")
140}
141
142#[derive(Debug, Default)]
145pub struct Known {
146 ids: HashSet<String>,
147 sessions: HashMap<String, Vec<Held>>,
150}
151
152impl Known {
153 pub fn read(store: &Path) -> Self {
155 let mut k = Known::default();
156 for e in read_jsonl::<Episode>(&store.join("episodes.jsonl")) {
157 k.add(&e);
158 }
159 k
160 }
161
162 pub fn holds(&self, e: &Episode) -> bool {
169 if self.ids.contains(&e.id) {
170 return true;
171 }
172 let said = words(&e.correction);
173 let (prompt, answer) = (prompt_episode(e), e.via == ANSWER);
174 self.sessions
175 .get(&e.session)
176 .is_some_and(|seen| seen.iter().any(|h| (!answer && !h.from_call && h.words == said) || (prompt && h.prompt && (h.ms - e.ms).abs() < SAME_MS)))
177 }
178
179 pub fn add(&mut self, e: &Episode) {
181 self.ids.insert(e.id.clone());
182 let held = Held { ms: e.ms, prompt: prompt_episode(e), from_call: from_call(e), words: words(&e.correction) };
183 self.sessions.entry(e.session.clone()).or_default().push(held);
184 }
185}
186
187#[derive(Debug)]
189struct Held {
190 ms: i64,
191 prompt: bool,
192 from_call: bool,
193 words: String,
194}
195
196fn from_call(e: &Episode) -> bool {
199 let s8: String = e.session.chars().take(8).collect();
200 e.id.strip_prefix(&s8).is_some_and(|rest| rest.starts_with("-T"))
201}
202
203fn prompt_episode(e: &Episode) -> bool {
205 e.rejected_tool.is_none() && e.via.is_empty() && e.kind != regret::KIND
206}
207
208pub fn record_new_in(store: &Path, known: &mut Known, e: &Episode) -> std::io::Result<bool> {
214 if known.holds(e) {
215 return Ok(false);
216 }
217 record_episode_in(store, e)?;
218 known.add(e);
219 Ok(true)
220}
221
222#[derive(Debug, Clone, Default, Serialize, Deserialize)]
225pub struct Lesson {
226 pub id: String,
227 pub origin_ms: i64,
228 pub repo: String,
229 pub question: String,
230 pub situations: Vec<String>,
231 pub consequence: String,
232 pub support: u32,
233 pub episodes: Vec<String>,
234 pub history: Vec<Value>,
235 pub version: u32,
236 #[serde(default)]
238 pub engineers: Vec<String>,
239 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
242 pub joined: std::collections::BTreeMap<String, i64>,
243 #[serde(default = "other_area")]
245 pub area: String,
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub supersedes: Vec<String>,
249 #[serde(default, skip_serializing_if = "String::is_empty")]
252 pub source: String,
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub scope: Vec<String>,
258 #[serde(default, skip_serializing_if = "String::is_empty")]
263 pub overrides: String,
264 #[serde(default, skip_serializing_if = "String::is_empty")]
266 pub origin: String,
267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub facets: Vec<String>,
272 #[serde(default, skip_serializing_if = "String::is_empty")]
275 pub reach: String,
276}
277
278fn other_area() -> String {
279 trust::OTHER.into()
280}
281
282pub fn home() -> PathBuf {
283 std::env::var("BORG_HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/share/borg"))
284}
285
286pub(crate) fn append_jsonl<T: Serialize>(path: &Path, v: &T) -> std::io::Result<()> {
287 append_line(path, &serde_json::to_string(v)?)
288}
289
290pub(crate) fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
294 if let Some(d) = path.parent() {
295 std::fs::create_dir_all(d)?;
296 }
297 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
298 f.write_all(format!("{line}\n").as_bytes())
299}
300
301fn read_jsonl<T: for<'de> Deserialize<'de>>(path: &Path) -> Vec<T> {
302 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
303}
304
305pub fn learning() -> bool {
307 std::env::var("BORG_LEARN").as_deref() != Ok("off")
308}
309
310pub fn engineer(cwd: &str) -> String {
312 if cwd.is_empty() {
313 return String::new();
314 }
315 Command::new("git")
316 .args(["-C", cwd, "config", "user.email"])
317 .stdin(std::process::Stdio::null())
318 .stderr(std::process::Stdio::null())
319 .output()
320 .ok()
321 .filter(|o| o.status.success())
322 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
323 .unwrap_or_default()
324}
325
326pub fn record_episode(e: &Episode) -> std::io::Result<()> {
327 record_episode_in(&home(), e)
328}
329
330pub fn record_episode_in(store: &Path, e: &Episode) -> std::io::Result<()> {
335 append_jsonl(&store.join("episodes.jsonl"), e)?;
336 count_correction(store, e);
337 Ok(())
338}
339
340pub fn count_correction(store: &Path, e: &Episode) {
343 if e.kind == crate::veto::KIND {
345 return;
346 }
347 let snippet = crate::stats::excerpt(&e.correction, crate::stats::FEED_CHARS);
348 let (why, text) = match &e.rejected_tool {
349 _ if e.kind == regret::KIND => ("regret", snippet),
350 _ if e.via == ANSWER => (ANSWER, snippet),
351 Some(tool) => ("rejected_tool", format!("{tool}: {snippet}")),
352 None => ("pushback", snippet),
353 };
354 crate::stats::count(store, crate::stats::CORRECTION, &e.session, |ev| {
355 ev.ms = e.ms;
356 ev.engineer.clone_from(&e.engineer);
357 ev.repo.clone_from(&e.repo);
358 ev.why = why.into();
359 ev.text = text;
360 ev.source = crate::stats::source(&e.harness, &e.harness_version);
361 });
362}
363
364pub fn lessons(path: &Path) -> Vec<Lesson> {
366 let mut by: HashMap<String, Lesson> = HashMap::new();
367 for l in read_jsonl::<Lesson>(path) {
368 if by.get(&l.id).is_none_or(|o| o.version <= l.version) {
369 by.insert(l.id.clone(), l);
370 }
371 }
372 let mut v: Vec<Lesson> = by.into_values().collect();
373 v.sort_by_key(|l| l.origin_ms);
374 v
375}
376
377const OPENING: &str = "An engineer corrected a coding agent. Decide whether the correction expresses a reusable engineering \
379lesson: a critical concern about quality, design, correctness, reuse, spec conformance or root cause that this engineer would \
380plausibly raise again about similar future work. Operational remarks (\"still working?\", \"stop\", \"push\", \"restart\") are NOT lessons.";
381
382const REACH_RULES: &str = "Then decide its reach: who should hear it. Ask: would a teammate working on the same code get this correction too?
384- personal: how this one engineer likes the agent to work with them, not a property of the code: commit message style \
385or length, AI attribution or co-author trailers, whether and when to commit, push, branch or open a PR, what to stage, \
386how to report or ask, language and tone, their own tooling habits.
387- repo: a convention of the repo it happened in that other repos do not share (its branch or deploy workflow, a tool \
388or command it mandates).
389- team: everything else: an engineering concern about the code, design, correctness or verification.";
390
391const FIELDS: &str = "If it is a lesson, write:
392- question: ONE question the agent should ask itself next time, general enough to transfer, keeping the technical substance;
393- situations: 1-3 lines, each `<action> on/in <kind of state or component> [under <condition>]`, without project/class names;
394- consequence: one line, what goes wrong or what the engineer requires instead;";
395
396const AREA: &str = "- area: the one kind of concern: concurrency, spec-conformance, reuse-existing, architecture-boundary, verification, minimality, \
398protocol-correctness, data-source, security, dependency-version, scope, or other if none fits.";
399
400const 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) \
402and agrees with this correction, set same_as to its id and write the merged, improved wording in question/situations/consequence. \
403Otherwise same_as is empty.
404
405If this correction contradicts or replaces a listed lesson (in the same situation it requires something different or the \
406opposite, e.g. the engineer reversed an earlier correction), list that lesson's id in supersedes; never the same_as lesson. \
407Otherwise supersedes is empty.";
408
409fn episode_prompt(e: &Episode, listed: &[String]) -> String {
411 let existing = if listed.is_empty() { "(none)".into() } else { listed.join("\n") };
412 if e.kind == crate::veto::KIND {
413 let agent = if e.agent.trim().is_empty() { "(not available)" } else { e.agent.as_str() };
414 return format!(
415 "{}\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",
416 crate::veto::OPENING,
417 crate::veto::INVARIANT,
418 e.task.chars().take(EXCERPT_CHARS).collect::<String>(),
419 e.correction,
420 );
421 }
422 if let Some(r) = e.regret.as_ref().filter(|_| e.kind == regret::KIND) {
423 let (head, sections) = (regret::head(r), regret::sections(r, &e.task));
424 let (reach, merge) = (regret::neutral(REACH_RULES), regret::neutral(MERGE_RULES));
425 return format!("{head}\n\n{reach}\n\n{}\n{AREA}\n\n{merge}\n\n{sections}## Existing lessons\n{existing}\n", regret::FIELDS);
426 }
427 format!(
428 "{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",
429 e.task.chars().take(EXCERPT_CHARS).collect::<String>(),
430 e.agent,
431 if e.via == ANSWER { ANSWERED } else { e.rejected_tool.as_deref().unwrap_or("") },
432 e.correction,
433 )
434}
435
436const ANSWERED: &str = "(The agent asked these questions; the correction below is the engineer's answer. An answer that only picks \
438what fits this one task is NOT a lesson; one that states a rule, a preference or a reason that holds beyond it is.)";
439
440fn schema() -> Value {
441 json!({"type": "object", "additionalProperties": false, "properties": {
442 "is_lesson": {"type": "boolean"}, "question": {"type": "string"},
443 "situations": {"type": "array", "items": {"type": "string"}}, "consequence": {"type": "string"},
444 "same_as": {"type": "string"}, "area": {"type": "string", "enum": trust::AREAS},
445 "supersedes": {"type": "array", "items": {"type": "string"}},
446 "reach": {"type": "string", "enum": ["team", "repo", "personal"]}},
447 "required": ["is_lesson", "question", "situations", "consequence", "same_as", "area", "supersedes", "reach"]})
448}
449
450fn veto_schema() -> Value {
452 let mut s = schema();
453 s["properties"]["invariant"] = json!({"type": "string"});
454 if let Some(r) = s["required"].as_array_mut() {
455 r.push(json!("invariant"));
456 }
457 s
458}
459
460pub fn schema_for(prompt: &str) -> Value {
463 if prompt.starts_with(crate::veto::OPENING) { veto_schema() } else { schema() }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub struct AskFailed;
470
471struct Answer {
473 question: String,
474 situations: Vec<String>,
475 consequence: String,
476 area: String,
477 reach: String,
478 same_as: String,
479 supersedes: Vec<String>,
480 invariant: String,
482}
483
484impl Answer {
485 fn parse(v: &Value) -> Result<Option<Answer>, AskFailed> {
487 match v["is_lesson"].as_bool() {
488 Some(true) => {}
489 Some(false) => return Ok(None),
490 None => return Err(AskFailed),
491 }
492 let strs = |k: &str| v[k].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect::<Vec<_>>();
493 let text = |k: &str| v[k].as_str().unwrap_or_default().to_string();
494 let question = v["question"].as_str().filter(|q| !q.trim().is_empty()).ok_or(AskFailed)?.to_string();
495 let reach = v["reach"].as_str().filter(|r| [crate::reach::REPO, crate::reach::PERSONAL].contains(r)).unwrap_or_default();
496 Ok(Some(Answer {
497 question,
498 situations: strs("situations"),
499 consequence: text("consequence"),
500 area: trust::area(v["area"].as_str().unwrap_or_default()).to_string(),
501 reach: reach.to_string(),
502 same_as: text("same_as"),
503 supersedes: strs("supersedes"),
504 invariant: text("invariant"),
505 }))
506 }
507}
508
509const MERGE_CANDIDATES: usize = 5;
513
514fn similar<'a>(e: &Episode, library: &'a [Lesson]) -> Vec<&'a Lesson> {
516 let as_challenges: Vec<Challenge> = library
517 .iter()
518 .map(|l| Challenge {
519 id: l.id.clone(),
520 origin_ms: l.origin_ms,
521 question: format!("{} {}", l.question, l.situations.join(" ")),
522 situations: l.situations.clone(),
523 consequence: l.consequence.clone(),
524 supersedes: l.supersedes.clone(),
527 engineers: l.engineers.clone(),
528 source: l.source.clone(),
529 ..Default::default() })
531 .collect();
532 let query = format!("{} {} {}", e.task, e.agent, e.correction);
533 conscience::select(&as_challenges, &query, MERGE_CANDIDATES).iter().filter_map(|c| library.iter().find(|l| l.id == c.id)).collect()
534}
535
536fn merged(old: &Lesson, e: &Episode, a: Answer, mut supersedes: Vec<String>) -> Lesson {
538 let mut history = old.history.clone();
539 history.push(json!({"question": old.question, "consequence": old.consequence, "version": old.version}));
540 let mut episodes = old.episodes.clone();
541 episodes.push(e.id.clone());
542 let (mut engineers, mut joined) = (old.engineers.clone(), old.joined.clone());
543 let same = |x: &String| x.trim().to_lowercase() == e.engineer.trim().to_lowercase();
546 if !e.engineer.trim().is_empty() && !engineers.iter().any(same) {
547 engineers.push(e.engineer.clone());
548 joined.insert(e.engineer.clone(), e.ms);
549 }
550 for id in old.supersedes.iter().rev() {
551 if !supersedes.contains(id) {
552 supersedes.insert(0, id.clone());
553 }
554 }
555 Lesson {
556 question: a.question,
557 situations: a.situations,
558 consequence: a.consequence,
559 support: old.support + 1,
560 episodes,
561 history,
562 version: old.version + 1,
563 engineers,
564 joined,
565 area: a.area,
566 supersedes,
567 facets: Vec::new(),
569 reach: crate::reach::narrower(&old.reach, &a.reach).to_string(),
570 ..old.clone()
571 }
572}
573
574fn fresh(e: &Episode, a: Answer, supersedes: Vec<String>) -> Lesson {
576 Lesson {
577 id: format!("L{}", e.id),
578 origin_ms: e.ms,
579 repo: e.repo.clone(),
580 question: a.question,
581 situations: a.situations,
582 consequence: a.consequence,
583 support: 1,
584 episodes: vec![e.id.clone()],
585 version: 1,
586 engineers: Some(e.engineer.clone()).filter(|x| !x.is_empty()).into_iter().collect(),
587 area: a.area,
588 supersedes,
589 reach: a.reach,
590 ..Lesson::default()
591 }
592}
593
594pub fn generalise(e: &Episode, library: &[Lesson], ask: &dyn Fn(&str) -> Option<Value>) -> Result<Option<Lesson>, AskFailed> {
601 generalise_with(e, library, ask).map(|o| o.map(|(l, _)| l))
602}
603
604pub fn generalise_with(e: &Episode, library: &[Lesson], ask: &dyn Fn(&str) -> Option<Value>) -> Result<Option<(Lesson, String)>, AskFailed> {
609 let library: Vec<Lesson> = library.iter().filter(|l| l.reach != crate::reach::PERSONAL).cloned().collect();
611 let similar = similar(e, &library);
612 let listed: Vec<String> = similar.iter().map(|l| format!("[{}] {} -> {}", l.id, l.question, l.consequence)).collect();
613 let Some(a) = Answer::parse(&ask(&episode_prompt(e, &listed)).ok_or(AskFailed)?)? else { return Ok(None) };
614 let same_as = similar.iter().find(|l| a.same_as == l.id);
615 let supersedes: Vec<String> =
617 a.supersedes.iter().filter(|id| similar.iter().any(|l| &l.id == *id) && same_as.is_none_or(|m| &m.id != *id)).cloned().collect();
618 let invariant = a.invariant.trim().to_string();
619 Ok(Some((
620 match same_as {
621 Some(old) => merged(old, e, a, supersedes),
622 None => fresh(e, a, supersedes),
623 },
624 invariant,
625 )))
626}
627
628pub fn ask_claude(prompt: &str) -> Option<Value> {
630 ask_model(prompt, &schema_for(prompt)).map(|(v, _)| v)
631}
632
633pub fn ask_model(prompt: &str, schema: &Value) -> Option<(Value, Option<f64>)> {
636 let started = std::time::Instant::now();
637 let answer = call_model(prompt, schema);
638 crate::stats::count(&home(), crate::stats::MODEL_CALL, "", |ev| {
639 ev.why = crate::stats::purpose().into();
640 ev.usd = answer.as_ref().and_then(|(_, c)| *c).unwrap_or(0.0);
641 ev.dur_ms = started.elapsed().as_millis() as u64;
642 if answer.is_none() {
643 ev.text = "no answer".into();
644 }
645 });
646 answer
647}
648
649fn call_model(prompt: &str, schema: &Value) -> Option<(Value, Option<f64>)> {
650 let candidates = crate::runner::choose(std::env::var("BORG_LEARN_MODEL").ok().as_deref());
651 crate::runner::structured(&candidates, prompt, schema, learn_timeout())
652}
653
654fn learn_timeout() -> std::time::Duration {
657 std::time::Duration::from_secs(std::env::var("BORG_LEARN_TIMEOUT_S").ok().and_then(|v| v.parse().ok()).unwrap_or(300))
658}
659
660pub fn now_ms() -> i64 {
661 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
662}
663
664pub fn learn_pending(session: Option<&str>, ask: &dyn Fn(&str) -> Option<Value>) -> (usize, usize, usize) {
670 learn_in(&home(), session, ask, learning(), now_ms())
671}
672
673pub fn learn_in(home: &Path, session: Option<&str>, ask: &dyn Fn(&str) -> Option<Value>, track: bool, now: i64) -> (usize, usize, usize) {
682 learn_in_reporting(home, session, ask, track, now, &mut |_| {})
683}
684
685pub fn print_step(st: Step) {
687 let left = (st.total - st.done) as f64 * st.secs / 60.0;
688 let mut v: String = st.verdict.chars().take(110).collect();
689 if v.len() < st.verdict.len() {
690 v.push('…');
691 }
692 eprintln!("[{}/{} · {:.0} s · ~{:.0} min left] {v}", st.done, st.total, st.secs, left);
693}
694
695pub struct Step<'a> {
698 pub done: usize,
699 pub total: usize,
700 pub verdict: &'a str,
701 pub secs: f64,
702}
703
704fn proposes(placed: &crate::reach::Placed, invariant: &str) -> bool {
707 matches!(placed, crate::reach::Placed::Here(_)) && !invariant.trim().is_empty()
708}
709
710pub fn regret_credit() -> bool {
713 std::env::var("BORG_REGRET_CREDIT").as_deref() != Ok("off")
714}
715
716pub fn test_credit() -> bool {
719 learning() && std::env::var("BORG_TEST_CREDIT").as_deref() != Ok("off")
720}
721
722fn credit_regrets(regrets: &[Episode], library: &[Lesson], outcomes: &mut Vec<outcome::Outcome>, path: &Path, credit: bool) {
741 let mut holder: HashMap<&str, &Lesson> = HashMap::new();
742 for l in library {
743 for id in &l.episodes {
744 holder.entry(id.as_str()).or_insert(l);
745 }
746 }
747 let deciding: HashSet<&str> = regrets.iter().filter_map(|e| e.regret.as_ref()).map(|r| r.decision_session.as_str()).collect();
748 let mut by_session: HashMap<String, Vec<outcome::Outcome>> = HashMap::new();
749 for o in outcomes.iter().filter(|o| deciding.contains(o.session.as_str())) {
750 by_session.entry(o.session.clone()).or_default().push(o.clone());
751 }
752 for e in regrets {
753 let Some(r) = &e.regret else { continue };
754 let Some(holder) = holder.get(e.id.as_str()) else { continue };
755 let target: Vec<String> = std::iter::once(holder.id.clone()).chain(holder.supersedes.iter().cloned()).collect();
756 let records = by_session.entry(r.decision_session.clone()).or_default();
757 let blamed = match credit {
758 true => outcome::regretted(records, &r.decision_session, r.decision_ms, &e.id, &target, regret::strength(r), e.ms, &e.repo),
759 false => Vec::new(),
760 };
761 let missed = match r.decision_ms > 0 && !r.decision_session.is_empty() {
762 true => outcome::missed(records, &r.decision_session, r.decision_ms, Some(&holder.id), &holder.supersedes),
763 false => Vec::new(),
764 };
765 let missed = missed.into_iter().map(|l| outcome::Outcome {
766 kind: outcome::MISSED.into(),
767 session: r.decision_session.clone(),
768 lessons: vec![l],
769 ms: r.decision_ms,
770 repo: e.repo.clone(),
771 cause: e.id.clone(),
772 ..Default::default()
773 });
774 for o in blamed.into_iter().chain(missed.collect::<Vec<_>>()) {
775 let _ = outcome::append(path, &o);
776 records.push(o.clone());
777 outcomes.push(o);
778 }
779 }
780}
781
782fn open(ctx: &Path, now: i64) -> bool {
785 let ms = std::fs::metadata(ctx).and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as i64);
786 ms.is_some_and(|ms| ms > now - outcome::IDLE_MS)
787}
788
789fn quiet(
795 episodes: &[Episode],
796 session: Option<&str>,
797 done: &HashSet<String>,
798 skipped: &HashSet<String>,
799 now: i64,
800 is_open: impl Fn(&str) -> bool,
801) -> HashSet<String> {
802 let Some(session) = session else { return HashSet::new() };
803 let mut newest: HashMap<&str, i64> = HashMap::new();
804 for e in episodes {
805 let n = newest.entry(e.session.as_str()).or_insert(e.ms);
806 *n = (*n).max(e.ms);
807 }
808 let waiting: HashSet<&str> =
809 episodes.iter().filter(|e| e.session != session && !done.contains(&e.id) && !skipped.contains(&e.id)).map(|e| e.session.as_str()).collect();
810 waiting.into_iter().filter(|s| newest[s] <= now - outcome::IDLE_MS && !is_open(s)).map(str::to_string).collect()
811}
812
813const RETIRED: [&str; 2] = ["group", "rework"];
815
816pub fn learn_in_reporting(
823 home: &Path,
824 session: Option<&str>,
825 ask: &dyn Fn(&str) -> Option<Value>,
826 track: bool,
827 now: i64,
828 report: &mut dyn FnMut(Step),
829) -> (usize, usize, usize) {
830 crate::live::locked(&home.join("lessons.jsonl"), || learn_unlocked(home, session, ask, track, now, report))
831}
832
833fn learn_unlocked(
834 home: &Path,
835 session: Option<&str>,
836 ask: &dyn Fn(&str) -> Option<Value>,
837 track: bool,
838 now: i64,
839 report: &mut dyn FnMut(Step),
840) -> (usize, usize, usize) {
841 let (ep_path, lessons_path) = (home.join("episodes.jsonl"), home.join("lessons.jsonl"));
842 let outcomes_path = outcome::path_beside(&lessons_path);
843 let mut outcomes = if track { outcome::read(&outcomes_path) } else { Vec::new() };
844 let mut library = lessons(&lessons_path);
845 let done: std::collections::HashSet<String> = library.iter().flat_map(|l| l.episodes.clone()).collect();
846 let skipped_path = home.join("episodes-not-lessons.txt");
847 let skipped: std::collections::HashSet<String> = std::fs::read_to_string(&skipped_path).unwrap_or_default().lines().map(str::to_string).collect();
848 let (mut new, mut merged, mut failed, mut failures_in_row) = (0, 0, 0, 0);
849 let mut last_episode: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
851 let mut pending: std::collections::HashSet<String> = std::collections::HashSet::new();
852 let episodes: Vec<Episode> = read_jsonl::<Episode>(&ep_path).into_iter().filter(|e| !RETIRED.contains(&e.kind.as_str())).collect();
854 let regrets: Vec<Episode> = episodes.iter().filter(|e| e.kind == regret::KIND && e.regret.is_some()).cloned().collect();
855 let state = std::env::var_os("BORG_STATE").map_or_else(|| home.join("state"), std::path::PathBuf::from);
856 let ctx = |s: &str| state.join(format!("{}.ctx", crate::notes::safe(s)));
857 let quiet = quiet(&episodes, session, &done, &skipped, now, |s| open(&ctx(s), now));
860 let mine = |e: &Episode| session.is_none_or(|s| s == e.session) || e.kind == crate::veto::KIND || quiet.contains(&e.session);
861 let vetoes: HashSet<String> = episodes.iter().filter(|e| e.kind == crate::veto::KIND).map(|e| e.id.clone()).collect();
862 let total = episodes.iter().filter(|e| !done.contains(&e.id) && !skipped.contains(&e.id) && mine(e)).count();
863 let mut asked = 0;
864 for e in episodes {
865 let last = last_episode.entry(e.session.clone()).or_insert(e.ms);
866 *last = (*last).max(e.ms);
867 if done.contains(&e.id) || skipped.contains(&e.id) {
868 continue;
869 }
870 if !mine(&e) {
871 pending.insert(e.session.clone());
872 continue;
873 }
874 if failures_in_row >= MAX_FAILURES {
875 failed += 1;
876 pending.insert(e.session.clone());
877 continue;
878 }
879 let started = std::time::Instant::now();
880 let verdict = generalise_with(&e, &library, ask);
881 asked += 1;
882 let what = match &verdict {
883 Err(AskFailed) => "no answer from the model, left pending".to_string(),
884 Ok(Some((l, _))) if l.version > 1 => format!("merged into {}: {}", l.id, l.question),
885 Ok(Some((l, _))) => format!("new lesson {}: {}", l.id, l.question),
886 Ok(None) => "not a lesson".to_string(),
887 };
888 report(Step { done: asked, total, verdict: &what, secs: started.elapsed().as_secs_f64() });
889 failures_in_row = if verdict.is_err() { failures_in_row + 1 } else { 0 };
890 match verdict {
891 Err(AskFailed) => {
892 failed += 1;
893 pending.insert(e.session.clone());
894 }
895 Ok(Some((l, invariant))) => {
896 if l.version > 1 {
897 merged += 1
898 } else {
899 new += 1
900 }
901 let placed = crate::reach::place(home, &l);
903 let source = match &e.regret {
905 Some(r) if regret::is_review(r) => "review",
906 Some(_) => "regret",
907 None if e.kind == crate::veto::KIND => "veto",
908 None => "session",
909 };
910 let (kind, why, counted_in) = match &placed {
911 crate::reach::Placed::Here(l) => {
912 let _ = append_jsonl(&lessons_path, &**l);
913 (if l.version > 1 { crate::stats::LESSON_MERGED } else { crate::stats::LESSON_NEW }, source, home.to_path_buf())
914 }
915 crate::reach::Placed::Private(store) => (crate::stats::LESSON_NEW, "personal", store.clone()),
916 crate::reach::Placed::Dropped => (crate::stats::NOT_A_LESSON, "personal", home.to_path_buf()),
917 };
918 crate::stats::count(&counted_in, kind, &e.session, |ev| {
919 ev.engineer = e.engineer.clone();
920 ev.repo = e.repo.clone();
921 ev.lessons = vec![l.id.clone()];
922 ev.why = why.into();
923 });
924 if e.kind == crate::veto::KIND {
925 let earlier = library.iter().find(|x| x.id == l.id);
927 if l.version > 1 && earlier.is_some_and(|o| o.episodes.iter().any(|id| id != &e.id && vetoes.contains(id))) {
928 crate::stats::count(&counted_in, crate::stats::VETO_REPEAT, &e.session, |ev| {
931 ev.ms = if e.ms > 0 { e.ms } else { ev.ms };
932 ev.engineer = e.engineer.clone();
933 ev.repo = e.repo.clone();
934 ev.lessons = vec![l.id.clone()];
935 ev.text = crate::stats::excerpt(&e.correction, crate::stats::FEED_CHARS);
936 });
937 }
938 if proposes(&placed, &invariant) {
939 let _ = crate::veto::propose(home, &l, &invariant, &e, now);
940 }
941 }
942 let target = (l.version > 1).then_some(l.id.as_str());
943 let track_results = track && e.kind != crate::veto::KIND;
946 let contradicted = match track_results {
947 true => outcome::contradicted(&outcomes, &e.session, e.ms, target, &l.supersedes),
948 false => Vec::new(),
949 };
950 for id in contradicted {
951 let o = outcome::Outcome {
952 kind: outcome::CORRECTED.into(),
953 session: e.session.clone(),
954 lessons: vec![id],
955 ms: e.ms,
956 repo: e.repo.clone(),
957 ..Default::default()
958 };
959 let _ = outcome::append(&outcomes_path, &o);
960 outcomes.push(o);
961 }
962 let missed = match track_results {
963 true => outcome::missed(&outcomes, &e.session, e.ms, target, &l.supersedes),
964 false => Vec::new(),
965 };
966 for id in missed {
967 let o = outcome::Outcome {
968 kind: outcome::MISSED.into(),
969 session: e.session.clone(),
970 lessons: vec![id],
971 ms: e.ms,
972 repo: e.repo.clone(),
973 ..Default::default()
974 };
975 let _ = outcome::append(&outcomes_path, &o);
976 outcomes.push(o);
977 }
978 library.retain(|x| x.id != l.id);
979 library.push(l);
980 }
981 Ok(None) => {
982 let _ = append_line(&skipped_path, &e.id);
983 crate::stats::count(home, crate::stats::NOT_A_LESSON, &e.session, |ev| ev.engineer = e.engineer.clone());
984 }
985 }
986 }
987 if track {
988 credit_regrets(®rets, &lessons(&lessons_path), &mut outcomes, &outcomes_path, regret_credit());
989 let mut ended: Vec<String> = outcome::idle_sessions(&outcomes, &last_episode, now)
992 .into_iter()
993 .filter(|s| !pending.contains(*s) && !open(&ctx(s), now))
994 .map(str::to_string)
995 .collect();
996 if let Some(s) = session.filter(|s| !ended.iter().any(|x| x == s) && !pending.contains(*s)) {
997 ended.push(s.to_string());
998 }
999 for s in &ended {
1000 if test_credit() {
1003 let tests = std::fs::read_to_string(state.join(format!("{}.tests", crate::notes::safe(s)))).unwrap_or_default();
1004 for o in outcome::tested(&outcomes, s, &tests) {
1005 let _ = outcome::append(&outcomes_path, &o);
1006 outcomes.push(o);
1007 }
1008 }
1009 let why = crate::hook::unjudged(&std::fs::read_to_string(ctx(s)).unwrap_or_default());
1012 for o in outcome::settle_with(&outcomes, s, now, &|l| why.get(l).copied().unwrap_or(outcome::UNJUDGED_NO_CALL)) {
1013 let _ = outcome::append(&outcomes_path, &o);
1014 outcomes.push(o);
1015 }
1016 }
1017 }
1018 (new, merged, failed)
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 use super::*;
1024
1025 #[test]
1026 fn concurrent_jsonl_appends_keep_every_line() {
1027 let dir = std::env::temp_dir().join(format!("borg-learn-append-{}", std::process::id()));
1028 let _ = std::fs::remove_dir_all(&dir);
1029 let p = dir.join("episodes.jsonl");
1030 let (writers, each) = (8, 2000);
1031 std::thread::scope(|scope| {
1032 for w in 0..writers {
1033 let p = &p;
1034 scope.spawn(move || (0..each).for_each(|i| append_jsonl(p, &serde_json::json!({"w": w, "i": i})).unwrap()));
1035 }
1036 });
1037 assert_eq!(read_jsonl::<Value>(&p).len(), writers * each);
1038 let _ = std::fs::remove_dir_all(dir);
1039 }
1040
1041 #[test]
1042 fn a_regret_is_asked_with_its_own_prompt_and_the_shared_rules() {
1043 let r = regret::Regret {
1044 decision_task: "read the flag in b".into(),
1045 fix_subject: "fix: take the lock".into(),
1046 signals: vec!["subject".into()],
1047 age_days: 3,
1048 ..regret::Regret::default()
1049 };
1050 let e = Episode {
1051 kind: regret::KIND.into(),
1052 regret: Some(r),
1053 task: "make the flag safe".into(),
1054 ..ep("Rdddd-ffff", "Fixed later (3d, subject): fix: take the lock")
1055 };
1056 let shown = std::cell::RefCell::new(String::new());
1057 let l = generalise(&e, &[], &|p| {
1058 *shown.borrow_mut() = p.to_string();
1059 Some(json!({"is_lesson": true, "question": "Before reading a shared flag without a lock, has a concurrent writer been considered?",
1060 "situations": ["reading a shared flag across threads"], "consequence": "a race", "same_as": "", "area": "concurrency", "supersedes": []}))
1061 })
1062 .unwrap()
1063 .unwrap();
1064 let p = shown.into_inner();
1065 assert!(p.starts_with("A coding agent made a decision in an earlier session") && p.contains("3 days later"), "{p}");
1066 for part in [
1067 regret::neutral(REACH_RULES).as_str(),
1068 regret::FIELDS,
1069 AREA,
1070 regret::neutral(MERGE_RULES).as_str(),
1071 "## The decision (task)\nread the flag in b",
1072 "## The fix (subject, task)\nfix: take the lock\nmake the flag safe",
1073 ] {
1074 assert!(p.contains(part), "{part}");
1075 }
1076 assert!(!p.contains("## Engineer's correction") && !p.contains(OPENING) && !p.contains("correction"), "{p}");
1077 assert_eq!((l.id.as_str(), l.area.as_str(), l.episodes.as_slice()), ("LRdddd-ffff", "concurrency", &["Rdddd-ffff".to_string()][..]));
1078 assert!(episode_prompt(&ep("e1", "no, the lock"), &[]).starts_with(OPENING));
1080 assert!(episode_prompt(&Episode { kind: regret::KIND.into(), ..ep("e2", "no") }, &[]).starts_with(OPENING));
1081 }
1082
1083 fn fake_veto_ask(p: &str) -> Option<Value> {
1085 let reason = p.split("## Engineer's veto\n").nth(1)?.lines().next()?;
1086 let after = |k: &str| reason.split(k).nth(1).and_then(|r| r.split_whitespace().next()).unwrap_or("").to_string();
1087 let invariant = reason.split("invariant:").nth(1).unwrap_or("").trim().to_string();
1088 Some(json!({"is_lesson": true, "question": format!("Q {reason}"), "situations": [], "consequence": "c",
1089 "same_as": after("merge:"), "supersedes": [], "invariant": invariant}))
1090 }
1091
1092 fn veto(id: &str, session: &str, ms: i64, reason: &str) -> Episode {
1093 Episode { kind: crate::veto::KIND.into(), ..at(id, session, ms, "ann@x", reason) }
1094 }
1095
1096 #[test]
1097 fn a_veto_is_asked_with_its_own_prompt_and_schema() {
1098 let p = episode_prompt(&veto("no-s-1", "s", 1, "never read the commit log for merges"), &[]);
1099 assert!(p.starts_with(crate::veto::OPENING), "{p}");
1100 for part in [
1101 "## Engineer's veto\nnever read the commit log for merges",
1102 crate::veto::INVARIANT,
1103 REACH_RULES,
1104 FIELDS,
1105 MERGE_RULES,
1106 "## Task the agent had\nplan the relay",
1107 ] {
1108 assert!(p.contains(part), "{part}");
1109 }
1110 assert!(!p.contains(OPENING) && !p.contains("## Engineer's correction"));
1111 let without = episode_prompt(&Episode { agent: String::new(), ..veto("no-s-2", "s", 1, "no") }, &[]);
1112 assert!(without.contains("## What the agent proposed (excerpt)\n(not available)"), "{without}");
1113 assert!(schema_for(&p)["required"].as_array().unwrap().contains(&json!("invariant")));
1114 assert!(schema_for(&p)["properties"]["invariant"].is_object());
1115 let correction = episode_prompt(&ep("e1", "no, the lock"), &[]);
1116 assert_eq!(schema_for(&correction), schema(), "a correction's call is unchanged");
1117 let (l, inv) =
1119 generalise_with(&veto("no-s-3", "s", 1, "merge lookups invariant: lookups go through the review API"), &[], &fake_veto_ask).unwrap().unwrap();
1120 assert_eq!((l.id.as_str(), inv.as_str()), ("Lno-s-3", "lookups go through the review API"));
1121 let (_, none) = generalise_with(&ep("e2", "no, the lock merge:x"), &[], &fake_ask).unwrap().unwrap();
1122 assert!(none.is_empty());
1123 }
1124
1125 #[test]
1126 fn a_veto_is_learned_at_any_sessions_end_and_never_counted_as_a_correction() {
1127 let home = temp_home("veto-any");
1128 crate::learn::record_episode_in(&home, &veto("no-s1-1", "s1", 1, "never read the commit log for merges")).unwrap();
1129 append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 2, "ann@x", "a correction of s1")).unwrap();
1130 assert!(!crate::stats::events(&home).iter().any(|e| e.kind == crate::stats::CORRECTION), "count_correction skips a veto");
1131 assert_eq!(learn_in(&home, Some("s2"), &fake_veto_ask, true, 3), (1, 0, 0));
1133 let ls = lessons(&home.join("lessons.jsonl"));
1134 assert_eq!(ls.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(), ["Lno-s1-1"]);
1135 let news: Vec<crate::stats::Event> = crate::stats::events(&home).into_iter().filter(|e| e.kind == crate::stats::LESSON_NEW).collect();
1136 assert_eq!(news[0].why, "veto");
1137 let _ = std::fs::remove_dir_all(home);
1138 }
1139
1140 #[test]
1141 fn a_veto_merged_into_an_earlier_vetos_lesson_is_a_repeat() {
1142 let home = temp_home("veto-repeat");
1143 let repeats = |home: &Path| crate::stats::events(home).iter().filter(|e| e.kind == crate::stats::VETO_REPEAT).count();
1144 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
1146 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-2", "s1", 2, "the lock again merge:Le0")).unwrap();
1147 let asked = outcome::Outcome { kind: outcome::ASKED.into(), session: "s1".into(), lessons: vec!["Le0".into()], ms: 1, ..Default::default() };
1149 outcome::append(&home.join("outcomes.jsonl"), &asked).unwrap();
1150 learn_in(&home, None, &|p| if p.starts_with(crate::veto::OPENING) { fake_veto_ask(p) } else { fake_ask(p) }, true, 3);
1151 assert_eq!(repeats(&home), 0);
1152 let results = outcome::read(&home.join("outcomes.jsonl"));
1153 assert!(!results.iter().any(|o| o.kind == outcome::CORRECTED || o.kind == outcome::MISSED), "{results:?}");
1154 assert!(!crate::stats::events(&home).iter().any(|e| e.kind == crate::stats::CORRECTED));
1155 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s2-4", "s2", 4, "the lock, third time merge:Le0")).unwrap();
1157 assert_eq!(learn_in(&home, Some("s2"), &fake_veto_ask, true, 5), (0, 1, 0));
1158 assert_eq!(repeats(&home), 1);
1159 let r = crate::stats::events(&home).into_iter().find(|e| e.kind == crate::stats::VETO_REPEAT).unwrap();
1160 assert_eq!((r.session.as_str(), r.lessons.clone()), ("s2", vec!["Le0".to_string()]));
1161 assert_eq!(r.ms, 4, "dated when the veto was said, the week of its own veto");
1162 let _ = std::fs::remove_dir_all(home);
1163 }
1164
1165 #[test]
1166 fn an_invariant_of_a_veto_becomes_a_proposed_constraint_never_a_constraint() {
1167 let home = temp_home("veto-invariant");
1168 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-1", "s1", 1, "no commit log invariant: merge lookups use the review API")).unwrap();
1169 append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-2", "s1", 2, "not now, later")).unwrap();
1170 learn_in(&home, Some("s1"), &fake_veto_ask, true, 3);
1171 let queue = crate::queen::entries(&home);
1172 assert_eq!(queue.len(), 1, "only the veto that named an invariant");
1173 assert_eq!((queue[0].kind, queue[0].session.as_str()), (crate::queen::Kind::Constraint, "s1"));
1174 assert!(
1175 queue[0].evidence.contains(&"invariant: merge lookups use the review API".to_string())
1176 && queue[0].evidence.contains(&"lesson Lno-s1-1".to_string())
1177 );
1178 assert!(!crate::veto::constraints_path(&home).exists());
1179 let l = Lesson { id: "L".into(), ..Lesson::default() };
1181 assert!(proposes(&crate::reach::Placed::Here(Box::new(l)), "x") && !proposes(&crate::reach::Placed::Dropped, "x"));
1182 assert!(!proposes(&crate::reach::Placed::Private(home.clone()), "x") && !proposes(&crate::reach::Placed::Here(Box::default()), " "));
1183 let _ = std::fs::remove_dir_all(home);
1184 }
1185
1186 #[test]
1187 fn detects_corrections_and_rejections() {
1188 assert!(is_correction("double check against spec document"));
1189 assert!(is_correction("No, let's not move this into the net"));
1190 assert!(!is_correction("let's pick up the gpt live plan"));
1191 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";
1192 assert_eq!(rejection(err).as_deref(), Some("carefully check TOCTOU"));
1193 assert_eq!(rejection("Exit code 1\nTraceback"), None);
1194 assert!(denied("Tool call denied by user: bash"));
1196 assert!(denied("Tool call rejected by user (edit)"));
1197 assert!(denied(
1198 "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."
1199 ));
1200 assert!(!denied(err), "a rejection with words is captured directly");
1201 assert!(!denied("Exit code 1\nTool call denied by user: bash"), "only the harness's own message, not tool output");
1202 }
1203
1204 #[test]
1205 fn known_holds_an_id_the_same_words_or_a_prompt_moments_apart() {
1206 let at = |id: &str, ms: i64, tool: Option<&str>, said: &str| Episode {
1207 id: id.into(),
1208 ms,
1209 session: "s1".into(),
1210 rejected_tool: tool.map(String::from),
1211 correction: said.into(),
1212 ..Episode::default()
1213 };
1214 let mut k = Known::default();
1215 k.add(&at("s1-100000", 100_000, Some("Edit {}"), "use backoff,\n not a fixed sleep"));
1217 k.add(&at("s1-200000", 200_000, None, "no, the other crate"));
1218 assert!(k.holds(&at("s1-Ttoolu_1", 70_000, Some("Edit {}"), "use backoff, not a fixed sleep")), "same words, whitespace aside");
1219 assert!(k.holds(&at("s1-100000", 1, None, "anything")), "same id");
1220 assert!(k.holds(&at("s1-201500", 201_500, None, "no, the other crate!")), "a prompt within SAME_MS");
1221 assert!(!k.holds(&at("s1-Ttoolu_2", 201_000, Some("Bash {}"), "keep the cache")), "a tool call's episode needs its id or its words");
1222 assert!(!k.holds(&Episode { via: ANSWER.into(), ..at("s1-Ttoolu_3", 200_500, None, "Exponential") }), "nor does an answer");
1223 assert!(!k.holds(&Episode { session: "s2".into(), ..at("s2-Tx", 100_000, Some("Edit {}"), "use backoff, not a fixed sleep") }));
1224 let mut t = Known::default();
1227 t.add(&at("s1-Ttoolu_4", 300_000, Some("Edit {}"), "keep it in the relay crate"));
1228 t.add(&Episode { via: ANSWER.into(), ..at("s1-Ttoolu_5", 400_000, None, "Create, no parent") });
1229 assert!(!t.holds(&at("s1-Ttoolu_6", 900_000, Some("Edit {}"), "keep it in the relay crate")), "another rejected call");
1230 assert!(!t.holds(&Episode { via: ANSWER.into(), ..at("s1-Ttoolu_7", 900_000, None, "Create, no parent") }), "another question");
1231 assert!(!k.holds(&Episode { via: ANSWER.into(), ..at("s1-Ttoolu_8", 900_000, None, "no, the other crate") }));
1233 assert_eq!(transcript_id("abcdefghij", "toolu_9", 5), "abcdefgh-Ttoolu_9");
1234 assert_eq!(transcript_id("abcdefghij", "", 5), "abcdefgh-5");
1235
1236 let store = std::env::temp_dir().join(format!("borg-known-{}", std::process::id()));
1238 let _ = std::fs::remove_dir_all(&store);
1239 std::fs::create_dir_all(&store).unwrap();
1240 let mut known = Known::read(&store);
1241 let e = at("s1-Tq", 5, None, "Exponential");
1242 assert!(record_new_in(&store, &mut known, &e).unwrap());
1243 assert!(!record_new_in(&store, &mut known, &e).unwrap());
1244 assert!(Known::read(&store).holds(&e));
1245 assert_eq!(read_jsonl::<Episode>(&store.join("episodes.jsonl")).len(), 1);
1246 let _ = std::fs::remove_dir_all(&store);
1247 }
1248
1249 #[test]
1250 fn an_answer_is_asked_as_an_answer_and_counted_as_one() {
1251 let answer = Episode { via: ANSWER.into(), agent: "Which backoff?\n options: Exponential | Linear".into(), ..ep("a1", "Exponential") };
1252 let p = episode_prompt(&answer, &[]);
1253 assert!(p.contains(ANSWERED) && p.contains("## Engineer's correction\nExponential"), "{p}");
1254 assert!(!episode_prompt(&ep("e1", "no"), &[]).contains(ANSWERED));
1255 let store = std::env::temp_dir().join(format!("borg-answer-count-{}", std::process::id()));
1256 let _ = std::fs::remove_dir_all(&store);
1257 count_correction(&store, &answer);
1258 let ev = crate::stats::live(&store);
1259 assert_eq!((ev.len(), ev[0].why.as_str()), (1, ANSWER));
1260 let _ = std::fs::remove_dir_all(&store);
1261 }
1262
1263 fn ep(id: &str, correction: &str) -> Episode {
1264 Episode {
1265 id: id.into(),
1266 ms: 1,
1267 repo: "r".into(),
1268 session: "s".into(),
1269 task: "plan the relay".into(),
1270 agent: "one ReentrantLock guards delivery".into(),
1271 rejected_tool: None,
1272 correction: correction.into(),
1273 ..Episode::default()
1274 }
1275 }
1276
1277 fn fake_ask(p: &str) -> Option<Value> {
1280 let correction = p.split("## Engineer's correction\n").nth(1)?.lines().next()?;
1281 let after = |k: &str| correction.split(k).nth(1).and_then(|r| r.split_whitespace().next()).unwrap_or("").to_string();
1282 let supersedes: Vec<String> = Some(after("supersede:")).filter(|x| !x.is_empty()).into_iter().collect();
1283 Some(json!({"is_lesson": true, "question": format!("Q {correction}"), "situations": [], "consequence": "c",
1284 "same_as": after("merge:"), "supersedes": supersedes}))
1285 }
1286
1287 fn results(home: &Path) -> Vec<(String, String, String)> {
1288 outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind != outcome::ASKED).map(|o| (o.kind, o.session, o.lessons.join(","))).collect()
1289 }
1290
1291 #[test]
1292 fn every_model_call_is_reported_with_its_place_and_verdict() {
1293 let home = temp_home("progress");
1294 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")] {
1295 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1296 }
1297 let mut steps = Vec::new();
1298 learn_in_reporting(&home, None, &fake_ask, false, 3, &mut |st| steps.push((st.done, st.total, st.verdict.split(':').next().unwrap().to_string())));
1299 assert_eq!(steps, [(1, 2, "new lesson Le0".to_string()), (2, 2, "merged into Le0".to_string())]);
1300 steps.clear();
1302 learn_in_reporting(&home, None, &fake_ask, false, 4, &mut |st| steps.push((st.done, st.total, String::new())));
1303 assert!(steps.is_empty());
1304 let _ = std::fs::remove_dir_all(home);
1305 }
1306
1307 #[test]
1308 fn a_lesson_contradicted_after_asking_is_corrected_not_followed() {
1309 let home = temp_home("contradicted");
1310 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
1311 learn_in(&home, Some("s0"), &fake_ask, true, 2);
1312 asked(&home, "s1", 10, &["Le0"]);
1314 append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 20, "bob@x", "no, CAS on the flag supersede:Le0")).unwrap();
1315 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (1, 0, 0));
1316 assert_eq!(results(&home), [(outcome::CORRECTED.into(), "s1".into(), "Le0".into())]);
1317 let _ = std::fs::remove_dir_all(home);
1318 }
1319
1320 #[test]
1321 fn a_correction_of_a_lesson_that_applied_but_was_not_asked_is_a_miss() {
1322 let home = temp_home("missed");
1323 for e in [at("e0", "s0", 1, "ann@x", "use the lock on the flag"), at("e1", "s0", 2, "bob@x", "retry with jitter")] {
1324 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1325 }
1326 learn_in(&home, Some("s0"), &fake_ask, true, 3);
1327 let graded = outcome::Outcome {
1330 kind: outcome::GRADED.into(),
1331 session: "s1".into(),
1332 lessons: vec!["Le0".into(), "Le1".into()],
1333 ms: 10,
1334 repo: "r".into(),
1335 grades: vec![2, 3],
1336 ranks: vec![0, 1],
1337 applied: 2,
1338 chosen: vec!["Le1".into()],
1339 ..Default::default()
1340 };
1341 outcome::append(&home.join("outcomes.jsonl"), &graded).unwrap();
1342 asked(&home, "s1", 11, &["Le1"]);
1343 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "cy@x", "no, the lock on the flag merge:Le0")).unwrap();
1344 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (0, 1, 0));
1345 let r = results(&home);
1346 assert!(r.contains(&(outcome::MISSED.into(), "s1".into(), "Le0".into())), "{r:?}");
1347 assert!(!r.iter().any(|(k, _, l)| k == outcome::MISSED && l == "Le1"), "{r:?}");
1349 learn_in(&home, Some("s1"), &fake_ask, true, 40);
1350 assert_eq!(results(&home).iter().filter(|(k, ..)| k == outcome::MISSED).count(), 1);
1351 let _ = std::fs::remove_dir_all(home);
1352 }
1353
1354 #[test]
1355 fn siding_with_one_side_of_a_disagreement_corrects_the_other() {
1356 let home = temp_home("sides");
1357 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")] {
1358 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1359 }
1360 learn_in(&home, Some("s0"), &fake_ask, true, 3);
1361 let o = outcome::Outcome {
1363 kind: outcome::ASKED.into(),
1364 session: "s1".into(),
1365 lessons: vec!["Le0".into(), "Le1".into()],
1366 ms: 10,
1367 repo: "r".into(),
1368 groups: vec![vec!["Le0".into(), "Le1".into()]],
1369 ..Default::default()
1370 };
1371 outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
1372 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "cy@x", "no, the lock on the flag merge:Le0")).unwrap();
1373 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (0, 1, 0));
1374 assert_eq!(results(&home), [(outcome::CORRECTED.into(), "s1".into(), "Le1".into()), (outcome::NOT_CORRECTED.into(), "s1".into(), "Le0".into())]);
1375 let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1377 assert_eq!(l0.joined.get("cy@x"), Some(&20));
1378 let _ = std::fs::remove_dir_all(home);
1379 }
1380
1381 #[test]
1382 fn the_learn_prompt_offers_at_most_merge_candidates_lessons_for_merging() {
1383 let lib: Vec<Lesson> = (0..MERGE_CANDIDATES + 2)
1384 .map(|i| Lesson {
1385 id: format!("L{i}"),
1386 origin_ms: i as i64,
1387 question: format!("Is the ReentrantLock held while delivery {i} runs?"),
1388 consequence: "c".into(),
1389 version: 1,
1390 ..Lesson::default()
1391 })
1392 .collect();
1393 let e = ep("e1", "no, the ReentrantLock must guard delivery");
1394 let offered = similar(&e, &lib);
1395 assert_eq!(offered.len(), MERGE_CANDIDATES, "every lesson matches; the prompt offers the best few");
1396 let prompt = std::cell::RefCell::new(String::new());
1397 let _ = generalise(&e, &lib, &|p| {
1398 *prompt.borrow_mut() = p.to_string();
1399 None
1400 });
1401 let named = lib.iter().filter(|l| prompt.borrow().contains(&format!("[{}]", l.id))).count();
1402 assert_eq!(named, MERGE_CANDIDATES, "{}", prompt.borrow());
1403 }
1404
1405 #[test]
1406 fn a_superseded_lesson_is_not_a_merge_target_its_successor_is_shown() {
1407 let lesson = |id: &str, ms: i64, q: &str, sup: &[&str]| Lesson {
1408 id: id.into(),
1409 origin_ms: ms,
1410 repo: "r".into(),
1411 question: q.into(),
1412 situations: vec![],
1413 consequence: "c".into(),
1414 support: 1,
1415 episodes: vec![],
1416 history: vec![],
1417 version: 1,
1418 engineers: vec!["ann@x".into()],
1419 joined: Default::default(),
1420 area: "concurrency".into(),
1421 supersedes: sup.iter().map(|s| s.to_string()).collect(),
1422 source: String::new(),
1423 scope: vec![],
1424 overrides: String::new(),
1425 origin: String::new(),
1426 facets: vec![],
1427 reach: String::new(),
1428 };
1429 let lib = [lesson("L0", 1, "Is the closed flag guarded by the ReentrantLock?", &[]), lesson("L1", 2, "Would a CAS suffice?", &["L0"])];
1430 let shown = std::cell::RefCell::new(String::new());
1433 let back = generalise(&ep("e9", "no, guard the closed flag with the ReentrantLock"), &lib, &|p| {
1434 *shown.borrow_mut() = p.to_string();
1435 Some(json!({"is_lesson": true, "question": "Is the closed flag guarded by the ReentrantLock?", "situations": [],
1436 "consequence": "c", "same_as": "L0", "area": "concurrency", "supersedes": ["L1"]}))
1437 })
1438 .unwrap()
1439 .unwrap();
1440 let shown = shown.into_inner();
1441 assert!(shown.contains("[L1]") && !shown.contains("[L0]"), "{shown}");
1442 assert_eq!((back.version, back.supersedes.clone()), (1, vec!["L1".to_string()]), "same_as on a hidden lesson is no merge");
1443 let all: Vec<Challenge> = lib
1444 .iter()
1445 .chain([&back])
1446 .map(|l| Challenge {
1447 id: l.id.clone(),
1448 origin_ms: l.origin_ms,
1449 question: l.question.clone(),
1450 engineers: l.engineers.clone(),
1451 supersedes: l.supersedes.clone(),
1452 ..Default::default()
1453 })
1454 .collect();
1455 assert_eq!(trust::successors(&all).get("L0").map(String::as_str), Some(back.id.as_str()));
1456 }
1457
1458 fn temp_home(name: &str) -> PathBuf {
1459 let d = std::env::temp_dir().join(format!("borg-learn-{name}-{}", std::process::id()));
1460 let _ = std::fs::remove_dir_all(&d);
1461 std::fs::create_dir_all(&d).unwrap();
1462 d
1463 }
1464
1465 fn at(id: &str, session: &str, ms: i64, engineer: &str, correction: &str) -> Episode {
1466 Episode { ms, session: session.into(), engineer: engineer.into(), ..ep(id, correction) }
1467 }
1468
1469 fn asked(home: &Path, session: &str, ms: i64, ids: &[&str]) {
1470 let o = outcome::Outcome {
1471 kind: outcome::ASKED.into(),
1472 session: session.into(),
1473 lessons: ids.iter().map(|i| i.to_string()).collect(),
1474 ms,
1475 repo: "r".into(),
1476 ..Default::default()
1477 };
1478 outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
1479 }
1480
1481 fn regret_at(id: &str, decision_ms: i64) -> Episode {
1482 let r = regret::Regret { decision_session: "s-dec".into(), decision_ms, signals: vec!["subject".into()], ..regret::Regret::default() };
1483 Episode { kind: regret::KIND.into(), regret: Some(r), ..at(id, "s-fix", 100, "bob@x", "Fixed later (3d, subject): fix: take the lock") }
1484 }
1485
1486 fn regret_ask(lesson: bool) -> impl Fn(&str) -> Option<Value> {
1488 move |_| {
1489 Some(json!({"is_lesson": lesson, "question": "Before reading a shared flag, has a writer been considered?", "situations": [],
1490 "consequence": "a race", "same_as": "", "supersedes": []}))
1491 }
1492 }
1493
1494 #[test]
1495 fn a_settled_lesson_says_why_the_judge_gave_it_no_verdict() {
1496 let home = temp_home("unjudged");
1497 asked(&home, "s1", 10, &["A", "B", "C", "D"]);
1498 let ignored =
1499 outcome::Outcome { kind: outcome::IGNORED.into(), session: "s1".into(), lessons: vec!["A".into()], ms: 12, repo: "r".into(), ..Default::default() };
1500 outcome::append(&home.join("outcomes.jsonl"), &ignored).unwrap();
1501 std::fs::create_dir_all(home.join("state")).unwrap();
1502 let ctx = "#borg-asked A,B,C,D @0123456789ab\n#borg-judged B=pending\n#borg-judged B=pending\n#borg-unjudged C why=slots\n";
1503 std::fs::write(home.join("state/s1.ctx"), ctx).unwrap();
1504 learn_in(&home, Some("s1"), &fake_ask, true, 100);
1505 let why: Vec<(String, String)> = outcome::read(&home.join("outcomes.jsonl"))
1506 .into_iter()
1507 .filter(|o| o.kind == outcome::NOT_CORRECTED)
1508 .map(|o| (o.lessons.join(","), o.reason))
1509 .collect();
1510 let want = [("A", outcome::UNJUDGED_NONE), ("B", outcome::UNJUDGED_TRIES), ("C", outcome::UNJUDGED_SLOTS), ("D", outcome::UNJUDGED_NO_CALL)];
1511 assert_eq!(why, want.map(|(l, r)| (l.to_string(), r.to_string())));
1512 let _ = std::fs::remove_dir_all(home);
1513 }
1514
1515 #[test]
1518 fn an_idle_session_still_working_is_not_settled() {
1519 let home = temp_home("open");
1520 asked(&home, "s1", 10, &["A"]);
1521 std::fs::create_dir_all(home.join("state")).unwrap();
1522 std::fs::write(home.join("state/s1.ctx"), "a prompt just now\n").unwrap();
1523 let now = now_ms();
1524 learn_in(&home, None, &fake_ask, true, now);
1525 assert!(results(&home).is_empty(), "still open: {:?}", results(&home));
1526 learn_in(&home, None, &fake_ask, true, now + outcome::IDLE_MS + 60_000);
1528 assert_eq!(results(&home), [(outcome::NOT_CORRECTED.into(), "s1".into(), "A".into())]);
1529 let _ = std::fs::remove_dir_all(home);
1530 }
1531
1532 #[test]
1536 fn a_regret_merged_into_a_lesson_graded_but_not_asked_before_the_decision_is_a_miss() {
1537 let home = temp_home("regret-miss");
1538 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
1539 learn_in(&home, Some("s0"), &fake_ask, true, 2);
1540 let graded = |ms: i64| outcome::Outcome {
1541 kind: outcome::GRADED.into(),
1542 session: "s-dec".into(),
1543 lessons: vec!["Le0".into()],
1544 ms,
1545 repo: "r".into(),
1546 grades: vec![2],
1547 ..Default::default()
1548 };
1549 for g in [graded(10), graded(30)] {
1550 outcome::append(&home.join("outcomes.jsonl"), &g).unwrap();
1551 }
1552 append_jsonl(&home.join("episodes.jsonl"), ®ret_at("R1", 20)).unwrap();
1553 let merge = |_: &str| Some(json!({"is_lesson": true, "question": "Q", "situations": [], "consequence": "c", "same_as": "Le0", "supersedes": []}));
1554 learn_in(&home, None, &merge, true, 200);
1555 let missed: Vec<(String, String, i64, String)> = outcome::read(&home.join("outcomes.jsonl"))
1556 .into_iter()
1557 .filter(|o| o.kind == outcome::MISSED)
1558 .map(|o| (o.lessons.join(","), o.session, o.ms, o.cause))
1559 .collect();
1560 assert_eq!(missed, [("Le0".to_string(), "s-dec".to_string(), 20, "R1".to_string())]);
1561 learn_in(&home, None, &merge, true, 300);
1562 assert_eq!(outcome::read(&home.join("outcomes.jsonl")).iter().filter(|o| o.kind == outcome::MISSED).count(), 1, "once");
1563 let _ = std::fs::remove_dir_all(home);
1564 }
1565
1566 #[test]
1567 fn a_regret_blames_lessons_asked_before_the_decision_once() {
1568 let regretted = |home: &Path| -> Vec<(String, String, f64, String)> {
1569 outcome::read(&home.join("outcomes.jsonl"))
1570 .into_iter()
1571 .filter(|o| o.kind == outcome::REGRETTED)
1572 .map(|o| (o.lessons.join(","), o.session, o.credit, o.cause))
1573 .collect()
1574 };
1575 let setup = |name: &str, e: Episode| {
1576 let home = temp_home(name);
1577 asked(&home, "s-dec", 10, &["Le0", "Le1"]);
1578 asked(&home, "s-dec", 50, &["Le2"]);
1579 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1580 home
1581 };
1582 let home = setup("regret", regret_at("R1", 20));
1583 assert_eq!(learn_in(&home, None, ®ret_ask(true), true, 200), (1, 0, 0));
1584 let got = regretted(&home);
1585 assert_eq!(
1586 got,
1587 [("Le0".into(), "s-dec".into(), 0.25, "R1".into()), ("Le1".into(), "s-dec".into(), 0.25, "R1".into())],
1588 "Le2 was asked after the decision"
1589 );
1590 let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1591 learn_in(&home, None, ®ret_ask(true), true, 300);
1592 assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before, "credited once");
1593 let _ = std::fs::remove_dir_all(home);
1594 let home = setup("regret-no", regret_at("R1", 20));
1596 learn_in(&home, None, ®ret_ask(false), true, 200);
1597 assert!(regretted(&home).is_empty(), "not a lesson");
1598 let _ = std::fs::remove_dir_all(home);
1599 let home = setup("regret-old", regret_at("R1", 0));
1600 learn_in(&home, None, ®ret_ask(true), true, 200);
1601 assert!(regretted(&home).is_empty(), "a regret from before decision times were kept");
1602 let _ = std::fs::remove_dir_all(home);
1603 let home = setup("regret-untracked", regret_at("R1", 20));
1604 learn_in(&home, None, ®ret_ask(true), false, 200);
1605 assert!(regretted(&home).is_empty(), "BORG_LEARN=off writes nothing");
1606 let _ = std::fs::remove_dir_all(home);
1607 }
1608
1609 #[test]
1610 fn records_outcomes_of_asked_lessons() {
1611 let home = temp_home("outcomes");
1612 for e in [at("e0", "s0", 1, "ann@x", "check TOCTOU on the flag"), at("e1", "s0", 2, "", "check against the spec")] {
1614 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1615 }
1616 assert_eq!(learn_in(&home, Some("s0"), &fake_ask, true, 3), (2, 0, 0));
1617 assert!(outcome::read(&home.join("outcomes.jsonl")).is_empty(), "nothing asked, nothing settled");
1618 asked(&home, "s1", 10, &["Le0", "Le1"]);
1621 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "bob@x", "flag still racy merge:Le0")).unwrap();
1622 asked(&home, "s1", 30, &["Le1"]);
1623 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 40), (0, 1, 0));
1624 let result: Vec<(String, String, i64)> =
1625 outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind != outcome::ASKED).map(|o| (o.kind, o.lessons.join(","), o.ms)).collect();
1626 assert_eq!(result, [(outcome::CORRECTED.into(), "Le0".into(), 20), (outcome::NOT_CORRECTED.into(), "Le1".into(), 40)]);
1627 let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1629 learn_in(&home, Some("s1"), &fake_ask, true, 50);
1630 assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
1631 let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1633 assert_eq!(l0.engineers, ["ann@x", "bob@x"]);
1634 append_jsonl(&home.join("episodes.jsonl"), &at("e3", "s2", 60, "Ann@X", "flag racy again merge:Le0")).unwrap();
1636 assert_eq!(learn_in(&home, Some("s2"), &fake_ask, true, 70), (0, 1, 0));
1637 let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1638 assert_eq!((l0.engineers.as_slice(), l0.joined.get("Ann@X")), (&["ann@x".to_string(), "bob@x".to_string()][..], None));
1639 let _ = std::fs::remove_dir_all(home);
1640 }
1641
1642 #[test]
1643 fn idle_sessions_settle_without_their_end_hook() {
1644 let home = temp_home("idle");
1645 for e in [at("e0", "s0", 1, "", "check TOCTOU on the flag"), at("e1", "s0", 2, "", "check against the spec")] {
1646 append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1647 }
1648 learn_in(&home, Some("s0"), &fake_ask, true, 3);
1649 asked(&home, "s1", 10, &["Le0", "Le1"]);
1651 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "", "flag still racy merge:Le0")).unwrap();
1652 asked(&home, "s3", 5, &["Le0"]);
1653 let idle = 20 + outcome::IDLE_MS;
1654 let results = || -> Vec<(String, String, String)> {
1655 outcome::read(&home.join("outcomes.jsonl"))
1656 .into_iter()
1657 .filter(|o| o.kind != outcome::ASKED)
1658 .map(|o| (o.kind, o.session, o.lessons.join(",")))
1659 .collect()
1660 };
1661 let not = |s: &str, l: &str| (outcome::NOT_CORRECTED.to_string(), s.to_string(), l.to_string());
1662 let ctx = home.join("state/s1.ctx");
1665 std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
1666 std::fs::write(&ctx, "").unwrap();
1667 asked(&home, "s2", idle - 1, &["Le1"]);
1668 learn_in(&home, Some("s2"), &fake_ask, true, idle);
1669 assert_eq!(results(), [not("s3", "Le0"), not("s2", "Le1")]);
1670 std::fs::remove_file(&ctx).unwrap();
1672 assert_eq!(learn_in(&home, None, &fake_ask, true, idle), (0, 1, 0));
1673 let corrected = (outcome::CORRECTED.to_string(), "s1".to_string(), "Le0".to_string());
1674 assert_eq!(results(), [not("s3", "Le0"), not("s2", "Le1"), corrected.clone(), not("s1", "Le1")]);
1675 asked(&home, "s4", idle, &["Le1"]);
1677 learn_in(&home, None, &fake_ask, true, idle + 1);
1678 assert_eq!(results().len(), 4);
1679 let _ = std::fs::remove_dir_all(home);
1680 }
1681
1682 #[test]
1683 fn a_quiet_sessions_episodes_are_learned_by_another_sessions_end() {
1684 let home = temp_home("quiet");
1685 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s1", 10, "", "check TOCTOU on the flag")).unwrap();
1687 append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s9", 10 + outcome::IDLE_MS, "", "check against the spec")).unwrap();
1689 append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s2", 20 + outcome::IDLE_MS, "", "no, keep the cache")).unwrap();
1690 let now = 30 + outcome::IDLE_MS;
1691 let ctx = home.join("state/s1.ctx");
1693 std::fs::create_dir_all(ctx.parent().unwrap()).unwrap();
1694 std::fs::write(&ctx, "").unwrap();
1695 assert_eq!(learn_in(&home, Some("s2"), &fake_ask, true, now), (1, 0, 0), "s2's own episode only");
1696 std::fs::remove_file(&ctx).unwrap();
1698 append_jsonl(&home.join("episodes.jsonl"), &at("e3", "s3", 25 + outcome::IDLE_MS, "", "no, the other crate")).unwrap();
1699 assert_eq!(learn_in(&home, Some("s3"), &fake_ask, true, now), (2, 0, 0), "s3's own and quiet s1's");
1700 let learned: HashSet<String> = lessons(&home.join("lessons.jsonl")).into_iter().flat_map(|l| l.episodes).collect();
1701 assert!(learned.contains("e0") && !learned.contains("e1"), "{learned:?}");
1702 let _ = std::fs::remove_dir_all(home);
1703 }
1704
1705 #[test]
1706 fn no_outcomes_written_without_tracking() {
1707 let home = temp_home("off");
1708 append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "", "check TOCTOU")).unwrap();
1709 learn_in(&home, Some("s0"), &fake_ask, true, 2);
1710 asked(&home, "s1", 10, &["Le0"]);
1711 let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1712 append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 20, "", "TOCTOU again merge:Le0")).unwrap();
1713 assert_eq!(learn_in(&home, Some("s1"), &fake_ask, false, 30), (0, 1, 0));
1714 assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
1715 let _ = std::fs::remove_dir_all(home);
1716 }
1717
1718 #[test]
1719 fn failed_model_calls_leave_episodes_waiting_and_stop_early() {
1720 let home = temp_home("failed");
1721 for i in 0..5 {
1722 append_jsonl(&home.join("episodes.jsonl"), &at(&format!("e{i}"), "s0", i, "", &format!("check case {i}"))).unwrap();
1723 }
1724 asked(&home, "s0", 0, &["Lx"]);
1725 let calls = std::cell::Cell::new(0);
1726 let down = |_: &str| -> Option<Value> {
1727 calls.set(calls.get() + 1);
1728 None
1729 };
1730 assert_eq!(learn_in(&home, Some("s0"), &down, true, 1 + outcome::IDLE_MS), (0, 0, 5));
1733 assert_eq!(calls.get(), MAX_FAILURES);
1734 assert!(!home.join("episodes-not-lessons.txt").exists());
1735 assert!(outcome::read(&home.join("outcomes.jsonl")).iter().all(|o| o.kind == outcome::ASKED));
1736 assert_eq!(learn_in(&home, Some("s0"), &fake_ask, true, 2 + outcome::IDLE_MS), (5, 0, 0));
1738 let _ = std::fs::remove_dir_all(home);
1739 }
1740
1741 #[test]
1742 fn area_and_supersedes_come_from_the_same_call() {
1743 let lib = vec![
1744 generalise(&ep("e1", "use a lock here"), &[], &|_| {
1745 Some(json!({"is_lesson": true, "question": "Is the flag guarded by a lock?", "situations": ["flag across threads"],
1746 "consequence": "race", "same_as": "", "area": "Concurrency", "supersedes": ["Lnope"]}))
1747 })
1748 .unwrap()
1749 .unwrap(),
1750 ];
1751 assert_eq!(lib[0].area, "concurrency", "case-insensitive; normalised");
1752 assert!(lib[0].supersedes.is_empty(), "an id the model was not shown is dropped");
1753 let old: Lesson = serde_json::from_str(
1755 r#"{"id":"L","origin_ms":1,"repo":"r","question":"q","situations":[],"consequence":"c",
1756 "support":1,"episodes":[],"history":[],"version":1}"#,
1757 )
1758 .unwrap();
1759 assert_eq!((old.area.as_str(), old.supersedes.len()), ("other", 0));
1760 let id = lib[0].id.clone();
1761 let reversed = generalise(&ep("e2", "no, the lock is wrong here, use the atomic flag"), &lib, &move |_| {
1762 Some(json!({"is_lesson": true, "question": "Is a lock-free atomic enough for the flag?", "situations": ["flag across threads"],
1763 "consequence": "lock contention", "same_as": "", "area": "made-up", "supersedes": [id.clone()]}))
1764 })
1765 .unwrap()
1766 .unwrap();
1767 assert_eq!((reversed.area.as_str(), reversed.supersedes.clone()), ("other", vec![lib[0].id.clone()]));
1768 assert!(!serde_json::to_string(&lib[0]).unwrap().contains("supersedes"), "empty supersedes stays off the line");
1769 let (rid, lid) = (reversed.id.clone(), lib[0].id.clone());
1771 let merged = generalise(&ep("e3", "again: atomic, not a lock"), &[lib[0].clone(), reversed.clone()], &move |_| {
1772 Some(json!({"is_lesson": true, "question": "Is a lock-free atomic enough?", "situations": ["flag across threads"],
1773 "consequence": "lock contention", "same_as": rid.clone(), "area": "concurrency", "supersedes": [rid.clone(), lid.clone()]}))
1774 })
1775 .unwrap()
1776 .unwrap();
1777 assert_eq!((merged.id.as_str(), merged.version, merged.supersedes.clone()), (reversed.id.as_str(), 2, vec![lib[0].id.clone()]));
1778 }
1779
1780 #[test]
1781 fn new_lesson_then_merge_keeps_history() {
1782 let first = generalise(&ep("e1", "carefully check TOCTOU on the atomic flags"), &[], &|_| {
1783 Some(json!({"is_lesson": true, "question": "Is every check-then-act on a shared flag TOCTOU-safe?",
1784 "situations": ["check-then-act on a shared mutable flag across threads"], "consequence": "race", "same_as": ""}))
1785 })
1786 .unwrap()
1787 .unwrap();
1788 assert_eq!((first.version, first.support), (1, 1));
1789 let id = first.id.clone();
1790 let merged = generalise(&ep("e2", "again: the closed flag check is racy"), std::slice::from_ref(&first), &move |p| {
1791 assert!(p.contains(&id), "the similar lesson must be offered for merging");
1792 Some(json!({"is_lesson": true, "question": "Is each check-then-act on shared flags atomic or harmless on race?",
1793 "situations": ["check-then-act on a shared mutable flag across threads"], "consequence": "race", "same_as": id}))
1794 })
1795 .unwrap()
1796 .unwrap();
1797 assert_eq!((merged.id.as_str(), merged.version, merged.support, merged.history.len()), (first.id.as_str(), 2, 2, 1));
1798 let skipped =
1799 generalise(&ep("e3", "stop"), &[], &|_| Some(json!({"is_lesson": false, "question": "", "situations": [], "consequence": "", "same_as": ""})));
1800 assert!(matches!(skipped, Ok(None)), "a verdict: not a lesson");
1801 assert_eq!(generalise(&ep("e4", "no, the lock"), &[], &|_| None).map(|_| ()), Err(AskFailed), "no answer is no verdict");
1802 let malformed = generalise(&ep("e5", "no, the lock"), &[], &|_| Some(json!({"error": "rate limited"})));
1803 assert_eq!(malformed.map(|_| ()), Err(AskFailed));
1804 }
1805}