Skip to main content

borg/
learn.rs

1//! The conscience's live learning loop.
2//!
3//! **Capture** (hooks, deterministic, 0 tokens): an engineer correction is a prompt that pushes
4//! back on what the drone just proposed ("no…", "double check…", "why do you…"), or a rejected
5//! tool call / plan whose rejection carries the engineer's words. Each becomes a raw *episode*:
6//! the task, the drone's proposal, the correction.
7//!
8//! **Generalise** (`borg learn`, at session end, one model call per episode): the episode becomes a
9//! lesson (question + situations → consequence) or is merged into an existing lesson that
10//! expresses the same concern — the old wording is kept in `history` and `support` grows, so the
11//! library consolidates instead of piling up near-duplicates. Each lesson lists the engineers
12//! (git user.email) whose corrections it was learned from, its area (the kind of concern, for trust per
13//! engineer × area, trust.rs) and, when the correction contradicts a lesson it was shown, which
14//! lessons it `supersedes`; all from the same model call.
15//!
16//! **Outcomes** (`borg learn`, 0 tokens): a merge into a lesson the conscience asked earlier in the
17//! same session, or a new lesson superseding one it asked, means asking did not prevent the
18//! correction; a correction siding with one side of a rendered disagreement corrects the other
19//! sides instead; see outcome.rs.
20//!
21//! **Vetoes** (`borg no`, veto.rs): an engineer's "no" is an episode of kind `veto`, asked with its own
22//! prompt (`veto::OPENING`), whose answer may also name an invariant. It is learned at the end of any
23//! session, not only its own (a terminal veto may name an ended session). A veto merged into a lesson
24//! an earlier veto made counts as `veto_repeat`; an invariant of a lesson placed in this store becomes
25//! a proposed constraint in the queen's queue (`veto::propose`), never a constraint.
26
27use 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;
39// After `MAX_FAILURES` failed model calls in a row `borg learn` stops (runner.rs: the model is
40// unavailable, and asking about every remaining episode would only burn time and quota).
41use 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());
50/// A tool call the engineer refused without saying why: omp's approval prompt ("Tool call denied by
51/// user: bash"), omp over ACP ("Tool call rejected by user (bash)"), Claude's plain rejection.
52static 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
56/// The longest excerpt an episode keeps of a correction, a rejected tool call, and the task as the
57/// learn prompt shows it. Guard: bounds what one episode adds to the store and to its prompt (a pasted
58/// log would otherwise go in whole); a regret's excerpts share it (regret.rs `CAP`).
59pub const EXCERPT_CHARS: usize = 1500;
60/// The session's task an episode keeps (hook.rs `capture_prompt`): the prompt it started from. Guard:
61/// bounds the `.task` state file and each episode's `task` (a pasted spec would otherwise go in whole);
62/// the learn prompt shows its first `EXCERPT_CHARS`.
63pub const TASK_CHARS: usize = 3000;
64/// The end of the drone's last proposal an episode keeps (hook.rs `proposal`). Guard: bounds one
65/// episode's `agent` text.
66pub const PROPOSAL_CHARS: usize = 2000;
67
68/// A prompt that pushes back on the drone's last proposal (same heuristic as the offline miner).
69pub fn is_correction(prompt: &str) -> bool {
70    let t = prompt.trim();
71    t.len() > 3 && !t.starts_with('<') && CORRECTION.is_match(t)
72}
73
74/// The engineer's words from a rejected tool call or plan, if any.
75pub 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
81/// A refusal that carries no words (see `DENIAL`): the engineer's reason is their next prompt.
82pub 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    /// Who corrected: `git config user.email` in the episode's cwd, empty if unknown.
97    #[serde(default)]
98    pub engineer: String,
99    /// Empty for a correction; `regret::KIND` for a decision a later session fixed.
100    #[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}
105
106/// A lesson as stored: the conscience's `Challenge` plus provenance and consolidation state.
107/// Append-only: a merge writes a new version with the same id; readers keep the latest.
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
109pub struct Lesson {
110    pub id: String,
111    pub origin_ms: i64,
112    pub repo: String,
113    pub question: String,
114    pub situations: Vec<String>,
115    pub consequence: String,
116    pub support: u32,
117    pub episodes: Vec<String>,
118    pub history: Vec<Value>,
119    pub version: u32,
120    /// Engineers whose corrections this lesson was learned from, first contributor first.
121    #[serde(default)]
122    pub engineers: Vec<String>,
123    /// When each later engineer joined (ms of their first episode merged in): trust.rs counts a
124    /// lesson's outcomes for an engineer only from then on. The first contributor is absent.
125    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
126    pub joined: std::collections::BTreeMap<String, i64>,
127    /// Kind of concern (trust.rs `AREAS`), set by the generalising model call; "other" if absent.
128    #[serde(default = "other_area")]
129    pub area: String,
130    /// Existing lessons this one replaces or contradicts; the conscience asks this one instead.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub supersedes: Vec<String>,
133    /// "queen" for a queen answer (queen.rs), "import" for team knowledge read from a directory
134    /// (assimilate_dir.rs), kept across merges; empty for learned lessons.
135    #[serde(default, skip_serializing_if = "String::is_empty")]
136    pub source: String,
137    /// Where the lesson applies, as names (systems, repos, services: "Orion", "harbor_orion"). Empty:
138    /// wherever it matches. A scoped lesson is asked only when the drone's context names one of them
139    /// (conscience.rs `in_scope`), so a team rule for one system never reaches another.
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub scope: Vec<String>,
142    /// The outside rule or idiom this lesson replaces, and why ("upstream petri-net-design seeds a budget
143    /// with a reset arc; Orion drains instead because ..."). Lessons replace lessons via `supersedes`;
144    /// this names what is not a lesson (a vendored skill, upstream docs). Rendered with the question:
145    /// an override that does not name what it replaces loses to it (seen in an earlier eval).
146    #[serde(default, skip_serializing_if = "String::is_empty")]
147    pub overrides: String,
148    /// Source location of an imported lesson (`path:line` in the source repo); empty for learned ones.
149    #[serde(default, skip_serializing_if = "String::is_empty")]
150    pub origin: String,
151    /// Alternative phrasings of the lesson for search (facets.rs): the concept without names, the symptom,
152    /// the action, the consequence, German, the identifiers as aliases. Each is its own BM25 document
153    /// (conscience.rs `rank`); never shown to a drone. Empty for lessons learned before facets existed.
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub facets: Vec<String>,
156    /// Who should hear the lesson (reach.rs): empty for the team, `repo` for its repo only, `personal`
157    /// for a tombstone withdrawn to an engineer's private collective.
158    #[serde(default, skip_serializing_if = "String::is_empty")]
159    pub reach: String,
160}
161
162fn other_area() -> String {
163    trust::OTHER.into()
164}
165
166pub fn home() -> PathBuf {
167    std::env::var("BORG_HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/share/borg"))
168}
169
170pub(crate) fn append_jsonl<T: Serialize>(path: &Path, v: &T) -> std::io::Result<()> {
171    append_line(path, &serde_json::to_string(v)?)
172}
173
174/// Appends `line` and its newline in one write. Under O_APPEND one write lands whole; `writeln!` makes
175/// two (the record, then "\n"), so two writers at once (two drones on one store) can leave
176/// `{A}{B}\n\n`, and every reader drops both unparseable records without a word.
177pub(crate) fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
178    if let Some(d) = path.parent() {
179        std::fs::create_dir_all(d)?;
180    }
181    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
182    f.write_all(format!("{line}\n").as_bytes())
183}
184
185fn read_jsonl<T: for<'de> Deserialize<'de>>(path: &Path) -> Vec<T> {
186    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
187}
188
189/// False when BORG_LEARN=off (eval drones): nothing is captured or recorded.
190pub fn learning() -> bool {
191    std::env::var("BORG_LEARN").as_deref() != Ok("off")
192}
193
194/// The engineer working in `cwd`: their git user.email, empty when unknown.
195pub fn engineer(cwd: &str) -> String {
196    if cwd.is_empty() {
197        return String::new();
198    }
199    Command::new("git")
200        .args(["-C", cwd, "config", "user.email"])
201        .stdin(std::process::Stdio::null())
202        .stderr(std::process::Stdio::null())
203        .output()
204        .ok()
205        .filter(|o| o.status.success())
206        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
207        .unwrap_or_default()
208}
209
210pub fn record_episode(e: &Episode) -> std::io::Result<()> {
211    record_episode_in(&home(), e)
212}
213
214/// `record_episode` into `store` (the end-of-session job writes to the route's store).
215///
216/// # Errors
217/// When the episode cannot be appended.
218pub fn record_episode_in(store: &Path, e: &Episode) -> std::io::Result<()> {
219    append_jsonl(&store.join("episodes.jsonl"), e)?;
220    count_correction(store, e);
221    Ok(())
222}
223
224/// A correction in the usage statistics: a rejected tool call, a pushback or a regret, with a short excerpt.
225pub fn count_correction(store: &Path, e: &Episode) {
226    // A veto counts itself (`stats::VETO`, veto.rs).
227    if e.kind == crate::veto::KIND {
228        return;
229    }
230    let snippet = crate::stats::excerpt(&e.correction, crate::stats::FEED_CHARS);
231    let (why, text) = match &e.rejected_tool {
232        _ if e.kind == regret::KIND => ("regret", snippet),
233        Some(tool) => ("rejected_tool", format!("{tool}: {snippet}")),
234        None => ("pushback", snippet),
235    };
236    crate::stats::count(store, crate::stats::CORRECTION, &e.session, |ev| {
237        ev.ms = e.ms;
238        ev.engineer.clone_from(&e.engineer);
239        ev.repo.clone_from(&e.repo);
240        ev.why = why.into();
241        ev.text = text;
242    });
243}
244
245/// The latest version of every lesson.
246pub fn lessons(path: &Path) -> Vec<Lesson> {
247    let mut by: HashMap<String, Lesson> = HashMap::new();
248    for l in read_jsonl::<Lesson>(path) {
249        if by.get(&l.id).is_none_or(|o| o.version <= l.version) {
250            by.insert(l.id.clone(), l);
251        }
252    }
253    let mut v: Vec<Lesson> = by.into_values().collect();
254    v.sort_by_key(|l| l.origin_ms);
255    v
256}
257
258/// The correction prompt's opening.
259const OPENING: &str = "An engineer corrected a coding agent. Decide whether the correction expresses a reusable engineering \
260lesson: a critical concern about quality, design, correctness, reuse, spec conformance or root cause that this engineer would \
261plausibly raise again about similar future work. Operational remarks (\"still working?\", \"stop\", \"push\", \"restart\") are NOT lessons.";
262
263/// Who should hear a lesson: shared by the correction and the regret prompt.
264const REACH_RULES: &str = "Then decide its reach: who should hear it. Ask: would a teammate working on the same code get this correction too?
265- personal: how this one engineer likes the agent to work with them, not a property of the code: commit message style \
266or length, AI attribution or co-author trailers, whether and when to commit, push, branch or open a PR, what to stage, \
267how to report or ask, language and tone, their own tooling habits.
268- repo: a convention of the repo it happened in that other repos do not share (its branch or deploy workflow, a tool \
269or command it mandates).
270- team: everything else: an engineering concern about the code, design, correctness or verification.";
271
272const FIELDS: &str = "If it is a lesson, write:
273- question: ONE question the agent should ask itself next time, general enough to transfer, keeping the technical substance;
274- situations: 1-3 lines, each `<action> on/in <kind of state or component> [under <condition>]`, without project/class names;
275- consequence: one line, what goes wrong or what the engineer requires instead;";
276
277/// The last field of either prompt.
278const AREA: &str = "- area: the one kind of concern: concurrency, spec-conformance, reuse-existing, architecture-boundary, verification, minimality, \
279protocol-correctness, data-source, security, dependency-version, scope, or other if none fits.";
280
281/// Merging and superseding: shared by the correction and the regret prompt.
282const 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) \
283and agrees with this correction, set same_as to its id and write the merged, improved wording in question/situations/consequence. \
284Otherwise same_as is empty.
285
286If this correction contradicts or replaces a listed lesson (in the same situation it requires something different or the \
287opposite, e.g. the engineer reversed an earlier correction), list that lesson's id in supersedes; never the same_as lesson. \
288Otherwise supersedes is empty.";
289
290/// The prompt for one episode: a correction's, a veto's (veto.rs) or a regret's (regret.rs).
291fn episode_prompt(e: &Episode, listed: &[String]) -> String {
292    let existing = if listed.is_empty() { "(none)".into() } else { listed.join("\n") };
293    if e.kind == crate::veto::KIND {
294        let agent = if e.agent.trim().is_empty() { "(not available)" } else { e.agent.as_str() };
295        return format!(
296            "{}\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",
297            crate::veto::OPENING,
298            crate::veto::INVARIANT,
299            e.task.chars().take(EXCERPT_CHARS).collect::<String>(),
300            e.correction,
301        );
302    }
303    if let Some(r) = e.regret.as_ref().filter(|_| e.kind == regret::KIND) {
304        let (head, sections) = (regret::head(r), regret::sections(r, &e.task));
305        let (reach, merge) = (regret::neutral(REACH_RULES), regret::neutral(MERGE_RULES));
306        return format!("{head}\n\n{reach}\n\n{}\n{AREA}\n\n{merge}\n\n{sections}## Existing lessons\n{existing}\n", regret::FIELDS);
307    }
308    format!(
309        "{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",
310        e.task.chars().take(EXCERPT_CHARS).collect::<String>(),
311        e.agent,
312        e.rejected_tool.as_deref().unwrap_or(""),
313        e.correction,
314    )
315}
316
317fn schema() -> Value {
318    json!({"type": "object", "additionalProperties": false, "properties": {
319        "is_lesson": {"type": "boolean"}, "question": {"type": "string"},
320        "situations": {"type": "array", "items": {"type": "string"}}, "consequence": {"type": "string"},
321        "same_as": {"type": "string"}, "area": {"type": "string", "enum": trust::AREAS},
322        "supersedes": {"type": "array", "items": {"type": "string"}},
323        "reach": {"type": "string", "enum": ["team", "repo", "personal"]}},
324        "required": ["is_lesson", "question", "situations", "consequence", "same_as", "area", "supersedes", "reach"]})
325}
326
327/// `schema` plus the invariant a veto's reason may name (veto.rs).
328fn veto_schema() -> Value {
329    let mut s = schema();
330    s["properties"]["invariant"] = json!({"type": "string"});
331    if let Some(r) = s["required"].as_array_mut() {
332        r.push(json!("invariant"));
333    }
334    s
335}
336
337/// The answer schema for `prompt`: a veto's prompt (it starts with `veto::OPENING`) gets the invariant
338/// field; correction and regret calls stay as they were.
339pub fn schema_for(prompt: &str) -> Value {
340    if prompt.starts_with(crate::veto::OPENING) { veto_schema() } else { schema() }
341}
342
343/// The model call failed (quota, rate limit, not logged in, network, malformed answer): unlike a
344/// verdict of "not a lesson", the episode stays waiting and a later `borg learn` retries it.
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub struct AskFailed;
347
348/// The model's verdict on an episode that is a lesson.
349struct Answer {
350    question: String,
351    situations: Vec<String>,
352    consequence: String,
353    area: String,
354    reach: String,
355    same_as: String,
356    supersedes: Vec<String>,
357    /// A veto's invariant (veto.rs); empty for every other episode, and when the model named none.
358    invariant: String,
359}
360
361impl Answer {
362    /// `Ok(None)` not a lesson; `Err` no usable verdict.
363    fn parse(v: &Value) -> Result<Option<Answer>, AskFailed> {
364        match v["is_lesson"].as_bool() {
365            Some(true) => {}
366            Some(false) => return Ok(None),
367            None => return Err(AskFailed),
368        }
369        let strs = |k: &str| v[k].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect::<Vec<_>>();
370        let text = |k: &str| v[k].as_str().unwrap_or_default().to_string();
371        let question = v["question"].as_str().filter(|q| !q.trim().is_empty()).ok_or(AskFailed)?.to_string();
372        let reach = v["reach"].as_str().filter(|r| [crate::reach::REPO, crate::reach::PERSONAL].contains(r)).unwrap_or_default();
373        Ok(Some(Answer {
374            question,
375            situations: strs("situations"),
376            consequence: text("consequence"),
377            area: trust::area(v["area"].as_str().unwrap_or_default()).to_string(),
378            reach: reach.to_string(),
379            same_as: text("same_as"),
380            supersedes: strs("supersedes"),
381            invariant: text("invariant"),
382        }))
383    }
384}
385
386/// The lessons most like an episode that the learn prompt offers for merging (`similar`). Baseline:
387/// the count since 0.1.0, never evaluated, the reference a later value is compared against. A duplicate
388/// ranked below it is not offered, so its episode can become a second lesson instead of a merge.
389const MERGE_CANDIDATES: usize = 5;
390
391/// The library's lessons most like the episode (at most `MERGE_CANDIDATES`), offered for merging.
392fn similar<'a>(e: &Episode, library: &'a [Lesson]) -> Vec<&'a Lesson> {
393    let as_challenges: Vec<Challenge> = library
394        .iter()
395        .map(|l| Challenge {
396            id: l.id.clone(),
397            origin_ms: l.origin_ms,
398            question: format!("{} {}", l.question, l.situations.join(" ")),
399            situations: l.situations.clone(),
400            consequence: l.consequence.clone(),
401            // A superseded lesson is not offered for merging; its live successor is, in its place. A
402            // re-reversal then contradicts the successor and becomes a new lesson superseding it.
403            supersedes: l.supersedes.clone(),
404            engineers: l.engineers.clone(),
405            source: l.source.clone(),
406            ..Default::default() // similarity for merging is about meaning, not usefulness or trust
407        })
408        .collect();
409    let query = format!("{} {} {}", e.task, e.agent, e.correction);
410    conscience::select(&as_challenges, &query, MERGE_CANDIDATES).iter().filter_map(|c| library.iter().find(|l| l.id == c.id)).collect()
411}
412
413/// `old` with the episode merged in: the new wording, the old one kept in `history`.
414fn merged(old: &Lesson, e: &Episode, a: Answer, mut supersedes: Vec<String>) -> Lesson {
415    let mut history = old.history.clone();
416    history.push(json!({"question": old.question, "consequence": old.consequence, "version": old.version}));
417    let mut episodes = old.episodes.clone();
418    episodes.push(e.id.clone());
419    let (mut engineers, mut joined) = (old.engineers.clone(), old.joined.clone());
420    // Emails compare case-insensitively; an alias (collective.rs "Engineers") is stored as
421    // its raw email and mapped when read, so the store never depends on this machine's config.
422    let same = |x: &String| x.trim().to_lowercase() == e.engineer.trim().to_lowercase();
423    if !e.engineer.trim().is_empty() && !engineers.iter().any(same) {
424        engineers.push(e.engineer.clone());
425        joined.insert(e.engineer.clone(), e.ms);
426    }
427    for id in old.supersedes.iter().rev() {
428        if !supersedes.contains(id) {
429            supersedes.insert(0, id.clone());
430        }
431    }
432    Lesson {
433        question: a.question,
434        situations: a.situations,
435        consequence: a.consequence,
436        support: old.support + 1,
437        episodes,
438        history,
439        version: old.version + 1,
440        engineers,
441        joined,
442        area: a.area,
443        supersedes,
444        // New wording: the old facets describe the old wording; `borg learn` generates new ones.
445        facets: Vec::new(),
446        reach: crate::reach::narrower(&old.reach, &a.reach).to_string(),
447        ..old.clone()
448    }
449}
450
451/// A new lesson from the episode alone.
452fn fresh(e: &Episode, a: Answer, supersedes: Vec<String>) -> Lesson {
453    Lesson {
454        id: format!("L{}", e.id),
455        origin_ms: e.ms,
456        repo: e.repo.clone(),
457        question: a.question,
458        situations: a.situations,
459        consequence: a.consequence,
460        support: 1,
461        episodes: vec![e.id.clone()],
462        version: 1,
463        engineers: Some(e.engineer.clone()).filter(|x| !x.is_empty()).into_iter().collect(),
464        area: a.area,
465        supersedes,
466        reach: a.reach,
467        ..Lesson::default()
468    }
469}
470
471/// Generalise one episode against the existing library: `Ok(Some)` a new lesson or a merged version
472/// of an existing one, `Ok(None)` not a lesson, `Err` no verdict. `ask` performs the model call
473/// (injectable for tests). A regret (regret.rs) gets its own prompt, the same verdict.
474///
475/// # Errors
476/// `AskFailed` when the model gave no usable verdict.
477pub fn generalise(e: &Episode, library: &[Lesson], ask: &dyn Fn(&str) -> Option<Value>) -> Result<Option<Lesson>, AskFailed> {
478    generalise_with(e, library, ask).map(|o| o.map(|(l, _)| l))
479}
480
481/// `generalise`, with the invariant a veto's answer named (empty otherwise).
482///
483/// # Errors
484/// `AskFailed` when the model gave no usable verdict.
485pub fn generalise_with(e: &Episode, library: &[Lesson], ask: &dyn Fn(&str) -> Option<Value>) -> Result<Option<(Lesson, String)>, AskFailed> {
486    // A withdrawn personal lesson (reach.rs) is no merge target: it lives in a private collective now.
487    let library: Vec<Lesson> = library.iter().filter(|l| l.reach != crate::reach::PERSONAL).cloned().collect();
488    let similar = similar(e, &library);
489    let listed: Vec<String> = similar.iter().map(|l| format!("[{}] {} -> {}", l.id, l.question, l.consequence)).collect();
490    let Some(a) = Answer::parse(&ask(&episode_prompt(e, &listed)).ok_or(AskFailed)?)? else { return Ok(None) };
491    let same_as = similar.iter().find(|l| a.same_as == l.id);
492    // Only lessons the model was shown can be superseded, and never the one it merges into.
493    let supersedes: Vec<String> =
494        a.supersedes.iter().filter(|id| similar.iter().any(|l| &l.id == *id) && same_as.is_none_or(|m| &m.id != *id)).cloned().collect();
495    let invariant = a.invariant.trim().to_string();
496    Ok(Some((
497        match same_as {
498            Some(old) => merged(old, e, a, supersedes),
499            None => fresh(e, a, supersedes),
500        },
501        invariant,
502    )))
503}
504
505/// One structured call to the drone's model (runner.rs picks claude, omp or opencode).
506pub fn ask_claude(prompt: &str) -> Option<Value> {
507    ask_model(prompt, &schema_for(prompt)).map(|(v, _)| v)
508}
509
510/// The model call behind `ask_claude`, for any JSON schema: the structured answer and its cost in USD
511/// (None when the CLI reported none). `BORG_LEARN_MODEL` picks the model, `runner::choose` the CLI.
512pub fn ask_model(prompt: &str, schema: &Value) -> Option<(Value, Option<f64>)> {
513    let started = std::time::Instant::now();
514    let answer = call_model(prompt, schema);
515    crate::stats::count(&home(), crate::stats::MODEL_CALL, "", |ev| {
516        ev.why = crate::stats::purpose().into();
517        ev.usd = answer.as_ref().and_then(|(_, c)| *c).unwrap_or(0.0);
518        ev.dur_ms = started.elapsed().as_millis() as u64;
519        if answer.is_none() {
520            ev.text = "no answer".into();
521        }
522    });
523    answer
524}
525
526fn call_model(prompt: &str, schema: &Value) -> Option<(Value, Option<f64>)> {
527    let candidates = crate::runner::choose(std::env::var("BORG_LEARN_MODEL").ok().as_deref());
528    crate::runner::structured(&candidates, prompt, schema, learn_timeout())
529}
530
531/// How long one learn-side model call may take (`BORG_LEARN_TIMEOUT_S`, default 300 s): it runs in
532/// the background, but a hung runner must not keep `borg learn` alive forever.
533fn learn_timeout() -> std::time::Duration {
534    std::time::Duration::from_secs(std::env::var("BORG_LEARN_TIMEOUT_S").ok().and_then(|v| v.parse().ok()).unwrap_or(300))
535}
536
537pub fn now_ms() -> i64 {
538    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
539}
540
541/// `borg learn [--session ID]`: generalise every episode not yet folded into a lesson.
542/// `--session` is given when that session ended: its asked lessons and the placebos the judge never
543/// reached are then settled. Any run also settles sessions idle for a day, so a missing `end` hook does
544/// not bias the outcomes.
545/// Returns (new lessons, merges, episodes still waiting because the model gave no verdict).
546pub fn learn_pending(session: Option<&str>, ask: &dyn Fn(&str) -> Option<Value>) -> (usize, usize, usize) {
547    learn_in(&home(), session, ask, learning(), now_ms())
548}
549
550/// `learn_pending` on the store in `home`. With `track`, outcomes of asked lessons are recorded
551/// (outcome.rs): a merge into a lesson asked earlier in the episode's session, and a lesson asked
552/// earlier that the new one supersedes, are `asked_then_corrected` (`outcome::contradicted`); at the end of `session`, and in every session idle for
553/// `outcome::IDLE_MS` at `now`, the other asked lessons are `asked_not_corrected`. So any run
554/// settles, not only the `end` hook's; a session it skipped episodes of is not settled as idle.
555/// Only a "not a lesson" verdict is recorded as skipped: an episode whose model call failed stays
556/// waiting (and its session unsettled) for the next run, and after `MAX_FAILURES` failures in a row
557/// the run stops asking. Returns (new lessons, merges, episodes still waiting for a verdict).
558pub fn learn_in(home: &Path, session: Option<&str>, ask: &dyn Fn(&str) -> Option<Value>, track: bool, now: i64) -> (usize, usize, usize) {
559    learn_in_reporting(home, session, ask, track, now, &mut |_| {})
560}
561
562/// A progress line on stderr: `[12/104 · 18 s · ~28 min left] new lesson L…: <question>`.
563pub fn print_step(st: Step) {
564    let left = (st.total - st.done) as f64 * st.secs / 60.0;
565    let mut v: String = st.verdict.chars().take(110).collect();
566    if v.len() < st.verdict.len() {
567        v.push('…');
568    }
569    eprintln!("[{}/{} · {:.0} s · ~{:.0} min left] {v}", st.done, st.total, st.secs, left);
570}
571
572/// One model verdict during a learn run, for a progress line: the episode's place among those this
573/// run asks about (`done` of `total`), and what came of it.
574pub struct Step<'a> {
575    pub done: usize,
576    pub total: usize,
577    pub verdict: &'a str,
578    pub secs: f64,
579}
580
581/// Whether a veto's lesson, placed as `placed`, proposes its `invariant` as a constraint: only a lesson
582/// placed in this store; a personal or dropped one never does, a preference being no invariant.
583fn proposes(placed: &crate::reach::Placed, invariant: &str) -> bool {
584    matches!(placed, crate::reach::Placed::Here(_)) && !invariant.trim().is_empty()
585}
586
587/// False when BORG_REGRET_CREDIT=off: a regret no longer counts against the lessons asked before the
588/// decision (`credit_regrets`).
589pub fn regret_credit() -> bool {
590    std::env::var("BORG_REGRET_CREDIT").as_deref() != Ok("off")
591}
592
593/// Crediting a session's own green test runs on (`outcome::tested`): learning is, and BORG_TEST_CREDIT is
594/// not `off` (an eval's frozen arm).
595pub fn test_credit() -> bool {
596    learning() && std::env::var("BORG_TEST_CREDIT").as_deref() != Ok("off")
597}
598
599/// Each regret episode generalised into a lesson of this store (`library`, as stored: a lesson placed in
600/// a private store is not in it) blames the lessons asked in its deciding session before the decision
601/// (`outcome::regretted`). Runs over every such episode on every run, so regrets learned before this
602/// existed are credited once too; the (cause, lesson) key keeps it idempotent. A regret the model judged
603/// "not a lesson", one still waiting, and one without `decision_ms` credit nothing. For a review regret
604/// the deciding session is the episode's own: a lesson `contradicted` already marked corrected there is
605/// not blamed twice.
606///
607/// Indexed once per run, so a run costs one pass over the log and the library plus each deciding
608/// session's own records: `regretted` reads only the deciding session's records, and a regret's credits
609/// carry that session too.
610///
611/// A regret is also a correction of the deciding session's work: the lesson it was folded into, and the
612/// lessons that one supersedes, if a graded check of that session found them applying before the
613/// decision and the session never asked them, were questions needed and not asked
614/// (`applied_then_corrected`, `outcome::missed`, at the decision's time and with the regret as `cause`),
615/// as for a correction merged into a lesson. These misses are recorded with `credit` off too: they blame
616/// no lesson asked.
617fn credit_regrets(regrets: &[Episode], library: &[Lesson], outcomes: &mut Vec<outcome::Outcome>, path: &Path, credit: bool) {
618    let mut holder: HashMap<&str, &Lesson> = HashMap::new();
619    for l in library {
620        for id in &l.episodes {
621            holder.entry(id.as_str()).or_insert(l);
622        }
623    }
624    let deciding: HashSet<&str> = regrets.iter().filter_map(|e| e.regret.as_ref()).map(|r| r.decision_session.as_str()).collect();
625    let mut by_session: HashMap<String, Vec<outcome::Outcome>> = HashMap::new();
626    for o in outcomes.iter().filter(|o| deciding.contains(o.session.as_str())) {
627        by_session.entry(o.session.clone()).or_default().push(o.clone());
628    }
629    for e in regrets {
630        let Some(r) = &e.regret else { continue };
631        let Some(holder) = holder.get(e.id.as_str()) else { continue };
632        let target: Vec<String> = std::iter::once(holder.id.clone()).chain(holder.supersedes.iter().cloned()).collect();
633        let records = by_session.entry(r.decision_session.clone()).or_default();
634        let blamed = match credit {
635            true => outcome::regretted(records, &r.decision_session, r.decision_ms, &e.id, &target, regret::strength(r), e.ms, &e.repo),
636            false => Vec::new(),
637        };
638        let missed = match r.decision_ms > 0 && !r.decision_session.is_empty() {
639            true => outcome::missed(records, &r.decision_session, r.decision_ms, Some(&holder.id), &holder.supersedes),
640            false => Vec::new(),
641        };
642        let missed = missed.into_iter().map(|l| outcome::Outcome {
643            kind: outcome::MISSED.into(),
644            session: r.decision_session.clone(),
645            lessons: vec![l],
646            ms: r.decision_ms,
647            repo: e.repo.clone(),
648            cause: e.id.clone(),
649            ..Default::default()
650        });
651        for o in blamed.into_iter().chain(missed.collect::<Vec<_>>()) {
652            let _ = outcome::append(path, &o);
653            records.push(o.clone());
654            outcomes.push(o);
655        }
656    }
657}
658
659/// Whether the session whose state file is `ctx` is still open at `now`: the file changed within
660/// `outcome::IDLE_MS` (every prompt and touch appends to it). No file: not open.
661fn open(ctx: &Path, now: i64) -> bool {
662    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);
663    ms.is_some_and(|ms| ms > now - outcome::IDLE_MS)
664}
665
666/// Episode kinds the removed hive made (a group's reflection, a rework), skipped where an older store has them.
667const RETIRED: [&str; 2] = ["group", "rework"];
668
669/// `learn_in`, calling `report` after every model call (the CLI prints it; the hooks pass a no-op).
670///
671/// One run per store at a time (an exclusive lock on `lessons.jsonl.lock`): session-end runs of several
672/// drones share the veto episodes and the regret credits, and two runs reading the store before either
673/// writes would learn a veto twice and count its credits twice. A waiting run then reads what the first
674/// one wrote and has nothing left to do for them.
675pub fn learn_in_reporting(
676    home: &Path,
677    session: Option<&str>,
678    ask: &dyn Fn(&str) -> Option<Value>,
679    track: bool,
680    now: i64,
681    report: &mut dyn FnMut(Step),
682) -> (usize, usize, usize) {
683    crate::live::locked(&home.join("lessons.jsonl"), || learn_unlocked(home, session, ask, track, now, report))
684}
685
686fn learn_unlocked(
687    home: &Path,
688    session: Option<&str>,
689    ask: &dyn Fn(&str) -> Option<Value>,
690    track: bool,
691    now: i64,
692    report: &mut dyn FnMut(Step),
693) -> (usize, usize, usize) {
694    let (ep_path, lessons_path) = (home.join("episodes.jsonl"), home.join("lessons.jsonl"));
695    let outcomes_path = outcome::path_beside(&lessons_path);
696    let mut outcomes = if track { outcome::read(&outcomes_path) } else { Vec::new() };
697    let mut library = lessons(&lessons_path);
698    let done: std::collections::HashSet<String> = library.iter().flat_map(|l| l.episodes.clone()).collect();
699    let skipped_path = home.join("episodes-not-lessons.txt");
700    let skipped: std::collections::HashSet<String> = std::fs::read_to_string(&skipped_path).unwrap_or_default().lines().map(str::to_string).collect();
701    let (mut new, mut merged, mut failed, mut failures_in_row) = (0, 0, 0, 0);
702    // Newest episode per session, and sessions with episodes this run left pending (--session).
703    let mut last_episode: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
704    let mut pending: std::collections::HashSet<String> = std::collections::HashSet::new();
705    // `RETIRED` kinds an older store holds are never learned.
706    let episodes: Vec<Episode> = read_jsonl::<Episode>(&ep_path).into_iter().filter(|e| !RETIRED.contains(&e.kind.as_str())).collect();
707    let regrets: Vec<Episode> = episodes.iter().filter(|e| e.kind == regret::KIND && e.regret.is_some()).cloned().collect();
708    // A veto is learned at the end of any session (veto.rs): a terminal veto may name one already ended.
709    let mine = |e: &Episode| session.is_none_or(|s| s == e.session) || e.kind == crate::veto::KIND;
710    let vetoes: HashSet<String> = episodes.iter().filter(|e| e.kind == crate::veto::KIND).map(|e| e.id.clone()).collect();
711    let total = episodes.iter().filter(|e| !done.contains(&e.id) && !skipped.contains(&e.id) && mine(e)).count();
712    let mut asked = 0;
713    for e in episodes {
714        let last = last_episode.entry(e.session.clone()).or_insert(e.ms);
715        *last = (*last).max(e.ms);
716        if done.contains(&e.id) || skipped.contains(&e.id) {
717            continue;
718        }
719        if !mine(&e) {
720            pending.insert(e.session.clone());
721            continue;
722        }
723        if failures_in_row >= MAX_FAILURES {
724            failed += 1;
725            pending.insert(e.session.clone());
726            continue;
727        }
728        let started = std::time::Instant::now();
729        let verdict = generalise_with(&e, &library, ask);
730        asked += 1;
731        let what = match &verdict {
732            Err(AskFailed) => "no answer from the model, left pending".to_string(),
733            Ok(Some((l, _))) if l.version > 1 => format!("merged into {}: {}", l.id, l.question),
734            Ok(Some((l, _))) => format!("new lesson {}: {}", l.id, l.question),
735            Ok(None) => "not a lesson".to_string(),
736        };
737        report(Step { done: asked, total, verdict: &what, secs: started.elapsed().as_secs_f64() });
738        failures_in_row = if verdict.is_err() { failures_in_row + 1 } else { 0 };
739        match verdict {
740            Err(AskFailed) => {
741                failed += 1;
742                pending.insert(e.session.clone());
743            }
744            Ok(Some((l, invariant))) => {
745                if l.version > 1 {
746                    merged += 1
747                } else {
748                    new += 1
749                }
750                // A personal lesson goes to the engineer's private collective, never into a team store (reach.rs).
751                let placed = crate::reach::place(home, &l);
752                // Where it came from, for the feed: a review finding, a later fix, or a correction.
753                let source = match &e.regret {
754                    Some(r) if regret::is_review(r) => "review",
755                    Some(_) => "regret",
756                    None if e.kind == crate::veto::KIND => "veto",
757                    None => "session",
758                };
759                let (kind, why, counted_in) = match &placed {
760                    crate::reach::Placed::Here(l) => {
761                        let _ = append_jsonl(&lessons_path, &**l);
762                        (if l.version > 1 { crate::stats::LESSON_MERGED } else { crate::stats::LESSON_NEW }, source, home.to_path_buf())
763                    }
764                    crate::reach::Placed::Private(store) => (crate::stats::LESSON_NEW, "personal", store.clone()),
765                    crate::reach::Placed::Dropped => (crate::stats::NOT_A_LESSON, "personal", home.to_path_buf()),
766                };
767                crate::stats::count(&counted_in, kind, &e.session, |ev| {
768                    ev.engineer = e.engineer.clone();
769                    ev.repo = e.repo.clone();
770                    ev.lessons = vec![l.id.clone()];
771                    ev.why = why.into();
772                });
773                if e.kind == crate::veto::KIND {
774                    // The same "no" said twice: merged into a lesson an earlier veto made.
775                    let earlier = library.iter().find(|x| x.id == l.id);
776                    if l.version > 1 && earlier.is_some_and(|o| o.episodes.iter().any(|id| id != &e.id && vetoes.contains(id))) {
777                        // Dated when the engineer said it, the week of its own `veto`, not when it was
778                        // learned: a weekly share of repeated vetoes pairs counts of the same no's.
779                        crate::stats::count(&counted_in, crate::stats::VETO_REPEAT, &e.session, |ev| {
780                            ev.ms = if e.ms > 0 { e.ms } else { ev.ms };
781                            ev.engineer = e.engineer.clone();
782                            ev.repo = e.repo.clone();
783                            ev.lessons = vec![l.id.clone()];
784                            ev.text = crate::stats::excerpt(&e.correction, crate::stats::FEED_CHARS);
785                        });
786                    }
787                    if proposes(&placed, &invariant) {
788                        let _ = crate::veto::propose(home, &l, &invariant, &e, now);
789                    }
790                }
791                let target = (l.version > 1).then_some(l.id.as_str());
792                // A veto credited the lessons shown before it already (veto.rs): it never doubles as a
793                // correction, nor as a missed question (`applied_then_corrected`).
794                let track_results = track && e.kind != crate::veto::KIND;
795                let contradicted = match track_results {
796                    true => outcome::contradicted(&outcomes, &e.session, e.ms, target, &l.supersedes),
797                    false => Vec::new(),
798                };
799                for id in contradicted {
800                    let o = outcome::Outcome {
801                        kind: outcome::CORRECTED.into(),
802                        session: e.session.clone(),
803                        lessons: vec![id],
804                        ms: e.ms,
805                        repo: e.repo.clone(),
806                        ..Default::default()
807                    };
808                    let _ = outcome::append(&outcomes_path, &o);
809                    outcomes.push(o);
810                }
811                let missed = match track_results {
812                    true => outcome::missed(&outcomes, &e.session, e.ms, target, &l.supersedes),
813                    false => Vec::new(),
814                };
815                for id in missed {
816                    let o = outcome::Outcome {
817                        kind: outcome::MISSED.into(),
818                        session: e.session.clone(),
819                        lessons: vec![id],
820                        ms: e.ms,
821                        repo: e.repo.clone(),
822                        ..Default::default()
823                    };
824                    let _ = outcome::append(&outcomes_path, &o);
825                    outcomes.push(o);
826                }
827                library.retain(|x| x.id != l.id);
828                library.push(l);
829            }
830            Ok(None) => {
831                let _ = append_line(&skipped_path, &e.id);
832                crate::stats::count(home, crate::stats::NOT_A_LESSON, &e.session, |ev| ev.engineer = e.engineer.clone());
833            }
834        }
835    }
836    if track {
837        credit_regrets(&regrets, &lessons(&lessons_path), &mut outcomes, &outcomes_path, regret_credit());
838        let state = std::env::var_os("BORG_STATE").map_or_else(|| home.join("state"), std::path::PathBuf::from);
839        let ctx = |s: &str| state.join(format!("{}.ctx", crate::notes::safe(s)));
840        // Settled only once ended: its `end` hook ran (`session`), or it is idle by the log and its state
841        // file is as quiet (`open`): a session working without asks or episodes for a day is still open.
842        let mut ended: Vec<String> = outcome::idle_sessions(&outcomes, &last_episode, now)
843            .into_iter()
844            .filter(|s| !pending.contains(*s) && !open(&ctx(s), now))
845            .map(str::to_string)
846            .collect();
847        if let Some(s) = session.filter(|s| !ended.iter().any(|x| x == s) && !pending.contains(*s)) {
848            ended.push(s.to_string());
849        }
850        for s in &ended {
851            // The session's own tests that went green after an ask (hook.rs `capture_test`) credit the
852            // lessons asked, once each, as it settles.
853            if test_credit() {
854                let tests = std::fs::read_to_string(state.join(format!("{}.tests", crate::notes::safe(s)))).unwrap_or_default();
855                for o in outcome::tested(&outcomes, s, &tests) {
856                    let _ = outcome::append(&outcomes_path, &o);
857                    outcomes.push(o);
858                }
859            }
860            // Each lesson left without a verdict says why (hook.rs `unjudged`, from the state file):
861            // censored, never an ignore (memory.rs).
862            let why = crate::hook::unjudged(&std::fs::read_to_string(ctx(s)).unwrap_or_default());
863            for o in outcome::settle_with(&outcomes, s, now, &|l| why.get(l).copied().unwrap_or(outcome::UNJUDGED_NO_CALL)) {
864                let _ = outcome::append(&outcomes_path, &o);
865                outcomes.push(o);
866            }
867        }
868    }
869    (new, merged, failed)
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    #[test]
877    fn concurrent_jsonl_appends_keep_every_line() {
878        let dir = std::env::temp_dir().join(format!("borg-learn-append-{}", std::process::id()));
879        let _ = std::fs::remove_dir_all(&dir);
880        let p = dir.join("episodes.jsonl");
881        let (writers, each) = (8, 2000);
882        std::thread::scope(|scope| {
883            for w in 0..writers {
884                let p = &p;
885                scope.spawn(move || (0..each).for_each(|i| append_jsonl(p, &serde_json::json!({"w": w, "i": i})).unwrap()));
886            }
887        });
888        assert_eq!(read_jsonl::<Value>(&p).len(), writers * each);
889        let _ = std::fs::remove_dir_all(dir);
890    }
891
892    #[test]
893    fn a_regret_is_asked_with_its_own_prompt_and_the_shared_rules() {
894        let r = regret::Regret {
895            decision_task: "read the flag in b".into(),
896            fix_subject: "fix: take the lock".into(),
897            signals: vec!["subject".into()],
898            age_days: 3,
899            ..regret::Regret::default()
900        };
901        let e = Episode {
902            kind: regret::KIND.into(),
903            regret: Some(r),
904            task: "make the flag safe".into(),
905            ..ep("Rdddd-ffff", "Fixed later (3d, subject): fix: take the lock")
906        };
907        let shown = std::cell::RefCell::new(String::new());
908        let l = generalise(&e, &[], &|p| {
909            *shown.borrow_mut() = p.to_string();
910            Some(json!({"is_lesson": true, "question": "Before reading a shared flag without a lock, has a concurrent writer been considered?",
911                "situations": ["reading a shared flag across threads"], "consequence": "a race", "same_as": "", "area": "concurrency", "supersedes": []}))
912        })
913        .unwrap()
914        .unwrap();
915        let p = shown.into_inner();
916        assert!(p.starts_with("A coding agent made a decision in an earlier session") && p.contains("3 days later"), "{p}");
917        for part in [
918            regret::neutral(REACH_RULES).as_str(),
919            regret::FIELDS,
920            AREA,
921            regret::neutral(MERGE_RULES).as_str(),
922            "## The decision (task)\nread the flag in b",
923            "## The fix (subject, task)\nfix: take the lock\nmake the flag safe",
924        ] {
925            assert!(p.contains(part), "{part}");
926        }
927        assert!(!p.contains("## Engineer's correction") && !p.contains(OPENING) && !p.contains("correction"), "{p}");
928        assert_eq!((l.id.as_str(), l.area.as_str(), l.episodes.as_slice()), ("LRdddd-ffff", "concurrency", &["Rdddd-ffff".to_string()][..]));
929        // A correction keeps its prompt; a kind without its regret is asked as a correction.
930        assert!(episode_prompt(&ep("e1", "no, the lock"), &[]).starts_with(OPENING));
931        assert!(episode_prompt(&Episode { kind: regret::KIND.into(), ..ep("e2", "no") }, &[]).starts_with(OPENING));
932    }
933
934    /// The injected model for vetoes: like `fake_ask` on the veto's section, plus `invariant:<rest>`.
935    fn fake_veto_ask(p: &str) -> Option<Value> {
936        let reason = p.split("## Engineer's veto\n").nth(1)?.lines().next()?;
937        let after = |k: &str| reason.split(k).nth(1).and_then(|r| r.split_whitespace().next()).unwrap_or("").to_string();
938        let invariant = reason.split("invariant:").nth(1).unwrap_or("").trim().to_string();
939        Some(json!({"is_lesson": true, "question": format!("Q {reason}"), "situations": [], "consequence": "c",
940            "same_as": after("merge:"), "supersedes": [], "invariant": invariant}))
941    }
942
943    fn veto(id: &str, session: &str, ms: i64, reason: &str) -> Episode {
944        Episode { kind: crate::veto::KIND.into(), ..at(id, session, ms, "ann@x", reason) }
945    }
946
947    #[test]
948    fn a_veto_is_asked_with_its_own_prompt_and_schema() {
949        let p = episode_prompt(&veto("no-s-1", "s", 1, "never read the commit log for merges"), &[]);
950        assert!(p.starts_with(crate::veto::OPENING), "{p}");
951        for part in [
952            "## Engineer's veto\nnever read the commit log for merges",
953            crate::veto::INVARIANT,
954            REACH_RULES,
955            FIELDS,
956            MERGE_RULES,
957            "## Task the agent had\nplan the relay",
958        ] {
959            assert!(p.contains(part), "{part}");
960        }
961        assert!(!p.contains(OPENING) && !p.contains("## Engineer's correction"));
962        let without = episode_prompt(&Episode { agent: String::new(), ..veto("no-s-2", "s", 1, "no") }, &[]);
963        assert!(without.contains("## What the agent proposed (excerpt)\n(not available)"), "{without}");
964        assert!(schema_for(&p)["required"].as_array().unwrap().contains(&json!("invariant")));
965        assert!(schema_for(&p)["properties"]["invariant"].is_object());
966        let correction = episode_prompt(&ep("e1", "no, the lock"), &[]);
967        assert_eq!(schema_for(&correction), schema(), "a correction's call is unchanged");
968        // The invariant comes back with the lesson; a correction's answer has none.
969        let (l, inv) =
970            generalise_with(&veto("no-s-3", "s", 1, "merge lookups invariant: lookups go through the review API"), &[], &fake_veto_ask).unwrap().unwrap();
971        assert_eq!((l.id.as_str(), inv.as_str()), ("Lno-s-3", "lookups go through the review API"));
972        let (_, none) = generalise_with(&ep("e2", "no, the lock merge:x"), &[], &fake_ask).unwrap().unwrap();
973        assert!(none.is_empty());
974    }
975
976    #[test]
977    fn a_veto_is_learned_at_any_sessions_end_and_never_counted_as_a_correction() {
978        let home = temp_home("veto-any");
979        crate::learn::record_episode_in(&home, &veto("no-s1-1", "s1", 1, "never read the commit log for merges")).unwrap();
980        append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 2, "ann@x", "a correction of s1")).unwrap();
981        assert!(!crate::stats::events(&home).iter().any(|e| e.kind == crate::stats::CORRECTION), "count_correction skips a veto");
982        // s2 ends: the veto of s1 is learned, s1's correction waits for its own session.
983        assert_eq!(learn_in(&home, Some("s2"), &fake_veto_ask, true, 3), (1, 0, 0));
984        let ls = lessons(&home.join("lessons.jsonl"));
985        assert_eq!(ls.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(), ["Lno-s1-1"]);
986        let news: Vec<crate::stats::Event> = crate::stats::events(&home).into_iter().filter(|e| e.kind == crate::stats::LESSON_NEW).collect();
987        assert_eq!(news[0].why, "veto");
988        let _ = std::fs::remove_dir_all(home);
989    }
990
991    #[test]
992    fn a_veto_merged_into_an_earlier_vetos_lesson_is_a_repeat() {
993        let home = temp_home("veto-repeat");
994        let repeats = |home: &Path| crate::stats::events(home).iter().filter(|e| e.kind == crate::stats::VETO_REPEAT).count();
995        // A lesson from a correction, then a veto merged into it: no repeat (the first "no").
996        append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
997        append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-2", "s1", 2, "the lock again merge:Le0")).unwrap();
998        // Le0 was asked in s1 before the veto: the veto credits it (veto.rs), learning never adds a correction.
999        let asked = outcome::Outcome { kind: outcome::ASKED.into(), session: "s1".into(), lessons: vec!["Le0".into()], ms: 1, ..Default::default() };
1000        outcome::append(&home.join("outcomes.jsonl"), &asked).unwrap();
1001        learn_in(&home, None, &|p| if p.starts_with(crate::veto::OPENING) { fake_veto_ask(p) } else { fake_ask(p) }, true, 3);
1002        assert_eq!(repeats(&home), 0);
1003        let results = outcome::read(&home.join("outcomes.jsonl"));
1004        assert!(!results.iter().any(|o| o.kind == outcome::CORRECTED || o.kind == outcome::MISSED), "{results:?}");
1005        assert!(!crate::stats::events(&home).iter().any(|e| e.kind == crate::stats::CORRECTED));
1006        // A second veto with the same reason: merged into the lesson a veto is part of.
1007        append_jsonl(&home.join("episodes.jsonl"), &veto("no-s2-4", "s2", 4, "the lock, third time merge:Le0")).unwrap();
1008        assert_eq!(learn_in(&home, Some("s2"), &fake_veto_ask, true, 5), (0, 1, 0));
1009        assert_eq!(repeats(&home), 1);
1010        let r = crate::stats::events(&home).into_iter().find(|e| e.kind == crate::stats::VETO_REPEAT).unwrap();
1011        assert_eq!((r.session.as_str(), r.lessons.clone()), ("s2", vec!["Le0".to_string()]));
1012        assert_eq!(r.ms, 4, "dated when the veto was said, the week of its own veto");
1013        let _ = std::fs::remove_dir_all(home);
1014    }
1015
1016    #[test]
1017    fn an_invariant_of_a_veto_becomes_a_proposed_constraint_never_a_constraint() {
1018        let home = temp_home("veto-invariant");
1019        append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-1", "s1", 1, "no commit log invariant: merge lookups use the review API")).unwrap();
1020        append_jsonl(&home.join("episodes.jsonl"), &veto("no-s1-2", "s1", 2, "not now, later")).unwrap();
1021        learn_in(&home, Some("s1"), &fake_veto_ask, true, 3);
1022        let queue = crate::queen::entries(&home);
1023        assert_eq!(queue.len(), 1, "only the veto that named an invariant");
1024        assert_eq!((queue[0].kind, queue[0].session.as_str()), (crate::queen::Kind::Constraint, "s1"));
1025        assert!(
1026            queue[0].evidence.contains(&"invariant: merge lookups use the review API".to_string())
1027                && queue[0].evidence.contains(&"lesson Lno-s1-1".to_string())
1028        );
1029        assert!(!crate::veto::constraints_path(&home).exists());
1030        // A personal or dropped lesson never proposes; nor does one without an invariant.
1031        let l = Lesson { id: "L".into(), ..Lesson::default() };
1032        assert!(proposes(&crate::reach::Placed::Here(Box::new(l)), "x") && !proposes(&crate::reach::Placed::Dropped, "x"));
1033        assert!(!proposes(&crate::reach::Placed::Private(home.clone()), "x") && !proposes(&crate::reach::Placed::Here(Box::default()), " "));
1034        let _ = std::fs::remove_dir_all(home);
1035    }
1036
1037    #[test]
1038    fn detects_corrections_and_rejections() {
1039        assert!(is_correction("double check against spec document"));
1040        assert!(is_correction("No, let's not move this into the net"));
1041        assert!(!is_correction("let's pick up the gpt live plan"));
1042        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";
1043        assert_eq!(rejection(err).as_deref(), Some("carefully check TOCTOU"));
1044        assert_eq!(rejection("Exit code 1\nTraceback"), None);
1045        // omp's refusals carry no words; the reason follows as the next prompt.
1046        assert!(denied("Tool call denied by user: bash"));
1047        assert!(denied("Tool call rejected by user (edit)"));
1048        assert!(denied(
1049            "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."
1050        ));
1051        assert!(!denied(err), "a rejection with words is captured directly");
1052        assert!(!denied("Exit code 1\nTool call denied by user: bash"), "only the harness's own message, not tool output");
1053    }
1054
1055    fn ep(id: &str, correction: &str) -> Episode {
1056        Episode {
1057            id: id.into(),
1058            ms: 1,
1059            repo: "r".into(),
1060            session: "s".into(),
1061            task: "plan the relay".into(),
1062            agent: "one ReentrantLock guards delivery".into(),
1063            rejected_tool: None,
1064            correction: correction.into(),
1065            ..Episode::default()
1066        }
1067    }
1068
1069    /// The injected model: `same_as` is the lesson id after "merge:" in the correction, if any, and
1070    /// `supersedes` the one after "supersede:".
1071    fn fake_ask(p: &str) -> Option<Value> {
1072        let correction = p.split("## Engineer's correction\n").nth(1)?.lines().next()?;
1073        let after = |k: &str| correction.split(k).nth(1).and_then(|r| r.split_whitespace().next()).unwrap_or("").to_string();
1074        let supersedes: Vec<String> = Some(after("supersede:")).filter(|x| !x.is_empty()).into_iter().collect();
1075        Some(json!({"is_lesson": true, "question": format!("Q {correction}"), "situations": [], "consequence": "c",
1076            "same_as": after("merge:"), "supersedes": supersedes}))
1077    }
1078
1079    fn results(home: &Path) -> Vec<(String, String, String)> {
1080        outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind != outcome::ASKED).map(|o| (o.kind, o.session, o.lessons.join(","))).collect()
1081    }
1082
1083    #[test]
1084    fn every_model_call_is_reported_with_its_place_and_verdict() {
1085        let home = temp_home("progress");
1086        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")] {
1087            append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1088        }
1089        let mut steps = Vec::new();
1090        learn_in_reporting(&home, None, &fake_ask, false, 3, &mut |st| steps.push((st.done, st.total, st.verdict.split(':').next().unwrap().to_string())));
1091        assert_eq!(steps, [(1, 2, "new lesson Le0".to_string()), (2, 2, "merged into Le0".to_string())]);
1092        // A second run has nothing left to ask, so it reports nothing.
1093        steps.clear();
1094        learn_in_reporting(&home, None, &fake_ask, false, 4, &mut |st| steps.push((st.done, st.total, String::new())));
1095        assert!(steps.is_empty());
1096        let _ = std::fs::remove_dir_all(home);
1097    }
1098
1099    #[test]
1100    fn a_lesson_contradicted_after_asking_is_corrected_not_followed() {
1101        let home = temp_home("contradicted");
1102        append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
1103        learn_in(&home, Some("s0"), &fake_ask, true, 2);
1104        // s1 asks Le0, then the engineer contradicts it: a new lesson superseding Le0.
1105        asked(&home, "s1", 10, &["Le0"]);
1106        append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 20, "bob@x", "no, CAS on the flag supersede:Le0")).unwrap();
1107        assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (1, 0, 0));
1108        assert_eq!(results(&home), [(outcome::CORRECTED.into(), "s1".into(), "Le0".into())]);
1109        let _ = std::fs::remove_dir_all(home);
1110    }
1111
1112    #[test]
1113    fn a_correction_of_a_lesson_that_applied_but_was_not_asked_is_a_miss() {
1114        let home = temp_home("missed");
1115        for e in [at("e0", "s0", 1, "ann@x", "use the lock on the flag"), at("e1", "s0", 2, "bob@x", "retry with jitter")] {
1116            append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1117        }
1118        learn_in(&home, Some("s0"), &fake_ask, true, 3);
1119        // s1's check graded both as applying and chose to ask only Le1; the engineer then corrects the
1120        // drone on Le0's concern: Le0 was needed and not asked.
1121        let graded = outcome::Outcome {
1122            kind: outcome::GRADED.into(),
1123            session: "s1".into(),
1124            lessons: vec!["Le0".into(), "Le1".into()],
1125            ms: 10,
1126            repo: "r".into(),
1127            grades: vec![2, 3],
1128            ranks: vec![0, 1],
1129            applied: 2,
1130            chosen: vec!["Le1".into()],
1131            ..Default::default()
1132        };
1133        outcome::append(&home.join("outcomes.jsonl"), &graded).unwrap();
1134        asked(&home, "s1", 11, &["Le1"]);
1135        append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "cy@x", "no, the lock on the flag merge:Le0")).unwrap();
1136        assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (0, 1, 0));
1137        let r = results(&home);
1138        assert!(r.contains(&(outcome::MISSED.into(), "s1".into(), "Le0".into())), "{r:?}");
1139        // The asked one is not a miss; a second run records nothing new.
1140        assert!(!r.iter().any(|(k, _, l)| k == outcome::MISSED && l == "Le1"), "{r:?}");
1141        learn_in(&home, Some("s1"), &fake_ask, true, 40);
1142        assert_eq!(results(&home).iter().filter(|(k, ..)| k == outcome::MISSED).count(), 1);
1143        let _ = std::fs::remove_dir_all(home);
1144    }
1145
1146    #[test]
1147    fn siding_with_one_side_of_a_disagreement_corrects_the_other() {
1148        let home = temp_home("sides");
1149        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")] {
1150            append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1151        }
1152        learn_in(&home, Some("s0"), &fake_ask, true, 3);
1153        // The conscience rendered Le0 and Le1 as one disagreement; the engineer sides with Le0.
1154        let o = outcome::Outcome {
1155            kind: outcome::ASKED.into(),
1156            session: "s1".into(),
1157            lessons: vec!["Le0".into(), "Le1".into()],
1158            ms: 10,
1159            repo: "r".into(),
1160            groups: vec![vec!["Le0".into(), "Le1".into()]],
1161            ..Default::default()
1162        };
1163        outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
1164        append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "cy@x", "no, the lock on the flag merge:Le0")).unwrap();
1165        assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 30), (0, 1, 0));
1166        assert_eq!(results(&home), [(outcome::CORRECTED.into(), "s1".into(), "Le1".into()), (outcome::NOT_CORRECTED.into(), "s1".into(), "Le0".into())]);
1167        // cy joined Le0 at the correction; ann's earlier evidence is not his.
1168        let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1169        assert_eq!(l0.joined.get("cy@x"), Some(&20));
1170        let _ = std::fs::remove_dir_all(home);
1171    }
1172
1173    #[test]
1174    fn the_learn_prompt_offers_at_most_merge_candidates_lessons_for_merging() {
1175        let lib: Vec<Lesson> = (0..MERGE_CANDIDATES + 2)
1176            .map(|i| Lesson {
1177                id: format!("L{i}"),
1178                origin_ms: i as i64,
1179                question: format!("Is the ReentrantLock held while delivery {i} runs?"),
1180                consequence: "c".into(),
1181                version: 1,
1182                ..Lesson::default()
1183            })
1184            .collect();
1185        let e = ep("e1", "no, the ReentrantLock must guard delivery");
1186        let offered = similar(&e, &lib);
1187        assert_eq!(offered.len(), MERGE_CANDIDATES, "every lesson matches; the prompt offers the best few");
1188        let prompt = std::cell::RefCell::new(String::new());
1189        let _ = generalise(&e, &lib, &|p| {
1190            *prompt.borrow_mut() = p.to_string();
1191            None
1192        });
1193        let named = lib.iter().filter(|l| prompt.borrow().contains(&format!("[{}]", l.id))).count();
1194        assert_eq!(named, MERGE_CANDIDATES, "{}", prompt.borrow());
1195    }
1196
1197    #[test]
1198    fn a_superseded_lesson_is_not_a_merge_target_its_successor_is_shown() {
1199        let lesson = |id: &str, ms: i64, q: &str, sup: &[&str]| Lesson {
1200            id: id.into(),
1201            origin_ms: ms,
1202            repo: "r".into(),
1203            question: q.into(),
1204            situations: vec![],
1205            consequence: "c".into(),
1206            support: 1,
1207            episodes: vec![],
1208            history: vec![],
1209            version: 1,
1210            engineers: vec!["ann@x".into()],
1211            joined: Default::default(),
1212            area: "concurrency".into(),
1213            supersedes: sup.iter().map(|s| s.to_string()).collect(),
1214            source: String::new(),
1215            scope: vec![],
1216            overrides: String::new(),
1217            origin: String::new(),
1218            facets: vec![],
1219            reach: String::new(),
1220        };
1221        let lib = [lesson("L0", 1, "Is the closed flag guarded by the ReentrantLock?", &[]), lesson("L1", 2, "Would a CAS suffice?", &["L0"])];
1222        // ann reverts to the lock: the model only sees L1 (in L0's place), contradicts it, and the
1223        // restored view becomes a new lesson that supersedes L1 and is asked again.
1224        let shown = std::cell::RefCell::new(String::new());
1225        let back = generalise(&ep("e9", "no, guard the closed flag with the ReentrantLock"), &lib, &|p| {
1226            *shown.borrow_mut() = p.to_string();
1227            Some(json!({"is_lesson": true, "question": "Is the closed flag guarded by the ReentrantLock?", "situations": [],
1228                "consequence": "c", "same_as": "L0", "area": "concurrency", "supersedes": ["L1"]}))
1229        })
1230        .unwrap()
1231        .unwrap();
1232        let shown = shown.into_inner();
1233        assert!(shown.contains("[L1]") && !shown.contains("[L0]"), "{shown}");
1234        assert_eq!((back.version, back.supersedes.clone()), (1, vec!["L1".to_string()]), "same_as on a hidden lesson is no merge");
1235        let all: Vec<Challenge> = lib
1236            .iter()
1237            .chain([&back])
1238            .map(|l| Challenge {
1239                id: l.id.clone(),
1240                origin_ms: l.origin_ms,
1241                question: l.question.clone(),
1242                engineers: l.engineers.clone(),
1243                supersedes: l.supersedes.clone(),
1244                ..Default::default()
1245            })
1246            .collect();
1247        assert_eq!(trust::successors(&all).get("L0").map(String::as_str), Some(back.id.as_str()));
1248    }
1249
1250    fn temp_home(name: &str) -> PathBuf {
1251        let d = std::env::temp_dir().join(format!("borg-learn-{name}-{}", std::process::id()));
1252        let _ = std::fs::remove_dir_all(&d);
1253        std::fs::create_dir_all(&d).unwrap();
1254        d
1255    }
1256
1257    fn at(id: &str, session: &str, ms: i64, engineer: &str, correction: &str) -> Episode {
1258        Episode { ms, session: session.into(), engineer: engineer.into(), ..ep(id, correction) }
1259    }
1260
1261    fn asked(home: &Path, session: &str, ms: i64, ids: &[&str]) {
1262        let o = outcome::Outcome {
1263            kind: outcome::ASKED.into(),
1264            session: session.into(),
1265            lessons: ids.iter().map(|i| i.to_string()).collect(),
1266            ms,
1267            repo: "r".into(),
1268            ..Default::default()
1269        };
1270        outcome::append(&home.join("outcomes.jsonl"), &o).unwrap();
1271    }
1272
1273    fn regret_at(id: &str, decision_ms: i64) -> Episode {
1274        let r = regret::Regret { decision_session: "s-dec".into(), decision_ms, signals: vec!["subject".into()], ..regret::Regret::default() };
1275        Episode { kind: regret::KIND.into(), regret: Some(r), ..at(id, "s-fix", 100, "bob@x", "Fixed later (3d, subject): fix: take the lock") }
1276    }
1277
1278    /// The model's verdict on a regret: a lesson, or (`lesson` false) not one.
1279    fn regret_ask(lesson: bool) -> impl Fn(&str) -> Option<Value> {
1280        move |_| {
1281            Some(json!({"is_lesson": lesson, "question": "Before reading a shared flag, has a writer been considered?", "situations": [],
1282            "consequence": "a race", "same_as": "", "supersedes": []}))
1283        }
1284    }
1285
1286    #[test]
1287    fn a_settled_lesson_says_why_the_judge_gave_it_no_verdict() {
1288        let home = temp_home("unjudged");
1289        asked(&home, "s1", 10, &["A", "B", "C", "D"]);
1290        let ignored =
1291            outcome::Outcome { kind: outcome::IGNORED.into(), session: "s1".into(), lessons: vec!["A".into()], ms: 12, repo: "r".into(), ..Default::default() };
1292        outcome::append(&home.join("outcomes.jsonl"), &ignored).unwrap();
1293        std::fs::create_dir_all(home.join("state")).unwrap();
1294        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";
1295        std::fs::write(home.join("state/s1.ctx"), ctx).unwrap();
1296        learn_in(&home, Some("s1"), &fake_ask, true, 100);
1297        let why: Vec<(String, String)> = outcome::read(&home.join("outcomes.jsonl"))
1298            .into_iter()
1299            .filter(|o| o.kind == outcome::NOT_CORRECTED)
1300            .map(|o| (o.lessons.join(","), o.reason))
1301            .collect();
1302        let want = [("A", outcome::UNJUDGED_NONE), ("B", outcome::UNJUDGED_TRIES), ("C", outcome::UNJUDGED_SLOTS), ("D", outcome::UNJUDGED_NO_CALL)];
1303        assert_eq!(why, want.map(|(l, r)| (l.to_string(), r.to_string())));
1304        let _ = std::fs::remove_dir_all(home);
1305    }
1306
1307    /// Settle waits for a session's end: idle by the log (no ask or episode for a day) is not enough while
1308    /// its state file is still being written.
1309    #[test]
1310    fn an_idle_session_still_working_is_not_settled() {
1311        let home = temp_home("open");
1312        asked(&home, "s1", 10, &["A"]);
1313        std::fs::create_dir_all(home.join("state")).unwrap();
1314        std::fs::write(home.join("state/s1.ctx"), "a prompt just now\n").unwrap();
1315        let now = now_ms();
1316        learn_in(&home, None, &fake_ask, true, now);
1317        assert!(results(&home).is_empty(), "still open: {:?}", results(&home));
1318        // A day after its last line, it ended.
1319        learn_in(&home, None, &fake_ask, true, now + outcome::IDLE_MS + 60_000);
1320        assert_eq!(results(&home), [(outcome::NOT_CORRECTED.into(), "s1".into(), "A".into())]);
1321        let _ = std::fs::remove_dir_all(home);
1322    }
1323
1324    /// A regret is a correction of the deciding session's work too: the lesson it merges into, graded
1325    /// applying in that session before the decision and never asked there, was a question needed and not
1326    /// asked.
1327    #[test]
1328    fn a_regret_merged_into_a_lesson_graded_but_not_asked_before_the_decision_is_a_miss() {
1329        let home = temp_home("regret-miss");
1330        append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "ann@x", "use the lock on the flag")).unwrap();
1331        learn_in(&home, Some("s0"), &fake_ask, true, 2);
1332        let graded = |ms: i64| outcome::Outcome {
1333            kind: outcome::GRADED.into(),
1334            session: "s-dec".into(),
1335            lessons: vec!["Le0".into()],
1336            ms,
1337            repo: "r".into(),
1338            grades: vec![2],
1339            ..Default::default()
1340        };
1341        for g in [graded(10), graded(30)] {
1342            outcome::append(&home.join("outcomes.jsonl"), &g).unwrap();
1343        }
1344        append_jsonl(&home.join("episodes.jsonl"), &regret_at("R1", 20)).unwrap();
1345        let merge = |_: &str| Some(json!({"is_lesson": true, "question": "Q", "situations": [], "consequence": "c", "same_as": "Le0", "supersedes": []}));
1346        learn_in(&home, None, &merge, true, 200);
1347        let missed: Vec<(String, String, i64, String)> = outcome::read(&home.join("outcomes.jsonl"))
1348            .into_iter()
1349            .filter(|o| o.kind == outcome::MISSED)
1350            .map(|o| (o.lessons.join(","), o.session, o.ms, o.cause))
1351            .collect();
1352        assert_eq!(missed, [("Le0".to_string(), "s-dec".to_string(), 20, "R1".to_string())]);
1353        learn_in(&home, None, &merge, true, 300);
1354        assert_eq!(outcome::read(&home.join("outcomes.jsonl")).iter().filter(|o| o.kind == outcome::MISSED).count(), 1, "once");
1355        let _ = std::fs::remove_dir_all(home);
1356    }
1357
1358    #[test]
1359    fn a_regret_blames_lessons_asked_before_the_decision_once() {
1360        let regretted = |home: &Path| -> Vec<(String, String, f64, String)> {
1361            outcome::read(&home.join("outcomes.jsonl"))
1362                .into_iter()
1363                .filter(|o| o.kind == outcome::REGRETTED)
1364                .map(|o| (o.lessons.join(","), o.session, o.credit, o.cause))
1365                .collect()
1366        };
1367        let setup = |name: &str, e: Episode| {
1368            let home = temp_home(name);
1369            asked(&home, "s-dec", 10, &["Le0", "Le1"]);
1370            asked(&home, "s-dec", 50, &["Le2"]);
1371            append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1372            home
1373        };
1374        let home = setup("regret", regret_at("R1", 20));
1375        assert_eq!(learn_in(&home, None, &regret_ask(true), true, 200), (1, 0, 0));
1376        let got = regretted(&home);
1377        assert_eq!(
1378            got,
1379            [("Le0".into(), "s-dec".into(), 0.25, "R1".into()), ("Le1".into(), "s-dec".into(), 0.25, "R1".into())],
1380            "Le2 was asked after the decision"
1381        );
1382        let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1383        learn_in(&home, None, &regret_ask(true), true, 300);
1384        assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before, "credited once");
1385        let _ = std::fs::remove_dir_all(home);
1386        // Not a lesson, no decision time, not tracking: nothing is blamed.
1387        let home = setup("regret-no", regret_at("R1", 20));
1388        learn_in(&home, None, &regret_ask(false), true, 200);
1389        assert!(regretted(&home).is_empty(), "not a lesson");
1390        let _ = std::fs::remove_dir_all(home);
1391        let home = setup("regret-old", regret_at("R1", 0));
1392        learn_in(&home, None, &regret_ask(true), true, 200);
1393        assert!(regretted(&home).is_empty(), "a regret from before decision times were kept");
1394        let _ = std::fs::remove_dir_all(home);
1395        let home = setup("regret-untracked", regret_at("R1", 20));
1396        learn_in(&home, None, &regret_ask(true), false, 200);
1397        assert!(regretted(&home).is_empty(), "BORG_LEARN=off writes nothing");
1398        let _ = std::fs::remove_dir_all(home);
1399    }
1400
1401    #[test]
1402    fn records_outcomes_of_asked_lessons() {
1403        let home = temp_home("outcomes");
1404        // Session s0 teaches lessons Le0 (TOCTOU) and Le1 (spec).
1405        for e in [at("e0", "s0", 1, "ann@x", "check TOCTOU on the flag"), at("e1", "s0", 2, "", "check against the spec")] {
1406            append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1407        }
1408        assert_eq!(learn_in(&home, Some("s0"), &fake_ask, true, 3), (2, 0, 0));
1409        assert!(outcome::read(&home.join("outcomes.jsonl")).is_empty(), "nothing asked, nothing settled");
1410        // s1: both asked at 10; the TOCTOU concern is corrected again at 20 (merged into Le0).
1411        // Le1 asked again after that correction, which must not count as corrected.
1412        asked(&home, "s1", 10, &["Le0", "Le1"]);
1413        append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "bob@x", "flag still racy merge:Le0")).unwrap();
1414        asked(&home, "s1", 30, &["Le1"]);
1415        assert_eq!(learn_in(&home, Some("s1"), &fake_ask, true, 40), (0, 1, 0));
1416        let result: Vec<(String, String, i64)> =
1417            outcome::read(&home.join("outcomes.jsonl")).into_iter().filter(|o| o.kind != outcome::ASKED).map(|o| (o.kind, o.lessons.join(","), o.ms)).collect();
1418        assert_eq!(result, [(outcome::CORRECTED.into(), "Le0".into(), 20), (outcome::NOT_CORRECTED.into(), "Le1".into(), 40)]);
1419        // Re-running learn for the same session appends nothing: deterministic and idempotent.
1420        let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1421        learn_in(&home, Some("s1"), &fake_ask, true, 50);
1422        assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
1423        // Engineers accumulate on the merged lesson.
1424        let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1425        assert_eq!(l0.engineers, ["ann@x", "bob@x"]);
1426        // The same email in another case is the same engineer: not added, no join recorded.
1427        append_jsonl(&home.join("episodes.jsonl"), &at("e3", "s2", 60, "Ann@X", "flag racy again merge:Le0")).unwrap();
1428        assert_eq!(learn_in(&home, Some("s2"), &fake_ask, true, 70), (0, 1, 0));
1429        let l0 = lessons(&home.join("lessons.jsonl")).into_iter().find(|l| l.id == "Le0").unwrap();
1430        assert_eq!((l0.engineers.as_slice(), l0.joined.get("Ann@X")), (&["ann@x".to_string(), "bob@x".to_string()][..], None));
1431        let _ = std::fs::remove_dir_all(home);
1432    }
1433
1434    #[test]
1435    fn idle_sessions_settle_without_their_end_hook() {
1436        let home = temp_home("idle");
1437        for e in [at("e0", "s0", 1, "", "check TOCTOU on the flag"), at("e1", "s0", 2, "", "check against the spec")] {
1438            append_jsonl(&home.join("episodes.jsonl"), &e).unwrap();
1439        }
1440        learn_in(&home, Some("s0"), &fake_ask, true, 3);
1441        // s1 and s3 never send `end`. s1 asks both and is corrected on Le0; s3 asks Le0, no correction.
1442        asked(&home, "s1", 10, &["Le0", "Le1"]);
1443        append_jsonl(&home.join("episodes.jsonl"), &at("e2", "s1", 20, "", "flag still racy merge:Le0")).unwrap();
1444        asked(&home, "s3", 5, &["Le0"]);
1445        let idle = 20 + outcome::IDLE_MS;
1446        let results = || -> Vec<(String, String, String)> {
1447            outcome::read(&home.join("outcomes.jsonl"))
1448                .into_iter()
1449                .filter(|o| o.kind != outcome::ASKED)
1450                .map(|o| (o.kind, o.session, o.lessons.join(",")))
1451                .collect()
1452        };
1453        let not = |s: &str, l: &str| (outcome::NOT_CORRECTED.to_string(), s.to_string(), l.to_string());
1454        // s2 ends a day later: its own ask settles, and idle s3; s1 not, its episode is still pending.
1455        asked(&home, "s2", idle - 1, &["Le1"]);
1456        learn_in(&home, Some("s2"), &fake_ask, true, idle);
1457        assert_eq!(results(), [not("s3", "Le0"), not("s2", "Le1")]);
1458        // A plain `borg learn` folds e2 in and settles s1 too, so s1 gives both kinds of outcome.
1459        assert_eq!(learn_in(&home, None, &fake_ask, true, idle), (0, 1, 0));
1460        let corrected = (outcome::CORRECTED.to_string(), "s1".to_string(), "Le0".to_string());
1461        assert_eq!(results(), [not("s3", "Le0"), not("s2", "Le1"), corrected.clone(), not("s1", "Le1")]);
1462        // A fresh session is not settled, and re-running appends nothing.
1463        asked(&home, "s4", idle, &["Le1"]);
1464        learn_in(&home, None, &fake_ask, true, idle + 1);
1465        assert_eq!(results().len(), 4);
1466        let _ = std::fs::remove_dir_all(home);
1467    }
1468
1469    #[test]
1470    fn no_outcomes_written_without_tracking() {
1471        let home = temp_home("off");
1472        append_jsonl(&home.join("episodes.jsonl"), &at("e0", "s0", 1, "", "check TOCTOU")).unwrap();
1473        learn_in(&home, Some("s0"), &fake_ask, true, 2);
1474        asked(&home, "s1", 10, &["Le0"]);
1475        let before = std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap();
1476        append_jsonl(&home.join("episodes.jsonl"), &at("e1", "s1", 20, "", "TOCTOU again merge:Le0")).unwrap();
1477        assert_eq!(learn_in(&home, Some("s1"), &fake_ask, false, 30), (0, 1, 0));
1478        assert_eq!(std::fs::read_to_string(home.join("outcomes.jsonl")).unwrap(), before);
1479        let _ = std::fs::remove_dir_all(home);
1480    }
1481
1482    #[test]
1483    fn failed_model_calls_leave_episodes_waiting_and_stop_early() {
1484        let home = temp_home("failed");
1485        for i in 0..5 {
1486            append_jsonl(&home.join("episodes.jsonl"), &at(&format!("e{i}"), "s0", i, "", &format!("check case {i}"))).unwrap();
1487        }
1488        asked(&home, "s0", 0, &["Lx"]);
1489        let calls = std::cell::Cell::new(0);
1490        let down = |_: &str| -> Option<Value> {
1491            calls.set(calls.get() + 1);
1492            None
1493        };
1494        // Quota hit: nothing is marked "not a lesson", the run gives up after MAX_FAILURES calls,
1495        // and the session is not settled while its episodes wait.
1496        assert_eq!(learn_in(&home, Some("s0"), &down, true, 1 + outcome::IDLE_MS), (0, 0, 5));
1497        assert_eq!(calls.get(), MAX_FAILURES);
1498        assert!(!home.join("episodes-not-lessons.txt").exists());
1499        assert!(outcome::read(&home.join("outcomes.jsonl")).iter().all(|o| o.kind == outcome::ASKED));
1500        // The model is back: the next run retries every episode.
1501        assert_eq!(learn_in(&home, Some("s0"), &fake_ask, true, 2 + outcome::IDLE_MS), (5, 0, 0));
1502        let _ = std::fs::remove_dir_all(home);
1503    }
1504
1505    #[test]
1506    fn area_and_supersedes_come_from_the_same_call() {
1507        let lib = vec![
1508            generalise(&ep("e1", "use a lock here"), &[], &|_| {
1509                Some(json!({"is_lesson": true, "question": "Is the flag guarded by a lock?", "situations": ["flag across threads"],
1510                    "consequence": "race", "same_as": "", "area": "Concurrency", "supersedes": ["Lnope"]}))
1511            })
1512            .unwrap()
1513            .unwrap(),
1514        ];
1515        assert_eq!(lib[0].area, "concurrency", "case-insensitive; normalised");
1516        assert!(lib[0].supersedes.is_empty(), "an id the model was not shown is dropped");
1517        // A lesson without an area (older store, or a model that ignores it) is "other".
1518        let old: Lesson = serde_json::from_str(
1519            r#"{"id":"L","origin_ms":1,"repo":"r","question":"q","situations":[],"consequence":"c",
1520            "support":1,"episodes":[],"history":[],"version":1}"#,
1521        )
1522        .unwrap();
1523        assert_eq!((old.area.as_str(), old.supersedes.len()), ("other", 0));
1524        let id = lib[0].id.clone();
1525        let reversed = generalise(&ep("e2", "no, the lock is wrong here, use the atomic flag"), &lib, &move |_| {
1526            Some(json!({"is_lesson": true, "question": "Is a lock-free atomic enough for the flag?", "situations": ["flag across threads"],
1527                "consequence": "lock contention", "same_as": "", "area": "made-up", "supersedes": [id.clone()]}))
1528        })
1529        .unwrap()
1530        .unwrap();
1531        assert_eq!((reversed.area.as_str(), reversed.supersedes.clone()), ("other", vec![lib[0].id.clone()]));
1532        assert!(!serde_json::to_string(&lib[0]).unwrap().contains("supersedes"), "empty supersedes stays off the line");
1533        // The merge target is never superseded; a merge keeps what the old version superseded.
1534        let (rid, lid) = (reversed.id.clone(), lib[0].id.clone());
1535        let merged = generalise(&ep("e3", "again: atomic, not a lock"), &[lib[0].clone(), reversed.clone()], &move |_| {
1536            Some(json!({"is_lesson": true, "question": "Is a lock-free atomic enough?", "situations": ["flag across threads"],
1537                "consequence": "lock contention", "same_as": rid.clone(), "area": "concurrency", "supersedes": [rid.clone(), lid.clone()]}))
1538        })
1539        .unwrap()
1540        .unwrap();
1541        assert_eq!((merged.id.as_str(), merged.version, merged.supersedes.clone()), (reversed.id.as_str(), 2, vec![lib[0].id.clone()]));
1542    }
1543
1544    #[test]
1545    fn new_lesson_then_merge_keeps_history() {
1546        let first = generalise(&ep("e1", "carefully check TOCTOU on the atomic flags"), &[], &|_| {
1547            Some(json!({"is_lesson": true, "question": "Is every check-then-act on a shared flag TOCTOU-safe?",
1548                "situations": ["check-then-act on a shared mutable flag across threads"], "consequence": "race", "same_as": ""}))
1549        })
1550        .unwrap()
1551        .unwrap();
1552        assert_eq!((first.version, first.support), (1, 1));
1553        let id = first.id.clone();
1554        let merged = generalise(&ep("e2", "again: the closed flag check is racy"), std::slice::from_ref(&first), &move |p| {
1555            assert!(p.contains(&id), "the similar lesson must be offered for merging");
1556            Some(json!({"is_lesson": true, "question": "Is each check-then-act on shared flags atomic or harmless on race?",
1557                "situations": ["check-then-act on a shared mutable flag across threads"], "consequence": "race", "same_as": id}))
1558        })
1559        .unwrap()
1560        .unwrap();
1561        assert_eq!((merged.id.as_str(), merged.version, merged.support, merged.history.len()), (first.id.as_str(), 2, 2, 1));
1562        let skipped =
1563            generalise(&ep("e3", "stop"), &[], &|_| Some(json!({"is_lesson": false, "question": "", "situations": [], "consequence": "", "same_as": ""})));
1564        assert!(matches!(skipped, Ok(None)), "a verdict: not a lesson");
1565        assert_eq!(generalise(&ep("e4", "no, the lock"), &[], &|_| None).map(|_| ()), Err(AskFailed), "no answer is no verdict");
1566        let malformed = generalise(&ep("e5", "no, the lock"), &[], &|_| Some(json!({"error": "rate limited"})));
1567        assert_eq!(malformed.map(|_| ()), Err(AskFailed));
1568    }
1569}