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