Skip to main content

borg/
memory_export.rs

1//! `borg memory`: the evidence behind each lesson, for an outside store to take over long-term knowledge.
2//!
3//! - `borg memory export [--json] [--dir DIR] [--absorbed] [--since YYYY-MM-DD]`: read-only, 0 tokens.
4//!   One evidence record per lesson of the collective DIR (default: the current directory) routes to;
5//!   an off directory prints nothing. `--absorbed` lists the absorbed lessons only, `--since` the lessons
6//!   whose last change (`Evidence::last_change_ms`) is on or after that UTC day.
7//! - `borg memory absorb <id>... [--note TEXT]` and `borg memory release <id>...`: the lesson is now
8//!   held outside Borg, or no longer (absorb.rs, which also says what an absorb does to delivery).
9//!
10//! **The record** (`--json`: `{"schema": SCHEMA, "collective", "lessons": [...]}`, `document`): the
11//! lesson as stored (id, version, repo, question, situations, consequence, area, scope, reach `team` or
12//! `repo`, overrides, supersedes and the computed `superseded_by`, source, origin, support, engineers
13//! under this machine's aliases, history: the old versions' wording); its memory exactly as the
14//! conscience weighs it (memory.rs `derive` over outcome.rs `read_team`, as conscience.rs `load`): the
15//! Beta belief's α, β, mean and 95% equal-tailed credible interval, the per-session result counts,
16//! activation, consolidated and dormant; its breadth: the distinct sessions that changed their work on
17//! it and the distinct repos of those changes (team lines carry hashed repos, so counts, never names);
18//! the raw counts of its placebo and tail-sample judgements from this machine's outcome log (`Judged`),
19//! no estimate and no threshold; whether it is absorbed (`{note, ms, by, version}`, else null) and,
20//! absorbed, its recurrence: the episodes merged into it since the absorbed version.
21//!
22//! Every text field but the ids (`id`, `supersedes`, `superseded_by`) and `reach` goes through notes.rs
23//! `redact`, the engineers and an absorb's `by` too. A personal lesson's tombstone (reach
24//! `personal`) is left out, and the store's `personal.jsonl` is never read. A field the logs do not hold is omitted, never guessed:
25//! `version`, `support` and `history` for a lesson line without them (an older or hand-written line),
26//! `placebo` and `tail` when the log holds no such judgement of the lesson, `recurrence` when the
27//! absorb named no version (the lesson was not in the store yet) or the store no longer holds that
28//! version's line.
29
30use 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
42/// Protocol: the export's schema version. A change to a field's meaning or a removed field raises it;
43/// an added field does not.
44pub const SCHEMA: u32 = 1;
45
46/// Which lessons `evidence` lists.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct Filter {
49    /// Only absorbed lessons.
50    pub absorbed: bool,
51    /// Only lessons whose last change is at or after this epoch ms.
52    pub since_ms: Option<i64>,
53}
54
55/// One lesson's evidence record (module doc).
56#[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    /// `team` or `repo` (reach.rs).
68    pub reach: String,
69    pub overrides: String,
70    pub supersedes: Vec<String>,
71    /// The lessons that list this one in their `supersedes`, sorted.
72    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    /// The lesson's last change, epoch ms: the latest of its first correction, each later engineer
90    /// joining, each of its episodes this machine holds, each outcome naming it, and its last absorb or
91    /// release.
92    pub last_change_ms: i64,
93}
94
95/// The memory the conscience weighs the lesson by (memory.rs `Memory`), rounded to 4 decimals.
96#[derive(Debug, Clone, Serialize, PartialEq)]
97pub struct MemoryView {
98    pub alpha: f64,
99    pub beta: f64,
100    pub mean: f64,
101    /// The Beta(α, β) belief's 2.5% and 97.5% quantiles.
102    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/// The distinct sessions that changed their work on the lesson, and the distinct repos of those
115/// changes: what memory.rs counts to consolidate it.
116#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
117pub struct Breadth {
118    pub sessions: usize,
119    pub repos: usize,
120}
121
122/// Raw judgement counts. Placebo: the judge's verdicts (changed, ignored, and from 0.7.5 not arisen) on
123/// the lesson where it was routed nowhere (outcome.rs `PLACEBO`), and of them the ones that found the drone changed its work on its point
124/// anyway. Tail: the graded checks whose tail sample showed the lesson past the pool budget (outcome.rs
125/// `Outcome::tail`), and of them the ones whose model graded it as applying.
126#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
127pub struct Judged {
128    pub count: u32,
129    pub applied: u32,
130}
131
132/// The lesson's absorb (absorb.rs `Record`).
133#[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
141/// Every line of the lessons file per id, in file order: the last is the lesson as the conscience
142/// reads it (conscience.rs `load_unweighted`).
143fn 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
153/// The lesson's current version (its last line's `version`), None for an id the lines lack or a line
154/// without one.
155fn 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
163/// The episodes merged into the lesson since its absorb: the latest line's episodes beyond those of the
164/// last line at the absorbed version. None when the absorb named no version or no line has it.
165fn 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
172/// The ms of this machine's episodes (`episodes.jsonl`), by id.
173fn 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
187/// The placebo and tail judgements of every lesson in this machine's outcome log (`Judged`).
188fn 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        // A placebo's verdicts: changed, ignored, or its point never came up (`asked_not_arisen`, 0.7.5 on:
192        // nothing in the work, no change). Unclear and a remaining consequence are no judgement.
193        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
211/// The evidence records of the store `store` (the directory of its `lessons.jsonl`) that `filter`
212/// lets through, by first correction then id. Never fails: missing logs are no evidence.
213pub 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    // Per lesson: the sessions and repos of its changes, and its outcomes' latest ms.
224    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
313/// The export of the store `store` as one JSON document, `{"schema": SCHEMA, "collective", "lessons"}`:
314/// what `borg memory export --json` prints and borg-web's `/api/memory` serves.
315pub fn document(collective: &str, store: &Path, filter: &Filter) -> Value {
316    json!({"schema": SCHEMA, "collective": collective, "lessons": evidence(store, filter)})
317}
318
319/// `borg status`'s line of a store: its absorbed lessons, and how many of them recurred (a correction
320/// merged into one since its absorb, `recurrence`). An absorbed id the store does not hold yet is not
321/// counted.
322pub 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
333// ---------------------------------------------------------------- the Beta interval
334
335/// ln Γ(x) for x > 0 (Lanczos, g = 7, nine terms: about 15 significant digits).
336fn 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        // Reflection: Γ(x) Γ(1 − x) = π / sin(πx).
350        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
358/// The continued fraction of the incomplete beta function (modified Lentz, as in Numerical Recipes'
359/// `betacf`).
360fn beta_cf(a: f64, b: f64, x: f64) -> f64 {
361    // Method: TINY keeps a denominator off zero; EPS is double precision; the fraction needs about
362    // √max(a, b) terms, so MAX_TERMS covers beliefs of millions of outcomes and is never the stop.
363    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
390/// The regularised incomplete beta function I_x(a, b): the Beta(a, b) CDF at x.
391fn 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
402/// The Beta(a, b) quantile at `p`, by bisection on the CDF. Method: the CDF is monotone, so 60 halvings
403/// place it within 2⁻⁶⁰ of the root, finer than the 4 decimals the export shows.
404fn 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
417/// The equal-tailed `level` credible interval of Beta(a, b).
418pub 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
423// ---------------------------------------------------------------- the command
424
425/// `borg memory`'s subcommands and flags (completions.rs).
426pub 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/// The parsed arguments after the subcommand.
436#[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
468/// `borg memory <export|absorb|release> ...`; the exit code.
469pub 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    // Routing compares path text, so a relative --dir is made absolute first, as hooks' cwd is.
482    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
521/// The compact listing: a header, then per lesson its belief, breadth and absorb, and its question.
522fn 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    // Under learn's lock on the lessons: no merge lands between reading a lesson's version and the
556    // absorb that names it, and two commands here stamp in the order they write.
557    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        // Stamped after what it sees: a peer's clock a little ahead never makes this command lose to it.
562        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        // Beta(1, 1) is uniform; Beta(2, 1) has CDF x²; Beta(1, 2) has CDF 1 − (1 − x)².
623        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        // Symmetric beliefs give symmetric intervals; more evidence narrows them; fractional counts work.
630        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                // A placebo's verdicts are no result: counted raw, never in the belief.
669                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        // Lb: no version, support or history on its line: omitted, not guessed; not absorbed: null.
694        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        // The filters, and the document.
698        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        // Released: no longer absorbed, nothing recurred.
705        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        // Learning still sees the absorbed lesson: it is offered for merging.
744        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}