Skip to main content

borg/
reach.rs

1//! Who should hear a lesson: its reach.
2//!
3//! A correction can be an engineering concern anyone on the code should hear (the team, the default),
4//! a convention of one repo (`repo`: delivered only there), or one engineer's taste in how an agent
5//! works with them (`personal`: commit style, attribution, branching, tone). A personal lesson stays in
6//! the collective it was learned in, in that store's local-only [`PERSONAL_FILE`] beside
7//! `lessons.jsonl`: never synced (borg-mesh `SYNCED` does not list it), so it is delivered only to this
8//! engineer, and only in this collective. Nothing crosses a collective boundary automatically; moving a
9//! lesson elsewhere is the engineer's explicit `borg move`.
10//!
11//! In `lessons.jsonl` a personal lesson is only a tombstone, a newer version that withdraws one of this
12//! engineer's own team lessons. A lesson another engineer is on is never withdrawn from the team.
13
14use std::path::{Path, PathBuf};
15
16use crate::conscience::Challenge;
17use crate::learn::{Episode, Lesson, append_jsonl};
18
19/// For a lesson of one repo only.
20pub const REPO: &str = "repo";
21/// For a personal lesson (in [`PERSONAL_FILE`]) and for its tombstone in `lessons.jsonl`.
22pub const PERSONAL: &str = "personal";
23/// This engineer's personal lessons of a store, beside its `lessons.jsonl`: the same `Lesson` lines,
24/// versioned the same way, never synced and never exported.
25pub const PERSONAL_FILE: &str = "personal.jsonl";
26
27/// The personal lessons file of the store `home`.
28pub fn personal_path(home: &Path) -> PathBuf {
29    home.join(PERSONAL_FILE)
30}
31
32/// The latest version of each live personal lesson of the store `home` (none when the file is absent):
33/// the ones `borg move` took elsewhere (their tombstone, [`MOVED`]) are gone from here.
34pub fn personal_lessons(home: &Path) -> Vec<Lesson> {
35    personal_all(home).into_iter().filter(|l| l.question != MOVED).collect()
36}
37
38/// The latest version of every line of the store's personal file, a move's tombstone included: what
39/// "learned" counts (learn.rs `learned_episodes`), for a tombstone keeps its episodes.
40pub fn personal_all(home: &Path) -> Vec<Lesson> {
41    crate::learn::lessons(&personal_path(home))
42}
43
44/// The ids in the store's personal file, a move's tombstones included: what the team's synced logs
45/// never name (outcome.rs `publish`, stats.rs `rollup`). Empty, and no read, when the file is absent.
46pub fn personal_ids(home: &Path) -> std::collections::HashSet<String> {
47    personal_all(home).into_iter().map(|l| l.id).collect()
48}
49
50/// The question of the tombstone `borg move` leaves where a lesson was (moves.rs): no text left to
51/// deliver, merge into or sync on; its episodes stay, so they stay learned.
52pub const MOVED: &str = "(moved to another collective)";
53
54/// Whether `c` may be delivered in a session working in `repos` (systems.rs `work_repos`). A personal
55/// lesson is delivered as a team one by the loader that reads it (conscience.rs `load_store_for`): what
56/// reaches this check as personal is a tombstone.
57pub fn deliverable(c: &Challenge, repos: &[String]) -> bool {
58    match c.reach.as_str() {
59        PERSONAL => false,
60        REPO => repos.iter().any(|r| r.eq_ignore_ascii_case(&c.repo)),
61        _ => true,
62    }
63}
64
65/// The narrower of two reaches: a merge never widens who hears a lesson (personal > repo > team).
66pub fn narrower<'a>(a: &'a str, b: &'a str) -> &'a str {
67    let rank = |r: &str| match r {
68        PERSONAL => 2,
69        REPO => 1,
70        _ => 0,
71    };
72    if rank(b) > rank(a) { b } else { a }
73}
74
75/// The question a tombstone carries in `lessons.jsonl` instead of the withdrawn lesson: the lesson is
76/// personal to its engineer now, kept in this store's [`PERSONAL_FILE`]. It syncs, so it says only that.
77pub const PERSONAL_TOMBSTONE: &str = "(personal to its engineer)";
78/// The question of the tombstones a borg before 0.7.4 wrote, when a personal lesson went to a private
79/// collective: read only to find them (moves.rs doctor's repair), never written.
80pub const WITHDRAWN: &str = "(personal preference, moved to a private collective)";
81
82/// Whether every engineer on `engineers` is `me` (under this machine's aliases), and there is one: only
83/// then is a lesson provably this engineer's alone. An unknown `me` (no git email) is nobody; a lesson
84/// with no engineer recorded (an old one, a queen answer without author, an import) is nobody's provably.
85pub fn only(engineers: &[String], me: &str) -> bool {
86    only_with(engineers, me, crate::collective::aliases())
87}
88
89/// [`only`] under explicit aliases (moves.rs).
90pub fn only_with(engineers: &[String], me: &str, aliases: &crate::collective::Aliases) -> bool {
91    !me.trim().is_empty() && !engineers.is_empty() && engineers.iter().all(|x| aliases.same(x.trim(), me.trim()))
92}
93
94/// Whether `l` was learned from sessions or queen answers, not imported: imports are written-down team
95/// knowledge, never withdrawn or re-sorted by one engineer's verdict.
96fn learned_here(l: &Lesson) -> bool {
97    l.source.is_empty() || l.source == "queen"
98}
99
100/// Where a learned lesson goes, in the store it was learned in (`write`).
101#[derive(Debug, Clone)]
102pub enum Placed {
103    /// `lessons.jsonl`, as usual.
104    Here(Box<Lesson>),
105    /// [`PERSONAL_FILE`]. `withdraw`: a tombstone for this engineer's own team lesson of the same id,
106    /// appended to `lessons.jsonl` after it.
107    Personal { lesson: Box<Lesson>, withdraw: Option<Box<Lesson>> },
108}
109
110impl Placed {
111    /// The lesson as it is written.
112    pub fn lesson(&self) -> &Lesson {
113        match self {
114            Placed::Here(l) | Placed::Personal { lesson: l, .. } => l,
115        }
116    }
117}
118
119/// Decides where `l`, the verdict on episode `e` in a store, goes. `mine`: `l.id` is one of the store's
120/// personal lessons; `team`: the store's team lesson with `l.id`, if any (a tombstone is none). Never a
121/// cross-store write: a personal lesson stays in this store's [`PERSONAL_FILE`].
122/// - Not personal and not mine: `lessons.jsonl`.
123/// - Mine already: a new version of it, personal (a merge never widens).
124/// - Personal and new: personal.
125/// - Personal, merged into a team lesson only this engineer (`e.engineer`) is on: personal, and the team
126///   version withdrawn.
127/// - Personal, merged into a team lesson another engineer is on, or no engineer is recorded on, or that
128///   was imported: the team lesson is left as it is and nothing of it is copied; the episode becomes a
129///   personal lesson of its own (`own`).
130pub fn place(l: &Lesson, e: &Episode, mine: bool, team: Option<&Lesson>) -> Placed {
131    if !mine && l.reach != PERSONAL {
132        return Placed::Here(Box::new(l.clone()));
133    }
134    let personal = |l: Lesson| Box::new(Lesson { reach: PERSONAL.into(), ..l });
135    match team {
136        Some(t) if !mine && !(only(&t.engineers, &e.engineer) && learned_here(t)) => Placed::Personal { lesson: personal(own(l, e)), withdraw: None },
137        Some(_) if !mine => Placed::Personal { lesson: personal(l.clone()), withdraw: Some(Box::new(tombstone(l))) },
138        _ => Placed::Personal { lesson: personal(l.clone()), withdraw: None },
139    }
140}
141
142/// The episode `e` alone as a new lesson in the wording of the verdict `l`: no other engineer, episode,
143/// history or supersession of the lesson it was merged into.
144fn own(l: &Lesson, e: &Episode) -> Lesson {
145    Lesson {
146        id: format!("L{}", e.id),
147        origin_ms: e.ms,
148        repo: e.repo.clone(),
149        question: l.question.clone(),
150        situations: l.situations.clone(),
151        consequence: l.consequence.clone(),
152        support: 1,
153        episodes: vec![e.id.clone()],
154        version: 1,
155        engineers: Some(e.engineer.clone()).filter(|x| !x.is_empty()).into_iter().collect(),
156        area: l.area.clone(),
157        ..Lesson::default()
158    }
159}
160
161/// Writes `p` to the store `home`: the personal lesson before its tombstone, so a failed write leaves
162/// the team lesson where it was rather than losing the lesson.
163///
164/// # Errors
165/// When a line cannot be appended.
166pub fn write(home: &Path, p: &Placed) -> std::io::Result<()> {
167    match p {
168        Placed::Here(l) => append_jsonl(&home.join("lessons.jsonl"), &**l),
169        Placed::Personal { lesson, withdraw } => {
170            append_jsonl(&personal_path(home), &**lesson)?;
171            match withdraw {
172                Some(t) => append_jsonl(&home.join("lessons.jsonl"), &**t),
173                None => Ok(()),
174            }
175        }
176    }
177}
178
179/// The newer version of `l` that withdraws it from `lessons.jsonl`: no text left to deliver or sync on.
180/// It keeps its episodes, so they stay learned.
181pub fn tombstone(l: &Lesson) -> Lesson {
182    Lesson {
183        version: l.version.max(1) + 1,
184        question: PERSONAL_TOMBSTONE.into(),
185        situations: Vec::new(),
186        consequence: String::new(),
187        history: Vec::new(),
188        facets: Vec::new(),
189        reach: PERSONAL.into(),
190        ..l.clone()
191    }
192}
193
194/// The answers a reach question offers (queen.rs `Kind::Reach`).
195pub const TEAM_OPTION: &str = "team: everyone working on this code should hear it";
196pub const PERSONAL_OPTION: &str = "personal: only for me, kept in this collective";
197/// Display: the characters of a lesson's question on a line of `borg reach`'s plan, after its id and
198/// before the model's reason: about one wide terminal line.
199const PLAN_CHARS: usize = 110;
200
201fn repo_option(repo: &str) -> String {
202    format!("repo: only in {}", if repo.is_empty() { "the repo it was learned in" } else { repo })
203}
204
205/// What an answer to a reach question chose: `PERSONAL`, `REPO` or "" (the team).
206fn verdict(text: &str) -> &'static str {
207    let t = text.trim().to_lowercase();
208    if t.starts_with("personal") || t.contains("private") || t.contains("only me") {
209        PERSONAL
210    } else if t.starts_with("repo") {
211        REPO
212    } else {
213        ""
214    }
215}
216
217/// Gives `l` (the latest version in the store `home`) the reach `to`: personal for `me` alone (kept in
218/// this store's [`PERSONAL_FILE`], the team version withdrawn), a new version for repo, nothing for the
219/// team. A lesson that is not provably `me`'s alone (`only`) is never re-sorted: an error, nothing written.
220pub fn apply_one(home: &Path, l: &Lesson, to: &str, me: &str) -> Result<(), String> {
221    match to {
222        PERSONAL | REPO if !only(&l.engineers, me) => Err(format!("{} is not yours alone (another engineer or none recorded): it stays with the team", l.id)),
223        PERSONAL => write(home, &Placed::Personal { lesson: Box::new(Lesson { reach: PERSONAL.into(), ..l.clone() }), withdraw: Some(Box::new(tombstone(l))) })
224            .map_err(|e| e.to_string()),
225        REPO => append_jsonl(&home.join("lessons.jsonl"), &Lesson { version: l.version + 1, reach: REPO.into(), ..l.clone() }).map_err(|e| e.to_string()),
226        _ => Ok(()),
227    }
228}
229
230/// The engineer working here: the git email of the current directory.
231fn me() -> String {
232    crate::learn::engineer(&std::env::current_dir().unwrap_or_default().display().to_string())
233}
234
235/// Applies the answer to a reach question (queen.rs `answer`), as the engineer working here.
236pub fn answer(store: &Path, e: &crate::queen::Entry, text: &str) -> Result<(), String> {
237    let id = e.evidence.first().and_then(|x| x.strip_prefix("lesson ")).ok_or("this question names no lesson")?;
238    let l = crate::learn::lessons(&store.join("lessons.jsonl")).into_iter().find(|l| l.id == id).ok_or(format!("lesson {id} is not in this collective"))?;
239    apply_one(store, &l, verdict(text), &me())
240}
241
242const PROMPT: &str = "A coding agent learned this lesson from an engineer's correction. Decide its reach: who should hear it. \
243Ask: would a teammate working on the same code get this correction too?
244- personal: how this one engineer likes the agent to work with them, not a property of the code: commit message style or \
245length, AI attribution or co-author trailers, whether and when to commit, push, branch or open a PR, what to stage, how to \
246report or ask, language and tone, their own tooling habits.
247- repo: a convention of the repo it was learned in that other repos do not share (its branch or deploy workflow, a tool or \
248command it mandates).
249- team: an engineering concern about the code, design, correctness or verification that any teammate should hear.
250Set confident to false when the lesson mixes these or you cannot tell. why: one short sentence.";
251
252fn schema() -> serde_json::Value {
253    serde_json::json!({"type": "object", "additionalProperties": false, "properties": {
254        "reach": {"type": "string", "enum": ["team", "repo", "personal"]}, "confident": {"type": "boolean"}, "why": {"type": "string"}},
255        "required": ["reach", "confident", "why"]})
256}
257
258/// One line of a reach plan: a lesson and the model's verdict on who should hear it.
259#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
260pub struct Verdict {
261    pub id: String,
262    pub repo: String,
263    pub question: String,
264    pub reach: String,
265    pub confident: bool,
266    pub why: String,
267}
268
269/// Whether a cleanup by `me` may consider `l`: learned from sessions or queen answers, still for the team,
270/// and no other engineer on it (`only`): this machine never re-sorts a teammate's lesson. Imports are
271/// left alone: written-down team knowledge, scoped by system already.
272fn candidate(l: &Lesson, me: &str) -> bool {
273    l.reach.is_empty() && learned_here(l) && only(&l.engineers, me)
274}
275
276/// The lessons of the store `home` a cleanup by `me` classifies (`candidate`).
277pub fn candidates(home: &Path, me: &str) -> Vec<Lesson> {
278    crate::learn::lessons(&home.join("lessons.jsonl")).into_iter().filter(|l| candidate(l, me)).collect()
279}
280
281/// A structured model call: the answer and its cost (learn.rs `ask_model`).
282pub type Ask = dyn Fn(&str, &serde_json::Value) -> Option<(serde_json::Value, Option<f64>)> + Sync;
283
284/// Classifies `lessons` with one model call each (`jobs` at a time). Returns the verdicts and the cost.
285/// After `runner::MAX_FAILURES` failed calls before the first answer the model is unavailable: the
286/// rest are not asked (they stay candidates for the next run).
287pub fn classify(lessons: &[Lesson], jobs: usize, ask: &Ask) -> (Vec<Verdict>, f64) {
288    use std::sync::atomic::{AtomicUsize, Ordering};
289    let out = std::sync::Mutex::new((Vec::new(), 0.0));
290    let (next, failures, answers) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0));
291    std::thread::scope(|s| {
292        for _ in 0..jobs.max(1) {
293            s.spawn(|| {
294                loop {
295                    if failures.load(Ordering::SeqCst) >= crate::runner::MAX_FAILURES && answers.load(Ordering::SeqCst) == 0 {
296                        break;
297                    }
298                    let i = next.fetch_add(1, Ordering::Relaxed);
299                    let Some(l) = lessons.get(i) else { break };
300                    let prompt = format!("{PROMPT}\n\nLesson (learned in repo {}):\nQuestion: {}\nConsequence: {}\n", l.repo, l.question, l.consequence);
301                    let Some((v, cost)) = ask(&prompt, &schema()) else {
302                        failures.fetch_add(1, Ordering::SeqCst);
303                        continue;
304                    };
305                    answers.fetch_add(1, Ordering::SeqCst);
306                    let reach = v["reach"].as_str().filter(|r| [REPO, PERSONAL].contains(r)).unwrap_or_default();
307                    let verdict = Verdict {
308                        id: l.id.clone(),
309                        repo: l.repo.clone(),
310                        question: l.question.clone(),
311                        reach: reach.into(),
312                        confident: v["confident"].as_bool().unwrap_or(false),
313                        why: v["why"].as_str().unwrap_or_default().into(),
314                    };
315                    let mut g = out.lock().unwrap();
316                    g.0.push(verdict);
317                    g.1 += cost.unwrap_or(0.0);
318                }
319            });
320        }
321    });
322    let (mut v, cost) = out.into_inner().unwrap();
323    v.sort_by(|a, b| a.id.cmp(&b.id));
324    (v, cost)
325}
326
327/// What applying a plan did.
328#[derive(Debug, Default, PartialEq)]
329pub struct Applied {
330    /// Made personal: kept for this engineer in this collective, withdrawn from the team.
331    pub personal: usize,
332    pub scoped: usize,
333    pub asked: usize,
334}
335
336/// Applies a plan to the store `home` as `me`: confident personal verdicts become personal, confident
337/// repo verdicts are scoped, unsure ones become queen questions; team verdicts change nothing. A lesson
338/// that is no `candidate` (now) is skipped, whatever the plan says.
339pub fn apply(home: &Path, plan: &[Verdict], me: &str, now: i64) -> Result<Applied, String> {
340    let lessons = crate::learn::lessons(&home.join("lessons.jsonl"));
341    let mut done = Applied::default();
342    for v in plan {
343        let Some(l) = lessons.iter().find(|l| l.id == v.id && candidate(l, me)) else { continue };
344        match (v.confident, v.reach.as_str()) {
345            (true, PERSONAL) => {
346                apply_one(home, l, PERSONAL, me)?;
347                done.personal += 1;
348            }
349            (true, REPO) => {
350                apply_one(home, l, REPO, me)?;
351                done.scoped += 1;
352            }
353            (true, _) => {}
354            (false, _) => {
355                let key = crate::queen::decision_key(crate::queen::Kind::Reach, &l.repo, &l.id);
356                let default = match v.reach.as_str() {
357                    PERSONAL => PERSONAL_OPTION.to_string(),
358                    REPO => repo_option(&l.repo),
359                    _ => TEAM_OPTION.to_string(),
360                };
361                let e = crate::queen::Entry {
362                    id: format!("q{}", &key[..8]),
363                    decision_key: key,
364                    kind: crate::queen::Kind::Reach,
365                    question: format!("Who should hear this lesson? \"{}\"", crate::stats::excerpt(&l.question, crate::queen::QUOTE_CHARS)),
366                    options: vec![TEAM_OPTION.into(), repo_option(&l.repo), PERSONAL_OPTION.into()],
367                    default,
368                    evidence: vec![format!("lesson {}", l.id), format!("model: {}", v.why)],
369                    session: "reach".into(),
370                    cwd: String::new(),
371                    repo: l.repo.clone(),
372                    engineer: l.engineers.first().cloned().unwrap_or_default(),
373                    ms: now,
374                    answer: None,
375                    answered_by: None,
376                    answered_ms: None,
377                    disagreeing: Vec::new(),
378                    dismissed: None,
379                    dismissed_ms: None,
380                    payload: None,
381                };
382                if crate::queen::seed(home, &e).map_err(|e| e.to_string())? {
383                    done.asked += 1;
384                }
385            }
386        }
387    }
388    Ok(done)
389}
390
391/// `borg reach [--plan FILE] [--apply] [--jobs N]`: the cleanup of a store's lessons by reach (module doc).
392pub fn main(args: &[&str]) -> i32 {
393    let flag = |name: &str| args.iter().position(|a| *a == name).and_then(|i| args.get(i + 1)).copied();
394    let apply_it = args.contains(&"--apply");
395    let cwd = std::env::current_dir().unwrap_or_default().display().to_string();
396    let cfg = match crate::collective::load() {
397        Ok(c) => c,
398        Err(e) => {
399            eprintln!("borg reach: invalid collectives config: {e}");
400            return 1;
401        }
402    };
403    let crate::collective::Route::Collective { name, home, .. } = crate::collective::route(cfg.as_ref(), &cwd, &crate::learn::home()) else {
404        eprintln!("borg reach: {cwd} belongs to no collective");
405        return 1;
406    };
407    let plan_path = flag("--plan").map(PathBuf::from).unwrap_or_else(|| home.join("reach-plan.json"));
408    if apply_it {
409        let plan: Vec<Verdict> = match std::fs::read_to_string(&plan_path).ok().and_then(|t| serde_json::from_str(&t).ok()) {
410            Some(p) => p,
411            None => {
412                eprintln!("borg reach: no plan at {} (run `borg reach` first)", plan_path.display());
413                return 1;
414            }
415        };
416        return match apply(&home, &plan, &me(), crate::learn::now_ms()) {
417            Ok(a) => {
418                println!(
419                    "{name}: {} kept personal (for you only, in {name}), {} limited to their repo, {} queued for the queen (`borg queen`)",
420                    a.personal, a.scoped, a.asked
421                );
422                0
423            }
424            Err(e) => {
425                eprintln!("borg reach: {e}");
426                1
427            }
428        };
429    }
430    let todo = candidates(&home, &me());
431    let jobs = flag("--jobs").and_then(|j| j.parse().ok()).unwrap_or(crate::runner::JOBS);
432    eprintln!("borg reach: classifying {} lessons of {name} (one model call each, {jobs} at a time)", todo.len());
433    let (plan, cost) = classify(&todo, jobs, &|p: &str, s: &serde_json::Value| crate::learn::ask_model(p, s));
434    for (label, pick) in [("personal: for you only, kept in this collective", PERSONAL), ("repo: deliver only in its repo", REPO)] {
435        let hits: Vec<&Verdict> = plan.iter().filter(|v| v.confident && v.reach == pick).collect();
436        println!("\n{label} ({})", hits.len());
437        for v in hits {
438            println!("  {}  {}  ({})", v.id, crate::stats::excerpt(&v.question, PLAN_CHARS), v.why);
439        }
440    }
441    let unsure: Vec<&Verdict> = plan.iter().filter(|v| !v.confident).collect();
442    println!("\nunsure: a question for the queen ({})", unsure.len());
443    for v in unsure {
444        println!("  {}  [{}?]  {}", v.id, if v.reach.is_empty() { "team" } else { &v.reach }, crate::stats::excerpt(&v.question, PLAN_CHARS));
445    }
446    let team = plan.iter().filter(|v| v.confident && v.reach.is_empty()).count();
447    println!("\nteam: unchanged ({team}); {} of {} classified, ${cost:.2}", plan.len(), todo.len());
448    match std::fs::write(&plan_path, serde_json::to_string_pretty(&plan).unwrap_or_default()) {
449        Ok(()) => {
450            println!("plan: {} (apply with `borg reach --apply --plan {}`)", plan_path.display(), plan_path.display());
451            0
452        }
453        Err(e) => {
454            eprintln!("borg reach: cannot write {}: {e}", plan_path.display());
455            1
456        }
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn lesson(reach: &str, repo: &str) -> Challenge {
465        Challenge { id: "L".into(), reach: reach.into(), repo: repo.into(), ..Default::default() }
466    }
467
468    #[test]
469    fn personal_is_never_delivered_and_repo_only_in_its_repo() {
470        let here = ["app".to_string()];
471        assert!(deliverable(&lesson("", "other"), &here), "team lessons go everywhere");
472        assert!(!deliverable(&lesson(PERSONAL, "app"), &here));
473        assert!(deliverable(&lesson(REPO, "App"), &here));
474        assert!(!deliverable(&lesson(REPO, "other"), &here));
475        assert!(!deliverable(&lesson(REPO, "app"), &[]), "unknown repo: a repo convention stays out");
476    }
477
478    fn tmp(tag: &str) -> PathBuf {
479        let d = std::env::temp_dir().join(format!("borg-reach-{tag}-{}", std::process::id()));
480        let _ = std::fs::remove_dir_all(&d);
481        std::fs::create_dir_all(&d).unwrap();
482        d
483    }
484
485    fn learned(id: &str, version: u32, reach: &str) -> Lesson {
486        Lesson { id: id.into(), question: "never add a co-author trailer?".into(), consequence: "c".into(), version, reach: reach.into(), ..Default::default() }
487    }
488
489    fn by(l: Lesson, engineers: &[&str]) -> Lesson {
490        Lesson { engineers: engineers.iter().map(|e| e.to_string()).collect(), ..l }
491    }
492
493    fn episode(id: &str, engineer: &str) -> Episode {
494        Episode { id: id.into(), ms: 7, repo: "app".into(), engineer: engineer.into(), ..Default::default() }
495    }
496
497    fn latest(path: &Path, id: &str) -> Option<Lesson> {
498        crate::learn::lessons(path).into_iter().find(|l| l.id == id)
499    }
500
501    #[test]
502    fn a_personal_lesson_stays_in_its_store_and_only_its_own_engineer_withdraws_a_team_one() {
503        let base = tmp("place");
504        let (team, other) = (crate::collective::store(&base, "work"), crate::collective::store(&base, "private"));
505        std::fs::create_dir_all(&team).unwrap();
506        let (lessons, personal) = (team.join("lessons.jsonl"), personal_path(&team));
507        // Brand new: kept in this store's personal file, nothing in lessons.jsonl, nothing elsewhere.
508        let p = place(&learned("L1", 1, PERSONAL), &episode("e1", "ann@x"), false, None);
509        write(&team, &p).unwrap();
510        assert!(crate::learn::lessons(&lessons).is_empty());
511        assert_eq!(latest(&personal, "L1").map(|l| (l.version, l.reach)), Some((1, PERSONAL.to_string())));
512        assert!(!other.exists(), "never written to another collective");
513        // Merged into a team lesson only ann is on: withdrawn there, kept in her personal file.
514        let mine = by(learned("L2", 2, ""), &["ann@x"]);
515        append_jsonl(&lessons, &mine).unwrap();
516        let p = place(&by(learned("L2", 3, PERSONAL), &["ann@x"]), &episode("e2", "ann@x"), false, Some(&mine));
517        write(&team, &p).unwrap();
518        let t = latest(&lessons, "L2").unwrap();
519        assert_eq!((t.version, t.reach.as_str(), t.question.as_str(), t.consequence.as_str()), (4, PERSONAL, PERSONAL_TOMBSTONE, ""));
520        assert_eq!(latest(&personal, "L2").map(|l| l.version), Some(3));
521        let c = Challenge { id: t.id.clone(), reach: t.reach.clone(), ..Default::default() };
522        assert!(!deliverable(&c, &["app".into()]), "the tombstone is never delivered");
523        // Merged into a team lesson bob is on too: the team lesson is untouched and not copied; ann's
524        // episode becomes a personal lesson of its own.
525        let shared = by(learned("L3", 1, ""), &["bob@x"]);
526        append_jsonl(&lessons, &shared).unwrap();
527        let before = std::fs::read_to_string(&lessons).unwrap();
528        let p = place(&by(learned("L3", 2, PERSONAL), &["bob@x", "ann@x"]), &episode("e3", "ann@x"), false, Some(&shared));
529        write(&team, &p).unwrap();
530        assert_eq!(std::fs::read_to_string(&lessons).unwrap(), before, "the teammate's lesson is untouched");
531        assert!(latest(&personal, "L3").is_none(), "no personal copy of it");
532        let own = latest(&personal, "Le3").unwrap();
533        assert_eq!((own.engineers, own.episodes, own.support), (vec!["ann@x".to_string()], vec!["e3".to_string()], 1));
534        // An unknown engineer is nobody: never withdraws a lesson someone is on.
535        assert!(matches!(place(&by(learned("L2", 3, PERSONAL), &["ann@x"]), &episode("e4", ""), false, Some(&mine)), Placed::Personal { withdraw: None, .. }));
536        // Nobody recorded on a team lesson, or an imported one: not provably ann's, never withdrawn; her
537        // episode becomes a personal lesson of its own.
538        let nobody = learned("L4", 1, "");
539        let imported = Lesson { source: "import".into(), ..by(learned("L5", 1, ""), &["ann@x"]) };
540        for t in [&nobody, &imported] {
541            let p = place(&Lesson { id: t.id.clone(), ..learned("x", 2, PERSONAL) }, &episode("e7", "ann@x"), false, Some(t));
542            assert!(matches!(&p, Placed::Personal { lesson, withdraw: None } if lesson.id == "Le7"), "{}: {p:?}", t.id);
543        }
544        // A merge into a personal lesson stays personal, whatever the verdict; a team one stays here.
545        assert!(
546            matches!(place(&learned("L1", 2, ""), &episode("e5", "ann@x"), true, None), Placed::Personal { lesson, withdraw: None } if lesson.reach == PERSONAL)
547        );
548        assert!(matches!(place(&learned("L9", 1, ""), &episode("e6", "ann@x"), false, None), Placed::Here(l) if l.reach.is_empty()));
549    }
550
551    #[test]
552    fn a_plan_makes_personal_scopes_and_asks_and_answers_apply_it() {
553        let base = tmp("plan");
554        let team = crate::collective::store(&base, "work");
555        std::fs::create_dir_all(&team).unwrap();
556        for (id, q) in [("La", "no co-author trailer?"), ("Lb", "stage branch refspec?"), ("Lc", "lock order?"), ("Ld", "report style?")] {
557            let l = Lesson { id: id.into(), question: q.into(), repo: "app".into(), version: 1, engineers: vec!["ann@x".into()], ..Default::default() };
558            append_jsonl(&team.join("lessons.jsonl"), &l).unwrap();
559        }
560        let fake = |p: &str, _: &serde_json::Value| {
561            let p = p.split("Question: ").nth(1).unwrap_or_default();
562            let (reach, confident) = if p.contains("trailer") {
563                ("personal", true)
564            } else if p.contains("refspec") {
565                ("repo", true)
566            } else if p.contains("report") {
567                ("personal", false)
568            } else {
569                ("team", true)
570            };
571            Some((serde_json::json!({"reach": reach, "confident": confident, "why": "w"}), Some(0.01)))
572        };
573        let (plan, cost) = classify(&candidates(&team, "ann@x"), 2, &fake);
574        assert_eq!((plan.len(), (cost * 100.0).round()), (4, 4.0));
575        let done = apply(&team, &plan, "ann@x", 1000).unwrap();
576        assert_eq!(done, Applied { personal: 1, scoped: 1, asked: 1 });
577        let lessons = team.join("lessons.jsonl");
578        assert_eq!(latest(&lessons, "La").unwrap().reach, PERSONAL);
579        assert_eq!(latest(&personal_path(&team), "La").unwrap().question, "no co-author trailer?", "kept in this store");
580        assert_eq!(latest(&lessons, "Lb").unwrap().reach, REPO);
581        assert_eq!(latest(&lessons, "Lc").unwrap().version, 1, "team: untouched");
582        assert!(candidates(&team, "ann@x").iter().all(|l| l.id == "Lc" || l.id == "Ld"), "personal and scoped ones are no candidates any more");
583        // The unsure one became a reach question; answering "personal" (option 3) applies it.
584        let q = crate::queen::entries(&team).into_iter().find(|e| e.kind == crate::queen::Kind::Reach).unwrap();
585        assert_eq!(q.options.len(), 3);
586        let l = latest(&lessons, "Ld").unwrap();
587        apply_one(&team, &l, verdict(&q.options[2]), "ann@x").unwrap();
588        assert_eq!(latest(&lessons, "Ld").unwrap().reach, PERSONAL);
589        assert_eq!((verdict(&q.options[0]), verdict(&q.options[1])), ("", REPO));
590    }
591
592    #[test]
593    fn reach_never_considers_a_lesson_another_engineer_is_on() {
594        let base = tmp("teammate");
595        let team = crate::collective::store(&base, "work");
596        let lessons = team.join("lessons.jsonl");
597        for (id, engineers) in [("Lmine", vec!["ann@x"]), ("Lbob", vec!["bob@x"]), ("Lboth", vec!["ann@x", "bob@x"]), ("Lnone", vec![])] {
598            append_jsonl(&lessons, &by(learned(id, 1, ""), &engineers)).unwrap();
599        }
600        let ids = |me: &str| {
601            let mut v = candidates(&team, me).into_iter().map(|l| l.id).collect::<Vec<_>>();
602            v.sort();
603            v
604        };
605        assert_eq!(ids("ann@x"), ["Lmine"], "a lesson nobody is recorded on is not provably ann's");
606        assert!(ids("").is_empty(), "an unknown engineer is nobody");
607        // A plan naming the teammate's lessons (stale or hand-edited) touches none of them.
608        let plan: Vec<Verdict> = ["Lbob", "Lboth"]
609            .iter()
610            .map(|id| Verdict { id: id.to_string(), repo: String::new(), question: String::new(), reach: PERSONAL.into(), confident: true, why: String::new() })
611            .collect();
612        let before = std::fs::read_to_string(&lessons).unwrap();
613        assert_eq!(apply(&team, &plan, "ann@x", 1).unwrap(), Applied::default());
614        assert_eq!(std::fs::read_to_string(&lessons).unwrap(), before);
615        assert!(!personal_path(&team).exists());
616        // A queen answer on one is refused, nothing written: personal or one repo alike.
617        for to in [PERSONAL, REPO] {
618            for id in ["Lboth", "Lnone"] {
619                assert!(apply_one(&team, &latest(&lessons, id).unwrap(), to, "ann@x").is_err(), "{id} {to}");
620            }
621        }
622        assert_eq!(std::fs::read_to_string(&lessons).unwrap(), before);
623    }
624
625    /// The guard: a model that never answers is asked `runner::MAX_FAILURES` times, not once per lesson.
626    #[test]
627    fn a_dead_model_is_asked_only_until_the_failure_guard() {
628        use std::sync::Arc;
629        use std::sync::atomic::{AtomicUsize, Ordering};
630        let lessons: Vec<Lesson> = (0..10).map(|i| Lesson { id: format!("L{i}"), ..Lesson::default() }).collect();
631        let calls = Arc::new(AtomicUsize::new(0));
632        let counted = Arc::clone(&calls);
633        let dead = move |_: &str, _: &serde_json::Value| {
634            counted.fetch_add(1, Ordering::SeqCst);
635            None
636        };
637        assert!(classify(&lessons, 1, &dead).0.is_empty());
638        assert_eq!(calls.load(Ordering::SeqCst), crate::runner::MAX_FAILURES);
639        // One answer first: the model is there, a later failure stops nothing.
640        let calls = Arc::new(AtomicUsize::new(0));
641        let counted = Arc::clone(&calls);
642        let flaky = move |_: &str, _: &serde_json::Value| {
643            (counted.fetch_add(1, Ordering::SeqCst) == 0).then(|| (serde_json::json!({"reach": "team", "confident": true, "why": "w"}), None))
644        };
645        assert_eq!(classify(&lessons, 1, &flaky).0.len(), 1);
646        assert_eq!(calls.load(Ordering::SeqCst), lessons.len(), "every lesson asked");
647    }
648
649    #[test]
650    fn merges_never_widen() {
651        assert_eq!(narrower("", REPO), REPO);
652        assert_eq!(narrower(PERSONAL, ""), PERSONAL);
653        assert_eq!(narrower(REPO, PERSONAL), PERSONAL);
654        assert_eq!(narrower("", ""), "");
655    }
656}