1use std::collections::BTreeMap;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20pub const TURN_END: &str = "turn_end";
22pub const GATE_SKIP: &str = "gate_skip";
24pub const CHECK: &str = "check";
27pub const CHECK_DROPPED: &str = "check_dropped";
31pub const BLOCK: &str = "block";
33pub const LIVE_CHECK: &str = "live_check";
36pub const LIVE_ASK: &str = "live_ask";
40pub const EARLY: &str = "early";
42pub const QUEUED_FREE: &str = "queued_free";
44pub const CHANGED: &str = "changed";
46pub const IGNORED: &str = "ignored";
47pub const CORRECTED: &str = "corrected";
48pub const NOT_CORRECTED: &str = "not_corrected";
49pub const REGRETTED: &str = "regretted";
52pub const TESTED: &str = "tested";
55pub const CORRECTION: &str = "correction";
60pub const ANSWERED: &str = "answer";
63pub const VETO: &str = "veto";
66pub const VETO_REPEAT: &str = "veto_repeat";
69pub const LESSON_NEW: &str = "lesson_new";
71pub const LESSON_MERGED: &str = "lesson_merged";
72pub const REVIEW_FINDING: &str = "review_finding";
75pub const NOT_A_LESSON: &str = "not_a_lesson";
77pub const MODEL_CALL: &str = "model_call";
80pub 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";
86pub const MSG_SENT: &str = "msg_sent";
90pub const MSG_DELIVERED: &str = "msg_delivered";
91pub const MSG_DROPPED: &str = "msg_dropped";
92pub 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";
99pub const QUEEN_NO: &str = "queen_no";
103pub const QUEEN_STAMP: &str = "queen_stamp";
104pub const QUEEN_MISSED: &str = "queen_missed";
105pub 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 #[serde(default, skip_serializing_if = "String::is_empty")]
130 pub text: String,
131 #[serde(default, skip_serializing_if = "String::is_empty")]
135 pub mode: String,
136 #[serde(default, skip_serializing_if = "String::is_empty")]
140 pub channel: String,
141 #[serde(default, skip_serializing_if = "String::is_empty")]
145 pub source: String,
146 #[serde(default, skip_serializing_if = "is_zero")]
148 pub rss_kb: u64,
149 #[serde(default, skip_serializing_if = "is_zero")]
152 pub limit_ms: u64,
153}
154
155pub 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 pub fn new(kind: &str, ms: i64) -> Self {
172 Event { kind: kind.into(), ms, ..Default::default() }
173 }
174}
175
176#[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 pub counts: BTreeMap<String, u64>,
186 pub usd: f64,
187 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
189 pub usd_by: BTreeMap<String, f64>,
190 pub dur_ms: u64,
191 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
193 pub lessons: BTreeMap<String, BTreeMap<String, u64>>,
194 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
198 pub usd_mode: BTreeMap<String, f64>,
199 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
201 pub channels: BTreeMap<String, u64>,
202 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207 pub why: BTreeMap<String, u64>,
208}
209
210impl Bucket {
211 fn same(&self, other: &Bucket) -> bool {
213 self.counts == other.counts
214 && self.lessons == other.lessons
215 && self.channels == other.channels
216 && self.why == other.why
217 && self.dur_ms == other.dur_ms
218 && (self.usd - other.usd).abs() < 1e-9
219 }
220
221 pub fn add(&mut self, other: &Bucket) {
223 for (k, v) in &other.counts {
224 *self.counts.entry(k.clone()).or_default() += v;
225 }
226 for (id, kinds) in &other.lessons {
227 let mine = self.lessons.entry(id.clone()).or_default();
228 for (k, v) in kinds {
229 *mine.entry(k.clone()).or_default() += v;
230 }
231 }
232 for (k, v) in &other.usd_by {
233 *self.usd_by.entry(k.clone()).or_default() += v;
234 }
235 for (k, v) in &other.usd_mode {
236 *self.usd_mode.entry(k.clone()).or_default() += v;
237 }
238 for (k, v) in &other.channels {
239 *self.channels.entry(k.clone()).or_default() += v;
240 }
241 for (k, v) in &other.why {
242 *self.why.entry(k.clone()).or_default() += v;
243 }
244 self.usd += other.usd;
245 self.dur_ms += other.dur_ms;
246 }
247}
248
249pub fn events_path(home: &Path) -> PathBuf {
250 home.join("stats").join("events.jsonl")
251}
252
253pub fn rollups_path(home: &Path) -> PathBuf {
254 home.join("stats.jsonl")
255}
256
257pub fn record(home: &Path, ev: &Event) {
259 if !crate::learn::learning() {
260 return;
261 }
262 let _ = crate::learn::append_jsonl(&events_path(home), ev);
263}
264
265pub fn count(home: &Path, kind: &str, session: &str, fill: impl FnOnce(&mut Event)) {
268 let mut ev = Event::new(kind, crate::learn::now_ms());
269 ev.session = session.into();
270 fill(&mut ev);
271 if ev.engineer.is_empty() && !session.is_empty() {
272 ev.engineer = crate::presence::read(home, session).map(|p| p.engineer).unwrap_or_default();
273 }
274 record(home, &ev);
275}
276
277pub const FEED_CHARS: usize = 160;
279
280pub fn excerpt(text: &str, max: usize) -> String {
282 let one = text.split_whitespace().collect::<Vec<_>>().join(" ");
283 match one.char_indices().nth(max) {
284 Some((i, _)) => format!("{}…", &one[..i]),
285 None => one,
286 }
287}
288
289static PURPOSE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
290
291pub fn set_purpose(purpose: &'static str) {
293 let _ = PURPOSE.set(purpose);
294}
295
296pub fn purpose() -> &'static str {
297 PURPOSE.get().copied().unwrap_or("model")
298}
299
300fn read_events(path: &Path) -> Vec<Event> {
301 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
302}
303
304pub fn events(home: &Path) -> Vec<Event> {
306 let mut all = read_events(&backfill_path(home));
307 all.extend(read_events(&events_path(home)));
308 all
309}
310
311pub fn live(home: &Path) -> Vec<Event> {
313 read_events(&events_path(home))
314}
315
316pub fn live_since(home: &Path) -> Option<i64> {
320 live(home).iter().filter(|e| e.kind != VETO_REPEAT).map(|e| e.ms).min()
321}
322
323pub fn backfill_path(home: &Path) -> PathBuf {
324 home.join("stats").join("backfill.jsonl")
325}
326
327fn lines(path: &Path) -> Vec<serde_json::Value> {
328 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
329}
330
331pub fn backfill(home: &Path) -> usize {
337 let path = backfill_path(home);
338 if path.exists() {
339 return 0;
340 }
341 let cutoff = live_since(home).unwrap_or_else(crate::learn::now_ms);
342 let mut out = derive_history(home);
343 out.retain(|e| e.ms > 0 && e.ms < cutoff);
344 out.sort_by_key(|e| e.ms);
345 let text: String = out.iter().map(|e| serde_json::to_string(e).unwrap_or_default() + "\n").collect();
346 if std::fs::create_dir_all(path.parent().unwrap_or(home)).is_err() || std::fs::write(&path, text).is_err() {
347 return 0;
348 }
349 out.len()
350}
351
352fn derive_history(home: &Path) -> Vec<Event> {
353 use serde_json::Value;
354 let s = |v: &Value, k: &str| v[k].as_str().unwrap_or_default().to_string();
355 let ms = |v: &Value, k: &str| v[k].as_i64().unwrap_or(0);
356 let ids = |v: &Value, k: &str| v[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(String::from).collect::<Vec<_>>();
357 let mut out = Vec::new();
358 let mut who: std::collections::HashMap<String, String> = std::collections::HashMap::new();
360 for e in lines(&home.join("episodes.jsonl")) {
361 let tool = s(&e, "rejected_tool");
362 if !s(&e, "engineer").is_empty() {
363 who.insert(s(&e, "session"), s(&e, "engineer"));
364 }
365 let snippet = excerpt(&s(&e, "correction"), FEED_CHARS);
366 if s(&e, "kind") == crate::veto::KIND {
368 out.push(Event {
369 ms: ms(&e, "ms"),
370 kind: VETO.into(),
371 session: s(&e, "session"),
372 engineer: s(&e, "engineer"),
373 repo: s(&e, "repo"),
374 text: snippet,
375 ..Default::default()
376 });
377 continue;
378 }
379 let answer = s(&e, "via") == crate::learn::ANSWER;
380 out.push(Event {
381 ms: ms(&e, "ms"),
382 kind: CORRECTION.into(),
383 session: s(&e, "session"),
384 engineer: s(&e, "engineer"),
385 repo: s(&e, "repo"),
386 why: if answer {
387 ANSWERED.into()
388 } else if tool.is_empty() {
389 "pushback".into()
390 } else {
391 "rejected_tool".into()
392 },
393 text: if tool.is_empty() || answer { snippet } else { format!("{tool}: {snippet}") },
394 source: source(&s(&e, "harness"), &s(&e, "harness_version")),
395 ..Default::default()
396 });
397 }
398 let engineer = |session: &str| who.get(session).cloned().unwrap_or_default();
399 let mut call: std::collections::HashMap<(String, &str, String, i64), usize> = std::collections::HashMap::new();
403 for o in lines(&home.join("outcomes.jsonl")) {
404 if o["channel"] == crate::outcome::PLACEBO {
406 continue;
407 }
408 let live = o["channel"] == "live";
409 let kind = match o["kind"].as_str().unwrap_or_default() {
410 crate::outcome::ASKED if o["channel"] == "early" || o["channel"] == "queued" => continue,
412 crate::outcome::ASKED if live => LIVE_ASK,
413 crate::outcome::ASKED => BLOCK,
414 crate::outcome::CHANGED => CHANGED,
415 crate::outcome::IGNORED => IGNORED,
416 crate::outcome::CORRECTED => CORRECTED,
417 crate::outcome::NOT_CORRECTED => NOT_CORRECTED,
418 crate::outcome::REGRETTED => REGRETTED,
419 crate::outcome::TESTED => TESTED,
420 crate::outcome::UNCLEAR | crate::outcome::REMAINS | crate::outcome::NOT_ARISEN => continue,
422 _ => continue,
423 };
424 let lessons = ids(&o, "lessons");
425 let session = s(&o, "session");
426 let asks = kind == BLOCK || kind == LIVE_ASK;
427 let key = (session.clone(), kind, s(&o, "channel"), ms(&o, "ms"));
428 let merges = [BLOCK, LIVE_ASK, CHANGED, IGNORED].contains(&kind);
429 if merges
430 && let Some(&i) = call.get(&key)
431 && let Some(e) = out.get_mut(i)
432 {
433 e.n += if asks { lessons.len() as u64 } else { 0 };
434 e.lessons.extend(lessons);
435 continue;
436 }
437 if merges {
438 call.insert(key, out.len());
439 }
440 out.push(Event {
441 ms: ms(&o, "ms"),
442 kind: kind.into(),
443 engineer: engineer(&session),
444 session,
445 repo: s(&o, "repo"),
446 n: if asks { lessons.len() as u64 } else { 0 },
447 lessons,
448 channel: s(&o, "channel"),
449 ..Default::default()
450 });
451 }
452 let mut seen = std::collections::HashSet::new();
454 for l in lines(&home.join("lessons.jsonl")) {
455 let id = s(&l, "id");
456 if id.is_empty() || !seen.insert(id.clone()) {
457 continue;
458 }
459 let source = Some(s(&l, "source")).filter(|x| !x.is_empty()).unwrap_or_else(|| "session".into());
460 out.push(Event {
461 ms: ms(&l, "origin_ms"),
462 kind: LESSON_NEW.into(),
463 engineer: ids(&l, "engineers").into_iter().next().unwrap_or_default(),
464 repo: s(&l, "repo"),
465 lessons: vec![id],
466 why: source,
467 ..Default::default()
468 });
469 }
470 let mut queen: BTreeMap<String, Value> = BTreeMap::new();
471 for q in lines(&home.join("queen").join("queue.jsonl")) {
472 queen.insert(s(&q, "id"), q);
473 }
474 for q in queen.values() {
475 let base = || Event { engineer: s(q, "engineer"), session: s(q, "session"), repo: s(q, "repo"), lessons: vec![s(q, "id")], ..Default::default() };
476 out.push(Event { ms: ms(q, "ms"), kind: QUEEN_QUEUED.into(), text: excerpt(&s(q, "question"), FEED_CHARS), ..base() });
477 if let Some(at) = q["answered_ms"].as_i64() {
478 let dur = u64::try_from(at - ms(q, "ms")).unwrap_or(0);
479 out.push(Event {
480 ms: at,
481 kind: QUEEN_ANSWERED.into(),
482 dur_ms: dur,
483 engineer: s(q, "answered_by"),
484 text: excerpt(&s(q, "answer"), FEED_CHARS),
485 ..base()
486 });
487 }
488 if let Some(at) = q["dismissed_ms"].as_i64() {
489 out.push(Event { ms: at, kind: QUEEN_DISMISSED.into(), text: s(q, "dismissed"), ..base() });
490 }
491 }
492 for (file, kind) in [("delivered.jsonl", QUEEN_DELIVERED), ("skips.jsonl", QUEEN_SKIPPED)] {
493 for m in lines(&home.join("queen").join(file)) {
494 let session = s(&m, "session");
495 out.push(Event { ms: ms(&m, "ms"), kind: kind.into(), engineer: engineer(&session), session, lessons: vec![s(&m, "id")], ..Default::default() });
496 }
497 }
498 for a in lines(&home.join("assimilated.jsonl")) {
499 out.push(Event {
500 ms: ms(&a, "ms"),
501 kind: ASSIMILATED.into(),
502 repo: s(&a, "source"),
503 n: ids(&a, "lessons").len() as u64,
504 usd: a["cost_usd"].as_f64().unwrap_or(0.0),
505 ..Default::default()
506 });
507 }
508 for (file, why) in [("experts.jsonl", "expert"), ("facets.jsonl", "facets")] {
509 for c in lines(&home.join(file)) {
510 let session = s(&c, "session");
511 out.push(Event {
512 ms: ms(&c, "ms"),
513 kind: MODEL_CALL.into(),
514 engineer: engineer(&session),
515 session,
516 repo: s(&c, "repo"),
517 usd: c["cost_usd"].as_f64().unwrap_or(0.0),
518 why: why.into(),
519 ..Default::default()
520 });
521 }
522 }
523 for (file, kind) in [(home.join("mesh").join("directives.jsonl"), STEER_ISSUED), (home.join("steer").join("inbox.jsonl"), STEER_DELIVERED)] {
526 for d in lines(&file) {
527 out.push(Event {
528 ms: ms(&d, "ms"),
529 kind: kind.into(),
530 engineer: s(&d, "engineer"),
531 why: s(&d, "kind"),
532 text: excerpt(&s(&d, "text"), FEED_CHARS),
533 ..Default::default()
534 });
535 }
536 }
537 for d in lines(&crate::steer::dropped_path(home)) {
538 out.push(Event { ms: ms(&d, "ms"), kind: STEER_DROPPED.into(), engineer: s(&d, "engineer"), why: s(&d, "why"), ..Default::default() });
539 }
540 out.extend(turns_from_state(&home.join("state"), &who));
541 out
542}
543
544fn turns_from_state(dir: &Path, who: &std::collections::HashMap<String, String>) -> Vec<Event> {
547 let mut out = Vec::new();
548 let Ok(rd) = std::fs::read_dir(dir) else { return out };
549 for f in rd.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "ctx")) {
550 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 {
551 continue;
552 };
553 let session = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
554 let base =
555 || Event { ms: at.as_millis() as i64, session: session.clone(), engineer: who.get(&session).cloned().unwrap_or_default(), ..Default::default() };
556 for line in std::fs::read_to_string(&f).unwrap_or_default().lines() {
557 if let Some(score) = line.strip_prefix("#borg-skip ") {
558 out.push(Event { kind: TURN_END.into(), ..base() });
559 out.push(Event { kind: GATE_SKIP.into(), why: score.trim().into(), ..base() });
560 } else if let Some(cost) = line.strip_prefix("#borg-predict-cost ") {
561 out.push(Event { kind: TURN_END.into(), ..base() });
562 out.push(Event { kind: CHECK.into(), usd: cost.trim().parse().unwrap_or(0.0), ..base() });
563 }
564 }
565 }
566 out
567}
568
569pub fn node(home: &Path) -> String {
572 let path = home.join("stats").join("node");
573 if let Ok(id) = std::fs::read_to_string(&path)
574 && !id.trim().is_empty()
575 {
576 return id.trim().to_string();
577 }
578 let id = fresh_id();
582 let _ = std::fs::create_dir_all(path.parent().unwrap_or(home));
583 let tmp = path.with_extension(format!("{id}.tmp"));
584 let linked = std::fs::write(&tmp, &id).and_then(|()| std::fs::hard_link(&tmp, &path));
585 let _ = std::fs::remove_file(&tmp);
586 match linked {
587 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
588 std::fs::read_to_string(&path).map(|s| s.trim().to_string()).ok().filter(|s| !s.is_empty()).unwrap_or(id)
589 }
590 _ => id,
591 }
592}
593
594fn fresh_id() -> String {
595 let mut bytes = [0u8; 8];
596 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
597 use std::io::Read;
598 let _ = f.read_exact(&mut bytes);
599 }
600 if bytes == [0; 8] {
601 use std::hash::{BuildHasher, Hasher};
602 let mut h = std::collections::hash_map::RandomState::new().build_hasher();
603 h.write_i64(crate::learn::now_ms());
604 h.write_u32(std::process::id());
605 bytes = h.finish().to_le_bytes();
606 }
607 bytes.iter().map(|b| format!("{b:02x}")).collect()
608}
609
610pub fn day(ms: i64) -> String {
612 let s = crate::install::utc(ms);
613 format!("{}-{}-{}", &s[0..4], &s[4..6], &s[6..8])
614}
615
616pub fn days_between(first: &str, last: &str) -> Vec<String> {
618 let (Some(a), Some(b)) = (day_number(first), day_number(last)) else { return Vec::new() };
619 (a..=b).map(|d| day(d * crate::clock::DAY_MS)).collect()
620}
621
622#[derive(Debug, Clone, Copy, Default, PartialEq)]
624pub struct AsyncTally {
625 pub checks: u64,
627 pub failed: u64,
628 pub timed_out: u64,
629 pub superseded: u64,
632 pub gone: u64,
633 pub unstarted: u64,
634 pub limit_ms: u64,
637 pub rss_kb: u64,
638 pub lessons: f64,
639 pub usd: f64,
640}
641
642const LIMIT_GRACE_MS: u64 = 1_000;
645
646#[must_use]
648pub fn async_tally(events: &[Event]) -> AsyncTally {
649 let mut t = AsyncTally::default();
650 let mut shown = 0u64;
651 let mut latest = i64::MIN;
652 for e in events {
653 match e.kind.as_str() {
654 CHECK if e.mode == crate::check_async::MODE => {
655 t.checks += 1;
656 shown += e.n;
657 t.usd += e.usd;
658 t.rss_kb = t.rss_kb.max(e.rss_kb);
659 if e.why == "failed" {
660 t.failed += 1;
661 t.timed_out += u64::from(e.limit_ms > 0 && e.dur_ms + LIMIT_GRACE_MS >= e.limit_ms);
662 }
663 if e.limit_ms > 0 && e.ms >= latest {
664 (latest, t.limit_ms) = (e.ms, e.limit_ms);
665 }
666 }
667 CHECK_DROPPED if e.why == crate::check_async::WHY_SUPERSEDED => t.superseded += 1,
668 CHECK_DROPPED if e.why == crate::check_async::WHY_GONE => t.gone += 1,
669 CHECK_DROPPED if e.why == crate::check_async::WHY_UNSTARTED || e.why == crate::check_async::WHY_UNPLACED => t.unstarted += 1,
670 _ => {}
671 }
672 }
673 if t.checks > 0 {
674 t.lessons = shown as f64 / t.checks as f64;
675 }
676 t
677}
678
679fn day_number(date: &str) -> Option<i64> {
681 let mut it = date.split('-').map(|p| p.parse::<i64>().ok());
682 let (y, m, d) = (it.next()??, it.next()??, it.next()??);
683 Some(crate::clock::days_from_civil(y, m, d))
684}
685
686#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
688pub struct Said {
689 pub corrections: u64,
693 pub vetoes: u64,
695}
696
697pub fn per_session(events: &[Event]) -> BTreeMap<String, Said> {
701 let mut out: BTreeMap<String, Said> = BTreeMap::new();
702 for e in events.iter().filter(|e| !e.session.is_empty()) {
703 match e.kind.as_str() {
704 CORRECTION if e.why != "regret" && e.why != crate::learn::ANSWER => out.entry(e.session.clone()).or_default().corrections += 1,
705 VETO => out.entry(e.session.clone()).or_default().vetoes += 1,
706 _ => {}
707 }
708 }
709 out
710}
711
712pub fn fold(node: &str, events: &[Event]) -> BTreeMap<String, Bucket> {
714 let mut out: BTreeMap<String, Bucket> = BTreeMap::new();
715 for e in events {
716 let day = day(e.ms);
717 let id = format!("stat:{node}:{}:{day}", e.engineer);
718 let b = out.entry(id.clone()).or_insert_with(|| Bucket { id, day, node: node.into(), engineer: e.engineer.clone(), ..Default::default() });
719 let kind = if e.kind == CORRECTION && e.why == crate::learn::ANSWER { ANSWERED } else { e.kind.as_str() };
721 *b.counts.entry(kind.to_string()).or_default() += 1;
722 if e.n > 0 {
723 *b.counts.entry(format!("{}_n", e.kind)).or_default() += e.n;
724 }
725 let why = match (e.kind.as_str(), e.why.as_str()) {
730 (GATE_SKIP, w @ ("noedit" | "empty" | "live_covered")) => w,
731 (GATE_SKIP, w) if w.parse::<f64>().is_ok() => "score",
732 (GATE_SKIP, w) if !w.is_empty() => "other",
733 (CHECK | LIVE_CHECK, "failed") => "failed",
734 (CHECK_DROPPED, w) if !w.is_empty() => w,
735 (LIVE_ASK, _) if e.mode == crate::check_async::MODE => crate::check_async::MODE,
736 _ => "",
737 };
738 if !why.is_empty() {
739 *b.why.entry(format!("{}:{why}", e.kind)).or_default() += 1;
740 }
741 if e.kind == CHECK && e.dur_ms > 0 {
743 *b.why.entry("check_ms".into()).or_default() += e.dur_ms;
744 *b.why.entry("check_timed".into()).or_default() += 1;
745 }
746 b.usd += e.usd;
747 if e.usd > 0.0 {
748 *b.usd_by.entry(e.kind.clone()).or_default() += e.usd;
749 }
750 if e.usd > 0.0 && (e.kind == CHECK || e.kind == LIVE_CHECK) {
752 let mode = if e.mode.is_empty() { "hook" } else { e.mode.as_str() };
753 *b.usd_mode.entry(mode.into()).or_default() += e.usd;
754 }
755 if !e.channel.is_empty() {
756 *b.channels.entry(format!("{}@{}", e.kind, e.channel)).or_default() += 1;
757 }
758 b.dur_ms += e.dur_ms;
759 for l in &e.lessons {
760 *b.lessons.entry(l.clone()).or_default().entry(e.kind.clone()).or_default() += 1;
761 }
762 }
763 out
764}
765
766pub fn rollups(home: &Path) -> BTreeMap<String, Bucket> {
770 let mut out: BTreeMap<String, (Bucket, [u8; 32])> = BTreeMap::new();
771 for l in std::fs::read_to_string(rollups_path(home)).unwrap_or_default().lines() {
772 let Ok(b) = serde_json::from_str::<Bucket>(l) else { continue };
773 let h = *blake3::hash(l.as_bytes()).as_bytes();
774 if out.get(&b.id).is_none_or(|(old, oh)| (b.version, h) > (old.version, *oh)) {
775 out.insert(b.id.clone(), (b, h));
776 }
777 }
778 out.into_iter().map(|(id, (b, _))| (id, b)).collect()
779}
780
781pub fn rollup(home: &Path) -> usize {
784 backfill(home);
785 let evs = events(home);
787 if evs.is_empty() {
788 return 0;
789 }
790 let me = node(home);
791 let published = rollups(home);
792 let personal = crate::reach::personal_ids(home);
794 let fresh: Vec<Bucket> = fold(&me, &evs)
795 .into_values()
796 .filter_map(|mut b| {
797 b.lessons.retain(|id, _| !personal.contains(id));
798 let old = published.get(&b.id);
799 if old.is_some_and(|o| o.same(&b)) {
800 return None;
801 }
802 b.version = old.map_or(1, |o| o.version + 1);
803 Some(b)
804 })
805 .collect();
806 if fresh.is_empty() {
807 return 0;
808 }
809 let lines: String = fresh.iter().map(|b| serde_json::to_string(b).unwrap_or_default() + "\n").collect();
810 let path = rollups_path(home);
811 let ok = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| f.write_all(lines.as_bytes()));
812 if ok.is_ok() { fresh.len() } else { 0 }
813}
814
815pub fn current(home: &Path) -> Vec<Bucket> {
818 backfill(home);
819 let me = node(home);
820 let mut all: BTreeMap<String, Bucket> = rollups(home).into_iter().filter(|(_, b)| b.node != me).collect();
821 all.extend(fold(&me, &events(home)));
822 all.into_values().collect()
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 #[test]
830 fn corrections_and_vetoes_count_per_session_merged_or_not() {
831 let ev = |kind: &str, session: &str, why: &str| Event { kind: kind.into(), session: session.into(), why: why.into(), ..Default::default() };
832 let events = [
833 ev(CORRECTION, "a", "pushback"),
834 ev(CORRECTION, "a", "rejected_tool"),
835 ev(CORRECTION, "a", "regret"),
836 ev(CORRECTION, "a", crate::learn::ANSWER),
837 ev(VETO, "a", "prompt"),
838 ev(VETO, "b", "cli"),
839 ev(CHANGED, "b", ""),
840 ev(CORRECTION, "", "pushback"),
841 ];
842 let got = per_session(&events);
843 assert_eq!(got["a"], Said { corrections: 2, vetoes: 1 }, "an answer to the drone's own question is no correction");
844 assert_eq!(got["b"], Said { corrections: 0, vetoes: 1 });
845 assert_eq!(got.len(), 2);
846 let buckets = fold("n", &events);
848 let b = buckets.values().next().unwrap();
849 assert_eq!((b.counts[CORRECTION], b.counts[ANSWERED]), (4, 1));
850 }
851
852 #[test]
853 fn the_history_derived_from_episodes_tells_answers_apart() {
854 let home = std::env::temp_dir().join(format!("borg-stats-answer-{}", std::process::id()));
855 let _ = std::fs::remove_dir_all(&home);
856 std::fs::create_dir_all(&home).unwrap();
857 let eps = [
858 serde_json::json!({"id":"e1","ms":1,"repo":"r","session":"s","task":"t","agent":"Which backoff?","rejected_tool":null,"correction":"Exponential","via":"answer"}),
859 serde_json::json!({"id":"e2","ms":2,"repo":"r","session":"s","task":"t","agent":"a","rejected_tool":null,"correction":"no, not like that"}),
860 ];
861 std::fs::write(home.join("episodes.jsonl"), eps.iter().map(|e| e.to_string() + "\n").collect::<String>()).unwrap();
862 let whys: Vec<String> = derive_history(&home).into_iter().filter(|e| e.kind == CORRECTION).map(|e| e.why).collect();
863 assert_eq!(whys, [ANSWERED, "pushback"]);
864 let _ = std::fs::remove_dir_all(&home);
865 }
866
867 #[test]
868 fn concurrent_first_calls_agree_on_one_node() {
869 for round in 0..20 {
872 let home = std::env::temp_dir().join(format!("borg-stats-node-{}-{round}", std::process::id()));
873 let _ = std::fs::remove_dir_all(&home);
874 let ids: Vec<String> = std::thread::scope(|s| {
875 let hs: Vec<_> = (0..16).map(|_| s.spawn(|| node(&home))).collect();
876 hs.into_iter().map(|h| h.join().unwrap()).collect()
877 });
878 assert!(ids.iter().all(|id| *id == ids[0] && id.len() == 16), "{ids:?}");
879 assert_eq!(node(&home), ids[0]);
880 let _ = std::fs::remove_dir_all(&home);
881 }
882 }
883
884 fn tmp(tag: &str) -> PathBuf {
885 let d = std::env::temp_dir().join(format!("borg-stats-{tag}-{}", std::process::id()));
886 let _ = std::fs::remove_dir_all(&d);
887 std::fs::create_dir_all(&d).unwrap();
888 d
889 }
890
891 fn ev(kind: &str, ms: i64, engineer: &str, lessons: &[&str], n: u64) -> Event {
892 Event { engineer: engineer.into(), lessons: lessons.iter().map(|s| s.to_string()).collect(), n, ..Event::new(kind, ms) }
893 }
894
895 const DAY: i64 = crate::clock::DAY_MS;
897
898 #[test]
899 fn days_between_fills_every_day() {
900 assert_eq!(days_between("2026-02-27", "2026-03-02"), ["2026-02-27", "2026-02-28", "2026-03-01", "2026-03-02"]);
901 assert_eq!(days_between("1970-01-01", "1970-01-01"), ["1970-01-01"]);
902 assert!(days_between("x", "1970-01-01").is_empty());
903 }
904
905 #[test]
906 fn day_is_utc_date() {
907 assert_eq!(day(0), "1970-01-01");
908 assert_eq!(day(DAY - 1), "1970-01-01");
909 assert_eq!(day(DAY), "1970-01-02");
910 }
911
912 #[test]
913 fn fold_counts_per_engineer_and_day() {
914 let evs = [
915 ev(TURN_END, 10, "a@x", &[], 0),
916 ev(CHECK, 20, "a@x", &[], 7),
917 ev(BLOCK, 30, "a@x", &["L1", "L2"], 2),
918 ev(TURN_END, DAY + 5, "a@x", &[], 0),
919 ev(TURN_END, 40, "b@x", &[], 0),
920 ];
921 let b = fold("n1", &evs);
922 assert_eq!(b.len(), 3, "two days for a, one for b");
923 let a = &b["stat:n1:a@x:1970-01-01"];
924 assert_eq!((a.counts[TURN_END], a.counts[CHECK], a.counts["check_n"], a.counts["block_n"]), (1, 1, 7, 2));
925 assert_eq!(a.lessons["L1"][BLOCK], 1);
926 }
927
928 #[test]
929 fn fold_splits_check_cost_by_mode_and_asks_by_channel() {
930 let with = |kind: &str, usd: f64, mode: &str, channel: &str| Event { usd, mode: mode.into(), channel: channel.into(), ..ev(kind, 10, "a@x", &[], 0) };
931 let evs = [
932 with(CHECK, 0.05, "", ""),
933 with(CHECK, 0.04, "live", ""),
934 with(LIVE_CHECK, 0.03, "live", ""),
935 with(MODEL_CALL, 0.5, "live", ""),
936 with(BLOCK, 0.0, "live", "stop"),
937 with(LIVE_ASK, 0.0, "live", "live"),
938 with(CHANGED, 0.0, "", "live"),
939 ];
940 let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
941 assert!((b.usd_mode["hook"] - 0.05).abs() < 1e-9 && (b.usd_mode["live"] - 0.07).abs() < 1e-9, "{:?}", b.usd_mode);
942 assert_eq!(b.usd_mode.len(), 2, "only the conscience's checks: {:?}", b.usd_mode);
943 assert_eq!((b.channels["block@stop"], b.channels["live_ask@live"], b.channels["changed@live"]), (1, 1, 1));
944 assert_eq!((b.counts[CHECK], b.counts[LIVE_CHECK]), (2, 1), "live checks are counted apart from turn-end checks");
945 }
946
947 #[test]
948 fn fold_counts_skip_and_drop_reasons_failed_checks_and_check_time() {
949 let why = |kind: &str, why: &str, dur_ms: u64| Event { why: why.into(), dur_ms, ..ev(kind, 10, "a@x", &[], 0) };
950 let evs = [
951 why(GATE_SKIP, "noedit", 0),
952 why(GATE_SKIP, "0.12", 0),
953 why(GATE_SKIP, "0.30", 0),
954 why(GATE_SKIP, "empty", 0),
955 why(GATE_SKIP, "live_covered", 0),
956 why(GATE_SKIP, "", 0),
957 why(CHECK, "failed", 4_000),
958 why(CHECK, "", 6_000),
959 why(CHECK, "", 0),
960 why(CHECK_DROPPED, "superseded", 0),
961 why(CHECK_DROPPED, "", 0),
962 Event { mode: crate::check_async::MODE.into(), ..why(LIVE_ASK, "wake", 0) },
963 why(LIVE_ASK, "", 0),
964 ];
965 let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
966 let n = |k: &str| b.counts.get(k).or(b.why.get(k)).copied().unwrap_or(0);
967 assert!(
968 b.counts.keys().all(|k| !k.contains(':') && !k.starts_with("check_ms") && k != "check_timed"),
969 "counts stay as 0.7.8 folds them: {:?}",
970 b.counts
971 );
972 assert_eq!((n("gate_skip:noedit"), n("gate_skip:score"), n("gate_skip:empty"), n("gate_skip:live_covered")), (1, 2, 1, 1), "{:?}", b.counts);
973 assert_eq!(n(GATE_SKIP), 6, "a skip without a reason is counted, with no reason");
974 assert_eq!((n("check:failed"), n("check_dropped:superseded")), (1, 1));
975 assert_eq!((n("check_ms"), n("check_timed")), (10_000, 2), "an untimed check is not averaged");
976 assert_eq!((n(LIVE_ASK), n("live_ask:async")), (2, 1), "a detached check's asks apart from live mode's");
977 assert!(!b.why.keys().any(|k| k.ends_with(':') || k == "gate_skip:other"), "no empty reason: {:?}", b.why);
978 }
979
980 #[test]
981 fn the_async_tally_counts_detached_checks_and_the_jobs_that_never_ran() {
982 let check = |ms: i64, n: u64, why: &str, dur_ms: u64, limit_ms: u64, rss_kb: u64| Event {
983 mode: crate::check_async::MODE.into(),
984 why: why.into(),
985 dur_ms,
986 limit_ms,
987 rss_kb,
988 usd: 0.01,
989 ..ev(CHECK, ms, "a@x", &[], n)
990 };
991 let dropped = |why: &str| Event { why: why.into(), mode: crate::check_async::MODE.into(), ..ev(CHECK_DROPPED, 5, "a@x", &[], 0) };
992 let evs = [
993 check(10, 40, "", 9_000, 45_000, 30_000),
994 check(30, 20, "failed", 44_500, 45_000, 52_000),
995 check(20, 30, "failed", 2_000, 60_000, 31_000),
996 ev(CHECK, 40, "a@x", &[], 99),
997 dropped("superseded"),
998 dropped("superseded"),
999 dropped("gone"),
1000 ];
1001 let t = async_tally(&evs);
1002 assert_eq!((t.checks, t.failed, t.timed_out, t.superseded, t.gone), (3, 2, 1, 2, 1), "{t:?}");
1003 assert_eq!((t.limit_ms, t.rss_kb), (45_000, 52_000), "the latest limit, the largest peak");
1004 assert!((t.lessons - 30.0).abs() < 1e-9 && (t.usd - 0.03).abs() < 1e-9, "a stop's own check is not counted: {t:?}");
1005 assert_eq!(async_tally(&[]), AsyncTally::default());
1006 let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
1008 assert_eq!((b.counts[CHECK], b.counts[CHECK_DROPPED]), (4, 3));
1009 assert!((b.usd_mode["async"] - 0.03).abs() < 1e-9, "{:?}", b.usd_mode);
1010 }
1011
1012 #[test]
1013 fn rollup_versions_only_on_change_and_is_idempotent() {
1014 let home = tmp("rollup");
1015 record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
1016 assert_eq!(rollup(&home), 1);
1017 assert_eq!(rollup(&home), 0, "nothing changed: nothing appended");
1018 record(&home, &ev(GATE_SKIP, 11, "a@x", &[], 0));
1019 assert_eq!(rollup(&home), 1);
1020 let r = rollups(&home);
1021 let b = r.values().next().unwrap();
1022 assert_eq!((b.version, b.counts[TURN_END], b.counts[GATE_SKIP]), (2, 1, 1));
1023 let text = std::fs::read_to_string(rollups_path(&home)).unwrap();
1024 assert!(!text.contains("session"), "no session ids leave the machine");
1025 }
1026
1027 #[test]
1032 fn reasons_publish_once_beside_an_older_borgs_buckets() {
1033 let home = tmp("rollup-why");
1034 record(&home, &Event { why: "noedit".into(), ..ev(GATE_SKIP, 10, "a@x", &[], 0) });
1035 record(&home, &Event { why: "failed".into(), dur_ms: 900, ..ev(CHECK, 11, "a@x", &[], 0) });
1036 let mut older = fold(&node(&home), &events(&home)).into_values().next().unwrap();
1037 older.why.clear();
1038 older.version = 1;
1039 std::fs::write(rollups_path(&home), serde_json::to_string(&older).unwrap() + "\n").unwrap();
1040 assert_eq!(rollup(&home), 1, "the reasons are published once");
1041 assert_eq!(rollup(&home), 0, "then nothing changes");
1042 let mine = rollups(&home).into_values().next().unwrap();
1043 assert_eq!((mine.version, &mine.counts), (2, &older.counts), "counts stay as the older borg folds them");
1044 assert_eq!((mine.why["gate_skip:noedit"], mine.why["check:failed"]), (1, 1));
1045 }
1046
1047 #[test]
1048 fn current_prefers_own_fresh_counts_and_keeps_peers() {
1049 let home = tmp("current");
1050 record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
1051 rollup(&home);
1052 record(&home, &ev(TURN_END, 12, "a@x", &[], 0));
1053 let peer = Bucket {
1054 id: "stat:other:b@x:1970-01-01".into(),
1055 version: 3,
1056 day: "1970-01-01".into(),
1057 node: "other".into(),
1058 engineer: "b@x".into(),
1059 ..Default::default()
1060 };
1061 let mut f = std::fs::OpenOptions::new().append(true).open(rollups_path(&home)).unwrap();
1062 writeln!(f, "{}", serde_json::to_string(&peer).unwrap()).unwrap();
1063 let cur = current(&home);
1064 assert_eq!(cur.len(), 2);
1065 let mine = cur.iter().find(|b| b.engineer == "a@x").unwrap();
1066 assert_eq!(mine.counts[TURN_END], 2, "own counts are folded fresh, not the stale rollup");
1067 }
1068
1069 #[test]
1072 fn rollups_break_version_ties_by_hash_whatever_the_order() {
1073 let bucket = |n: u64| {
1074 let mut b = Bucket { id: "stat:n:a@x:1970-01-01".into(), version: 2, ..Default::default() };
1075 b.counts.insert(TURN_END.into(), n);
1076 serde_json::to_string(&b).unwrap()
1077 };
1078 let (a, b) = (bucket(1), bucket(2));
1079 let shown = |name: &str, first: &str, second: &str| {
1080 let home = tmp(name);
1081 std::fs::write(rollups_path(&home), format!("{first}\n{second}\n")).unwrap();
1082 rollups(&home).into_values().next().unwrap().counts[TURN_END]
1083 };
1084 assert_eq!(shown("ties-ab", &a, &b), shown("ties-ba", &b, &a));
1085 }
1086
1087 #[test]
1088 fn record_fails_open() {
1089 let home = tmp("open");
1090 std::fs::write(home.join("stats"), "a file where the directory should be").unwrap();
1091 record(&home, &ev(TURN_END, 1, "a@x", &[], 0));
1092 assert!(events(&home).is_empty());
1093 }
1094
1095 #[test]
1096 fn backfill_derives_history_before_live_counting_once() {
1097 let home = tmp("backfill");
1098 let w = |f: &str, lines: &[serde_json::Value]| {
1099 let p = home.join(f);
1100 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1101 std::fs::write(p, lines.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
1102 };
1103 use serde_json::json;
1104 w(
1105 "episodes.jsonl",
1106 &[
1107 json!({"id":"e1","ms":100,"repo":"app","session":"s1","task":"t","agent":"a","rejected_tool":"Bash","correction":"no, not like that","engineer":"a@x"}),
1108 json!({"id":"e2","ms":5000,"repo":"app","session":"s2","task":"t","agent":"a","rejected_tool":null,"correction":"after live counting began","engineer":"a@x"}),
1109 ],
1110 );
1111 w(
1112 "outcomes.jsonl",
1113 &[
1114 json!({"kind":"asked","session":"s1","lessons":["L1","L2"],"ms":110,"repo":"app"}),
1115 json!({"kind":"asked","session":"s1","lessons":["L3"],"ms":112,"repo":"app","channel":"early"}),
1117 json!({"kind":"asked","session":"s1","lessons":["L4"],"ms":114,"repo":"app","channel":"queued"}),
1118 json!({"kind":"asked_then_changed","session":"s1","lessons":["L1"],"ms":120,"repo":"app"}),
1119 ],
1120 );
1121 w(
1122 "lessons.jsonl",
1123 &[json!({"id":"L1","origin_ms":90,"question":"q","version":1,"engineers":["a@x"]}), json!({"id":"L1","origin_ms":90,"question":"q2","version":2})],
1124 );
1125 w("queen/queue.jsonl", &[json!({"id":"Q1","ms":130,"question":"which?","engineer":"a@x","answered_ms":190,"answer":"this","answered_by":"a@x"})]);
1126 record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
1127 let n = backfill(&home);
1128 let all = read_events(&backfill_path(&home));
1129 assert_eq!(n, all.len());
1130 let kinds: Vec<&str> = all.iter().map(|e| e.kind.as_str()).collect();
1131 assert_eq!(kinds, [LESSON_NEW, CORRECTION, BLOCK, CHANGED, QUEEN_QUEUED, QUEEN_ANSWERED], "ordered by time, the later correction left out");
1132 let c = &all[1];
1133 assert_eq!((c.why.as_str(), c.text.as_str(), c.engineer.as_str()), ("rejected_tool", "Bash: no, not like that", "a@x"));
1134 assert_eq!(all[3].engineer, "a@x", "outcomes take the session's engineer from its corrections");
1135 assert_eq!(all[5].dur_ms, 60, "time to answer");
1136 assert_eq!(backfill(&home), 0, "once");
1137 assert_eq!(events(&home).len(), all.len() + 1, "history and live events together");
1138 }
1139
1140 #[test]
1145 fn backfill_counts_one_event_per_call_as_live_counting_does() {
1146 let home = tmp("backfill-call");
1147 use serde_json::json;
1148 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});
1149 let log = [
1150 rec("asked", &["L1", "L2"], 110, "stop", "c1"),
1151 rec("asked", &["L3"], 110, "stop", "c2"),
1152 rec("asked", &["L4"], 120, "live", "c3"),
1153 rec("asked", &["L5"], 120, "live", "c4"),
1154 rec("asked_then_changed", &["L1"], 130, "stop", "c1"),
1155 rec("asked_then_changed", &["L3"], 130, "stop", "c2"),
1156 rec("asked", &["L6"], 140, "stop", "c5"),
1157 rec("asked_not_corrected", &["L2"], 150, "stop", "c1"),
1158 rec("asked_not_corrected", &["L6"], 150, "stop", "c5"),
1159 ];
1160 std::fs::write(home.join("outcomes.jsonl"), log.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
1161 record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
1162 backfill(&home);
1163 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();
1164 let row = |kind: &str, ms: i64, n: u64, lessons: &[&str]| (kind.to_string(), ms, n, lessons.iter().map(|l| l.to_string()).collect::<Vec<_>>());
1165 assert_eq!(
1166 all,
1167 [
1168 row(BLOCK, 110, 3, &["L1", "L2", "L3"]),
1169 row(LIVE_ASK, 120, 2, &["L4", "L5"]),
1170 row(CHANGED, 130, 0, &["L1", "L3"]),
1171 row(BLOCK, 140, 1, &["L6"]),
1172 row(NOT_CORRECTED, 150, 0, &["L2"]),
1173 row(NOT_CORRECTED, 150, 0, &["L6"]),
1174 ]
1175 );
1176 }
1177
1178 #[test]
1179 fn node_id_is_stable() {
1180 let home = tmp("node");
1181 let a = node(&home);
1182 assert_eq!(a.len(), 16);
1183 assert_eq!(node(&home), a);
1184 }
1185}