1use std::collections::{BTreeMap, HashMap};
51use std::path::Path;
52
53use crate::conscience::{self, Challenge};
54use crate::outcome::{self, Outcome};
55use crate::{collective, learn};
56
57pub const AREAS: [&str; 12] = [
59 "concurrency",
60 "spec-conformance",
61 "reuse-existing",
62 "architecture-boundary",
63 "verification",
64 "minimality",
65 "protocol-correctness",
66 "data-source",
67 "security",
68 "dependency-version",
69 "scope",
70 OTHER,
71];
72
73pub const OTHER: &str = "other";
74
75pub const NEUTRAL: f64 = 0.5;
77
78pub fn area(raw: &str) -> &'static str {
80 let a = raw.trim().to_lowercase().replace(['_', ' '], "-");
81 AREAS.iter().find(|x| **x == a).copied().unwrap_or(OTHER)
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86pub struct Evidence {
87 pub followed: u32,
88 pub corrected: u32,
89 pub superseded: u32,
90}
91
92impl Evidence {
93 pub fn trust(&self) -> f64 {
95 let (f, c, s) = (self.followed as f64, self.corrected as f64, self.superseded as f64);
96 (1.0 + f) / (2.0 + f + c + 2.0 * s)
97 }
98
99 fn add(&mut self, o: Evidence) {
100 self.followed += o.followed;
101 self.corrected += o.corrected;
102 self.superseded += o.superseded;
103 }
104}
105
106#[derive(Debug, Default)]
108pub struct Trust {
109 evidence: BTreeMap<(String, String), Evidence>,
110}
111
112impl Trust {
113 pub fn compute(lessons: &[Challenge], outcomes: &[Outcome]) -> Trust {
115 let sessions = outcome::sessions(outcomes);
116 let followed = |id: &str| sessions.get(id).is_some_and(|ss| outcome::tally(ss.values(), i64::MIN).0 > 0);
117 let replaced: std::collections::HashSet<&str> =
118 edges(lessons).into_iter().filter(|(x, y)| decisive(x, y) || followed(&x.id)).map(|(_, y)| y.id.as_str()).collect();
119 let mut evidence: BTreeMap<(String, String), Evidence> = BTreeMap::new();
120 for c in lessons {
121 let superseded = u32::from(replaced.contains(c.id.as_str()));
122 let mut authors: Vec<&str> = c.engineers.iter().map(String::as_str).filter(|x| !x.is_empty()).collect();
123 authors.sort();
124 authors.dedup();
125 for a in authors {
126 let since = c.joined.get(a).copied().unwrap_or(i64::MIN);
128 let (followed, corrected) = sessions.get(c.id.as_str()).map_or((0, 0), |ss| outcome::tally(ss.values(), since));
129 let e = Evidence { followed, corrected, superseded };
130 evidence.entry((a.to_string(), area(&c.area).to_string())).or_default().add(e);
131 }
132 }
133 Trust { evidence }
134 }
135
136 pub fn of(&self, engineer: &str, area_name: &str) -> f64 {
138 self.evidence.get(&(engineer.to_string(), area(area_name).to_string())).map_or(NEUTRAL, Evidence::trust)
139 }
140
141 pub fn lesson(&self, c: &Challenge) -> f64 {
143 let authors: Vec<&String> = c.engineers.iter().filter(|e| !e.is_empty()).collect();
144 match authors.len() {
145 0 => NEUTRAL,
146 n => authors.iter().map(|e| self.of(e, &c.area)).sum::<f64>() / n as f64,
147 }
148 }
149
150 pub fn rows(&self) -> impl Iterator<Item = (&str, &str, Evidence)> {
152 self.evidence.iter().map(|((e, a), x)| (e.as_str(), a.as_str(), *x))
153 }
154
155 pub fn has_evidence(&self) -> bool {
157 self.evidence.values().any(|e| *e != Evidence::default())
158 }
159}
160
161pub fn of_store(home: &Path) -> Trust {
164 of_store_with(home, collective::aliases())
165}
166
167pub fn of_store_with(home: &Path, aliases: &collective::Aliases) -> Trust {
169 let lessons = home.join("lessons.jsonl");
170 Trust::compute(&conscience::load_unweighted_with(&lessons, None, aliases), &outcome::read_team(&lessons))
171}
172
173fn edges(lessons: &[Challenge]) -> Vec<(&Challenge, &Challenge)> {
177 if lessons.iter().all(|c| c.supersedes.is_empty()) {
178 return Vec::new();
179 }
180 let by: HashMap<&str, &Challenge> = lessons.iter().map(|c| (c.id.as_str(), c)).collect();
181 let declared: HashMap<&str, Vec<&str>> =
182 lessons.iter().map(|c| (c.id.as_str(), c.supersedes.iter().map(String::as_str).filter(|y| *y != c.id && by.contains_key(y)).collect())).collect();
183 let reaches = |from: &str, to: &str| -> bool {
184 let mut stack = vec![from];
185 let mut seen = std::collections::HashSet::new();
186 while let Some(x) = stack.pop() {
187 if x == to {
188 return true;
189 }
190 if seen.insert(x) {
191 stack.extend(declared.get(x).into_iter().flatten().copied());
192 }
193 }
194 false
195 };
196 lessons.iter().flat_map(|x| declared[x.id.as_str()].iter().filter(|y| !reaches(y, &x.id)).map(|y| (x, by[y])).collect::<Vec<_>>()).collect()
197}
198
199fn decisive(x: &Challenge, y: &Challenge) -> bool {
202 x.source == "queen" || x.engineers.iter().any(|e| !e.is_empty() && y.engineers.contains(e))
203}
204
205pub fn successors(lessons: &[Challenge]) -> HashMap<String, String> {
210 let mut direct: HashMap<&str, &Challenge> = HashMap::new();
211 for (x, y) in edges(lessons) {
212 if !(decisive(x, y) || x.trust >= y.trust) {
213 continue;
214 }
215 let newer = |o: &&Challenge| (x.origin_ms, x.id.as_str()) > (o.origin_ms, o.id.as_str());
216 if direct.get(y.id.as_str()).is_none_or(newer) {
217 direct.insert(&y.id, x);
218 }
219 }
220 direct
221 .keys()
222 .map(|&y| {
223 let mut s = direct[y];
224 for _ in 0..lessons.len() {
225 match direct.get(s.id.as_str()) {
226 Some(next) => s = next,
227 None => break,
228 }
229 }
230 (y.to_string(), s.id.clone())
231 })
232 .collect()
233}
234
235pub fn summary(home: &Path) -> String {
237 summary_with(home, collective::aliases())
238}
239
240pub fn summary_with(home: &Path, aliases: &collective::Aliases) -> String {
242 let t = of_store_with(home, aliases);
243 let mut rows: Vec<(&str, &str, Evidence)> = t.rows().filter(|(_, _, e)| *e != Evidence::default()).collect();
244 if rows.is_empty() {
245 let engineers: std::collections::BTreeSet<&str> = t.rows().map(|(e, _, _)| e).collect();
246 return format!("no evidence yet ({} engineers neutral at 0.50)", engineers.len());
247 }
248 rows.sort_by(|a, b| b.2.trust().total_cmp(&a.2.trust()).then(a.0.cmp(b.0)).then(a.1.cmp(b.1)));
249 let (top, low) = (rows[0], rows[rows.len() - 1]);
250 let mut s = format!("highest {} in {} {:.2}", top.0, top.1, top.2.trust());
251 if rows.len() > 1 {
252 s.push_str(&format!(", lowest {} in {} {:.2}", low.0, low.1, low.2.trust()));
253 }
254 format!("{s} ({} with evidence; borg trust)", rows.len())
255}
256
257pub fn table(t: &Trust) -> String {
259 let rows: Vec<(&str, &str, Evidence)> = t.rows().collect();
260 if rows.is_empty() {
261 return "no lessons with known engineers yet\n".into();
262 }
263 let w = rows.iter().map(|r| r.0.len()).max().unwrap_or(0).max("engineer".len());
264 let a = AREAS.iter().map(|x| x.len()).max().unwrap_or(0);
265 let mut s = format!("{:<w$} {:<a$} trust followed corrected superseded\n", "engineer", "area");
266 for (e, ar, x) in rows {
267 s.push_str(&format!("{e:<w$} {ar:<a$} {:.2} {:>8} {:>9} {:>10}\n", x.trust(), x.followed, x.corrected, x.superseded));
268 }
269 s
270}
271
272pub fn main(args: &[&str]) -> i32 {
275 let mut cwd = std::env::current_dir().unwrap_or_default().display().to_string();
276 let mut it = args.iter();
277 while let Some(a) = it.next() {
278 match (*a, it.next()) {
279 ("--dir", Some(d)) => cwd = d.to_string(),
280 _ => {
281 eprintln!("usage: borg trust [--dir DIR]");
282 return 2;
283 }
284 }
285 }
286 match collective::load().map(|cfg| collective::route(cfg.as_ref(), &cwd, &learn::home())) {
287 Ok(collective::Route::Collective { name, home, .. }) => {
288 println!("collective {name} ({})", home.display());
289 println!("trust = (1 + followed) / (2 + followed + corrected + 2 x superseded); 0.50 = no evidence");
290 match crate::outcome::on() {
291 true => println!("followed/corrected: sessions on this machine and the team's synced outcomes; superseded: shared lessons\n"),
292 false => println!("followed/corrected: sessions on this machine only (BORG_SHARE_OUTCOMES=off); superseded: shared lessons\n"),
293 }
294 print!("{}", table(&of_store(&home)));
295 0
296 }
297 Ok(collective::Route::Off) => {
298 println!("off: {cwd} belongs to no collective; no trust is kept here");
299 0
300 }
301 Err(e) => {
302 eprintln!("borg trust: invalid collectives config ({e}); everything is off");
303 1
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::outcome::{ASKED, CORRECTED, NOT_CORRECTED};
312
313 fn lesson(id: &str, ms: i64, area: &str, engineers: &[&str], supersedes: &[&str]) -> Challenge {
314 Challenge {
315 id: id.into(),
316 origin_ms: ms,
317 question: format!("question {id}"),
318 area: area.into(),
319 engineers: engineers.iter().map(|e| e.to_string()).collect(),
320 supersedes: supersedes.iter().map(|e| e.to_string()).collect(),
321 ..Default::default()
322 }
323 }
324
325 fn o(kind: &str, session: &str, id: &str) -> Outcome {
326 Outcome { kind: kind.into(), session: session.into(), lessons: vec![id.into()], ms: 1, repo: "r".into(), ..Default::default() }
327 }
328
329 #[test]
330 fn area_parsing_and_fallback() {
331 assert_eq!(area("concurrency"), "concurrency");
332 assert_eq!(area(" Spec_Conformance "), "spec-conformance");
333 assert_eq!(area("reuse existing"), "reuse-existing");
334 assert_eq!(area(""), OTHER);
335 assert_eq!(area("vibes"), OTHER);
336 let c: Challenge = serde_json::from_str(r#"{"id":"a","origin_ms":1,"question":"q"}"#).unwrap();
338 assert_eq!(area(&c.area), OTHER);
339 }
340
341 #[test]
342 fn neutral_without_evidence_and_laplace_smoothing() {
343 assert_eq!(Evidence::default().trust(), NEUTRAL);
344 let t = Trust::compute(&[lesson("a", 1, "concurrency", &["ann@x"], &[])], &[]);
345 assert_eq!(t.of("ann@x", "concurrency"), NEUTRAL);
346 assert_eq!(t.of("nobody@x", "security"), NEUTRAL);
347 assert!(!t.has_evidence());
348 let e = |f, c, s| Evidence { followed: f, corrected: c, superseded: s }.trust();
349 assert!((e(1, 0, 0) - 2.0 / 3.0).abs() < 1e-12);
350 assert!((e(0, 1, 0) - 1.0 / 3.0).abs() < 1e-12);
351 assert_eq!(e(0, 0, 1), 0.25);
352 assert!(e(1000, 0, 0) < 1.0 && e(0, 1000, 1000) > 0.0, "strictly inside (0, 1)");
353 }
354
355 #[test]
356 fn followed_raises_trust_in_that_area_only() {
357 let ls = [lesson("a", 1, "concurrency", &["ann@x", "bob@x"], &[])];
358 let outs = [o(ASKED, "s1", "a"), o(NOT_CORRECTED, "s1", "a"), o(ASKED, "s2", "a"), o(NOT_CORRECTED, "s2", "a")];
359 let t = Trust::compute(&ls, &outs);
360 assert_eq!(t.of("ann@x", "concurrency"), 0.75);
361 assert_eq!(t.of("bob@x", "concurrency"), 0.75);
362 assert_eq!(t.of("ann@x", "security"), NEUTRAL);
363 }
364
365 #[test]
368 fn a_placebo_or_unclear_result_moves_no_trust() {
369 let ls = [lesson("a", 1, "concurrency", &["ann@x"], &[])];
370 let outs = [o(ASKED, "s1", "a"), o(NOT_CORRECTED, "s1", "a")];
371 let base = Trust::compute(&ls, &outs);
372 let placebo = |kind: &str| Outcome { channel: crate::outcome::PLACEBO.into(), ..o(kind, "s2", "a") };
373 let mut more = outs.to_vec();
374 more.extend([placebo(CORRECTED), placebo(NOT_CORRECTED), placebo(crate::outcome::CHANGED), o(crate::outcome::UNCLEAR, "s3", "a")]);
375 assert_eq!(Trust::compute(&ls, &more).of("ann@x", "concurrency"), base.of("ann@x", "concurrency"));
376 assert_eq!(base.of("ann@x", "concurrency"), 2.0 / 3.0);
377 }
378
379 #[test]
380 fn asked_and_still_corrected_lowers_trust() {
381 let ls = [lesson("a", 1, "scope", &["ann@x"], &[])];
382 let outs = [o(ASKED, "s1", "a"), o(CORRECTED, "s1", "a"), o(NOT_CORRECTED, "s1", "a"), o(ASKED, "s2", "a"), o(NOT_CORRECTED, "s2", "a")];
384 let t = Trust::compute(&ls, &outs);
385 assert_eq!(t.rows().next().unwrap().2, Evidence { followed: 1, corrected: 1, superseded: 0 });
386 assert_eq!(t.of("ann@x", "scope"), 0.5);
387 let t = Trust::compute(&ls, &[o(CORRECTED, "s1", "a")]);
388 assert!((t.of("ann@x", "scope") - 1.0 / 3.0).abs() < 1e-12);
389 }
390
391 #[test]
392 fn superseded_lowers_its_authors_even_when_they_reverse_themselves() {
393 let ls = [
395 lesson("b1", 1, "concurrency", &["bob@x"], &[]),
396 lesson("a1", 2, "concurrency", &["ann@x"], &["b1"]),
397 lesson("a2", 3, "security", &["ann@x"], &[]),
398 lesson("a3", 4, "security", &["ann@x"], &["a2"]),
399 ];
400 let t = Trust::compute(&ls, &[]);
401 assert_eq!(t.of("bob@x", "concurrency"), NEUTRAL, "another engineer's contradiction is unsettled: no penalty yet");
402 assert_eq!(t.of("ann@x", "security"), 0.25, "a self-reversal counts against the reversed lesson's author at once");
403 let t = Trust::compute(&ls, &[o(NOT_CORRECTED, "s1", "a1")]);
405 assert_eq!(t.of("bob@x", "concurrency"), 0.25);
406 assert!((t.of("ann@x", "concurrency") - 2.0 / 3.0).abs() < 1e-12, "replacing a claim is no evidence for the replacer, being followed is");
407 assert_eq!(successors(&ls), HashMap::from([("b1".to_string(), "a1".to_string()), ("a2".to_string(), "a3".to_string())]));
408 }
409
410 #[test]
411 fn chains_resolve_and_cycles_hide_nobody() {
412 let chain = [lesson("a", 1, "", &["x@x"], &[]), lesson("b", 2, "", &["y@x"], &["a"]), lesson("c", 3, "", &["z@x"], &["b"])];
413 let s = successors(&chain);
414 assert_eq!((s["a"].as_str(), s["b"].as_str(), s.len()), ("c", "c", 2));
415 let cycle = [lesson("a", 1, "", &["x@x"], &["b"]), lesson("b", 2, "", &["y@x"], &["a"]), lesson("c", 3, "", &[], &["missing", "c"])];
417 assert!(successors(&cycle).is_empty());
418 assert!(!Trust::compute(&cycle, &[]).has_evidence());
419 }
420
421 #[test]
422 fn a_later_coauthor_earns_only_from_sessions_after_joining() {
423 let mut l = lesson("a", 1, "concurrency", &["ann@x", "bob@x"], &[]);
425 l.joined.insert("bob@x".into(), 100);
426 let mut outs: Vec<Outcome> = (0..20).map(|i| Outcome { ms: 10 + i, ..o(NOT_CORRECTED, &format!("s{i}"), "a") }).collect();
427 outs.push(Outcome { ms: 100, ..o(CORRECTED, "j", "a") });
429 let t = Trust::compute(std::slice::from_ref(&l), &outs);
430 assert!((t.of("ann@x", "concurrency") - 21.0 / 23.0).abs() < 1e-12);
431 assert_eq!(t.of("bob@x", "concurrency"), NEUTRAL, "no inherited trust");
432 outs.push(Outcome { ms: 200, ..o(NOT_CORRECTED, "later", "a") });
433 assert!((Trust::compute(&[l], &outs).of("bob@x", "concurrency") - 2.0 / 3.0).abs() < 1e-12);
434 }
435
436 #[test]
437 fn a_less_trusted_contradiction_hides_nothing_self_reversals_and_queen_answers_do() {
438 let mut ann = lesson("ann", 1, "concurrency", &["ann@x"], &[]);
439 let mut bob = lesson("bob", 2, "concurrency", &["bob@x"], &["ann"]);
440 (ann.trust, bob.trust) = (0.9, 0.2);
441 assert!(successors(&[ann.clone(), bob.clone()]).is_empty(), "both stay live");
442 bob.trust = 0.9;
443 assert_eq!(successors(&[ann.clone(), bob.clone()])["ann"], "bob", "at least as trusted: hides");
444 bob.trust = 0.2;
445 let mut queen = lesson("q", 3, "concurrency", &["lead@x"], &["ann"]);
446 (queen.source, queen.trust) = ("queen".into(), 0.1);
447 assert_eq!(successors(&[ann.clone(), queen.clone()])["ann"], "q");
448 let own = Challenge { trust: 0.1, ..lesson("own", 3, "concurrency", &["ann@x"], &["ann"]) };
449 assert_eq!(successors(&[ann.clone(), own])["ann"], "own");
450 assert_eq!(Trust::compute(&[ann, queen], &[]).of("ann@x", "concurrency"), 0.25);
452 }
453
454 #[test]
455 fn lesson_trust_is_the_mean_of_its_authors_in_its_area() {
456 let ls = [lesson("a", 1, "concurrency", &["ann@x"], &[]), lesson("b", 2, "concurrency", &["bob@x"], &[])];
457 let t = Trust::compute(&ls, &[o(NOT_CORRECTED, "s", "a"), o(CORRECTED, "s", "b")]);
458 let both = lesson("c", 3, "concurrency", &["ann@x", "bob@x"], &[]);
459 assert!((t.lesson(&both) - (2.0 / 3.0 + 1.0 / 3.0) / 2.0).abs() < 1e-12);
460 assert_eq!(t.lesson(&lesson("d", 4, "concurrency", &[], &[])), NEUTRAL);
461 assert_eq!(t.lesson(&lesson("e", 4, "security", &["ann@x"], &[])), NEUTRAL);
462 }
463
464 fn store(name: &str, lessons: &[serde_json::Value], outcomes: &[Outcome]) -> std::path::PathBuf {
465 let dir = std::env::temp_dir().join(format!("borg-trust-{name}-{}", std::process::id()));
466 let _ = std::fs::remove_dir_all(&dir);
467 std::fs::create_dir_all(&dir).unwrap();
468 std::fs::write(dir.join("lessons.jsonl"), lessons.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
469 for x in outcomes {
470 outcome::append(&dir.join("outcomes.jsonl"), x).unwrap();
471 }
472 dir
473 }
474
475 fn dana() -> collective::Aliases {
476 let cfg: collective::Config =
477 serde_json::from_str(r#"{"engineers": [{"name": "dana", "emails": ["Dana.Example@corp.example", "da@home.example"]}]}"#).unwrap();
478 cfg.aliases()
479 }
480
481 #[test]
482 fn an_engineers_emails_share_one_trust() {
483 use serde_json::json;
484 let l = |id: &str, e: &[&str]| json!({"id": id, "origin_ms": 1, "question": "q", "area": "concurrency", "engineers": e});
485 let dir = store(
486 "aliases",
487 &[l("a", &["Dana.Example@corp.example"]), l("b", &["da@home.example"]), l("c", &["ann@x"])],
488 &[o(NOT_CORRECTED, "s1", "a"), o(NOT_CORRECTED, "s2", "b"), o(CORRECTED, "s3", "c")],
489 );
490 let none = collective::Aliases::default();
492 let t = of_store_with(&dir, &none);
493 let rows: Vec<(&str, Evidence)> = t.rows().map(|(e, _, x)| (e, x)).collect();
494 assert_eq!(rows.len(), 3, "{rows:?}");
495 assert!((t.of("da@home.example", "concurrency") - 2.0 / 3.0).abs() < 1e-12);
496 assert_eq!(summary_with(&dir, &none), "highest da@home.example in concurrency 0.67, lowest ann@x in concurrency 0.33 (3 with evidence; borg trust)");
497 let t = of_store_with(&dir, &dana());
499 assert_eq!(t.rows().map(|(e, _, x)| (e.to_string(), x.followed)).collect::<Vec<_>>(), [("ann@x".into(), 0), ("dana".to_string(), 2)]);
500 assert_eq!(t.of("dana", "concurrency"), 0.75);
501 assert!(table(&t).contains("dana") && !table(&t).contains("corp.example"));
502 let _ = std::fs::remove_dir_all(dir);
503 }
504
505 #[test]
506 fn status_counts_an_engineers_emails_once() {
507 use serde_json::json;
508 let l = |id: &str, e: &[&str]| json!({"id": id, "origin_ms": 1, "question": "q", "engineers": e});
509 let dir = store("status", &[l("a", &["Dana.Example@corp.example"]), l("b", &["da@home.example", "DA@home.example"])], &[]);
510 assert_eq!(summary_with(&dir, &collective::Aliases::default()), "no evidence yet (2 engineers neutral at 0.50)", "case alone never splits");
511 assert_eq!(summary_with(&dir, &dana()), "no evidence yet (1 engineers neutral at 0.50)");
512 let _ = std::fs::remove_dir_all(dir);
513 }
514
515 #[test]
516 fn aliases_settle_self_reversals_and_joins() {
517 use serde_json::json;
518 let dir = store(
520 "reversal",
521 &[
522 json!({"id": "a", "origin_ms": 1, "question": "q", "area": "security", "engineers": ["Dana.Example@corp.example"]}),
523 json!({"id": "b", "origin_ms": 2, "question": "q", "area": "security", "engineers": ["da@home.example"], "supersedes": ["a"]}),
524 json!({"id": "c", "origin_ms": 3, "question": "q", "area": "scope", "engineers": ["Dana.Example@corp.example", "da@home.example"],
526 "joined": {"da@home.example": 100}}),
527 ],
528 &[Outcome { ms: 10, ..o(NOT_CORRECTED, "early", "c") }],
529 );
530 let t = of_store_with(&dir, &collective::Aliases::default());
531 assert_eq!(t.of("Dana.Example@corp.example".to_lowercase().as_str(), "security"), NEUTRAL, "two engineers: an unsettled contradiction");
532 assert_eq!(t.of("da@home.example", "scope"), NEUTRAL, "joined after the session");
533 let t = of_store_with(&dir, &dana());
534 assert_eq!(t.of("dana", "security"), 0.25, "one engineer reversing himself");
535 assert!((t.of("dana", "scope") - 2.0 / 3.0).abs() < 1e-12, "the session counts: he was there from the start");
536 let lessons = conscience::load_with(&dir.join("lessons.jsonl"), None, &dana());
537 assert_eq!(lessons[2].engineers, ["dana"]);
538 assert!(lessons[2].joined.is_empty());
539 let _ = std::fs::remove_dir_all(dir);
540 }
541
542 #[test]
543 fn table_and_summary_read_one_store() {
544 let dir = std::env::temp_dir().join(format!("borg-trust-table-{}", std::process::id()));
545 let _ = std::fs::remove_dir_all(&dir);
546 std::fs::create_dir_all(&dir).unwrap();
547 assert!(summary(&dir).starts_with("no evidence yet (0 engineers"));
548 let line =
549 |id: &str, area: &str, e: &str| format!("{}\n", serde_json::json!({"id": id, "origin_ms": 1, "question": "q", "area": area, "engineers": [e]}));
550 std::fs::write(dir.join("lessons.jsonl"), line("a", "concurrency", "ann@x") + &line("b", "scope", "bob@x")).unwrap();
551 for x in [o(NOT_CORRECTED, "s", "a"), o(CORRECTED, "s", "b")] {
552 outcome::append(&dir.join("outcomes.jsonl"), &x).unwrap();
553 }
554 let t = of_store(&dir);
555 let text = table(&t);
556 assert!(text.lines().nth(1).unwrap().starts_with("ann@x concurrency"), "{text}");
557 assert!(text.contains("0.67") && text.contains("0.33"), "{text}");
558 assert_eq!(summary(&dir), "highest ann@x in concurrency 0.67, lowest bob@x in scope 0.33 (2 with evidence; borg trust)");
559 let _ = std::fs::remove_dir_all(dir);
560 }
561}