1use std::collections::{BTreeSet, HashMap, HashSet};
31use std::path::{Path, PathBuf};
32
33use serde::Serialize;
34use serde_json::{Value, json};
35
36use crate::absorb::{self, Record};
37use crate::conscience::{self, Challenge};
38use crate::notes::redact;
39use crate::outcome::{self, Outcome};
40use crate::{collective, learn, reach};
41
42pub const SCHEMA: u32 = 1;
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct Filter {
49 pub absorbed: bool,
51 pub since_ms: Option<i64>,
53}
54
55#[derive(Debug, Clone, Serialize, PartialEq)]
57pub struct Evidence {
58 pub id: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub version: Option<u32>,
61 pub repo: String,
62 pub question: String,
63 pub situations: Vec<String>,
64 pub consequence: String,
65 pub area: String,
66 pub scope: Vec<String>,
67 pub reach: String,
69 pub overrides: String,
70 pub supersedes: Vec<String>,
71 pub superseded_by: Vec<String>,
73 pub source: String,
74 pub origin: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub support: Option<u64>,
77 pub engineers: Vec<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub history: Option<Vec<Value>>,
80 pub memory: MemoryView,
81 pub breadth: Breadth,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub placebo: Option<Judged>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub tail: Option<Judged>,
86 pub absorbed: Option<Absorbed>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub recurrence: Option<usize>,
89 pub last_change_ms: i64,
93}
94
95#[derive(Debug, Clone, Serialize, PartialEq)]
97pub struct MemoryView {
98 pub alpha: f64,
99 pub beta: f64,
100 pub mean: f64,
101 pub ci95: [f64; 2],
103 pub changed: u32,
104 pub ignored: u32,
105 pub corrected: u32,
106 pub vetoed: u32,
107 pub regretted: f64,
108 pub tested: u32,
109 pub activation: f64,
110 pub consolidated: bool,
111 pub dormant: bool,
112}
113
114#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
117pub struct Breadth {
118 pub sessions: usize,
119 pub repos: usize,
120}
121
122#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
127pub struct Judged {
128 pub count: u32,
129 pub applied: u32,
130}
131
132#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
134pub struct Absorbed {
135 pub note: Option<String>,
136 pub ms: i64,
137 pub by: String,
138 pub version: Option<u32>,
139}
140
141fn lines_by_id(lessons: &Path) -> HashMap<String, Vec<Value>> {
144 let mut by: HashMap<String, Vec<Value>> = HashMap::new();
145 for v in std::fs::read_to_string(lessons).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()) {
146 if let Some(id) = v["id"].as_str().filter(|i| !i.is_empty()) {
147 by.entry(id.to_string()).or_default().push(v);
148 }
149 }
150 by
151}
152
153fn current_version(lines: &HashMap<String, Vec<Value>>, id: &str) -> Option<u32> {
156 lines.get(id).and_then(|v| v.last()).and_then(|l| l["version"].as_u64()).and_then(|n| u32::try_from(n).ok())
157}
158
159fn episodes(line: &Value) -> impl Iterator<Item = &str> {
160 line["episodes"].as_array().into_iter().flatten().filter_map(Value::as_str)
161}
162
163fn recurrence(lines: &[Value], r: &Record) -> Option<usize> {
166 let v = u64::from(r.version?);
167 let at = lines.iter().rev().find(|l| l["version"].as_u64() == Some(v))?;
168 let then: HashSet<&str> = episodes(at).collect();
169 Some(lines.last().map_or(0, |l| episodes(l).filter(|e| !then.contains(e)).count()))
170}
171
172fn episode_ms(store: &Path) -> HashMap<String, i64> {
174 let text = std::fs::read_to_string(store.join("episodes.jsonl")).unwrap_or_default();
175 text.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).filter_map(|v| Some((v["id"].as_str()?.to_string(), v["ms"].as_i64()?))).collect()
176}
177
178fn redact_value(v: &Value) -> Value {
179 match v {
180 Value::String(s) => Value::String(redact(s)),
181 Value::Array(a) => Value::Array(a.iter().map(redact_value).collect()),
182 Value::Object(o) => Value::Object(o.iter().map(|(k, v)| (k.clone(), redact_value(v))).collect()),
183 other => other.clone(),
184 }
185}
186
187fn judgements(local: &[Outcome]) -> (HashMap<&str, Judged>, HashMap<&str, Judged>) {
189 let (mut placebo, mut tail): (HashMap<&str, Judged>, HashMap<&str, Judged>) = Default::default();
190 for o in local {
191 if o.channel == outcome::PLACEBO && matches!(o.kind.as_str(), outcome::CHANGED | outcome::IGNORED | outcome::NOT_ARISEN) {
194 for l in &o.lessons {
195 let j = placebo.entry(l.as_str()).or_default();
196 j.count += 1;
197 j.applied += u32::from(o.kind == outcome::CHANGED);
198 }
199 }
200 if o.kind == outcome::GRADED {
201 for l in &o.tail {
202 let j = tail.entry(l.as_str()).or_default();
203 j.count += 1;
204 j.applied += u32::from(o.lessons.contains(l));
205 }
206 }
207 }
208 (placebo, tail)
209}
210
211pub fn evidence(store: &Path, filter: &Filter) -> Vec<Evidence> {
214 let path = store.join("lessons.jsonl");
215 let lessons: Vec<Challenge> = conscience::load_unweighted(&path, None);
216 let outcomes = outcome::read_team(&path);
217 let memory = crate::memory::derive(&outcomes, &lessons, None);
218 let local = outcome::read(&outcome::path_beside(&path));
219 let (placebo, tail) = judgements(&local);
220 let lines = lines_by_id(&path);
221 let log = absorb::latest(&absorb::path(store), None);
222 let episode_ms = episode_ms(store);
223 let mut changes: HashMap<&str, (HashSet<&str>, HashSet<&str>)> = HashMap::new();
225 let mut last_outcome: HashMap<&str, i64> = HashMap::new();
226 for o in &outcomes {
227 for l in &o.lessons {
228 let m = last_outcome.entry(l.as_str()).or_insert(o.ms);
229 *m = (*m).max(o.ms);
230 if o.kind == outcome::CHANGED && outcome::is_result(o) {
231 let (s, r) = changes.entry(l.as_str()).or_default();
232 s.insert(o.session.as_str());
233 r.insert(o.repo.as_str());
234 }
235 }
236 }
237 let live: Vec<&Challenge> = lessons.iter().filter(|c| c.reach != reach::PERSONAL).collect();
238 let mut by: HashMap<&str, BTreeSet<String>> = HashMap::new();
239 for c in &live {
240 for s in &c.supersedes {
241 by.entry(s.as_str()).or_default().insert(c.id.clone());
242 }
243 }
244 let mut out: Vec<(i64, Evidence)> = live
245 .iter()
246 .filter_map(|c| {
247 let rec = log.get(&c.id);
248 let absorbed = rec.filter(|r| r.absorbed());
249 if filter.absorbed && absorbed.is_none() {
250 return None;
251 }
252 let all = lines.get(&c.id).map(Vec::as_slice).unwrap_or_default();
253 let line = all.last().cloned().unwrap_or(Value::Null);
254 let mut last = c.origin_ms.max(c.joined.values().copied().max().unwrap_or(0));
255 last = last.max(episodes(&line).filter_map(|e| episode_ms.get(e).copied()).max().unwrap_or(0));
256 last = last.max(last_outcome.get(c.id.as_str()).copied().unwrap_or(0)).max(rec.map_or(0, |r| r.ms));
257 if filter.since_ms.is_some_and(|s| last < s) {
258 return None;
259 }
260 let m = &memory[&c.id];
261 let (alpha, beta) = (m.alpha(), m.beta());
262 let (lo, hi) = interval(alpha, beta, 0.95);
263 let breadth = changes.get(c.id.as_str()).map_or_else(Breadth::default, |(s, r)| Breadth { sessions: s.len(), repos: r.len() });
264 Some((
265 c.origin_ms,
266 Evidence {
267 id: c.id.clone(),
268 version: line["version"].as_u64().and_then(|n| u32::try_from(n).ok()),
269 repo: redact(&c.repo),
270 question: redact(&c.question),
271 situations: c.situations.iter().map(String::as_str).map(redact).collect(),
272 consequence: redact(&c.consequence),
273 area: redact(&c.area),
274 scope: c.scope.iter().map(String::as_str).map(redact).collect(),
275 reach: if c.reach == reach::REPO { reach::REPO.into() } else { "team".into() },
276 overrides: redact(&c.overrides),
277 supersedes: c.supersedes.clone(),
278 superseded_by: by.get(c.id.as_str()).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
279 source: redact(&c.source),
280 origin: redact(line["origin"].as_str().unwrap_or_default()),
281 support: line["support"].as_u64(),
282 engineers: c.engineers.iter().map(String::as_str).map(redact).collect(),
283 history: line["history"].as_array().map(|h| h.iter().map(redact_value).collect()),
284 memory: MemoryView {
285 alpha: outcome::round_p(alpha),
286 beta: outcome::round_p(beta),
287 mean: outcome::round_p(m.mean()),
288 ci95: [outcome::round_p(lo), outcome::round_p(hi)],
289 changed: m.changed,
290 ignored: m.ignored,
291 corrected: m.corrected,
292 vetoed: m.vetoed,
293 regretted: outcome::round_p(m.regretted),
294 tested: m.tested,
295 activation: outcome::round_p(m.activation),
296 consolidated: m.consolidated,
297 dormant: m.dormant,
298 },
299 breadth,
300 placebo: placebo.get(c.id.as_str()).copied(),
301 tail: tail.get(c.id.as_str()).copied(),
302 absorbed: absorbed.map(|r| Absorbed { note: r.note.as_deref().map(redact), ms: r.ms, by: redact(&r.by), version: r.version }),
303 recurrence: absorbed.and_then(|r| recurrence(all, r)),
304 last_change_ms: last,
305 },
306 ))
307 })
308 .collect();
309 out.sort_by(|a, b| (a.0, &a.1.id).cmp(&(b.0, &b.1.id)));
310 out.into_iter().map(|(_, e)| e).collect()
311}
312
313pub fn document(collective: &str, store: &Path, filter: &Filter) -> Value {
316 json!({"schema": SCHEMA, "collective": collective, "lessons": evidence(store, filter)})
317}
318
319pub fn summary(store: &Path) -> (usize, usize) {
323 let lines = lines_by_id(&store.join("lessons.jsonl"));
324 let state = absorb::state(&absorb::path(store), None);
325 let held: Vec<(&Record, &[Value])> = state
326 .values()
327 .filter_map(|r| lines.get(&r.id).map(|l| (r, l.as_slice())))
328 .filter(|(_, l)| l.last().is_some_and(|x| x["reach"].as_str() != Some(reach::PERSONAL)))
329 .collect();
330 (held.len(), held.iter().filter(|(r, l)| recurrence(l, r).is_some_and(|n| n > 0)).count())
331}
332
333fn ln_gamma(x: f64) -> f64 {
337 const G: [f64; 9] = [
338 0.999_999_999_999_809_9,
339 676.520_368_121_885_1,
340 -1_259.139_216_722_402_8,
341 771.323_428_777_653_1,
342 -176.615_029_162_140_6,
343 12.507_343_278_686_905,
344 -0.138_571_095_265_720_12,
345 9.984_369_578_019_572e-6,
346 1.505_632_735_149_311_6e-7,
347 ];
348 if x < 0.5 {
349 return (std::f64::consts::PI / (std::f64::consts::PI * x).sin()).ln() - ln_gamma(1.0 - x);
351 }
352 let x = x - 1.0;
353 let a = G[1..].iter().enumerate().fold(G[0], |s, (i, g)| s + g / (x + i as f64 + 1.0));
354 let t = x + 7.5;
355 0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
356}
357
358fn beta_cf(a: f64, b: f64, x: f64) -> f64 {
361 const TINY: f64 = 1e-300;
364 const EPS: f64 = 1e-15;
365 const MAX_TERMS: u32 = 10_000;
366 let (qab, qap, qam) = (a + b, a + 1.0, a - 1.0);
367 let mut c = 1.0;
368 let mut d = 1.0 - qab * x / qap;
369 d = if d.abs() < TINY { TINY } else { d };
370 d = 1.0 / d;
371 let mut h = d;
372 for m in 1..=MAX_TERMS {
373 let m = f64::from(m);
374 let m2 = 2.0 * m;
375 for aa in [m * (b - m) * x / ((qam + m2) * (a + m2)), -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))] {
376 d = 1.0 + aa * d;
377 d = if d.abs() < TINY { TINY } else { d };
378 c = 1.0 + aa / c;
379 c = if c.abs() < TINY { TINY } else { c };
380 d = 1.0 / d;
381 h *= d * c;
382 }
383 if (d * c - 1.0).abs() < EPS {
384 break;
385 }
386 }
387 h
388}
389
390fn beta_cdf(a: f64, b: f64, x: f64) -> f64 {
392 if x <= 0.0 {
393 return 0.0;
394 }
395 if x >= 1.0 {
396 return 1.0;
397 }
398 let front = (ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b) + a * x.ln() + b * (1.0 - x).ln()).exp();
399 if x < (a + 1.0) / (a + b + 2.0) { front * beta_cf(a, b, x) / a } else { 1.0 - front * beta_cf(b, a, 1.0 - x) / b }
400}
401
402fn beta_quantile(a: f64, b: f64, p: f64) -> f64 {
405 let (mut lo, mut hi) = (0.0, 1.0);
406 for _ in 0..60 {
407 let mid = 0.5 * (lo + hi);
408 if beta_cdf(a, b, mid) < p {
409 lo = mid;
410 } else {
411 hi = mid;
412 }
413 }
414 0.5 * (lo + hi)
415}
416
417pub fn interval(a: f64, b: f64, level: f64) -> (f64, f64) {
419 let tail = (1.0 - level) / 2.0;
420 (beta_quantile(a, b, tail), beta_quantile(a, b, 1.0 - tail))
421}
422
423pub const SUBS: &[&str] = &["export", "absorb", "release"];
427pub const FLAGS: &[&str] = &["--json", "--dir", "--absorbed", "--since", "--note"];
428
429const USAGE: &str = "usage: borg memory export [--json] [--dir DIR] [--absorbed] [--since YYYY-MM-DD]
430 borg memory absorb <id>... [--note TEXT] [--dir DIR] [--json]
431 borg memory release <id>... [--dir DIR] [--json]
432 export: each lesson's evidence (0 tokens); absorb: a lesson is now held outside Borg, which stops
433 delivering it; release: Borg delivers it again";
434
435#[derive(Debug, Default, PartialEq)]
437struct Args<'a> {
438 ids: Vec<&'a str>,
439 dir: Option<&'a str>,
440 note: Option<&'a str>,
441 since: Option<&'a str>,
442 json: bool,
443 absorbed: bool,
444}
445
446fn parse<'a>(args: &[&'a str]) -> Option<Args<'a>> {
447 let mut a = Args::default();
448 let mut it = args.iter();
449 while let Some(w) = it.next() {
450 match *w {
451 "--json" => a.json = true,
452 "--absorbed" => a.absorbed = true,
453 "--dir" => a.dir = Some(it.next()?),
454 "--note" => a.note = Some(it.next()?),
455 "--since" => a.since = Some(it.next()?),
456 w if w.starts_with("--") => return None,
457 w => a.ids.push(w),
458 }
459 }
460 Some(a)
461}
462
463fn usage() -> i32 {
464 eprintln!("{USAGE}");
465 2
466}
467
468pub fn main(args: &[&str]) -> i32 {
470 let Some((&sub, rest)) = args.split_first() else { return usage() };
471 let Some(a) = parse(rest) else { return usage() };
472 let valid = match sub {
473 "export" => a.ids.is_empty() && a.note.is_none(),
474 "absorb" => !a.ids.is_empty() && !a.absorbed && a.since.is_none(),
475 "release" => !a.ids.is_empty() && !a.absorbed && a.since.is_none() && a.note.is_none(),
476 _ => false,
477 };
478 if !valid {
479 return usage();
480 }
481 let dir = a.dir.map_or_else(|| std::env::current_dir().unwrap_or_default(), PathBuf::from);
483 let cwd = std::path::absolute(&dir).unwrap_or(dir).display().to_string();
484 let route = match collective::load() {
485 Ok(cfg) => collective::route(cfg.as_ref(), &cwd, &learn::home()),
486 Err(e) => {
487 eprintln!("borg memory: invalid collectives config ({e}); everything is off");
488 return 1;
489 }
490 };
491 let collective::Route::Collective { name, home, .. } = &route else {
492 if sub == "export" {
493 return 0;
494 }
495 eprintln!("off: {cwd} belongs to no collective; borg records nothing here");
496 return 1;
497 };
498 match sub {
499 "export" => export_cmd(name, home, &a),
500 _ => absorb_cmd(sub, home, &cwd, &a),
501 }
502}
503
504fn export_cmd(name: &str, home: &Path, a: &Args) -> i32 {
505 let since_ms = match a.since.map(|d| (d.len() == 10).then(|| borg_trace::parse_ts(&format!("{d}T00:00:00Z"))).filter(|ms| *ms > 0)) {
506 Some(None) => {
507 eprintln!("borg memory export: --since wants YYYY-MM-DD\n{USAGE}");
508 return 2;
509 }
510 s => s.flatten(),
511 };
512 let filter = Filter { absorbed: a.absorbed, since_ms };
513 if a.json {
514 println!("{}", serde_json::to_string_pretty(&document(name, home, &filter)).unwrap_or_else(|_| "null".into()));
515 return 0;
516 }
517 print!("{}", render(name, &evidence(home, &filter)));
518 0
519}
520
521fn render(name: &str, records: &[Evidence]) -> String {
523 let absorbed = records.iter().filter(|e| e.absorbed.is_some()).count();
524 let mut s = format!("collective {name}: {} lessons, {absorbed} absorbed (borg memory export --json: every field)\n", records.len());
525 for e in records {
526 let version = e.version.map_or_else(String::new, |v| format!(" v{v}"));
527 let m = &e.memory;
528 let held = match &e.absorbed {
529 None => String::new(),
530 Some(x) => {
531 let note = x.note.as_deref().map_or_else(String::new, |n| format!(": {}", crate::stats::excerpt(n, 60)));
532 let recurred = e.recurrence.map_or_else(String::new, |n| format!(", recurred {n}"));
533 format!(" absorbed{note}{recurred}")
534 }
535 };
536 s.push_str(&format!(
537 "{}{version} {} mean {:.2} ({:.2}-{:.2}) changed {} ({} sessions, {} repos) corrected {}{held}\n",
538 e.id, e.reach, m.mean, m.ci95[0], m.ci95[1], m.changed, e.breadth.sessions, e.breadth.repos, m.corrected
539 ));
540 s.push_str(&format!(" {}\n", crate::stats::excerpt(&e.question, 160)));
541 }
542 s
543}
544
545fn absorb_cmd(sub: &str, home: &Path, cwd: &str, a: &Args) -> i32 {
546 let note = match absorb::note(a.note) {
547 Ok(n) => n,
548 Err(e) => {
549 eprintln!("borg memory absorb: {e}");
550 return 2;
551 }
552 };
553 let kind = if sub == "absorb" { absorb::ABSORB } else { absorb::RELEASE };
554 let by = learn::engineer(cwd);
555 let (lines, before, written) = crate::live::locked(&home.join("lessons.jsonl"), || {
558 let lines = lines_by_id(&home.join("lessons.jsonl"));
559 let seen = absorb::latest(&absorb::path(home), None);
560 let before: HashSet<String> = seen.values().filter(|r| r.absorbed()).map(|r| r.id.clone()).collect();
561 let now = absorb::stamp(&seen, &a.ids, learn::now_ms());
563 let written = absorb::write(home, &a.ids, kind, note.as_deref(), &by, now, &|id| current_version(&lines, id));
564 (lines, before, written)
565 });
566 let written = match written {
567 Ok(w) => w,
568 Err(e) => {
569 eprintln!("borg memory {sub}: cannot write {}: {e}", absorb::path(home).display());
570 return 1;
571 }
572 };
573 for r in &written {
574 if !lines.contains_key(&r.id) {
575 eprintln!("borg memory {sub}: {} is not a lesson of this collective (yet): recorded, it applies when the lesson arrives", r.id);
576 } else if kind == absorb::RELEASE && !before.contains(r.id.as_str()) {
577 eprintln!("borg memory release: {} was not absorbed here (yet): recorded", r.id);
578 }
579 }
580 if a.json {
581 let out: Vec<Value> = written
582 .iter()
583 .map(|r| {
584 let mut v = serde_json::to_value(r).unwrap_or_default();
585 v["known"] = json!(lines.contains_key(&r.id));
586 v
587 })
588 .collect();
589 println!("{}", Value::Array(out));
590 return 0;
591 }
592 for r in &written {
593 match (kind, &r.note) {
594 (absorb::RELEASE, _) => println!("released {}: borg delivers it again", r.id),
595 (_, Some(n)) => {
596 println!("absorbed {} (version {}): held in {n}; borg no longer delivers it", r.id, r.version.map_or("unknown".into(), |v| v.to_string()))
597 }
598 (_, None) => println!("absorbed {} (version {}): borg no longer delivers it", r.id, r.version.map_or("unknown".into(), |v| v.to_string())),
599 }
600 }
601 0
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 fn temp(name: &str) -> PathBuf {
609 let d = std::env::temp_dir().join(format!("borg-memory-export-{name}-{}", std::process::id()));
610 let _ = std::fs::remove_dir_all(&d);
611 std::fs::create_dir_all(&d).unwrap();
612 d
613 }
614
615 fn close(a: f64, b: f64) -> bool {
616 (a - b).abs() < 1e-9
617 }
618
619 #[test]
620 fn the_beta_interval_matches_closed_forms() {
621 assert!(close(ln_gamma(5.0), 24f64.ln()) && close(ln_gamma(0.5), std::f64::consts::PI.sqrt().ln()));
622 let (lo, hi) = interval(1.0, 1.0, 0.95);
624 assert!(close(lo, 0.025) && close(hi, 0.975), "{lo} {hi}");
625 let (lo, hi) = interval(2.0, 1.0, 0.95);
626 assert!(close(lo, 0.025f64.sqrt()) && close(hi, 0.975f64.sqrt()), "{lo} {hi}");
627 let (lo, hi) = interval(1.0, 2.0, 0.95);
628 assert!(close(lo, 1.0 - 0.975f64.sqrt()) && close(hi, 1.0 - 0.025f64.sqrt()), "{lo} {hi}");
629 let (lo, hi) = interval(6.0, 6.0, 0.95);
631 assert!(close(lo, 1.0 - hi));
632 assert!(interval(60.0, 60.0, 0.95).1 - interval(60.0, 60.0, 0.95).0 < hi - lo);
633 let (lo, hi) = interval(1.5, 3.0, 0.95);
634 assert!(close(beta_cdf(1.5, 3.0, lo), 0.025) && close(beta_cdf(1.5, 3.0, hi), 0.975));
635 }
636
637 fn write_lines(path: &Path, lines: &[Value]) {
638 std::fs::write(path, lines.iter().map(|l| format!("{l}\n")).collect::<String>()).unwrap();
639 }
640
641 fn o(kind: &str, session: &str, lessons: &[&str], ms: i64, repo: &str, channel: &str) -> Value {
642 json!({"kind": kind, "session": session, "lessons": lessons, "ms": ms, "repo": repo, "channel": channel})
643 }
644
645 #[test]
646 fn the_export_holds_the_lesson_its_memory_breadth_judgements_and_absorb() {
647 let store = temp("record");
648 write_lines(
649 &store.join("lessons.jsonl"),
650 &[
651 json!({"id": "La", "origin_ms": 10, "repo": "app", "question": "Is the lock held across the await?", "situations": ["send under a lock"],
652 "consequence": "a slow client stalls senders", "support": 1, "episodes": ["e1"], "history": [], "version": 1, "engineers": ["a@x"]}),
653 json!({"id": "La", "origin_ms": 10, "repo": "app", "question": "Is the send lock held across any await?", "situations": ["send under a lock"],
654 "consequence": "a slow client stalls senders", "support": 2, "episodes": ["e1", "e2"],
655 "history": [{"question": "Is the lock held across the await? token ghp_0123456789abcdefghijABCDEFGHIJ012345", "consequence": "c", "version": 1}],
656 "version": 2, "engineers": ["a@x"], "area": "concurrency"}),
657 json!({"id": "Lb", "origin_ms": 20, "repo": "app", "question": "Is the queue drained by one writer?", "supersedes": ["La"], "reach": "repo"}),
658 json!({"id": "Lgone", "origin_ms": 30, "question": "(withdrawn)", "reach": "personal"}),
659 ],
660 );
661 write_lines(
662 &store.join("outcomes.jsonl"),
663 &[
664 o("asked", "s1", &["La"], 100, "app", "stop"),
665 o("asked_then_changed", "s1", &["La"], 110, "app", "stop"),
666 o("asked_then_changed", "s2", &["La"], 120, "web", "stop"),
667 o("asked_then_corrected", "s3", &["La"], 130, "app", "stop"),
668 o("asked_then_changed", "s4", &["La"], 140, "app", "placebo"),
670 o("asked_ignored", "s5", &["La"], 150, "app", "placebo"),
671 json!({"kind": "graded", "session": "s6", "lessons": ["La"], "tail": ["La", "Lb"], "ms": 160, "repo": "app"}),
672 ],
673 );
674 absorb::write(&store, &["La"], absorb::ABSORB, Some("skills/review@2026-10-30"), "lead@x", 200, &|_| Some(1)).unwrap();
675 let all = evidence(&store, &Filter::default());
676 assert_eq!(all.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["La", "Lb"], "a personal tombstone is left out");
677 let a = &all[0];
678 assert_eq!((a.version, a.support, a.area.as_str(), a.reach.as_str()), (Some(2), Some(2), "concurrency", "team"));
679 assert_eq!(a.superseded_by, ["Lb"]);
680 assert_eq!((a.memory.changed, a.memory.corrected), (2, 1));
681 assert_eq!((a.memory.alpha, a.memory.beta), (3.0, 3.0), "Beta(1 + 2 changed, 1 + 2 × 1 corrected)");
682 assert!(a.memory.ci95[0] < a.memory.mean && a.memory.mean < a.memory.ci95[1]);
683 assert_eq!(a.breadth, Breadth { sessions: 2, repos: 2 });
684 assert_eq!(a.placebo, Some(Judged { count: 2, applied: 1 }));
685 assert_eq!(a.tail, Some(Judged { count: 1, applied: 1 }));
686 assert_eq!(all[1].tail, Some(Judged { count: 1, applied: 0 }));
687 assert_eq!(all[1].placebo, None, "no placebo judgement: omitted");
688 let held = a.absorbed.as_ref().unwrap();
689 assert_eq!((held.note.as_deref(), held.by.as_str(), held.version, held.ms), (Some("skills/review@2026-10-30"), "lead@x", Some(1), 200));
690 assert_eq!(a.recurrence, Some(1), "e2 merged after version 1");
691 assert_eq!(a.last_change_ms, 200);
692 assert!(!serde_json::to_string(&a.history).unwrap().contains("ghp_0123"), "history is redacted");
693 let b = serde_json::to_value(&all[1]).unwrap();
695 assert!(b.get("version").is_none() && b.get("support").is_none() && b.get("history").is_none() && b.get("recurrence").is_none(), "{b}");
696 assert_eq!((b["absorbed"].clone(), b["reach"].as_str()), (Value::Null, Some("repo")));
697 let only = evidence(&store, &Filter { absorbed: true, ..Filter::default() });
699 assert_eq!(only.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["La"]);
700 assert_eq!(evidence(&store, &Filter { since_ms: Some(170), ..Filter::default() }).len(), 1, "Lb last changed at its first correction, 20");
701 let doc = document("corp", &store, &Filter::default());
702 assert_eq!((doc["schema"].as_u64(), doc["collective"].as_str(), doc["lessons"].as_array().map(Vec::len)), (Some(1), Some("corp"), Some(2)));
703 assert_eq!(summary(&store), (1, 1));
704 absorb::write(&store, &["La"], absorb::RELEASE, None, "lead@x", 300, &|_| None).unwrap();
706 assert!(evidence(&store, &Filter::default())[0].absorbed.is_none());
707 assert_eq!(summary(&store), (0, 0));
708 let _ = std::fs::remove_dir_all(store);
709 }
710
711 #[test]
712 fn recurrence_needs_the_absorbed_version() {
713 let lines = vec![json!({"id": "L", "version": 1, "episodes": ["e1"]}), json!({"id": "L", "version": 3, "episodes": ["e1", "e2", "e3"]})];
714 let r = |v: Option<u32>| Record { id: "L".into(), kind: absorb::ABSORB.into(), ms: 1, by: String::new(), note: None, version: v };
715 assert_eq!(recurrence(&lines, &r(Some(1))), Some(2));
716 assert_eq!(recurrence(&lines, &r(Some(3))), Some(0));
717 assert_eq!(recurrence(&lines, &r(Some(2))), None, "no line at that version: not guessed");
718 assert_eq!(recurrence(&lines, &r(None)), None, "absorbed before the lesson arrived");
719 }
720
721 #[test]
722 fn a_correction_learned_after_the_absorb_merges_into_it_and_recurs() {
723 let store = temp("learn");
724 let lesson = json!({"id": "La", "origin_ms": 1, "repo": "app", "question": "Is the send lock held across any await?", "situations": ["send under a lock"],
725 "consequence": "a slow client stalls senders", "support": 1, "episodes": ["e1"], "history": [], "version": 1});
726 write_lines(&store.join("lessons.jsonl"), &[lesson]);
727 absorb::write(&store, &["La"], absorb::ABSORB, Some("skills/review@1"), "lead@x", 5, &|id| {
728 current_version(&lines_by_id(&store.join("lessons.jsonl")), id)
729 })
730 .unwrap();
731 let e = learn::Episode {
732 id: "e9".into(),
733 ms: 10,
734 repo: "app".into(),
735 session: "s9".into(),
736 task: "send frames".into(),
737 agent: "I hold the send lock across the await".into(),
738 correction: "no, the send lock must never be held across an await".into(),
739 engineer: "b@x".into(),
740 ..Default::default()
741 };
742 learn::record_episode_in(&store, &e).unwrap();
743 let ask = |p: &str| {
745 assert!(p.contains("La"), "the absorbed lesson is a merge candidate: {p}");
746 Some(json!({"is_lesson": true, "question": "Is any lock held across an await?", "situations": ["send under a lock"],
747 "consequence": "a slow client stalls senders", "same_as": "La", "area": "concurrency", "supersedes": [], "reach": "team"}))
748 };
749 assert_eq!(learn::learn_in(&store, None, &ask, false, 20), (0, 1, 0));
750 let a = evidence(&store, &Filter::default()).remove(0);
751 assert_eq!((a.version, a.recurrence), (Some(2), Some(1)));
752 assert_eq!(summary(&store), (1, 1));
753 let _ = std::fs::remove_dir_all(store);
754 }
755
756 #[test]
757 fn the_arguments_parse() {
758 assert_eq!(
759 parse(&["La", "Lb", "--note", "skills/x", "--dir", "/w", "--json"]),
760 Some(Args { ids: vec!["La", "Lb"], dir: Some("/w"), note: Some("skills/x"), json: true, ..Args::default() })
761 );
762 assert_eq!(parse(&["--since"]), None);
763 assert_eq!(parse(&["--frobnicate"]), None);
764 }
765}