Skip to main content

borg_web/
learning.rs

1//! `/api/learning`: whether learning works in this collective, week by week, from the logs Borg keeps
2//! anyway (no state of its own, so the history is there from the first view).
3//!
4//! Each section says where its numbers come from: `team` when they fold synced logs (stats.jsonl
5//! rollups, lessons.jsonl) in a team collective, `this machine`
6//! when they fold the local event log (stats/events.jsonl, which keeps the lesson sources) or the
7//! collective is local.
8//! - lessons written per source (session, review, regret, veto, queen, import, ...), and
9//!   the team's lessons written (from lessons.jsonl, each lesson once) and merged;
10//! - asked lessons' verdicts: changed the work, ignored, corrected anyway (the asked→changed rate);
11//! - vetoes and repeated vetoes (the same "no" said twice: a lesson that did not hold).
12//!
13//! The weeks run from the first counted one to the current one (`partial_week`). The history before
14//! live counting began (stats.rs `backfill`) derives no merges, repeated vetoes or lesson sources:
15//! those series are null before `live_from`, unknown rather than zero.
16
17use std::collections::BTreeMap;
18use std::path::Path;
19
20use borg::clock::{self, DAY_MS};
21use borg::collective::Share;
22use borg::{learn, stats};
23use serde_json::{Value, json};
24
25/// The Monday (UTC) of the week `ms` falls in, as a day number since 1970-01-01 (a Thursday).
26fn week(ms: i64) -> i64 {
27    let d = ms.div_euclid(DAY_MS);
28    d - (d + 3).rem_euclid(7)
29}
30
31/// The Monday of the week `day` (`YYYY-MM-DD`) falls in; None for an unreadable day.
32fn week_of_day(day: &str) -> Option<i64> {
33    let mut it = day.split('-').map(|p| p.parse::<i64>().ok());
34    let (y, m, d) = (it.next()??, it.next()??, it.next()??);
35    Some(week(clock::days_from_civil(y, m, d) * DAY_MS))
36}
37
38/// Counts per (series, week).
39#[derive(Default)]
40struct Weekly(BTreeMap<String, BTreeMap<i64, u64>>);
41
42impl Weekly {
43    fn add(&mut self, series: &str, week: i64, n: u64) {
44        if n > 0 {
45            *self.0.entry(series.to_string()).or_default().entry(week).or_default() += n;
46        }
47    }
48
49    fn weeks(&self) -> impl Iterator<Item = i64> + '_ {
50        self.0.values().flat_map(|w| w.keys().copied())
51    }
52
53    /// `series`' counts over `weeks`, zero where nothing was counted.
54    fn of(&self, series: &str, weeks: &[i64]) -> Vec<u64> {
55        let w = self.0.get(series);
56        weeks.iter().map(|k| w.and_then(|w| w.get(k)).copied().unwrap_or(0)).collect()
57    }
58}
59
60/// Monday day numbers from the first week any series counted to the week of `now` (the newest is
61/// the current, partial week), contiguous: idle weeks show as zeros, never as the last active ones.
62/// Empty when nothing was counted.
63fn span(all: &[&Weekly], now: i64) -> Vec<i64> {
64    let ks: Vec<i64> = all.iter().flat_map(|w| w.weeks()).collect();
65    match (ks.iter().min(), ks.iter().max()) {
66        (Some(&a), Some(&b)) => (a..=b.max(week(now))).step_by(7).collect(),
67        _ => Vec::new(),
68    }
69}
70
71/// `counts` with the weeks before `from` (the week live counting began) unknown: null, never a 0.
72fn known(counts: Vec<u64>, weeks: &[i64], from: Option<i64>) -> Vec<Option<u64>> {
73    counts.into_iter().zip(weeks).map(|(n, w)| from.is_some_and(|f| *w >= f).then_some(n)).collect()
74}
75
76pub fn view(name: &str, home: &Path, share: Share) -> Value {
77    view_at(name, home, share, learn::now_ms())
78}
79
80fn view_at(name: &str, home: &Path, share: Share, now: i64) -> Value {
81    let synced = if share == Share::Team { "team" } else { "this machine" };
82    let local = "this machine";
83    // The derived history (stats.rs `backfill`) has no merges, no repeated vetoes and no lesson
84    // sources (and its lessons include peers'): those series start when live counting began here.
85    let buckets = stats::current(home);
86    let live_week = stats::live_since(home).map(week);
87    // Lessons written by source: the live events keep the source (`why`), the rollups do not.
88    let mut sources = Weekly::default();
89    for e in stats::live(home) {
90        if e.kind == stats::LESSON_NEW {
91            sources.add(if e.why.is_empty() { "session" } else { &e.why }, week(e.ms), 1);
92        }
93    }
94    // Team-wide counts from every node's daily rollups.
95    let mut team = Weekly::default();
96    const KINDS: [&str; 7] = [stats::LESSON_MERGED, stats::CHANGED, stats::IGNORED, stats::CORRECTED, stats::NOT_CORRECTED, stats::VETO, stats::VETO_REPEAT];
97    for b in &buckets {
98        let Some(w) = week_of_day(&b.day) else { continue };
99        for k in KINDS {
100            team.add(k, w, b.counts.get(k).copied().unwrap_or(0));
101        }
102    }
103    // New lessons from the lessons themselves (synced in a team): each once, in the week it was
104    // learned, whoever learned it; a node's rollup would count a peer's lesson again in its history.
105    let lessons = learn::lessons(&home.join("lessons.jsonl"));
106    let mut first: BTreeMap<&str, i64> = BTreeMap::new();
107    for l in &lessons {
108        let ms = first.entry(l.id.as_str()).or_insert(l.origin_ms);
109        *ms = (*ms).min(l.origin_ms);
110    }
111    for ms in first.values().filter(|ms| **ms > 0) {
112        team.add(stats::LESSON_NEW, week(*ms), 1);
113    }
114    let weeks = span(&[&sources, &team], now);
115    let mut names: Vec<(&String, u64)> = sources.0.iter().map(|(k, w)| (k, w.values().sum())).collect();
116    names.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
117    let by_source: Vec<Value> = names.iter().map(|(s, n)| json!({"source": s, "total": n, "weeks": known(sources.of(s, &weeks), &weeks, live_week)})).collect();
118    let t = |k: &str| team.of(k, &weeks);
119    let since = |k: &str| known(t(k), &weeks, live_week);
120    json!({
121        "collective": name,
122        "share": if share == Share::Team { "team" } else { "local" },
123        "weeks": weeks.iter().map(|w| stats::day(w * DAY_MS)).collect::<Vec<_>>(),
124        // The newest week is the current one, still running.
125        "partial_week": weeks.last().is_some_and(|w| *w == week(now)),
126        // The Monday live counting began on this machine: before it, the series that are null.
127        "live_from": live_week.map(|w| stats::day(w * DAY_MS)),
128        "lessons": {"scope": local, "by_source": by_source,
129            "team": {"scope": synced, "new": t(stats::LESSON_NEW), "merged": since(stats::LESSON_MERGED)}},
130        "asked": {"scope": synced, "changed": t(stats::CHANGED), "ignored": t(stats::IGNORED), "corrected": t(stats::CORRECTED),
131            "not_corrected": t(stats::NOT_CORRECTED)},
132        "vetoes": {"scope": synced, "veto": t(stats::VETO), "repeat": since(stats::VETO_REPEAT)},
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn weeks_start_on_monday() {
142        // 2026-10-25 is a Sunday, 2026-10-19 its Monday, 2026-10-26 the next.
143        let monday = week_of_day("2026-10-19").unwrap();
144        assert_eq!(stats::day(monday * DAY_MS), "2026-10-19");
145        assert_eq!(week_of_day("2026-10-25"), Some(monday));
146        assert_eq!(week_of_day("2026-10-26"), Some(monday + 7));
147        assert_eq!(week(0), week_of_day("1969-12-29").unwrap(), "1970-01-01 was a Thursday");
148    }
149
150    #[test]
151    fn the_weeks_run_to_the_current_one_and_unknown_weeks_are_null() {
152        // Counts in the weeks of 10-12 and 10-19, viewed on 2026-12-01 (a Tuesday): the idle weeks
153        // up to that week's Monday 11-30 are there, so the last four are idle, not the last active.
154        let (a, b) = (week_of_day("2026-10-12").unwrap(), week_of_day("2026-10-19").unwrap());
155        let mut w = Weekly::default();
156        w.add("x", a, 2);
157        w.add("x", b, 1);
158        let now = (week_of_day("2026-11-30").unwrap() + 1) * DAY_MS + 3_600_000;
159        let weeks = span(&[&w], now);
160        assert_eq!(weeks.len(), 8);
161        assert_eq!(stats::day(weeks[7] * DAY_MS), "2026-11-30");
162        assert_eq!(w.of("x", &weeks), [2, 1, 0, 0, 0, 0, 0, 0]);
163        assert!(span(&[&Weekly::default()], now).is_empty(), "nothing counted: no weeks");
164        assert_eq!(known(vec![4, 5, 6], &weeks[..3], Some(b)), [None, Some(5), Some(6)]);
165        assert_eq!(known(vec![4], &weeks[..1], None), [None], "no live counting yet: unknown");
166    }
167}