Skip to main content

borg/
stats.rs

1//! What Borg did, counted: the collective's usage statistics.
2//!
3//! Two files per collective store:
4//! - `stats/events.jsonl` (local, never synced): one line per event, written by the hook or command
5//!   where it happened (`record`). It holds session ids and repos, so it stays on this machine.
6//! - `stats.jsonl` (synced by the mesh, `borg-mesh` lines.rs `SYNCED`): one rollup per (node, engineer,
7//!   UTC day), folded from the local events (`rollup`). A bucket is a versioned record (`id` +
8//!   `version`), so the mesh's `settle` keeps its latest version on every machine with no merge logic
9//!   of its own. Only the node that owns a bucket writes it, so buckets never conflict. A rollup carries
10//!   counts, cost and lesson ids, never prompts or sessions.
11//!
12//! Recording fails open and costs one append: a broken store never disturbs the drone.
13
14use std::collections::BTreeMap;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20/// A turn end the conscience saw (one per Stop that was not the rework after its own block).
21pub const TURN_END: &str = "turn_end";
22/// The free gate skipped the model call (`why`: the score, or `noedit`).
23pub const GATE_SKIP: &str = "gate_skip";
24/// One turn-end model call (`n`: lessons shown, `usd`, `dur_ms`; `why: "failed"` when it gave no answer).
25pub const CHECK: &str = "check";
26/// A block: one extra round of agent work (`n`: questions, `lessons`: the asked ids).
27pub const BLOCK: &str = "block";
28/// Live mode (live.rs): one model call during the turn (`n`, `usd`, `dur_ms`, `why: "failed"`, as `check`).
29pub const LIVE_CHECK: &str = "live_check";
30/// Live mode: grade-3 lessons a live check asked, delivered with the next prompt or tool call (`n`,
31/// `lessons`). Never an extra round: the drone answers as it works.
32pub const LIVE_ASK: &str = "live_ask";
33/// Lessons handed over free at a prompt or touch (`n`, `lessons`).
34pub const EARLY: &str = "early";
35/// Grade-2 and grade-1 lessons queued to arrive free at the next prompt (`n`, `lessons`).
36pub const QUEUED_FREE: &str = "queued_free";
37/// Outcomes of asked lessons (outcome.rs): used, ignored, corrected anyway, not corrected.
38pub const CHANGED: &str = "changed";
39pub const IGNORED: &str = "ignored";
40pub const CORRECTED: &str = "corrected";
41pub const NOT_CORRECTED: &str = "not_corrected";
42/// A later fix, or a review finding, counted against a lesson asked before the decision (outcome.rs
43/// `regretted`; `lessons`: the blamed lesson).
44pub const REGRETTED: &str = "regretted";
45/// `borg learn` credited a lesson asked before the session's own tests went green (outcome.rs `tested`;
46/// `lessons`: that lesson).
47pub const TESTED: &str = "tested";
48/// The engineer corrected the drone (an episode): `why` is `rejected_tool` or `pushback`, `text` a snippet.
49pub const CORRECTION: &str = "correction";
50/// The engineer said no (`borg no`, veto.rs): `lessons` the lessons shown so far (credited `vetoed`),
51/// `n` their number, `why` `cli`, `prompt` or `mesh`, `text` the reason.
52pub const VETO: &str = "veto";
53/// `borg learn` merged a veto into a lesson an earlier veto made: the same "no" said twice, dated when
54/// that veto was said (its episode's ms), so it falls in the week of its own `veto`.
55pub const VETO_REPEAT: &str = "veto_repeat";
56/// A lesson written (`why`: its source, session/import/queen/veto) or merged into an existing one.
57pub const LESSON_NEW: &str = "lesson_new";
58pub const LESSON_MERGED: &str = "lesson_merged";
59/// A review step's finding that a fix step fixed before any commit, recorded as an episode
60/// (review_fix.rs; `why`: its severity, `text`: its redacted title, for this machine's feed only).
61pub const REVIEW_FINDING: &str = "review_finding";
62/// `borg learn` judged an episode not to be a lesson.
63pub const NOT_A_LESSON: &str = "not_a_lesson";
64/// A model call outside the turn end (`why`: learn, assimilate, facets, expert; `usd`, `dur_ms`).
65pub const MODEL_CALL: &str = "model_call";
66/// Steering: a directive issued, shown to a drone (`why`: say/pause/resume), refused on receipt
67/// (`why`: the reason), and a tool call refused because the drone was paused (`why`: the tool).
68pub const STEER_ISSUED: &str = "steer_issued";
69pub const STEER_DELIVERED: &str = "steer_delivered";
70pub const STEER_DROPPED: &str = "steer_dropped";
71pub const PAUSE_REFUSED: &str = "pause_refused";
72/// Messages between sessions (msg.rs): one sent (`why`: tell/ask), shown to a session (`why`: its kind),
73/// and dropped (`why`: `rate`, the sender's limit; `bounds`, `n` lines the log's `KEEP` cut).
74pub const MSG_SENT: &str = "msg_sent";
75pub const MSG_DELIVERED: &str = "msg_delivered";
76pub const MSG_DROPPED: &str = "msg_dropped";
77/// The queen's queue: a question queued, handed to a drone, answered (`dur_ms`: time to answer),
78/// skipped, dismissed.
79pub const QUEEN_QUEUED: &str = "queen_queued";
80pub const QUEEN_DELIVERED: &str = "queen_delivered";
81pub const QUEEN_ANSWERED: &str = "queen_answered";
82pub const QUEEN_SKIPPED: &str = "queen_skipped";
83pub const QUEEN_DISMISSED: &str = "queen_dismissed";
84/// The queen's attention reward (escalation.rs), once per event: an escalation answered by changing
85/// course, one answered "yes, fine", and a "no" after merge from a session never escalated (`why`: the
86/// kind, or `veto`/`regret`; `text` starts with the question, episode or link id).
87pub const QUEEN_NO: &str = "queen_no";
88pub const QUEEN_STAMP: &str = "queen_stamp";
89pub const QUEEN_MISSED: &str = "queen_missed";
90/// One imported chunk of team documents (`n`: lessons, `usd`).
91pub const ASSIMILATED: &str = "assimilated";
92
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
94pub struct Event {
95    pub ms: i64,
96    pub kind: String,
97    #[serde(default, skip_serializing_if = "String::is_empty")]
98    pub session: String,
99    #[serde(default, skip_serializing_if = "String::is_empty")]
100    pub engineer: String,
101    #[serde(default, skip_serializing_if = "String::is_empty")]
102    pub repo: String,
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub lessons: Vec<String>,
105    #[serde(default, skip_serializing_if = "is_zero")]
106    pub n: u64,
107    #[serde(default, skip_serializing_if = "is_zero_f")]
108    pub usd: f64,
109    #[serde(default, skip_serializing_if = "is_zero")]
110    pub dur_ms: u64,
111    #[serde(default, skip_serializing_if = "String::is_empty")]
112    pub why: String,
113    /// A short excerpt for this machine's own feed (a correction's words). Never folded into a rollup.
114    #[serde(default, skip_serializing_if = "String::is_empty")]
115    pub text: String,
116    /// The delivery mode the hook ran in (live.rs: `hook` or `live`); empty outside a hook.
117    #[serde(default, skip_serializing_if = "String::is_empty")]
118    pub mode: String,
119    /// How an asked lesson reached the drone: `stop` (a block), `live` (a live check), `early`, `queued`.
120    /// On asks and on their results (`changed`, `ignored`).
121    #[serde(default, skip_serializing_if = "String::is_empty")]
122    pub channel: String,
123}
124
125fn is_zero(v: &u64) -> bool {
126    *v == 0
127}
128
129fn is_zero_f(v: &f64) -> bool {
130    *v == 0.0
131}
132
133impl Event {
134    /// An event now, in the delivery mode of this process (the hook exports `BORG_MODE`, main.rs).
135    pub fn new(kind: &str, ms: i64) -> Self {
136        let mode = std::env::var("BORG_MODE").map(|_| crate::live::current().name().to_string()).unwrap_or_default();
137        Event { kind: kind.into(), ms, mode, ..Default::default() }
138    }
139}
140
141/// One node's counts for one engineer on one UTC day: a line of `stats.jsonl`.
142#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
143pub struct Bucket {
144    pub id: String,
145    pub version: u64,
146    pub day: String,
147    pub node: String,
148    pub engineer: String,
149    /// Events per kind; `<kind>_n` sums their `n` (questions asked, lessons shown).
150    pub counts: BTreeMap<String, u64>,
151    pub usd: f64,
152    /// Cost per kind (turn-end checks, other model calls, imports).
153    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
154    pub usd_by: BTreeMap<String, f64>,
155    pub dur_ms: u64,
156    /// Events per kind for each lesson they named.
157    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
158    pub lessons: BTreeMap<String, BTreeMap<String, u64>>,
159    /// The conscience's model cost (`check` and `live_check`) per delivery mode (`hook`, `live`). A
160    /// split of part of `usd`, so never stacked with `usd_by`.
161    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
162    pub usd_mode: BTreeMap<String, f64>,
163    /// Events per `<kind>@<channel>` for events that name a channel (`block@stop`, `changed@live`).
164    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
165    pub channels: BTreeMap<String, u64>,
166}
167
168impl Bucket {
169    /// Equal counts, cost and lessons: nothing to publish (`id` and `version` aside).
170    fn same(&self, other: &Bucket) -> bool {
171        self.counts == other.counts
172            && self.lessons == other.lessons
173            && self.channels == other.channels
174            && self.dur_ms == other.dur_ms
175            && (self.usd - other.usd).abs() < 1e-9
176    }
177
178    /// Adds `other`'s counts (team totals across nodes and engineers).
179    pub fn add(&mut self, other: &Bucket) {
180        for (k, v) in &other.counts {
181            *self.counts.entry(k.clone()).or_default() += v;
182        }
183        for (id, kinds) in &other.lessons {
184            let mine = self.lessons.entry(id.clone()).or_default();
185            for (k, v) in kinds {
186                *mine.entry(k.clone()).or_default() += v;
187            }
188        }
189        for (k, v) in &other.usd_by {
190            *self.usd_by.entry(k.clone()).or_default() += v;
191        }
192        for (k, v) in &other.usd_mode {
193            *self.usd_mode.entry(k.clone()).or_default() += v;
194        }
195        for (k, v) in &other.channels {
196            *self.channels.entry(k.clone()).or_default() += v;
197        }
198        self.usd += other.usd;
199        self.dur_ms += other.dur_ms;
200    }
201}
202
203pub fn events_path(home: &Path) -> PathBuf {
204    home.join("stats").join("events.jsonl")
205}
206
207pub fn rollups_path(home: &Path) -> PathBuf {
208    home.join("stats.jsonl")
209}
210
211/// Appends `ev` to the local event log. Fails open; records nothing with BORG_LEARN=off (eval drones).
212pub fn record(home: &Path, ev: &Event) {
213    if !crate::learn::learning() {
214        return;
215    }
216    let _ = crate::learn::append_jsonl(&events_path(home), ev);
217}
218
219/// Records one event in the store `home` now; `fill` adds the details. The engineer defaults to the
220/// one presence.rs recorded for the session, so callers need not ask git again.
221pub fn count(home: &Path, kind: &str, session: &str, fill: impl FnOnce(&mut Event)) {
222    let mut ev = Event::new(kind, crate::learn::now_ms());
223    ev.session = session.into();
224    fill(&mut ev);
225    if ev.engineer.is_empty() && !session.is_empty() {
226        ev.engineer = crate::presence::read(home, session).map(|p| p.engineer).unwrap_or_default();
227    }
228    record(home, &ev);
229}
230
231/// The first `max` characters of `text` on one line (a feed excerpt).
232pub fn excerpt(text: &str, max: usize) -> String {
233    let one = text.split_whitespace().collect::<Vec<_>>().join(" ");
234    match one.char_indices().nth(max) {
235        Some((i, _)) => format!("{}…", &one[..i]),
236        None => one,
237    }
238}
239
240static PURPOSE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
241
242/// What this process's model calls are for (learn, assimilate, facets), set once by the command.
243pub fn set_purpose(purpose: &'static str) {
244    let _ = PURPOSE.set(purpose);
245}
246
247pub fn purpose() -> &'static str {
248    PURPOSE.get().copied().unwrap_or("model")
249}
250
251fn read_events(path: &Path) -> Vec<Event> {
252    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
253}
254
255/// Every local event: the history derived once from the older logs (`backfill`) and the live ones.
256pub fn events(home: &Path) -> Vec<Event> {
257    let mut all = read_events(&backfill_path(home));
258    all.extend(read_events(&events_path(home)));
259    all
260}
261
262/// The live events only (stats/events.jsonl), without the derived history.
263pub fn live(home: &Path) -> Vec<Event> {
264    read_events(&events_path(home))
265}
266
267/// When live counting began in `home`: its first live event (None: none yet). A repeated veto is
268/// dated when it was said, earlier than its line, so it never marks the start. What `backfill`
269/// derives is before it; a series backfill cannot derive (merges, repeated vetoes) is unknown there.
270pub fn live_since(home: &Path) -> Option<i64> {
271    live(home).iter().filter(|e| e.kind != VETO_REPEAT).map(|e| e.ms).min()
272}
273
274pub fn backfill_path(home: &Path) -> PathBuf {
275    home.join("stats").join("backfill.jsonl")
276}
277
278fn lines(path: &Path) -> Vec<serde_json::Value> {
279    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
280}
281
282/// Derives, once per store, the events that happened before live counting began from the logs Borg
283/// kept anyway: corrections (episodes), outcomes, lessons, the queen's queue, imports, expert and
284/// facet calls, steering drops, and the turn ends recorded in the session state (dated by the state
285/// file's time). Only what happened before the first live event counts, so nothing is counted twice.
286/// Returns how many events it wrote; 0 when it ran before.
287pub fn backfill(home: &Path) -> usize {
288    let path = backfill_path(home);
289    if path.exists() {
290        return 0;
291    }
292    let cutoff = live_since(home).unwrap_or_else(crate::learn::now_ms);
293    let mut out = derive_history(home);
294    out.retain(|e| e.ms > 0 && e.ms < cutoff);
295    out.sort_by_key(|e| e.ms);
296    let text: String = out.iter().map(|e| serde_json::to_string(e).unwrap_or_default() + "\n").collect();
297    if std::fs::create_dir_all(path.parent().unwrap_or(home)).is_err() || std::fs::write(&path, text).is_err() {
298        return 0;
299    }
300    out.len()
301}
302
303fn derive_history(home: &Path) -> Vec<Event> {
304    use serde_json::Value;
305    let s = |v: &Value, k: &str| v[k].as_str().unwrap_or_default().to_string();
306    let ms = |v: &Value, k: &str| v[k].as_i64().unwrap_or(0);
307    let ids = |v: &Value, k: &str| v[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(String::from).collect::<Vec<_>>();
308    let mut out = Vec::new();
309    // Who worked in which session: corrections name their engineer; outcomes do not.
310    let mut who: std::collections::HashMap<String, String> = std::collections::HashMap::new();
311    for e in lines(&home.join("episodes.jsonl")) {
312        let tool = s(&e, "rejected_tool");
313        if !s(&e, "engineer").is_empty() {
314            who.insert(s(&e, "session"), s(&e, "engineer"));
315        }
316        let snippet = excerpt(&s(&e, "correction"), 160);
317        // A veto (veto.rs) is counted as one, never as a correction.
318        if s(&e, "kind") == crate::veto::KIND {
319            out.push(Event {
320                ms: ms(&e, "ms"),
321                kind: VETO.into(),
322                session: s(&e, "session"),
323                engineer: s(&e, "engineer"),
324                repo: s(&e, "repo"),
325                text: snippet,
326                ..Default::default()
327            });
328            continue;
329        }
330        out.push(Event {
331            ms: ms(&e, "ms"),
332            kind: CORRECTION.into(),
333            session: s(&e, "session"),
334            engineer: s(&e, "engineer"),
335            repo: s(&e, "repo"),
336            why: if tool.is_empty() { "pushback".into() } else { "rejected_tool".into() },
337            text: if tool.is_empty() { snippet } else { format!("{tool}: {snippet}") },
338            ..Default::default()
339        });
340    }
341    let engineer = |session: &str| who.get(session).cloned().unwrap_or_default();
342    for o in lines(&home.join("outcomes.jsonl")) {
343        let live = o["channel"] == "live";
344        let kind = match o["kind"].as_str().unwrap_or_default() {
345            // A free delivery counts once, as its own event (`early`, `queued_free`), never as a block.
346            crate::outcome::ASKED if o["channel"] == "early" || o["channel"] == "queued" => continue,
347            crate::outcome::ASKED if live => LIVE_ASK,
348            crate::outcome::ASKED => BLOCK,
349            crate::outcome::CHANGED => CHANGED,
350            crate::outcome::IGNORED => IGNORED,
351            crate::outcome::CORRECTED => CORRECTED,
352            crate::outcome::NOT_CORRECTED => NOT_CORRECTED,
353            crate::outcome::REGRETTED => REGRETTED,
354            crate::outcome::TESTED => TESTED,
355            _ => continue,
356        };
357        let lessons = ids(&o, "lessons");
358        let session = s(&o, "session");
359        out.push(Event {
360            ms: ms(&o, "ms"),
361            kind: kind.into(),
362            engineer: engineer(&session),
363            session,
364            repo: s(&o, "repo"),
365            n: if kind == BLOCK || kind == LIVE_ASK { lessons.len() as u64 } else { 0 },
366            lessons,
367            channel: s(&o, "channel"),
368            ..Default::default()
369        });
370    }
371    // A lesson's first version is when it was learned; later versions carry no time of their own.
372    let mut seen = std::collections::HashSet::new();
373    for l in lines(&home.join("lessons.jsonl")) {
374        let id = s(&l, "id");
375        if id.is_empty() || !seen.insert(id.clone()) {
376            continue;
377        }
378        let source = Some(s(&l, "source")).filter(|x| !x.is_empty()).unwrap_or_else(|| "session".into());
379        out.push(Event {
380            ms: ms(&l, "origin_ms"),
381            kind: LESSON_NEW.into(),
382            engineer: ids(&l, "engineers").into_iter().next().unwrap_or_default(),
383            repo: s(&l, "repo"),
384            lessons: vec![id],
385            why: source,
386            ..Default::default()
387        });
388    }
389    let mut queen: BTreeMap<String, Value> = BTreeMap::new();
390    for q in lines(&home.join("queen").join("queue.jsonl")) {
391        queen.insert(s(&q, "id"), q);
392    }
393    for q in queen.values() {
394        let base = || Event { engineer: s(q, "engineer"), session: s(q, "session"), repo: s(q, "repo"), lessons: vec![s(q, "id")], ..Default::default() };
395        out.push(Event { ms: ms(q, "ms"), kind: QUEEN_QUEUED.into(), text: excerpt(&s(q, "question"), 160), ..base() });
396        if let Some(at) = q["answered_ms"].as_i64() {
397            let dur = u64::try_from(at - ms(q, "ms")).unwrap_or(0);
398            out.push(Event { ms: at, kind: QUEEN_ANSWERED.into(), dur_ms: dur, engineer: s(q, "answered_by"), text: excerpt(&s(q, "answer"), 160), ..base() });
399        }
400        if let Some(at) = q["dismissed_ms"].as_i64() {
401            out.push(Event { ms: at, kind: QUEEN_DISMISSED.into(), text: s(q, "dismissed"), ..base() });
402        }
403    }
404    for (file, kind) in [("delivered.jsonl", QUEEN_DELIVERED), ("skips.jsonl", QUEEN_SKIPPED)] {
405        for m in lines(&home.join("queen").join(file)) {
406            let session = s(&m, "session");
407            out.push(Event { ms: ms(&m, "ms"), kind: kind.into(), engineer: engineer(&session), session, lessons: vec![s(&m, "id")], ..Default::default() });
408        }
409    }
410    for a in lines(&home.join("assimilated.jsonl")) {
411        out.push(Event {
412            ms: ms(&a, "ms"),
413            kind: ASSIMILATED.into(),
414            repo: s(&a, "source"),
415            n: ids(&a, "lessons").len() as u64,
416            usd: a["cost_usd"].as_f64().unwrap_or(0.0),
417            ..Default::default()
418        });
419    }
420    for (file, why) in [("experts.jsonl", "expert"), ("facets.jsonl", "facets")] {
421        for c in lines(&home.join(file)) {
422            let session = s(&c, "session");
423            out.push(Event {
424                ms: ms(&c, "ms"),
425                kind: MODEL_CALL.into(),
426                engineer: engineer(&session),
427                session,
428                repo: s(&c, "repo"),
429                usd: c["cost_usd"].as_f64().unwrap_or(0.0),
430                why: why.into(),
431                ..Default::default()
432            });
433        }
434    }
435    // History: an older store's directives (the mesh outbox, the steer inbox). Nothing writes these
436    // files any more; steering now counts as it happens (msg.rs `send`, `deliver`).
437    for (file, kind) in [(home.join("mesh").join("directives.jsonl"), STEER_ISSUED), (home.join("steer").join("inbox.jsonl"), STEER_DELIVERED)] {
438        for d in lines(&file) {
439            out.push(Event {
440                ms: ms(&d, "ms"),
441                kind: kind.into(),
442                engineer: s(&d, "engineer"),
443                why: s(&d, "kind"),
444                text: excerpt(&s(&d, "text"), 160),
445                ..Default::default()
446            });
447        }
448    }
449    for d in lines(&crate::steer::dropped_path(home)) {
450        out.push(Event { ms: ms(&d, "ms"), kind: STEER_DROPPED.into(), engineer: s(&d, "engineer"), why: s(&d, "why"), ..Default::default() });
451    }
452    out.extend(turns_from_state(&home.join("state"), &who));
453    out
454}
455
456/// Turn ends, gate skips and model checks from the per-session state markers, dated by the state
457/// file's last change (the markers carry no time of their own).
458fn turns_from_state(dir: &Path, who: &std::collections::HashMap<String, String>) -> Vec<Event> {
459    let mut out = Vec::new();
460    let Ok(rd) = std::fs::read_dir(dir) else { return out };
461    for f in rd.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "ctx")) {
462        let Some(at) = std::fs::metadata(&f).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) else {
463            continue;
464        };
465        let session = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
466        let base =
467            || Event { ms: at.as_millis() as i64, session: session.clone(), engineer: who.get(&session).cloned().unwrap_or_default(), ..Default::default() };
468        for line in std::fs::read_to_string(&f).unwrap_or_default().lines() {
469            if let Some(score) = line.strip_prefix("#borg-skip ") {
470                out.push(Event { kind: TURN_END.into(), ..base() });
471                out.push(Event { kind: GATE_SKIP.into(), why: score.trim().into(), ..base() });
472            } else if let Some(cost) = line.strip_prefix("#borg-predict-cost ") {
473                out.push(Event { kind: TURN_END.into(), ..base() });
474                out.push(Event { kind: CHECK.into(), usd: cost.trim().parse().unwrap_or(0.0), ..base() });
475            }
476        }
477    }
478    out
479}
480
481/// This machine's id in the collective's statistics, created once (`stats/node`). Not the mesh key:
482/// stats work in local collectives too, and nothing here should need the member key.
483pub fn node(home: &Path) -> String {
484    let path = home.join("stats").join("node");
485    if let Ok(id) = std::fs::read_to_string(&path)
486        && !id.trim().is_empty()
487    {
488        return id.trim().to_string();
489    }
490    // Created whole and only once: the id is written to a file of its own, then hard-linked into
491    // place, which fails when another process got there first; its id wins. A plain write could hand
492    // two first callers two ids, and a reader an empty file between its truncate and write.
493    let id = fresh_id();
494    let _ = std::fs::create_dir_all(path.parent().unwrap_or(home));
495    let tmp = path.with_extension(format!("{id}.tmp"));
496    let linked = std::fs::write(&tmp, &id).and_then(|()| std::fs::hard_link(&tmp, &path));
497    let _ = std::fs::remove_file(&tmp);
498    match linked {
499        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
500            std::fs::read_to_string(&path).map(|s| s.trim().to_string()).ok().filter(|s| !s.is_empty()).unwrap_or(id)
501        }
502        _ => id,
503    }
504}
505
506fn fresh_id() -> String {
507    let mut bytes = [0u8; 8];
508    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
509        use std::io::Read;
510        let _ = f.read_exact(&mut bytes);
511    }
512    if bytes == [0; 8] {
513        use std::hash::{BuildHasher, Hasher};
514        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
515        h.write_i64(crate::learn::now_ms());
516        h.write_u32(std::process::id());
517        bytes = h.finish().to_le_bytes();
518    }
519    bytes.iter().map(|b| format!("{b:02x}")).collect()
520}
521
522/// `YYYY-MM-DD` (UTC) of an epoch-ms time.
523pub fn day(ms: i64) -> String {
524    let s = crate::install::utc(ms);
525    format!("{}-{}-{}", &s[0..4], &s[4..6], &s[6..8])
526}
527
528/// Every `YYYY-MM-DD` from `first` to `last` (UTC), both included; empty if either is malformed.
529pub fn days_between(first: &str, last: &str) -> Vec<String> {
530    let (Some(a), Some(b)) = (day_number(first), day_number(last)) else { return Vec::new() };
531    (a..=b).map(|d| day(d * 86_400_000)).collect()
532}
533
534/// Days since 1970-01-01 of a `YYYY-MM-DD` date (Howard Hinnant's days_from_civil).
535fn day_number(date: &str) -> Option<i64> {
536    let mut it = date.split('-').map(|p| p.parse::<i64>().ok());
537    let (y, m, d) = (it.next()??, it.next()??, it.next()??);
538    let y = if m <= 2 { y - 1 } else { y };
539    let era = y.div_euclid(400);
540    let yoe = y - era * 400;
541    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
542    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
543    Some(era * 146_097 + doe - 719_468)
544}
545
546/// The local events folded into this node's buckets, keyed by bucket id (version 0).
547pub fn fold(node: &str, events: &[Event]) -> BTreeMap<String, Bucket> {
548    let mut out: BTreeMap<String, Bucket> = BTreeMap::new();
549    for e in events {
550        let day = day(e.ms);
551        let id = format!("stat:{node}:{}:{day}", e.engineer);
552        let b = out.entry(id.clone()).or_insert_with(|| Bucket { id, day, node: node.into(), engineer: e.engineer.clone(), ..Default::default() });
553        *b.counts.entry(e.kind.clone()).or_default() += 1;
554        if e.n > 0 {
555            *b.counts.entry(format!("{}_n", e.kind)).or_default() += e.n;
556        }
557        b.usd += e.usd;
558        if e.usd > 0.0 {
559            *b.usd_by.entry(e.kind.clone()).or_default() += e.usd;
560        }
561        // Checks from before modes existed were all turn-end checks: hook mode.
562        if e.usd > 0.0 && (e.kind == CHECK || e.kind == LIVE_CHECK) {
563            let mode = if e.mode.is_empty() { "hook" } else { e.mode.as_str() };
564            *b.usd_mode.entry(mode.into()).or_default() += e.usd;
565        }
566        if !e.channel.is_empty() {
567            *b.channels.entry(format!("{}@{}", e.kind, e.channel)).or_default() += 1;
568        }
569        b.dur_ms += e.dur_ms;
570        for l in &e.lessons {
571            *b.lessons.entry(l.clone()).or_default().entry(e.kind.clone()).or_default() += 1;
572        }
573    }
574    out
575}
576
577/// The latest version of every bucket in `stats.jsonl` (every node's, after the mesh merged them). Two
578/// lines of one version (two rollups at once) are ranked as borg-mesh lines.rs `settle` ranks them, by
579/// the line's blake3 hash, so every node shows the same one whatever order the lines arrived in.
580pub fn rollups(home: &Path) -> BTreeMap<String, Bucket> {
581    let mut out: BTreeMap<String, (Bucket, [u8; 32])> = BTreeMap::new();
582    for l in std::fs::read_to_string(rollups_path(home)).unwrap_or_default().lines() {
583        let Ok(b) = serde_json::from_str::<Bucket>(l) else { continue };
584        let h = *blake3::hash(l.as_bytes()).as_bytes();
585        if out.get(&b.id).is_none_or(|(old, oh)| (b.version, h) > (old.version, *oh)) {
586            out.insert(b.id.clone(), (b, h));
587        }
588    }
589    out.into_iter().map(|(id, (b, _))| (id, b)).collect()
590}
591
592/// Publishes this node's buckets whose counts changed since their last line in `stats.jsonl`, each
593/// with the next version. Returns how many lines it appended (0 when nothing changed).
594pub fn rollup(home: &Path) -> usize {
595    backfill(home);
596    // No events, nothing to publish: and no node id created in a store that never counted anything.
597    let evs = events(home);
598    if evs.is_empty() {
599        return 0;
600    }
601    let me = node(home);
602    let published = rollups(home);
603    let fresh: Vec<Bucket> = fold(&me, &evs)
604        .into_values()
605        .filter_map(|mut b| {
606            let old = published.get(&b.id);
607            if old.is_some_and(|o| o.same(&b)) {
608                return None;
609            }
610            b.version = old.map_or(1, |o| o.version + 1);
611            Some(b)
612        })
613        .collect();
614    if fresh.is_empty() {
615        return 0;
616    }
617    let lines: String = fresh.iter().map(|b| serde_json::to_string(b).unwrap_or_default() + "\n").collect();
618    let path = rollups_path(home);
619    let ok = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| f.write_all(lines.as_bytes()));
620    if ok.is_ok() { fresh.len() } else { 0 }
621}
622
623/// Every bucket the page shows: other nodes' published rollups, and this node's own counts folded
624/// fresh from its local events, so local activity shows before the next rollup.
625pub fn current(home: &Path) -> Vec<Bucket> {
626    backfill(home);
627    let me = node(home);
628    let mut all: BTreeMap<String, Bucket> = rollups(home).into_iter().filter(|(_, b)| b.node != me).collect();
629    all.extend(fold(&me, &events(home)));
630    all.into_values().collect()
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn concurrent_first_calls_agree_on_one_node() {
639        // The node id stamps the outcomes and stats that sync: two hooks creating it at once must
640        // agree on one.
641        for round in 0..20 {
642            let home = std::env::temp_dir().join(format!("borg-stats-node-{}-{round}", std::process::id()));
643            let _ = std::fs::remove_dir_all(&home);
644            let ids: Vec<String> = std::thread::scope(|s| {
645                let hs: Vec<_> = (0..16).map(|_| s.spawn(|| node(&home))).collect();
646                hs.into_iter().map(|h| h.join().unwrap()).collect()
647            });
648            assert!(ids.iter().all(|id| *id == ids[0] && id.len() == 16), "{ids:?}");
649            assert_eq!(node(&home), ids[0]);
650            let _ = std::fs::remove_dir_all(&home);
651        }
652    }
653
654    fn tmp(tag: &str) -> PathBuf {
655        let d = std::env::temp_dir().join(format!("borg-stats-{tag}-{}", std::process::id()));
656        let _ = std::fs::remove_dir_all(&d);
657        std::fs::create_dir_all(&d).unwrap();
658        d
659    }
660
661    fn ev(kind: &str, ms: i64, engineer: &str, lessons: &[&str], n: u64) -> Event {
662        Event { engineer: engineer.into(), lessons: lessons.iter().map(|s| s.to_string()).collect(), n, ..Event::new(kind, ms) }
663    }
664
665    const DAY: i64 = 86_400_000;
666
667    #[test]
668    fn days_between_fills_every_day() {
669        assert_eq!(days_between("2026-02-27", "2026-03-02"), ["2026-02-27", "2026-02-28", "2026-03-01", "2026-03-02"]);
670        assert_eq!(days_between("1970-01-01", "1970-01-01"), ["1970-01-01"]);
671        assert!(days_between("x", "1970-01-01").is_empty());
672    }
673
674    #[test]
675    fn day_is_utc_date() {
676        assert_eq!(day(0), "1970-01-01");
677        assert_eq!(day(DAY - 1), "1970-01-01");
678        assert_eq!(day(DAY), "1970-01-02");
679    }
680
681    #[test]
682    fn fold_counts_per_engineer_and_day() {
683        let evs = [
684            ev(TURN_END, 10, "a@x", &[], 0),
685            ev(CHECK, 20, "a@x", &[], 7),
686            ev(BLOCK, 30, "a@x", &["L1", "L2"], 2),
687            ev(TURN_END, DAY + 5, "a@x", &[], 0),
688            ev(TURN_END, 40, "b@x", &[], 0),
689        ];
690        let b = fold("n1", &evs);
691        assert_eq!(b.len(), 3, "two days for a, one for b");
692        let a = &b["stat:n1:a@x:1970-01-01"];
693        assert_eq!((a.counts[TURN_END], a.counts[CHECK], a.counts["check_n"], a.counts["block_n"]), (1, 1, 7, 2));
694        assert_eq!(a.lessons["L1"][BLOCK], 1);
695    }
696
697    #[test]
698    fn fold_splits_check_cost_by_mode_and_asks_by_channel() {
699        let with = |kind: &str, usd: f64, mode: &str, channel: &str| Event { usd, mode: mode.into(), channel: channel.into(), ..ev(kind, 10, "a@x", &[], 0) };
700        let evs = [
701            with(CHECK, 0.05, "", ""),
702            with(CHECK, 0.04, "live", ""),
703            with(LIVE_CHECK, 0.03, "live", ""),
704            with(MODEL_CALL, 0.5, "live", ""),
705            with(BLOCK, 0.0, "live", "stop"),
706            with(LIVE_ASK, 0.0, "live", "live"),
707            with(CHANGED, 0.0, "", "live"),
708        ];
709        let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
710        assert!((b.usd_mode["hook"] - 0.05).abs() < 1e-9 && (b.usd_mode["live"] - 0.07).abs() < 1e-9, "{:?}", b.usd_mode);
711        assert_eq!(b.usd_mode.len(), 2, "only the conscience's checks: {:?}", b.usd_mode);
712        assert_eq!((b.channels["block@stop"], b.channels["live_ask@live"], b.channels["changed@live"]), (1, 1, 1));
713        assert_eq!((b.counts[CHECK], b.counts[LIVE_CHECK]), (2, 1), "live checks are counted apart from turn-end checks");
714    }
715
716    #[test]
717    fn rollup_versions_only_on_change_and_is_idempotent() {
718        let home = tmp("rollup");
719        record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
720        assert_eq!(rollup(&home), 1);
721        assert_eq!(rollup(&home), 0, "nothing changed: nothing appended");
722        record(&home, &ev(GATE_SKIP, 11, "a@x", &[], 0));
723        assert_eq!(rollup(&home), 1);
724        let r = rollups(&home);
725        let b = r.values().next().unwrap();
726        assert_eq!((b.version, b.counts[TURN_END], b.counts[GATE_SKIP]), (2, 1, 1));
727        let text = std::fs::read_to_string(rollups_path(&home)).unwrap();
728        assert!(!text.contains("session"), "no session ids leave the machine");
729    }
730
731    #[test]
732    fn current_prefers_own_fresh_counts_and_keeps_peers() {
733        let home = tmp("current");
734        record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
735        rollup(&home);
736        record(&home, &ev(TURN_END, 12, "a@x", &[], 0));
737        let peer = Bucket {
738            id: "stat:other:b@x:1970-01-01".into(),
739            version: 3,
740            day: "1970-01-01".into(),
741            node: "other".into(),
742            engineer: "b@x".into(),
743            ..Default::default()
744        };
745        let mut f = std::fs::OpenOptions::new().append(true).open(rollups_path(&home)).unwrap();
746        writeln!(f, "{}", serde_json::to_string(&peer).unwrap()).unwrap();
747        let cur = current(&home);
748        assert_eq!(cur.len(), 2);
749        let mine = cur.iter().find(|b| b.engineer == "a@x").unwrap();
750        assert_eq!(mine.counts[TURN_END], 2, "own counts are folded fresh, not the stale rollup");
751    }
752
753    /// Two v+1 lines of one bucket (two rollups at once) reach peers in either order: every node shows
754    /// the same one, the one mesh `settle` makes last (higher hash).
755    #[test]
756    fn rollups_break_version_ties_by_hash_whatever_the_order() {
757        let bucket = |n: u64| {
758            let mut b = Bucket { id: "stat:n:a@x:1970-01-01".into(), version: 2, ..Default::default() };
759            b.counts.insert(TURN_END.into(), n);
760            serde_json::to_string(&b).unwrap()
761        };
762        let (a, b) = (bucket(1), bucket(2));
763        let shown = |name: &str, first: &str, second: &str| {
764            let home = tmp(name);
765            std::fs::write(rollups_path(&home), format!("{first}\n{second}\n")).unwrap();
766            rollups(&home).into_values().next().unwrap().counts[TURN_END]
767        };
768        assert_eq!(shown("ties-ab", &a, &b), shown("ties-ba", &b, &a));
769    }
770
771    #[test]
772    fn record_fails_open() {
773        let home = tmp("open");
774        std::fs::write(home.join("stats"), "a file where the directory should be").unwrap();
775        record(&home, &ev(TURN_END, 1, "a@x", &[], 0));
776        assert!(events(&home).is_empty());
777    }
778
779    #[test]
780    fn backfill_derives_history_before_live_counting_once() {
781        let home = tmp("backfill");
782        let w = |f: &str, lines: &[serde_json::Value]| {
783            let p = home.join(f);
784            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
785            std::fs::write(p, lines.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
786        };
787        use serde_json::json;
788        w(
789            "episodes.jsonl",
790            &[
791                json!({"id":"e1","ms":100,"repo":"app","session":"s1","task":"t","agent":"a","rejected_tool":"Bash","correction":"no, not like that","engineer":"a@x"}),
792                json!({"id":"e2","ms":5000,"repo":"app","session":"s2","task":"t","agent":"a","rejected_tool":null,"correction":"after live counting began","engineer":"a@x"}),
793            ],
794        );
795        w(
796            "outcomes.jsonl",
797            &[
798                json!({"kind":"asked","session":"s1","lessons":["L1","L2"],"ms":110,"repo":"app"}),
799                // Free deliveries are counted live as `early` and `queued_free`: never a block here.
800                json!({"kind":"asked","session":"s1","lessons":["L3"],"ms":112,"repo":"app","channel":"early"}),
801                json!({"kind":"asked","session":"s1","lessons":["L4"],"ms":114,"repo":"app","channel":"queued"}),
802                json!({"kind":"asked_then_changed","session":"s1","lessons":["L1"],"ms":120,"repo":"app"}),
803            ],
804        );
805        w(
806            "lessons.jsonl",
807            &[json!({"id":"L1","origin_ms":90,"question":"q","version":1,"engineers":["a@x"]}), json!({"id":"L1","origin_ms":90,"question":"q2","version":2})],
808        );
809        w("queen/queue.jsonl", &[json!({"id":"Q1","ms":130,"question":"which?","engineer":"a@x","answered_ms":190,"answer":"this","answered_by":"a@x"})]);
810        record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
811        let n = backfill(&home);
812        let all = read_events(&backfill_path(&home));
813        assert_eq!(n, all.len());
814        let kinds: Vec<&str> = all.iter().map(|e| e.kind.as_str()).collect();
815        assert_eq!(kinds, [LESSON_NEW, CORRECTION, BLOCK, CHANGED, QUEEN_QUEUED, QUEEN_ANSWERED], "ordered by time, the later correction left out");
816        let c = &all[1];
817        assert_eq!((c.why.as_str(), c.text.as_str(), c.engineer.as_str()), ("rejected_tool", "Bash: no, not like that", "a@x"));
818        assert_eq!(all[3].engineer, "a@x", "outcomes take the session's engineer from its corrections");
819        assert_eq!(all[5].dur_ms, 60, "time to answer");
820        assert_eq!(backfill(&home), 0, "once");
821        assert_eq!(events(&home).len(), all.len() + 1, "history and live events together");
822    }
823
824    #[test]
825    fn node_id_is_stable() {
826        let home = tmp("node");
827        let a = node(&home);
828        assert_eq!(a.len(), 16);
829        assert_eq!(node(&home), a);
830    }
831}