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):
25/// a stop's own, or a detached check's (`mode` `async`, with `rss_kb` and `limit_ms`, check_async.rs).
26pub const CHECK: &str = "check";
27/// A detached check that never called the model (check_async.rs `not_run`; `why`: `superseded`, a newer
28/// stop's job replaced it while another check ran; `gone`, the session ended before it ran). Counted
29/// apart from `check`, which counts model calls.
30pub const CHECK_DROPPED: &str = "check_dropped";
31/// A block: one extra round of agent work (`n`: questions, `lessons`: the asked ids).
32pub const BLOCK: &str = "block";
33/// Live mode, before 0.7.6 (removed; history only): one model call during the turn (`n`, `usd`,
34/// `dur_ms`, `why: "failed"`, as `check`).
35pub const LIVE_CHECK: &str = "live_check";
36/// Grade-3 lessons a check asked, delivered with the next prompt or tool call (`n`, `lessons`): a
37/// detached check's (check_async.rs, since 0.7.7), or a live check's before 0.7.6. Never an extra round:
38/// the drone answers as it works.
39pub const LIVE_ASK: &str = "live_ask";
40/// Lessons handed over free at a prompt or touch (`n`, `lessons`).
41pub const EARLY: &str = "early";
42/// Grade-2 and grade-1 lessons queued to arrive free at the next prompt (`n`, `lessons`).
43pub const QUEUED_FREE: &str = "queued_free";
44/// Outcomes of asked lessons (outcome.rs): used, ignored, corrected anyway, not corrected.
45pub const CHANGED: &str = "changed";
46pub const IGNORED: &str = "ignored";
47pub const CORRECTED: &str = "corrected";
48pub const NOT_CORRECTED: &str = "not_corrected";
49/// A later fix, or a review finding, counted against a lesson asked before the decision (outcome.rs
50/// `regretted`; `lessons`: the blamed lesson).
51pub const REGRETTED: &str = "regretted";
52/// `borg learn` credited a lesson asked before the session's own tests went green (outcome.rs `tested`;
53/// `lessons`: that lesson).
54pub const TESTED: &str = "tested";
55/// The engineer corrected the drone (an episode): `why` is `rejected_tool`, `pushback`, `regret` or
56/// `answer` (learn.rs `ANSWER`: an answer to the drone's own question), `text` a snippet. An answer is
57/// rolled up under [`ANSWERED`], never as a correction: the corrections tiles keep counting rejected
58/// tool calls and pushback.
59pub const CORRECTION: &str = "correction";
60/// The rollup count of the answers to a drone's own questions (a `CORRECTION` event whose `why` is
61/// learn.rs `ANSWER`; `fold`).
62pub const ANSWERED: &str = "answer";
63/// The engineer said no (`borg no`, veto.rs): `lessons` the lessons shown so far (credited `vetoed`),
64/// `n` their number, `why` `cli`, `prompt` or `mesh`, `text` the reason.
65pub const VETO: &str = "veto";
66/// `borg learn` merged a veto into a lesson an earlier veto made: the same "no" said twice, dated when
67/// that veto was said (its episode's ms), so it falls in the week of its own `veto`.
68pub const VETO_REPEAT: &str = "veto_repeat";
69/// A lesson written (`why`: its source, session/import/queen/veto) or merged into an existing one.
70pub const LESSON_NEW: &str = "lesson_new";
71pub const LESSON_MERGED: &str = "lesson_merged";
72/// A review step's finding that a fix step fixed before any commit, recorded as an episode
73/// (review_fix.rs; `why`: its severity, `text`: its redacted title, for this machine's feed only).
74pub const REVIEW_FINDING: &str = "review_finding";
75/// `borg learn` judged an episode not to be a lesson.
76pub const NOT_A_LESSON: &str = "not_a_lesson";
77/// A model call outside the turn end (`why`: learn, assimilate, facets, expert, judge: the session-end
78/// judge, hook.rs `judge_session`, with `n` the lessons sent to the judge; `usd`, `dur_ms`).
79pub const MODEL_CALL: &str = "model_call";
80/// Steering: a directive issued, shown to a drone (`why`: say/pause/resume), refused on receipt
81/// (`why`: the reason), and a tool call refused because the drone was paused (`why`: the tool).
82pub const STEER_ISSUED: &str = "steer_issued";
83pub const STEER_DELIVERED: &str = "steer_delivered";
84pub const STEER_DROPPED: &str = "steer_dropped";
85pub const PAUSE_REFUSED: &str = "pause_refused";
86/// Messages between sessions (msg.rs): one sent (`why`: tell/ask), shown to a session (`why`: its kind),
87/// and dropped (`why`: `window`, the sender's window to its recipient was full, msg.rs `window_for`;
88/// events before 0.6.0 say `rate`; `bounds`, `n` lines the log's bound, msg.rs `MEMBER_BYTES`, cut).
89pub const MSG_SENT: &str = "msg_sent";
90pub const MSG_DELIVERED: &str = "msg_delivered";
91pub const MSG_DROPPED: &str = "msg_dropped";
92/// The queen's queue: a question queued, handed to a drone, answered (`dur_ms`: time to answer),
93/// skipped, dismissed.
94pub const QUEEN_QUEUED: &str = "queen_queued";
95pub const QUEEN_DELIVERED: &str = "queen_delivered";
96pub const QUEEN_ANSWERED: &str = "queen_answered";
97pub const QUEEN_SKIPPED: &str = "queen_skipped";
98pub const QUEEN_DISMISSED: &str = "queen_dismissed";
99/// The queen's attention reward (escalation.rs), once per event: an escalation answered by changing
100/// course, one answered "yes, fine", and a "no" after merge from a session never escalated (`why`: the
101/// kind, or `veto`/`regret`; `text` starts with the question, episode or link id).
102pub const QUEEN_NO: &str = "queen_no";
103pub const QUEEN_STAMP: &str = "queen_stamp";
104pub const QUEEN_MISSED: &str = "queen_missed";
105/// One imported chunk of team documents (`n`: lessons, `usd`).
106pub const ASSIMILATED: &str = "assimilated";
107
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109pub struct Event {
110    pub ms: i64,
111    pub kind: String,
112    #[serde(default, skip_serializing_if = "String::is_empty")]
113    pub session: String,
114    #[serde(default, skip_serializing_if = "String::is_empty")]
115    pub engineer: String,
116    #[serde(default, skip_serializing_if = "String::is_empty")]
117    pub repo: String,
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub lessons: Vec<String>,
120    #[serde(default, skip_serializing_if = "is_zero")]
121    pub n: u64,
122    #[serde(default, skip_serializing_if = "is_zero_f")]
123    pub usd: f64,
124    #[serde(default, skip_serializing_if = "is_zero")]
125    pub dur_ms: u64,
126    #[serde(default, skip_serializing_if = "String::is_empty")]
127    pub why: String,
128    /// A short excerpt for this machine's own feed (a correction's words). Never folded into a rollup.
129    #[serde(default, skip_serializing_if = "String::is_empty")]
130    pub text: String,
131    /// How a check ran: `async` for a detached check (check_async.rs, 0.7.7 on); before 0.7.6 the
132    /// delivery mode the hook ran in (`hook` or `live`). Empty for a stop's own check and outside a
133    /// check; folded into `Bucket::usd_mode`.
134    #[serde(default, skip_serializing_if = "String::is_empty")]
135    pub mode: String,
136    /// How an asked lesson reached the drone: `stop` (a block), `early`, `queued`, or `live` (a live
137    /// check, before 0.7.6).
138    /// On asks and on their results (`changed`, `ignored`).
139    #[serde(default, skip_serializing_if = "String::is_empty")]
140    pub channel: String,
141    /// On a correction: the harness, and its version where known, whose own transcript it was read from
142    /// (learn.rs `Episode::harness`; sweep.rs, `borg assimilate`), e.g. `claude 2.1.312`; empty when a
143    /// hook captured it, or `borg assimilate` before 0.7.2 imported it.
144    #[serde(default, skip_serializing_if = "String::is_empty")]
145    pub source: String,
146    /// On a detached check's `check` (check_async.rs): its process's peak resident memory, KiB.
147    #[serde(default, skip_serializing_if = "is_zero")]
148    pub rss_kb: u64,
149    /// On a detached check's `check`: the limit its model call had (check_async.rs `call_limit`), ms; a
150    /// failed call that took it all timed out (`async_tally`).
151    #[serde(default, skip_serializing_if = "is_zero")]
152    pub limit_ms: u64,
153}
154
155/// An episode's [`Event::source`]: its `harness` and `harness_version`, joined; empty for the hooks' (and
156/// imports from before 0.7.2).
157pub fn source(harness: &str, version: &str) -> String {
158    format!("{harness} {version}").trim().to_string()
159}
160
161fn is_zero(v: &u64) -> bool {
162    *v == 0
163}
164
165fn is_zero_f(v: &f64) -> bool {
166    *v == 0.0
167}
168
169impl Event {
170    /// An event now.
171    pub fn new(kind: &str, ms: i64) -> Self {
172        Event { kind: kind.into(), ms, ..Default::default() }
173    }
174}
175
176/// One node's counts for one engineer on one UTC day: a line of `stats.jsonl`.
177#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
178pub struct Bucket {
179    pub id: String,
180    pub version: u64,
181    pub day: String,
182    pub node: String,
183    pub engineer: String,
184    /// Events per kind; `<kind>_n` sums their `n` (questions asked, lessons shown).
185    pub counts: BTreeMap<String, u64>,
186    pub usd: f64,
187    /// Cost per kind (turn-end checks, other model calls, imports).
188    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
189    pub usd_by: BTreeMap<String, f64>,
190    pub dur_ms: u64,
191    /// Events per kind for each lesson they named.
192    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
193    pub lessons: BTreeMap<String, BTreeMap<String, u64>>,
194    /// The conscience's model cost (`check` and `live_check`) per mode: `async` for detached checks
195    /// (0.7.7 on), `live` for live mode's events before 0.7.6, `hook` for every other (an event without a
196    /// mode: a stop's own check). A split of part of `usd`, so never stacked with `usd_by`.
197    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
198    pub usd_mode: BTreeMap<String, f64>,
199    /// Events per `<kind>@<channel>` for events that name a channel (`block@stop`, `changed@live`).
200    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
201    pub channels: BTreeMap<String, u64>,
202}
203
204impl Bucket {
205    /// Equal counts, cost and lessons: nothing to publish (`id` and `version` aside).
206    fn same(&self, other: &Bucket) -> bool {
207        self.counts == other.counts
208            && self.lessons == other.lessons
209            && self.channels == other.channels
210            && self.dur_ms == other.dur_ms
211            && (self.usd - other.usd).abs() < 1e-9
212    }
213
214    /// Adds `other`'s counts (team totals across nodes and engineers).
215    pub fn add(&mut self, other: &Bucket) {
216        for (k, v) in &other.counts {
217            *self.counts.entry(k.clone()).or_default() += v;
218        }
219        for (id, kinds) in &other.lessons {
220            let mine = self.lessons.entry(id.clone()).or_default();
221            for (k, v) in kinds {
222                *mine.entry(k.clone()).or_default() += v;
223            }
224        }
225        for (k, v) in &other.usd_by {
226            *self.usd_by.entry(k.clone()).or_default() += v;
227        }
228        for (k, v) in &other.usd_mode {
229            *self.usd_mode.entry(k.clone()).or_default() += v;
230        }
231        for (k, v) in &other.channels {
232            *self.channels.entry(k.clone()).or_default() += v;
233        }
234        self.usd += other.usd;
235        self.dur_ms += other.dur_ms;
236    }
237}
238
239pub fn events_path(home: &Path) -> PathBuf {
240    home.join("stats").join("events.jsonl")
241}
242
243pub fn rollups_path(home: &Path) -> PathBuf {
244    home.join("stats.jsonl")
245}
246
247/// Appends `ev` to the local event log. Fails open; records nothing with BORG_LEARN=off (eval drones).
248pub fn record(home: &Path, ev: &Event) {
249    if !crate::learn::learning() {
250        return;
251    }
252    let _ = crate::learn::append_jsonl(&events_path(home), ev);
253}
254
255/// Records one event in the store `home` now; `fill` adds the details. The engineer defaults to the
256/// one presence.rs recorded for the session, so callers need not ask git again.
257pub fn count(home: &Path, kind: &str, session: &str, fill: impl FnOnce(&mut Event)) {
258    let mut ev = Event::new(kind, crate::learn::now_ms());
259    ev.session = session.into();
260    fill(&mut ev);
261    if ev.engineer.is_empty() && !session.is_empty() {
262        ev.engineer = crate::presence::read(home, session).map(|p| p.engineer).unwrap_or_default();
263    }
264    record(home, &ev);
265}
266
267/// Display: the characters of a feed excerpt (`excerpt`): one line of borg web's activity feed.
268pub const FEED_CHARS: usize = 160;
269
270/// The first `max` characters of `text` on one line (a feed excerpt, `FEED_CHARS`).
271pub fn excerpt(text: &str, max: usize) -> String {
272    let one = text.split_whitespace().collect::<Vec<_>>().join(" ");
273    match one.char_indices().nth(max) {
274        Some((i, _)) => format!("{}…", &one[..i]),
275        None => one,
276    }
277}
278
279static PURPOSE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
280
281/// What this process's model calls are for (learn, assimilate, facets), set once by the command.
282pub fn set_purpose(purpose: &'static str) {
283    let _ = PURPOSE.set(purpose);
284}
285
286pub fn purpose() -> &'static str {
287    PURPOSE.get().copied().unwrap_or("model")
288}
289
290fn read_events(path: &Path) -> Vec<Event> {
291    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
292}
293
294/// Every local event: the history derived once from the older logs (`backfill`) and the live ones.
295pub fn events(home: &Path) -> Vec<Event> {
296    let mut all = read_events(&backfill_path(home));
297    all.extend(read_events(&events_path(home)));
298    all
299}
300
301/// The live events only (stats/events.jsonl), without the derived history.
302pub fn live(home: &Path) -> Vec<Event> {
303    read_events(&events_path(home))
304}
305
306/// When live counting began in `home`: its first live event (None: none yet). A repeated veto is
307/// dated when it was said, earlier than its line, so it never marks the start. What `backfill`
308/// derives is before it; a series backfill cannot derive (merges, repeated vetoes) is unknown there.
309pub fn live_since(home: &Path) -> Option<i64> {
310    live(home).iter().filter(|e| e.kind != VETO_REPEAT).map(|e| e.ms).min()
311}
312
313pub fn backfill_path(home: &Path) -> PathBuf {
314    home.join("stats").join("backfill.jsonl")
315}
316
317fn lines(path: &Path) -> Vec<serde_json::Value> {
318    std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
319}
320
321/// Derives, once per store, the events that happened before live counting began from the logs Borg
322/// kept anyway: corrections (episodes), outcomes, lessons, the queen's queue, imports, expert and
323/// facet calls, steering drops, and the turn ends recorded in the session state (dated by the state
324/// file's time). Only what happened before the first live event counts, so nothing is counted twice.
325/// Returns how many events it wrote; 0 when it ran before.
326pub fn backfill(home: &Path) -> usize {
327    let path = backfill_path(home);
328    if path.exists() {
329        return 0;
330    }
331    let cutoff = live_since(home).unwrap_or_else(crate::learn::now_ms);
332    let mut out = derive_history(home);
333    out.retain(|e| e.ms > 0 && e.ms < cutoff);
334    out.sort_by_key(|e| e.ms);
335    let text: String = out.iter().map(|e| serde_json::to_string(e).unwrap_or_default() + "\n").collect();
336    if std::fs::create_dir_all(path.parent().unwrap_or(home)).is_err() || std::fs::write(&path, text).is_err() {
337        return 0;
338    }
339    out.len()
340}
341
342fn derive_history(home: &Path) -> Vec<Event> {
343    use serde_json::Value;
344    let s = |v: &Value, k: &str| v[k].as_str().unwrap_or_default().to_string();
345    let ms = |v: &Value, k: &str| v[k].as_i64().unwrap_or(0);
346    let ids = |v: &Value, k: &str| v[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(String::from).collect::<Vec<_>>();
347    let mut out = Vec::new();
348    // Who worked in which session: corrections name their engineer; outcomes do not.
349    let mut who: std::collections::HashMap<String, String> = std::collections::HashMap::new();
350    for e in lines(&home.join("episodes.jsonl")) {
351        let tool = s(&e, "rejected_tool");
352        if !s(&e, "engineer").is_empty() {
353            who.insert(s(&e, "session"), s(&e, "engineer"));
354        }
355        let snippet = excerpt(&s(&e, "correction"), FEED_CHARS);
356        // A veto (veto.rs) is counted as one, never as a correction.
357        if s(&e, "kind") == crate::veto::KIND {
358            out.push(Event {
359                ms: ms(&e, "ms"),
360                kind: VETO.into(),
361                session: s(&e, "session"),
362                engineer: s(&e, "engineer"),
363                repo: s(&e, "repo"),
364                text: snippet,
365                ..Default::default()
366            });
367            continue;
368        }
369        let answer = s(&e, "via") == crate::learn::ANSWER;
370        out.push(Event {
371            ms: ms(&e, "ms"),
372            kind: CORRECTION.into(),
373            session: s(&e, "session"),
374            engineer: s(&e, "engineer"),
375            repo: s(&e, "repo"),
376            why: if answer {
377                ANSWERED.into()
378            } else if tool.is_empty() {
379                "pushback".into()
380            } else {
381                "rejected_tool".into()
382            },
383            text: if tool.is_empty() || answer { snippet } else { format!("{tool}: {snippet}") },
384            source: source(&s(&e, "harness"), &s(&e, "harness_version")),
385            ..Default::default()
386        });
387    }
388    let engineer = |session: &str| who.get(session).cloned().unwrap_or_default();
389    // One call writes one record per routing check (a stop's block, a live ask, the judge's verdicts of
390    // a kind) and counts them live as one event: such records of one session, kind, channel and stamp
391    // are one event here too, their lessons joined.
392    let mut call: std::collections::HashMap<(String, &str, String, i64), usize> = std::collections::HashMap::new();
393    for o in lines(&home.join("outcomes.jsonl")) {
394        // A placebo's result is no result (outcome.rs `is_result`), as live counting leaves it out.
395        if o["channel"] == crate::outcome::PLACEBO {
396            continue;
397        }
398        let live = o["channel"] == "live";
399        let kind = match o["kind"].as_str().unwrap_or_default() {
400            // A free delivery counts once, as its own event (`early`, `queued_free`), never as a block.
401            crate::outcome::ASKED if o["channel"] == "early" || o["channel"] == "queued" => continue,
402            crate::outcome::ASKED if live => LIVE_ASK,
403            crate::outcome::ASKED => BLOCK,
404            crate::outcome::CHANGED => CHANGED,
405            crate::outcome::IGNORED => IGNORED,
406            crate::outcome::CORRECTED => CORRECTED,
407            crate::outcome::NOT_CORRECTED => NOT_CORRECTED,
408            crate::outcome::REGRETTED => REGRETTED,
409            crate::outcome::TESTED => TESTED,
410            // Measurements, never results (outcome.rs `is_result`): no event, as live counting skips them.
411            crate::outcome::UNCLEAR | crate::outcome::REMAINS | crate::outcome::NOT_ARISEN => continue,
412            _ => continue,
413        };
414        let lessons = ids(&o, "lessons");
415        let session = s(&o, "session");
416        let asks = kind == BLOCK || kind == LIVE_ASK;
417        let key = (session.clone(), kind, s(&o, "channel"), ms(&o, "ms"));
418        let merges = [BLOCK, LIVE_ASK, CHANGED, IGNORED].contains(&kind);
419        if merges
420            && let Some(&i) = call.get(&key)
421            && let Some(e) = out.get_mut(i)
422        {
423            e.n += if asks { lessons.len() as u64 } else { 0 };
424            e.lessons.extend(lessons);
425            continue;
426        }
427        if merges {
428            call.insert(key, out.len());
429        }
430        out.push(Event {
431            ms: ms(&o, "ms"),
432            kind: kind.into(),
433            engineer: engineer(&session),
434            session,
435            repo: s(&o, "repo"),
436            n: if asks { lessons.len() as u64 } else { 0 },
437            lessons,
438            channel: s(&o, "channel"),
439            ..Default::default()
440        });
441    }
442    // A lesson's first version is when it was learned; later versions carry no time of their own.
443    let mut seen = std::collections::HashSet::new();
444    for l in lines(&home.join("lessons.jsonl")) {
445        let id = s(&l, "id");
446        if id.is_empty() || !seen.insert(id.clone()) {
447            continue;
448        }
449        let source = Some(s(&l, "source")).filter(|x| !x.is_empty()).unwrap_or_else(|| "session".into());
450        out.push(Event {
451            ms: ms(&l, "origin_ms"),
452            kind: LESSON_NEW.into(),
453            engineer: ids(&l, "engineers").into_iter().next().unwrap_or_default(),
454            repo: s(&l, "repo"),
455            lessons: vec![id],
456            why: source,
457            ..Default::default()
458        });
459    }
460    let mut queen: BTreeMap<String, Value> = BTreeMap::new();
461    for q in lines(&home.join("queen").join("queue.jsonl")) {
462        queen.insert(s(&q, "id"), q);
463    }
464    for q in queen.values() {
465        let base = || Event { engineer: s(q, "engineer"), session: s(q, "session"), repo: s(q, "repo"), lessons: vec![s(q, "id")], ..Default::default() };
466        out.push(Event { ms: ms(q, "ms"), kind: QUEEN_QUEUED.into(), text: excerpt(&s(q, "question"), FEED_CHARS), ..base() });
467        if let Some(at) = q["answered_ms"].as_i64() {
468            let dur = u64::try_from(at - ms(q, "ms")).unwrap_or(0);
469            out.push(Event {
470                ms: at,
471                kind: QUEEN_ANSWERED.into(),
472                dur_ms: dur,
473                engineer: s(q, "answered_by"),
474                text: excerpt(&s(q, "answer"), FEED_CHARS),
475                ..base()
476            });
477        }
478        if let Some(at) = q["dismissed_ms"].as_i64() {
479            out.push(Event { ms: at, kind: QUEEN_DISMISSED.into(), text: s(q, "dismissed"), ..base() });
480        }
481    }
482    for (file, kind) in [("delivered.jsonl", QUEEN_DELIVERED), ("skips.jsonl", QUEEN_SKIPPED)] {
483        for m in lines(&home.join("queen").join(file)) {
484            let session = s(&m, "session");
485            out.push(Event { ms: ms(&m, "ms"), kind: kind.into(), engineer: engineer(&session), session, lessons: vec![s(&m, "id")], ..Default::default() });
486        }
487    }
488    for a in lines(&home.join("assimilated.jsonl")) {
489        out.push(Event {
490            ms: ms(&a, "ms"),
491            kind: ASSIMILATED.into(),
492            repo: s(&a, "source"),
493            n: ids(&a, "lessons").len() as u64,
494            usd: a["cost_usd"].as_f64().unwrap_or(0.0),
495            ..Default::default()
496        });
497    }
498    for (file, why) in [("experts.jsonl", "expert"), ("facets.jsonl", "facets")] {
499        for c in lines(&home.join(file)) {
500            let session = s(&c, "session");
501            out.push(Event {
502                ms: ms(&c, "ms"),
503                kind: MODEL_CALL.into(),
504                engineer: engineer(&session),
505                session,
506                repo: s(&c, "repo"),
507                usd: c["cost_usd"].as_f64().unwrap_or(0.0),
508                why: why.into(),
509                ..Default::default()
510            });
511        }
512    }
513    // History: an older store's directives (the mesh outbox, the steer inbox). Nothing writes these
514    // files any more; steering now counts as it happens (msg.rs `send`, `deliver`).
515    for (file, kind) in [(home.join("mesh").join("directives.jsonl"), STEER_ISSUED), (home.join("steer").join("inbox.jsonl"), STEER_DELIVERED)] {
516        for d in lines(&file) {
517            out.push(Event {
518                ms: ms(&d, "ms"),
519                kind: kind.into(),
520                engineer: s(&d, "engineer"),
521                why: s(&d, "kind"),
522                text: excerpt(&s(&d, "text"), FEED_CHARS),
523                ..Default::default()
524            });
525        }
526    }
527    for d in lines(&crate::steer::dropped_path(home)) {
528        out.push(Event { ms: ms(&d, "ms"), kind: STEER_DROPPED.into(), engineer: s(&d, "engineer"), why: s(&d, "why"), ..Default::default() });
529    }
530    out.extend(turns_from_state(&home.join("state"), &who));
531    out
532}
533
534/// Turn ends, gate skips and model checks from the per-session state markers, dated by the state
535/// file's last change (the markers carry no time of their own).
536fn turns_from_state(dir: &Path, who: &std::collections::HashMap<String, String>) -> Vec<Event> {
537    let mut out = Vec::new();
538    let Ok(rd) = std::fs::read_dir(dir) else { return out };
539    for f in rd.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "ctx")) {
540        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 {
541            continue;
542        };
543        let session = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
544        let base =
545            || Event { ms: at.as_millis() as i64, session: session.clone(), engineer: who.get(&session).cloned().unwrap_or_default(), ..Default::default() };
546        for line in std::fs::read_to_string(&f).unwrap_or_default().lines() {
547            if let Some(score) = line.strip_prefix("#borg-skip ") {
548                out.push(Event { kind: TURN_END.into(), ..base() });
549                out.push(Event { kind: GATE_SKIP.into(), why: score.trim().into(), ..base() });
550            } else if let Some(cost) = line.strip_prefix("#borg-predict-cost ") {
551                out.push(Event { kind: TURN_END.into(), ..base() });
552                out.push(Event { kind: CHECK.into(), usd: cost.trim().parse().unwrap_or(0.0), ..base() });
553            }
554        }
555    }
556    out
557}
558
559/// This machine's id in the collective's statistics, created once (`stats/node`). Not the mesh key:
560/// stats work in local collectives too, and nothing here should need the member key.
561pub fn node(home: &Path) -> String {
562    let path = home.join("stats").join("node");
563    if let Ok(id) = std::fs::read_to_string(&path)
564        && !id.trim().is_empty()
565    {
566        return id.trim().to_string();
567    }
568    // Created whole and only once: the id is written to a file of its own, then hard-linked into
569    // place, which fails when another process got there first; its id wins. A plain write could hand
570    // two first callers two ids, and a reader an empty file between its truncate and write.
571    let id = fresh_id();
572    let _ = std::fs::create_dir_all(path.parent().unwrap_or(home));
573    let tmp = path.with_extension(format!("{id}.tmp"));
574    let linked = std::fs::write(&tmp, &id).and_then(|()| std::fs::hard_link(&tmp, &path));
575    let _ = std::fs::remove_file(&tmp);
576    match linked {
577        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
578            std::fs::read_to_string(&path).map(|s| s.trim().to_string()).ok().filter(|s| !s.is_empty()).unwrap_or(id)
579        }
580        _ => id,
581    }
582}
583
584fn fresh_id() -> String {
585    let mut bytes = [0u8; 8];
586    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
587        use std::io::Read;
588        let _ = f.read_exact(&mut bytes);
589    }
590    if bytes == [0; 8] {
591        use std::hash::{BuildHasher, Hasher};
592        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
593        h.write_i64(crate::learn::now_ms());
594        h.write_u32(std::process::id());
595        bytes = h.finish().to_le_bytes();
596    }
597    bytes.iter().map(|b| format!("{b:02x}")).collect()
598}
599
600/// `YYYY-MM-DD` (UTC) of an epoch-ms time.
601pub fn day(ms: i64) -> String {
602    let s = crate::install::utc(ms);
603    format!("{}-{}-{}", &s[0..4], &s[4..6], &s[6..8])
604}
605
606/// Every `YYYY-MM-DD` from `first` to `last` (UTC), both included; empty if either is malformed.
607pub fn days_between(first: &str, last: &str) -> Vec<String> {
608    let (Some(a), Some(b)) = (day_number(first), day_number(last)) else { return Vec::new() };
609    (a..=b).map(|d| day(d * crate::clock::DAY_MS)).collect()
610}
611
612/// What the detached checks (check_async.rs) did, from the local events (`async_tally`).
613#[derive(Debug, Clone, Copy, Default, PartialEq)]
614pub struct AsyncTally {
615    /// Model calls, and of them those that gave no answer, and those that took their whole limit.
616    pub checks: u64,
617    pub failed: u64,
618    pub timed_out: u64,
619    /// Jobs that never called the model: a newer stop's replaced them, the session had ended, or their
620    /// job could not be written, started or placed (check_async.rs `not_run`).
621    pub superseded: u64,
622    pub gone: u64,
623    pub unstarted: u64,
624    /// The latest call's limit (ms), the largest peak memory of a check (KiB), and the lessons a check
625    /// showed its model, on average.
626    pub limit_ms: u64,
627    pub rss_kb: u64,
628    pub lessons: f64,
629    pub usd: f64,
630}
631
632/// Grace: a failed call this close to its limit (ms) took it all. The runner kills it at the limit; its
633/// own start and teardown are within this.
634const LIMIT_GRACE_MS: u64 = 1_000;
635
636/// The detached checks among `events` (`mode` `async`), and their checks that never ran.
637#[must_use]
638pub fn async_tally(events: &[Event]) -> AsyncTally {
639    let mut t = AsyncTally::default();
640    let mut shown = 0u64;
641    let mut latest = i64::MIN;
642    for e in events {
643        match e.kind.as_str() {
644            CHECK if e.mode == crate::check_async::MODE => {
645                t.checks += 1;
646                shown += e.n;
647                t.usd += e.usd;
648                t.rss_kb = t.rss_kb.max(e.rss_kb);
649                if e.why == "failed" {
650                    t.failed += 1;
651                    t.timed_out += u64::from(e.limit_ms > 0 && e.dur_ms + LIMIT_GRACE_MS >= e.limit_ms);
652                }
653                if e.limit_ms > 0 && e.ms >= latest {
654                    (latest, t.limit_ms) = (e.ms, e.limit_ms);
655                }
656            }
657            CHECK_DROPPED if e.why == crate::check_async::WHY_SUPERSEDED => t.superseded += 1,
658            CHECK_DROPPED if e.why == crate::check_async::WHY_GONE => t.gone += 1,
659            CHECK_DROPPED if e.why == crate::check_async::WHY_UNSTARTED || e.why == crate::check_async::WHY_UNPLACED => t.unstarted += 1,
660            _ => {}
661        }
662    }
663    if t.checks > 0 {
664        t.lessons = shown as f64 / t.checks as f64;
665    }
666    t
667}
668
669/// Days since 1970-01-01 of a `YYYY-MM-DD` date (clock.rs `days_from_civil`).
670fn day_number(date: &str) -> Option<i64> {
671    let mut it = date.split('-').map(|p| p.parse::<i64>().ok());
672    let (y, m, d) = (it.next()??, it.next()??, it.next()??);
673    Some(crate::clock::days_from_civil(y, m, d))
674}
675
676/// One session's corrections and vetoes (`per_session`).
677#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
678pub struct Said {
679    /// The engineer's corrections: a rejected tool call or a pushback (`CORRECTION`, a regret aside:
680    /// a regret is a later fix, counted in the session that fixed, not by the engineer in this one; an
681    /// answer to the drone's own question aside too: the drone asked for it).
682    pub corrections: u64,
683    /// `borg no` vetoes (`VETO`).
684    pub vetoes: u64,
685}
686
687/// Per session, what the engineer said against its work: every correction and veto, whether or not
688/// `borg learn` merged it into a lesson (a merge is what outcome.rs sees; most corrections merge
689/// into none). The denominator for how often a mistake was seen at all, beside the results.
690pub fn per_session(events: &[Event]) -> BTreeMap<String, Said> {
691    let mut out: BTreeMap<String, Said> = BTreeMap::new();
692    for e in events.iter().filter(|e| !e.session.is_empty()) {
693        match e.kind.as_str() {
694            CORRECTION if e.why != "regret" && e.why != crate::learn::ANSWER => out.entry(e.session.clone()).or_default().corrections += 1,
695            VETO => out.entry(e.session.clone()).or_default().vetoes += 1,
696            _ => {}
697        }
698    }
699    out
700}
701
702/// The local events folded into this node's buckets, keyed by bucket id (version 0).
703pub fn fold(node: &str, events: &[Event]) -> BTreeMap<String, Bucket> {
704    let mut out: BTreeMap<String, Bucket> = BTreeMap::new();
705    for e in events {
706        let day = day(e.ms);
707        let id = format!("stat:{node}:{}:{day}", e.engineer);
708        let b = out.entry(id.clone()).or_insert_with(|| Bucket { id, day, node: node.into(), engineer: e.engineer.clone(), ..Default::default() });
709        // An answer to the drone's question is counted apart, so "correction" keeps its meaning.
710        let kind = if e.kind == CORRECTION && e.why == crate::learn::ANSWER { ANSWERED } else { e.kind.as_str() };
711        *b.counts.entry(kind.to_string()).or_default() += 1;
712        if e.n > 0 {
713            *b.counts.entry(format!("{}_n", e.kind)).or_default() += e.n;
714        }
715        b.usd += e.usd;
716        if e.usd > 0.0 {
717            *b.usd_by.entry(e.kind.clone()).or_default() += e.usd;
718        }
719        // Checks from before modes existed were all turn-end checks: hook mode.
720        if e.usd > 0.0 && (e.kind == CHECK || e.kind == LIVE_CHECK) {
721            let mode = if e.mode.is_empty() { "hook" } else { e.mode.as_str() };
722            *b.usd_mode.entry(mode.into()).or_default() += e.usd;
723        }
724        if !e.channel.is_empty() {
725            *b.channels.entry(format!("{}@{}", e.kind, e.channel)).or_default() += 1;
726        }
727        b.dur_ms += e.dur_ms;
728        for l in &e.lessons {
729            *b.lessons.entry(l.clone()).or_default().entry(e.kind.clone()).or_default() += 1;
730        }
731    }
732    out
733}
734
735/// The latest version of every bucket in `stats.jsonl` (every node's, after the mesh merged them). Two
736/// lines of one version (two rollups at once) are ranked as borg-mesh lines.rs `settle` ranks them, by
737/// the line's blake3 hash, so every node shows the same one whatever order the lines arrived in.
738pub fn rollups(home: &Path) -> BTreeMap<String, Bucket> {
739    let mut out: BTreeMap<String, (Bucket, [u8; 32])> = BTreeMap::new();
740    for l in std::fs::read_to_string(rollups_path(home)).unwrap_or_default().lines() {
741        let Ok(b) = serde_json::from_str::<Bucket>(l) else { continue };
742        let h = *blake3::hash(l.as_bytes()).as_bytes();
743        if out.get(&b.id).is_none_or(|(old, oh)| (b.version, h) > (old.version, *oh)) {
744            out.insert(b.id.clone(), (b, h));
745        }
746    }
747    out.into_iter().map(|(id, (b, _))| (id, b)).collect()
748}
749
750/// Publishes this node's buckets whose counts changed since their last line in `stats.jsonl`, each
751/// with the next version. Returns how many lines it appended (0 when nothing changed).
752pub fn rollup(home: &Path) -> usize {
753    backfill(home);
754    // No events, nothing to publish: and no node id created in a store that never counted anything.
755    let evs = events(home);
756    if evs.is_empty() {
757        return 0;
758    }
759    let me = node(home);
760    let published = rollups(home);
761    // This engineer's personal lessons (reach.rs) are no team's business, not even by id.
762    let personal = crate::reach::personal_ids(home);
763    let fresh: Vec<Bucket> = fold(&me, &evs)
764        .into_values()
765        .filter_map(|mut b| {
766            b.lessons.retain(|id, _| !personal.contains(id));
767            let old = published.get(&b.id);
768            if old.is_some_and(|o| o.same(&b)) {
769                return None;
770            }
771            b.version = old.map_or(1, |o| o.version + 1);
772            Some(b)
773        })
774        .collect();
775    if fresh.is_empty() {
776        return 0;
777    }
778    let lines: String = fresh.iter().map(|b| serde_json::to_string(b).unwrap_or_default() + "\n").collect();
779    let path = rollups_path(home);
780    let ok = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| f.write_all(lines.as_bytes()));
781    if ok.is_ok() { fresh.len() } else { 0 }
782}
783
784/// Every bucket the page shows: other nodes' published rollups, and this node's own counts folded
785/// fresh from its local events, so local activity shows before the next rollup.
786pub fn current(home: &Path) -> Vec<Bucket> {
787    backfill(home);
788    let me = node(home);
789    let mut all: BTreeMap<String, Bucket> = rollups(home).into_iter().filter(|(_, b)| b.node != me).collect();
790    all.extend(fold(&me, &events(home)));
791    all.into_values().collect()
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn corrections_and_vetoes_count_per_session_merged_or_not() {
800        let ev = |kind: &str, session: &str, why: &str| Event { kind: kind.into(), session: session.into(), why: why.into(), ..Default::default() };
801        let events = [
802            ev(CORRECTION, "a", "pushback"),
803            ev(CORRECTION, "a", "rejected_tool"),
804            ev(CORRECTION, "a", "regret"),
805            ev(CORRECTION, "a", crate::learn::ANSWER),
806            ev(VETO, "a", "prompt"),
807            ev(VETO, "b", "cli"),
808            ev(CHANGED, "b", ""),
809            ev(CORRECTION, "", "pushback"),
810        ];
811        let got = per_session(&events);
812        assert_eq!(got["a"], Said { corrections: 2, vetoes: 1 }, "an answer to the drone's own question is no correction");
813        assert_eq!(got["b"], Said { corrections: 0, vetoes: 1 });
814        assert_eq!(got.len(), 2);
815        // Rolled up, an answer has a count of its own: the corrections tiles keep their meaning.
816        let buckets = fold("n", &events);
817        let b = buckets.values().next().unwrap();
818        assert_eq!((b.counts[CORRECTION], b.counts[ANSWERED]), (4, 1));
819    }
820
821    #[test]
822    fn the_history_derived_from_episodes_tells_answers_apart() {
823        let home = std::env::temp_dir().join(format!("borg-stats-answer-{}", std::process::id()));
824        let _ = std::fs::remove_dir_all(&home);
825        std::fs::create_dir_all(&home).unwrap();
826        let eps = [
827            serde_json::json!({"id":"e1","ms":1,"repo":"r","session":"s","task":"t","agent":"Which backoff?","rejected_tool":null,"correction":"Exponential","via":"answer"}),
828            serde_json::json!({"id":"e2","ms":2,"repo":"r","session":"s","task":"t","agent":"a","rejected_tool":null,"correction":"no, not like that"}),
829        ];
830        std::fs::write(home.join("episodes.jsonl"), eps.iter().map(|e| e.to_string() + "\n").collect::<String>()).unwrap();
831        let whys: Vec<String> = derive_history(&home).into_iter().filter(|e| e.kind == CORRECTION).map(|e| e.why).collect();
832        assert_eq!(whys, [ANSWERED, "pushback"]);
833        let _ = std::fs::remove_dir_all(&home);
834    }
835
836    #[test]
837    fn concurrent_first_calls_agree_on_one_node() {
838        // The node id stamps the outcomes and stats that sync: two hooks creating it at once must
839        // agree on one.
840        for round in 0..20 {
841            let home = std::env::temp_dir().join(format!("borg-stats-node-{}-{round}", std::process::id()));
842            let _ = std::fs::remove_dir_all(&home);
843            let ids: Vec<String> = std::thread::scope(|s| {
844                let hs: Vec<_> = (0..16).map(|_| s.spawn(|| node(&home))).collect();
845                hs.into_iter().map(|h| h.join().unwrap()).collect()
846            });
847            assert!(ids.iter().all(|id| *id == ids[0] && id.len() == 16), "{ids:?}");
848            assert_eq!(node(&home), ids[0]);
849            let _ = std::fs::remove_dir_all(&home);
850        }
851    }
852
853    fn tmp(tag: &str) -> PathBuf {
854        let d = std::env::temp_dir().join(format!("borg-stats-{tag}-{}", std::process::id()));
855        let _ = std::fs::remove_dir_all(&d);
856        std::fs::create_dir_all(&d).unwrap();
857        d
858    }
859
860    fn ev(kind: &str, ms: i64, engineer: &str, lessons: &[&str], n: u64) -> Event {
861        Event { engineer: engineer.into(), lessons: lessons.iter().map(|s| s.to_string()).collect(), n, ..Event::new(kind, ms) }
862    }
863
864    /// Derived: one day (clock.rs `DAY_MS`), the tests' unit.
865    const DAY: i64 = crate::clock::DAY_MS;
866
867    #[test]
868    fn days_between_fills_every_day() {
869        assert_eq!(days_between("2026-02-27", "2026-03-02"), ["2026-02-27", "2026-02-28", "2026-03-01", "2026-03-02"]);
870        assert_eq!(days_between("1970-01-01", "1970-01-01"), ["1970-01-01"]);
871        assert!(days_between("x", "1970-01-01").is_empty());
872    }
873
874    #[test]
875    fn day_is_utc_date() {
876        assert_eq!(day(0), "1970-01-01");
877        assert_eq!(day(DAY - 1), "1970-01-01");
878        assert_eq!(day(DAY), "1970-01-02");
879    }
880
881    #[test]
882    fn fold_counts_per_engineer_and_day() {
883        let evs = [
884            ev(TURN_END, 10, "a@x", &[], 0),
885            ev(CHECK, 20, "a@x", &[], 7),
886            ev(BLOCK, 30, "a@x", &["L1", "L2"], 2),
887            ev(TURN_END, DAY + 5, "a@x", &[], 0),
888            ev(TURN_END, 40, "b@x", &[], 0),
889        ];
890        let b = fold("n1", &evs);
891        assert_eq!(b.len(), 3, "two days for a, one for b");
892        let a = &b["stat:n1:a@x:1970-01-01"];
893        assert_eq!((a.counts[TURN_END], a.counts[CHECK], a.counts["check_n"], a.counts["block_n"]), (1, 1, 7, 2));
894        assert_eq!(a.lessons["L1"][BLOCK], 1);
895    }
896
897    #[test]
898    fn fold_splits_check_cost_by_mode_and_asks_by_channel() {
899        let with = |kind: &str, usd: f64, mode: &str, channel: &str| Event { usd, mode: mode.into(), channel: channel.into(), ..ev(kind, 10, "a@x", &[], 0) };
900        let evs = [
901            with(CHECK, 0.05, "", ""),
902            with(CHECK, 0.04, "live", ""),
903            with(LIVE_CHECK, 0.03, "live", ""),
904            with(MODEL_CALL, 0.5, "live", ""),
905            with(BLOCK, 0.0, "live", "stop"),
906            with(LIVE_ASK, 0.0, "live", "live"),
907            with(CHANGED, 0.0, "", "live"),
908        ];
909        let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
910        assert!((b.usd_mode["hook"] - 0.05).abs() < 1e-9 && (b.usd_mode["live"] - 0.07).abs() < 1e-9, "{:?}", b.usd_mode);
911        assert_eq!(b.usd_mode.len(), 2, "only the conscience's checks: {:?}", b.usd_mode);
912        assert_eq!((b.channels["block@stop"], b.channels["live_ask@live"], b.channels["changed@live"]), (1, 1, 1));
913        assert_eq!((b.counts[CHECK], b.counts[LIVE_CHECK]), (2, 1), "live checks are counted apart from turn-end checks");
914    }
915
916    #[test]
917    fn the_async_tally_counts_detached_checks_and_the_jobs_that_never_ran() {
918        let check = |ms: i64, n: u64, why: &str, dur_ms: u64, limit_ms: u64, rss_kb: u64| Event {
919            mode: crate::check_async::MODE.into(),
920            why: why.into(),
921            dur_ms,
922            limit_ms,
923            rss_kb,
924            usd: 0.01,
925            ..ev(CHECK, ms, "a@x", &[], n)
926        };
927        let dropped = |why: &str| Event { why: why.into(), mode: crate::check_async::MODE.into(), ..ev(CHECK_DROPPED, 5, "a@x", &[], 0) };
928        let evs = [
929            check(10, 40, "", 9_000, 45_000, 30_000),
930            check(30, 20, "failed", 44_500, 45_000, 52_000),
931            check(20, 30, "failed", 2_000, 60_000, 31_000),
932            ev(CHECK, 40, "a@x", &[], 99),
933            dropped("superseded"),
934            dropped("superseded"),
935            dropped("gone"),
936        ];
937        let t = async_tally(&evs);
938        assert_eq!((t.checks, t.failed, t.timed_out, t.superseded, t.gone), (3, 2, 1, 2, 1), "{t:?}");
939        assert_eq!((t.limit_ms, t.rss_kb), (45_000, 52_000), "the latest limit, the largest peak");
940        assert!((t.lessons - 30.0).abs() < 1e-9 && (t.usd - 0.03).abs() < 1e-9, "a stop's own check is not counted: {t:?}");
941        assert_eq!(async_tally(&[]), AsyncTally::default());
942        // A job that never ran is no model check in the rollup either.
943        let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
944        assert_eq!((b.counts[CHECK], b.counts[CHECK_DROPPED]), (4, 3));
945        assert!((b.usd_mode["async"] - 0.03).abs() < 1e-9, "{:?}", b.usd_mode);
946    }
947
948    #[test]
949    fn rollup_versions_only_on_change_and_is_idempotent() {
950        let home = tmp("rollup");
951        record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
952        assert_eq!(rollup(&home), 1);
953        assert_eq!(rollup(&home), 0, "nothing changed: nothing appended");
954        record(&home, &ev(GATE_SKIP, 11, "a@x", &[], 0));
955        assert_eq!(rollup(&home), 1);
956        let r = rollups(&home);
957        let b = r.values().next().unwrap();
958        assert_eq!((b.version, b.counts[TURN_END], b.counts[GATE_SKIP]), (2, 1, 1));
959        let text = std::fs::read_to_string(rollups_path(&home)).unwrap();
960        assert!(!text.contains("session"), "no session ids leave the machine");
961    }
962
963    #[test]
964    fn current_prefers_own_fresh_counts_and_keeps_peers() {
965        let home = tmp("current");
966        record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
967        rollup(&home);
968        record(&home, &ev(TURN_END, 12, "a@x", &[], 0));
969        let peer = Bucket {
970            id: "stat:other:b@x:1970-01-01".into(),
971            version: 3,
972            day: "1970-01-01".into(),
973            node: "other".into(),
974            engineer: "b@x".into(),
975            ..Default::default()
976        };
977        let mut f = std::fs::OpenOptions::new().append(true).open(rollups_path(&home)).unwrap();
978        writeln!(f, "{}", serde_json::to_string(&peer).unwrap()).unwrap();
979        let cur = current(&home);
980        assert_eq!(cur.len(), 2);
981        let mine = cur.iter().find(|b| b.engineer == "a@x").unwrap();
982        assert_eq!(mine.counts[TURN_END], 2, "own counts are folded fresh, not the stale rollup");
983    }
984
985    /// Two v+1 lines of one bucket (two rollups at once) reach peers in either order: every node shows
986    /// the same one, the one mesh `settle` makes last (higher hash).
987    #[test]
988    fn rollups_break_version_ties_by_hash_whatever_the_order() {
989        let bucket = |n: u64| {
990            let mut b = Bucket { id: "stat:n:a@x:1970-01-01".into(), version: 2, ..Default::default() };
991            b.counts.insert(TURN_END.into(), n);
992            serde_json::to_string(&b).unwrap()
993        };
994        let (a, b) = (bucket(1), bucket(2));
995        let shown = |name: &str, first: &str, second: &str| {
996            let home = tmp(name);
997            std::fs::write(rollups_path(&home), format!("{first}\n{second}\n")).unwrap();
998            rollups(&home).into_values().next().unwrap().counts[TURN_END]
999        };
1000        assert_eq!(shown("ties-ab", &a, &b), shown("ties-ba", &b, &a));
1001    }
1002
1003    #[test]
1004    fn record_fails_open() {
1005        let home = tmp("open");
1006        std::fs::write(home.join("stats"), "a file where the directory should be").unwrap();
1007        record(&home, &ev(TURN_END, 1, "a@x", &[], 0));
1008        assert!(events(&home).is_empty());
1009    }
1010
1011    #[test]
1012    fn backfill_derives_history_before_live_counting_once() {
1013        let home = tmp("backfill");
1014        let w = |f: &str, lines: &[serde_json::Value]| {
1015            let p = home.join(f);
1016            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1017            std::fs::write(p, lines.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
1018        };
1019        use serde_json::json;
1020        w(
1021            "episodes.jsonl",
1022            &[
1023                json!({"id":"e1","ms":100,"repo":"app","session":"s1","task":"t","agent":"a","rejected_tool":"Bash","correction":"no, not like that","engineer":"a@x"}),
1024                json!({"id":"e2","ms":5000,"repo":"app","session":"s2","task":"t","agent":"a","rejected_tool":null,"correction":"after live counting began","engineer":"a@x"}),
1025            ],
1026        );
1027        w(
1028            "outcomes.jsonl",
1029            &[
1030                json!({"kind":"asked","session":"s1","lessons":["L1","L2"],"ms":110,"repo":"app"}),
1031                // Free deliveries are counted live as `early` and `queued_free`: never a block here.
1032                json!({"kind":"asked","session":"s1","lessons":["L3"],"ms":112,"repo":"app","channel":"early"}),
1033                json!({"kind":"asked","session":"s1","lessons":["L4"],"ms":114,"repo":"app","channel":"queued"}),
1034                json!({"kind":"asked_then_changed","session":"s1","lessons":["L1"],"ms":120,"repo":"app"}),
1035            ],
1036        );
1037        w(
1038            "lessons.jsonl",
1039            &[json!({"id":"L1","origin_ms":90,"question":"q","version":1,"engineers":["a@x"]}), json!({"id":"L1","origin_ms":90,"question":"q2","version":2})],
1040        );
1041        w("queen/queue.jsonl", &[json!({"id":"Q1","ms":130,"question":"which?","engineer":"a@x","answered_ms":190,"answer":"this","answered_by":"a@x"})]);
1042        record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
1043        let n = backfill(&home);
1044        let all = read_events(&backfill_path(&home));
1045        assert_eq!(n, all.len());
1046        let kinds: Vec<&str> = all.iter().map(|e| e.kind.as_str()).collect();
1047        assert_eq!(kinds, [LESSON_NEW, CORRECTION, BLOCK, CHANGED, QUEEN_QUEUED, QUEEN_ANSWERED], "ordered by time, the later correction left out");
1048        let c = &all[1];
1049        assert_eq!((c.why.as_str(), c.text.as_str(), c.engineer.as_str()), ("rejected_tool", "Bash: no, not like that", "a@x"));
1050        assert_eq!(all[3].engineer, "a@x", "outcomes take the session's engineer from its corrections");
1051        assert_eq!(all[5].dur_ms, 60, "time to answer");
1052        assert_eq!(backfill(&home), 0, "once");
1053        assert_eq!(events(&home).len(), all.len() + 1, "history and live events together");
1054    }
1055
1056    /// A stop's block asked lessons that two checks routed, a live ask delivered two checks' questions,
1057    /// and the judge's call wrote one verdict record per check: each is one record per check in the log
1058    /// and one event live, so one event in the history too, its lessons joined. Records of separate
1059    /// calls (another stamp) and results counted per record stay apart.
1060    #[test]
1061    fn backfill_counts_one_event_per_call_as_live_counting_does() {
1062        let home = tmp("backfill-call");
1063        use serde_json::json;
1064        let rec = |kind: &str, lessons: &[&str], ms: i64, channel: &str, check: &str| json!({"kind": kind, "session": "s1", "lessons": lessons, "ms": ms, "repo": "app", "channel": channel, "check": check});
1065        let log = [
1066            rec("asked", &["L1", "L2"], 110, "stop", "c1"),
1067            rec("asked", &["L3"], 110, "stop", "c2"),
1068            rec("asked", &["L4"], 120, "live", "c3"),
1069            rec("asked", &["L5"], 120, "live", "c4"),
1070            rec("asked_then_changed", &["L1"], 130, "stop", "c1"),
1071            rec("asked_then_changed", &["L3"], 130, "stop", "c2"),
1072            rec("asked", &["L6"], 140, "stop", "c5"),
1073            rec("asked_not_corrected", &["L2"], 150, "stop", "c1"),
1074            rec("asked_not_corrected", &["L6"], 150, "stop", "c5"),
1075        ];
1076        std::fs::write(home.join("outcomes.jsonl"), log.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
1077        record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
1078        backfill(&home);
1079        let all: Vec<(String, i64, u64, Vec<String>)> = read_events(&backfill_path(&home)).into_iter().map(|e| (e.kind, e.ms, e.n, e.lessons)).collect();
1080        let row = |kind: &str, ms: i64, n: u64, lessons: &[&str]| (kind.to_string(), ms, n, lessons.iter().map(|l| l.to_string()).collect::<Vec<_>>());
1081        assert_eq!(
1082            all,
1083            [
1084                row(BLOCK, 110, 3, &["L1", "L2", "L3"]),
1085                row(LIVE_ASK, 120, 2, &["L4", "L5"]),
1086                row(CHANGED, 130, 0, &["L1", "L3"]),
1087                row(BLOCK, 140, 1, &["L6"]),
1088                row(NOT_CORRECTED, 150, 0, &["L2"]),
1089                row(NOT_CORRECTED, 150, 0, &["L6"]),
1090            ]
1091        );
1092    }
1093
1094    #[test]
1095    fn node_id_is_stable() {
1096        let home = tmp("node");
1097        let a = node(&home);
1098        assert_eq!(a.len(), 16);
1099        assert_eq!(node(&home), a);
1100    }
1101}