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). Personal lessons belong in
6//! that engineer's private collective, never in a team one: they would steer everyone's drones. In a
7//! team store a personal lesson is only a tombstone, a newer version that withdraws it; the full text
8//! lives in the private store, which never syncs.
9
10use std::path::{Path, PathBuf};
11
12use crate::conscience::Challenge;
13use crate::learn::{Lesson, append_jsonl};
14
15/// For a lesson of one repo only.
16pub const REPO: &str = "repo";
17/// For a tombstone in a team store: withdrawn to an engineer's private collective.
18pub const PERSONAL: &str = "personal";
19
20/// Whether `c` may be delivered in a session working in `repos` (systems.rs `work_repos`).
21pub fn deliverable(c: &Challenge, repos: &[String]) -> bool {
22    match c.reach.as_str() {
23        PERSONAL => false,
24        REPO => repos.iter().any(|r| r.eq_ignore_ascii_case(&c.repo)),
25        _ => true,
26    }
27}
28
29/// The narrower of two reaches: a merge never widens who hears a lesson (personal > repo > team).
30pub fn narrower<'a>(a: &'a str, b: &'a str) -> &'a str {
31    let rank = |r: &str| match r {
32        PERSONAL => 2,
33        REPO => 1,
34        _ => 0,
35    };
36    if rank(b) > rank(a) { b } else { a }
37}
38
39/// The question a tombstone carries in the team store instead of the withdrawn lesson.
40pub const WITHDRAWN: &str = "(personal preference, moved to a private collective)";
41
42/// Where a lesson ended up.
43#[derive(Debug, Clone)]
44pub enum Placed {
45    /// Write this to the store it was learned in, as usual (a personal lesson learned in the private
46    /// collective itself loses its marker: everything there is personal).
47    Here(Box<Lesson>),
48    /// Personal: copied to this private store (and withdrawn from the team store if it was there).
49    Private(PathBuf),
50    /// Personal, but this machine has no private collective to keep it: dropped, never shared.
51    Dropped,
52}
53
54/// Decides where `l`, learned in the store `home`, belongs, and writes it there if that is not `home`.
55pub fn place(home: &Path, l: &Lesson) -> Placed {
56    if l.reach != PERSONAL {
57        return Placed::Here(Box::new(l.clone()));
58    }
59    place_with(home, l, crate::collective::load().ok().flatten().as_ref())
60}
61
62/// `place` under an explicit config.
63pub fn place_with(home: &Path, l: &Lesson, cfg: Option<&crate::collective::Config>) -> Placed {
64    if l.reach != PERSONAL {
65        return Placed::Here(Box::new(l.clone()));
66    }
67    match crate::collective::personal_store(cfg, home) {
68        // Learned in the private collective itself: everything there is personal already.
69        Some(p) if p == home => Placed::Here(Box::new(Lesson { reach: String::new(), ..l.clone() })),
70        Some(p) => {
71            if move_to(home, &p, l).is_ok() {
72                Placed::Private(p)
73            } else {
74                Placed::Dropped
75            }
76        }
77        None => {
78            // A merge into a team lesson that turned personal still withdraws it.
79            if l.version > 1 {
80                let _ = append_jsonl(&home.join("lessons.jsonl"), &tombstone(l));
81            }
82            Placed::Dropped
83        }
84    }
85}
86
87/// Copies `l` to the private store `private` (as team-free text, `reach` empty) and, if the team store
88/// `team` holds an earlier version, withdraws it there with a tombstone.
89pub fn move_to(team: &Path, private: &Path, l: &Lesson) -> std::io::Result<()> {
90    let held = crate::learn::lessons(&private.join("lessons.jsonl")).into_iter().find(|x| x.id == l.id).map_or(0, |x| x.version);
91    let copy = Lesson { version: held + 1, reach: String::new(), ..l.clone() };
92    std::fs::create_dir_all(private)?;
93    append_jsonl(&private.join("lessons.jsonl"), &copy)?;
94    if crate::learn::lessons(&team.join("lessons.jsonl")).iter().any(|x| x.id == l.id) {
95        append_jsonl(&team.join("lessons.jsonl"), &tombstone(l))?;
96    }
97    Ok(())
98}
99
100/// The newer version of `l` that withdraws it from a team store: no text left to deliver or sync on.
101pub fn tombstone(l: &Lesson) -> Lesson {
102    Lesson {
103        version: l.version.max(1) + 1,
104        question: WITHDRAWN.into(),
105        situations: Vec::new(),
106        consequence: String::new(),
107        history: Vec::new(),
108        facets: Vec::new(),
109        reach: PERSONAL.into(),
110        ..l.clone()
111    }
112}
113
114/// The answers a reach question offers (queen.rs `Kind::Reach`).
115pub const TEAM_OPTION: &str = "team: everyone working on this code should hear it";
116pub const PERSONAL_OPTION: &str = "personal: move it to my private collective";
117/// Display: the characters of a lesson's question on a line of `borg reach`'s plan, after its id and
118/// before the model's reason: about one wide terminal line.
119const PLAN_CHARS: usize = 110;
120
121fn repo_option(repo: &str) -> String {
122    format!("repo: only in {}", if repo.is_empty() { "the repo it was learned in" } else { repo })
123}
124
125/// What an answer to a reach question chose: `PERSONAL`, `REPO` or "" (the team).
126fn verdict(text: &str) -> &'static str {
127    let t = text.trim().to_lowercase();
128    if t.starts_with("personal") || t.contains("private") || t.contains("only me") {
129        PERSONAL
130    } else if t.starts_with("repo") {
131        REPO
132    } else {
133        ""
134    }
135}
136
137/// Gives `l` (the latest version in the store `home`) the reach `to`: moved out for personal, a new
138/// version for repo, nothing for the team.
139pub fn apply_one(home: &Path, l: &Lesson, to: &str, cfg: Option<&crate::collective::Config>) -> Result<(), String> {
140    match to {
141        PERSONAL => {
142            let private = crate::collective::personal_store(cfg, home)
143                .filter(|p| p != home)
144                .ok_or("no private collective to move it to: add one with \"share\": \"local\" (or name it in \"personal\")")?;
145            move_to(home, &private, l).map_err(|e| e.to_string())
146        }
147        REPO => append_jsonl(&home.join("lessons.jsonl"), &Lesson { version: l.version + 1, reach: REPO.into(), ..l.clone() }).map_err(|e| e.to_string()),
148        _ => Ok(()),
149    }
150}
151
152/// Applies the answer to a reach question (queen.rs `answer`).
153pub fn answer(store: &Path, e: &crate::queen::Entry, text: &str) -> Result<(), String> {
154    let id = e.evidence.first().and_then(|x| x.strip_prefix("lesson ")).ok_or("this question names no lesson")?;
155    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"))?;
156    apply_one(store, &l, verdict(text), crate::collective::load().ok().flatten().as_ref())
157}
158
159const PROMPT: &str = "A coding agent learned this lesson from an engineer's correction. Decide its reach: who should hear it. \
160Ask: would a teammate working on the same code get this correction too?
161- personal: how this one engineer likes the agent to work with them, not a property of the code: commit message style or \
162length, AI attribution or co-author trailers, whether and when to commit, push, branch or open a PR, what to stage, how to \
163report or ask, language and tone, their own tooling habits.
164- repo: a convention of the repo it was learned in that other repos do not share (its branch or deploy workflow, a tool or \
165command it mandates).
166- team: an engineering concern about the code, design, correctness or verification that any teammate should hear.
167Set confident to false when the lesson mixes these or you cannot tell. why: one short sentence.";
168
169fn schema() -> serde_json::Value {
170    serde_json::json!({"type": "object", "additionalProperties": false, "properties": {
171        "reach": {"type": "string", "enum": ["team", "repo", "personal"]}, "confident": {"type": "boolean"}, "why": {"type": "string"}},
172        "required": ["reach", "confident", "why"]})
173}
174
175/// One line of a reach plan: a lesson and the model's verdict on who should hear it.
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct Verdict {
178    pub id: String,
179    pub repo: String,
180    pub question: String,
181    pub reach: String,
182    pub confident: bool,
183    pub why: String,
184}
185
186/// The lessons a cleanup classifies: learned from sessions or queen answers, still for the team.
187/// Imports are left alone: written-down team knowledge, scoped by system already.
188pub fn candidates(home: &Path) -> Vec<Lesson> {
189    crate::learn::lessons(&home.join("lessons.jsonl")).into_iter().filter(|l| l.reach.is_empty() && (l.source.is_empty() || l.source == "queen")).collect()
190}
191
192/// A structured model call: the answer and its cost (learn.rs `ask_model`).
193pub type Ask = dyn Fn(&str, &serde_json::Value) -> Option<(serde_json::Value, Option<f64>)> + Sync;
194
195/// Classifies `lessons` with one model call each (`jobs` at a time). Returns the verdicts and the cost.
196/// After `runner::MAX_FAILURES` failed calls before the first answer the model is unavailable: the
197/// rest are not asked (they stay candidates for the next run).
198pub fn classify(lessons: &[Lesson], jobs: usize, ask: &Ask) -> (Vec<Verdict>, f64) {
199    use std::sync::atomic::{AtomicUsize, Ordering};
200    let out = std::sync::Mutex::new((Vec::new(), 0.0));
201    let (next, failures, answers) = (AtomicUsize::new(0), AtomicUsize::new(0), AtomicUsize::new(0));
202    std::thread::scope(|s| {
203        for _ in 0..jobs.max(1) {
204            s.spawn(|| {
205                loop {
206                    if failures.load(Ordering::SeqCst) >= crate::runner::MAX_FAILURES && answers.load(Ordering::SeqCst) == 0 {
207                        break;
208                    }
209                    let i = next.fetch_add(1, Ordering::Relaxed);
210                    let Some(l) = lessons.get(i) else { break };
211                    let prompt = format!("{PROMPT}\n\nLesson (learned in repo {}):\nQuestion: {}\nConsequence: {}\n", l.repo, l.question, l.consequence);
212                    let Some((v, cost)) = ask(&prompt, &schema()) else {
213                        failures.fetch_add(1, Ordering::SeqCst);
214                        continue;
215                    };
216                    answers.fetch_add(1, Ordering::SeqCst);
217                    let reach = v["reach"].as_str().filter(|r| [REPO, PERSONAL].contains(r)).unwrap_or_default();
218                    let verdict = Verdict {
219                        id: l.id.clone(),
220                        repo: l.repo.clone(),
221                        question: l.question.clone(),
222                        reach: reach.into(),
223                        confident: v["confident"].as_bool().unwrap_or(false),
224                        why: v["why"].as_str().unwrap_or_default().into(),
225                    };
226                    let mut g = out.lock().unwrap();
227                    g.0.push(verdict);
228                    g.1 += cost.unwrap_or(0.0);
229                }
230            });
231        }
232    });
233    let (mut v, cost) = out.into_inner().unwrap();
234    v.sort_by(|a, b| a.id.cmp(&b.id));
235    (v, cost)
236}
237
238/// What applying a plan did.
239#[derive(Debug, Default, PartialEq)]
240pub struct Applied {
241    pub moved: usize,
242    pub scoped: usize,
243    pub asked: usize,
244}
245
246/// Applies a plan to the store `home`: confident personal verdicts move, confident repo verdicts are
247/// scoped, unsure ones become queen questions; team verdicts change nothing.
248pub fn apply(home: &Path, plan: &[Verdict], cfg: Option<&crate::collective::Config>, now: i64) -> Result<Applied, String> {
249    let lessons = crate::learn::lessons(&home.join("lessons.jsonl"));
250    let mut done = Applied::default();
251    for v in plan {
252        let Some(l) = lessons.iter().find(|l| l.id == v.id && l.reach.is_empty()) else { continue };
253        match (v.confident, v.reach.as_str()) {
254            (true, PERSONAL) => {
255                apply_one(home, l, PERSONAL, cfg)?;
256                done.moved += 1;
257            }
258            (true, REPO) => {
259                apply_one(home, l, REPO, cfg)?;
260                done.scoped += 1;
261            }
262            (true, _) => {}
263            (false, _) => {
264                let key = crate::queen::decision_key(crate::queen::Kind::Reach, &l.repo, &l.id);
265                let default = match v.reach.as_str() {
266                    PERSONAL => PERSONAL_OPTION.to_string(),
267                    REPO => repo_option(&l.repo),
268                    _ => TEAM_OPTION.to_string(),
269                };
270                let e = crate::queen::Entry {
271                    id: format!("q{}", &key[..8]),
272                    decision_key: key,
273                    kind: crate::queen::Kind::Reach,
274                    question: format!("Who should hear this lesson? \"{}\"", crate::stats::excerpt(&l.question, crate::queen::QUOTE_CHARS)),
275                    options: vec![TEAM_OPTION.into(), repo_option(&l.repo), PERSONAL_OPTION.into()],
276                    default,
277                    evidence: vec![format!("lesson {}", l.id), format!("model: {}", v.why)],
278                    session: "reach".into(),
279                    cwd: String::new(),
280                    repo: l.repo.clone(),
281                    engineer: l.engineers.first().cloned().unwrap_or_default(),
282                    ms: now,
283                    answer: None,
284                    answered_by: None,
285                    answered_ms: None,
286                    disagreeing: Vec::new(),
287                    dismissed: None,
288                    dismissed_ms: None,
289                    payload: None,
290                };
291                if crate::queen::seed(home, &e).map_err(|e| e.to_string())? {
292                    done.asked += 1;
293                }
294            }
295        }
296    }
297    Ok(done)
298}
299
300/// `borg reach [--plan FILE] [--apply] [--jobs N]`: the cleanup of a store's lessons by reach (module doc).
301pub fn main(args: &[&str]) -> i32 {
302    let flag = |name: &str| args.iter().position(|a| *a == name).and_then(|i| args.get(i + 1)).copied();
303    let apply_it = args.contains(&"--apply");
304    let cwd = std::env::current_dir().unwrap_or_default().display().to_string();
305    let cfg = match crate::collective::load() {
306        Ok(c) => c,
307        Err(e) => {
308            eprintln!("borg reach: invalid collectives config: {e}");
309            return 1;
310        }
311    };
312    let crate::collective::Route::Collective { name, home, .. } = crate::collective::route(cfg.as_ref(), &cwd, &crate::learn::home()) else {
313        eprintln!("borg reach: {cwd} belongs to no collective");
314        return 1;
315    };
316    let plan_path = flag("--plan").map(PathBuf::from).unwrap_or_else(|| home.join("reach-plan.json"));
317    if apply_it {
318        let plan: Vec<Verdict> = match std::fs::read_to_string(&plan_path).ok().and_then(|t| serde_json::from_str(&t).ok()) {
319            Some(p) => p,
320            None => {
321                eprintln!("borg reach: no plan at {} (run `borg reach` first)", plan_path.display());
322                return 1;
323            }
324        };
325        return match apply(&home, &plan, cfg.as_ref(), crate::learn::now_ms()) {
326            Ok(a) => {
327                println!(
328                    "{name}: {} moved to the private collective, {} limited to their repo, {} queued for the queen (`borg queen`)",
329                    a.moved, a.scoped, a.asked
330                );
331                0
332            }
333            Err(e) => {
334                eprintln!("borg reach: {e}");
335                1
336            }
337        };
338    }
339    let todo = candidates(&home);
340    let jobs = flag("--jobs").and_then(|j| j.parse().ok()).unwrap_or(crate::runner::JOBS);
341    eprintln!("borg reach: classifying {} lessons of {name} (one model call each, {jobs} at a time)", todo.len());
342    let (plan, cost) = classify(&todo, jobs, &|p: &str, s: &serde_json::Value| crate::learn::ask_model(p, s));
343    for (label, pick) in [("personal: move to the private collective", PERSONAL), ("repo: deliver only in its repo", REPO)] {
344        let hits: Vec<&Verdict> = plan.iter().filter(|v| v.confident && v.reach == pick).collect();
345        println!("\n{label} ({})", hits.len());
346        for v in hits {
347            println!("  {}  {}  ({})", v.id, crate::stats::excerpt(&v.question, PLAN_CHARS), v.why);
348        }
349    }
350    let unsure: Vec<&Verdict> = plan.iter().filter(|v| !v.confident).collect();
351    println!("\nunsure: a question for the queen ({})", unsure.len());
352    for v in unsure {
353        println!("  {}  [{}?]  {}", v.id, if v.reach.is_empty() { "team" } else { &v.reach }, crate::stats::excerpt(&v.question, PLAN_CHARS));
354    }
355    let team = plan.iter().filter(|v| v.confident && v.reach.is_empty()).count();
356    println!("\nteam: unchanged ({team}); {} of {} classified, ${cost:.2}", plan.len(), todo.len());
357    match std::fs::write(&plan_path, serde_json::to_string_pretty(&plan).unwrap_or_default()) {
358        Ok(()) => {
359            println!("plan: {} (apply with `borg reach --apply --plan {}`)", plan_path.display(), plan_path.display());
360            0
361        }
362        Err(e) => {
363            eprintln!("borg reach: cannot write {}: {e}", plan_path.display());
364            1
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn lesson(reach: &str, repo: &str) -> Challenge {
374        Challenge { id: "L".into(), reach: reach.into(), repo: repo.into(), ..Default::default() }
375    }
376
377    #[test]
378    fn personal_is_never_delivered_and_repo_only_in_its_repo() {
379        let here = ["app".to_string()];
380        assert!(deliverable(&lesson("", "other"), &here), "team lessons go everywhere");
381        assert!(!deliverable(&lesson(PERSONAL, "app"), &here));
382        assert!(deliverable(&lesson(REPO, "App"), &here));
383        assert!(!deliverable(&lesson(REPO, "other"), &here));
384        assert!(!deliverable(&lesson(REPO, "app"), &[]), "unknown repo: a repo convention stays out");
385    }
386
387    fn tmp(tag: &str) -> PathBuf {
388        let d = std::env::temp_dir().join(format!("borg-reach-{tag}-{}", std::process::id()));
389        let _ = std::fs::remove_dir_all(&d);
390        std::fs::create_dir_all(&d).unwrap();
391        d
392    }
393
394    fn config(local: &[&str]) -> crate::collective::Config {
395        let mut collectives = vec![serde_json::json!({"name": "work", "paths": ["/w"], "share": "team"})];
396        collectives.extend(local.iter().map(|n| serde_json::json!({"name": n, "paths": [format!("/{n}")], "share": "local"})));
397        serde_json::from_value(serde_json::json!({"collectives": collectives})).unwrap()
398    }
399
400    fn learned(id: &str, version: u32, reach: &str) -> Lesson {
401        Lesson { id: id.into(), question: "never add a co-author trailer?".into(), consequence: "c".into(), version, reach: reach.into(), ..Default::default() }
402    }
403
404    #[test]
405    fn a_personal_lesson_goes_to_the_private_store_and_is_withdrawn_from_the_team() {
406        let base = tmp("move");
407        let cfg = config(&["private"]);
408        let (team, private) = (crate::collective::store(&base, "work"), crate::collective::store(&base, "private"));
409        std::fs::create_dir_all(&team).unwrap();
410        // Brand new: copied to private, nothing written to the team store.
411        assert!(matches!(place_with(&team, &learned("L1", 1, PERSONAL), Some(&cfg)), Placed::Private(p) if p == private));
412        assert!(crate::learn::lessons(&team.join("lessons.jsonl")).is_empty());
413        let copy = &crate::learn::lessons(&private.join("lessons.jsonl"))[0];
414        assert_eq!((copy.id.as_str(), copy.reach.as_str(), copy.version), ("L1", "", 1));
415        // A team lesson that turns out personal: the team store keeps only a newer tombstone.
416        append_jsonl(&team.join("lessons.jsonl"), &learned("L2", 3, "")).unwrap();
417        move_to(&team, &private, &learned("L2", 3, "")).unwrap();
418        let t = crate::learn::lessons(&team.join("lessons.jsonl")).into_iter().find(|l| l.id == "L2").unwrap();
419        assert_eq!((t.version, t.reach.as_str(), t.question.as_str(), t.consequence.as_str()), (4, PERSONAL, WITHDRAWN, ""));
420        let c = Challenge { id: t.id.clone(), reach: t.reach.clone(), ..Default::default() };
421        assert!(!deliverable(&c, &["app".into()]), "the tombstone is never delivered");
422    }
423
424    #[test]
425    fn without_a_private_collective_personal_lessons_are_dropped_not_shared() {
426        let base = tmp("drop");
427        let team = crate::collective::store(&base, "work");
428        std::fs::create_dir_all(&team).unwrap();
429        assert!(matches!(place_with(&team, &learned("L1", 1, PERSONAL), Some(&config(&[]))), Placed::Dropped));
430        assert!(matches!(place_with(&team, &learned("L1", 1, PERSONAL), Some(&config(&["a", "b"]))), Placed::Dropped), "two local ones and no key: none");
431        assert!(crate::learn::lessons(&team.join("lessons.jsonl")).is_empty());
432        assert!(matches!(place_with(&team, &learned("L2", 1, ""), Some(&config(&[]))), Placed::Here(l) if l.reach.is_empty()));
433    }
434
435    #[test]
436    fn learned_inside_the_private_collective_it_stays_there() {
437        let base = tmp("here");
438        let private = crate::collective::store(&base, "private");
439        let kept = place_with(&private, &learned("L1", 1, PERSONAL), Some(&config(&["private"])));
440        assert!(matches!(kept, Placed::Here(l) if l.reach.is_empty()), "kept, and deliverable there");
441    }
442
443    #[test]
444    fn a_plan_moves_scopes_and_asks_and_answers_apply_the_move() {
445        let base = tmp("plan");
446        let cfg = config(&["private"]);
447        let (team, private) = (crate::collective::store(&base, "work"), crate::collective::store(&base, "private"));
448        std::fs::create_dir_all(&team).unwrap();
449        for (id, q) in [("La", "no co-author trailer?"), ("Lb", "stage branch refspec?"), ("Lc", "lock order?"), ("Ld", "report style?")] {
450            append_jsonl(&team.join("lessons.jsonl"), &Lesson { id: id.into(), question: q.into(), repo: "app".into(), version: 1, ..Default::default() })
451                .unwrap();
452        }
453        let fake = |p: &str, _: &serde_json::Value| {
454            let p = p.split("Question: ").nth(1).unwrap_or_default();
455            let (reach, confident) = if p.contains("trailer") {
456                ("personal", true)
457            } else if p.contains("refspec") {
458                ("repo", true)
459            } else if p.contains("report") {
460                ("personal", false)
461            } else {
462                ("team", true)
463            };
464            Some((serde_json::json!({"reach": reach, "confident": confident, "why": "w"}), Some(0.01)))
465        };
466        let (plan, cost) = classify(&candidates(&team), 2, &fake);
467        assert_eq!((plan.len(), (cost * 100.0).round()), (4, 4.0));
468        let done = apply(&team, &plan, Some(&cfg), 1000).unwrap();
469        assert_eq!(done, Applied { moved: 1, scoped: 1, asked: 1 });
470        let latest = |h: &Path, id: &str| crate::learn::lessons(&h.join("lessons.jsonl")).into_iter().find(|l| l.id == id);
471        assert_eq!(latest(&team, "La").unwrap().reach, PERSONAL);
472        assert_eq!(latest(&private, "La").unwrap().question, "no co-author trailer?");
473        assert_eq!(latest(&team, "Lb").unwrap().reach, REPO);
474        assert_eq!(latest(&team, "Lc").unwrap().version, 1, "team: untouched");
475        assert!(candidates(&team).iter().all(|l| l.id == "Lc" || l.id == "Ld"), "moved and scoped ones are no candidates any more");
476        // The unsure one became a reach question; answering "personal" (option 3) moves it.
477        let q = crate::queen::entries(&team).into_iter().find(|e| e.kind == crate::queen::Kind::Reach).unwrap();
478        assert_eq!(q.options.len(), 3);
479        let l = latest(&team, "Ld").unwrap();
480        apply_one(&team, &l, verdict(&q.options[2]), Some(&cfg)).unwrap();
481        assert_eq!(latest(&team, "Ld").unwrap().reach, PERSONAL);
482        assert_eq!((verdict(&q.options[0]), verdict(&q.options[1])), ("", REPO));
483    }
484
485    /// The guard: a model that never answers is asked `runner::MAX_FAILURES` times, not once per lesson.
486    #[test]
487    fn a_dead_model_is_asked_only_until_the_failure_guard() {
488        use std::sync::Arc;
489        use std::sync::atomic::{AtomicUsize, Ordering};
490        let lessons: Vec<Lesson> = (0..10).map(|i| Lesson { id: format!("L{i}"), ..Lesson::default() }).collect();
491        let calls = Arc::new(AtomicUsize::new(0));
492        let counted = Arc::clone(&calls);
493        let dead = move |_: &str, _: &serde_json::Value| {
494            counted.fetch_add(1, Ordering::SeqCst);
495            None
496        };
497        assert!(classify(&lessons, 1, &dead).0.is_empty());
498        assert_eq!(calls.load(Ordering::SeqCst), crate::runner::MAX_FAILURES);
499        // One answer first: the model is there, a later failure stops nothing.
500        let calls = Arc::new(AtomicUsize::new(0));
501        let counted = Arc::clone(&calls);
502        let flaky = move |_: &str, _: &serde_json::Value| {
503            (counted.fetch_add(1, Ordering::SeqCst) == 0).then(|| (serde_json::json!({"reach": "team", "confident": true, "why": "w"}), None))
504        };
505        assert_eq!(classify(&lessons, 1, &flaky).0.len(), 1);
506        assert_eq!(calls.load(Ordering::SeqCst), lessons.len(), "every lesson asked");
507    }
508
509    #[test]
510    fn merges_never_widen() {
511        assert_eq!(narrower("", REPO), REPO);
512        assert_eq!(narrower(PERSONAL, ""), PERSONAL);
513        assert_eq!(narrower(REPO, PERSONAL), PERSONAL);
514        assert_eq!(narrower("", ""), "");
515    }
516}