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";
26pub const BLOCK: &str = "block";
28pub const LIVE_CHECK: &str = "live_check";
30pub const LIVE_ASK: &str = "live_ask";
33pub const EARLY: &str = "early";
35pub const QUEUED_FREE: &str = "queued_free";
37pub const CHANGED: &str = "changed";
39pub const IGNORED: &str = "ignored";
40pub const CORRECTED: &str = "corrected";
41pub const NOT_CORRECTED: &str = "not_corrected";
42pub const REGRETTED: &str = "regretted";
45pub const TESTED: &str = "tested";
48pub const CORRECTION: &str = "correction";
53pub const ANSWERED: &str = "answer";
56pub const VETO: &str = "veto";
59pub const VETO_REPEAT: &str = "veto_repeat";
62pub const LESSON_NEW: &str = "lesson_new";
64pub const LESSON_MERGED: &str = "lesson_merged";
65pub const REVIEW_FINDING: &str = "review_finding";
68pub const NOT_A_LESSON: &str = "not_a_lesson";
70pub const MODEL_CALL: &str = "model_call";
73pub const STEER_ISSUED: &str = "steer_issued";
76pub const STEER_DELIVERED: &str = "steer_delivered";
77pub const STEER_DROPPED: &str = "steer_dropped";
78pub const PAUSE_REFUSED: &str = "pause_refused";
79pub const MSG_SENT: &str = "msg_sent";
83pub const MSG_DELIVERED: &str = "msg_delivered";
84pub const MSG_DROPPED: &str = "msg_dropped";
85pub const QUEEN_QUEUED: &str = "queen_queued";
88pub const QUEEN_DELIVERED: &str = "queen_delivered";
89pub const QUEEN_ANSWERED: &str = "queen_answered";
90pub const QUEEN_SKIPPED: &str = "queen_skipped";
91pub const QUEEN_DISMISSED: &str = "queen_dismissed";
92pub const QUEEN_NO: &str = "queen_no";
96pub const QUEEN_STAMP: &str = "queen_stamp";
97pub const QUEEN_MISSED: &str = "queen_missed";
98pub const ASSIMILATED: &str = "assimilated";
100
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102pub struct Event {
103 pub ms: i64,
104 pub kind: String,
105 #[serde(default, skip_serializing_if = "String::is_empty")]
106 pub session: String,
107 #[serde(default, skip_serializing_if = "String::is_empty")]
108 pub engineer: String,
109 #[serde(default, skip_serializing_if = "String::is_empty")]
110 pub repo: String,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub lessons: Vec<String>,
113 #[serde(default, skip_serializing_if = "is_zero")]
114 pub n: u64,
115 #[serde(default, skip_serializing_if = "is_zero_f")]
116 pub usd: f64,
117 #[serde(default, skip_serializing_if = "is_zero")]
118 pub dur_ms: u64,
119 #[serde(default, skip_serializing_if = "String::is_empty")]
120 pub why: String,
121 #[serde(default, skip_serializing_if = "String::is_empty")]
123 pub text: String,
124 #[serde(default, skip_serializing_if = "String::is_empty")]
126 pub mode: String,
127 #[serde(default, skip_serializing_if = "String::is_empty")]
130 pub channel: String,
131 #[serde(default, skip_serializing_if = "String::is_empty")]
135 pub source: String,
136}
137
138pub fn source(harness: &str, version: &str) -> String {
141 format!("{harness} {version}").trim().to_string()
142}
143
144fn is_zero(v: &u64) -> bool {
145 *v == 0
146}
147
148fn is_zero_f(v: &f64) -> bool {
149 *v == 0.0
150}
151
152impl Event {
153 pub fn new(kind: &str, ms: i64) -> Self {
155 let mode = std::env::var("BORG_MODE").map(|_| crate::live::current().name().to_string()).unwrap_or_default();
156 Event { kind: kind.into(), ms, mode, ..Default::default() }
157 }
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
162pub struct Bucket {
163 pub id: String,
164 pub version: u64,
165 pub day: String,
166 pub node: String,
167 pub engineer: String,
168 pub counts: BTreeMap<String, u64>,
170 pub usd: f64,
171 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
173 pub usd_by: BTreeMap<String, f64>,
174 pub dur_ms: u64,
175 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
177 pub lessons: BTreeMap<String, BTreeMap<String, u64>>,
178 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
181 pub usd_mode: BTreeMap<String, f64>,
182 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
184 pub channels: BTreeMap<String, u64>,
185}
186
187impl Bucket {
188 fn same(&self, other: &Bucket) -> bool {
190 self.counts == other.counts
191 && self.lessons == other.lessons
192 && self.channels == other.channels
193 && self.dur_ms == other.dur_ms
194 && (self.usd - other.usd).abs() < 1e-9
195 }
196
197 pub fn add(&mut self, other: &Bucket) {
199 for (k, v) in &other.counts {
200 *self.counts.entry(k.clone()).or_default() += v;
201 }
202 for (id, kinds) in &other.lessons {
203 let mine = self.lessons.entry(id.clone()).or_default();
204 for (k, v) in kinds {
205 *mine.entry(k.clone()).or_default() += v;
206 }
207 }
208 for (k, v) in &other.usd_by {
209 *self.usd_by.entry(k.clone()).or_default() += v;
210 }
211 for (k, v) in &other.usd_mode {
212 *self.usd_mode.entry(k.clone()).or_default() += v;
213 }
214 for (k, v) in &other.channels {
215 *self.channels.entry(k.clone()).or_default() += v;
216 }
217 self.usd += other.usd;
218 self.dur_ms += other.dur_ms;
219 }
220}
221
222pub fn events_path(home: &Path) -> PathBuf {
223 home.join("stats").join("events.jsonl")
224}
225
226pub fn rollups_path(home: &Path) -> PathBuf {
227 home.join("stats.jsonl")
228}
229
230pub fn record(home: &Path, ev: &Event) {
232 if !crate::learn::learning() {
233 return;
234 }
235 let _ = crate::learn::append_jsonl(&events_path(home), ev);
236}
237
238pub fn count(home: &Path, kind: &str, session: &str, fill: impl FnOnce(&mut Event)) {
241 let mut ev = Event::new(kind, crate::learn::now_ms());
242 ev.session = session.into();
243 fill(&mut ev);
244 if ev.engineer.is_empty() && !session.is_empty() {
245 ev.engineer = crate::presence::read(home, session).map(|p| p.engineer).unwrap_or_default();
246 }
247 record(home, &ev);
248}
249
250pub const FEED_CHARS: usize = 160;
252
253pub fn excerpt(text: &str, max: usize) -> String {
255 let one = text.split_whitespace().collect::<Vec<_>>().join(" ");
256 match one.char_indices().nth(max) {
257 Some((i, _)) => format!("{}…", &one[..i]),
258 None => one,
259 }
260}
261
262static PURPOSE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
263
264pub fn set_purpose(purpose: &'static str) {
266 let _ = PURPOSE.set(purpose);
267}
268
269pub fn purpose() -> &'static str {
270 PURPOSE.get().copied().unwrap_or("model")
271}
272
273fn read_events(path: &Path) -> Vec<Event> {
274 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
275}
276
277pub fn events(home: &Path) -> Vec<Event> {
279 let mut all = read_events(&backfill_path(home));
280 all.extend(read_events(&events_path(home)));
281 all
282}
283
284pub fn live(home: &Path) -> Vec<Event> {
286 read_events(&events_path(home))
287}
288
289pub fn live_since(home: &Path) -> Option<i64> {
293 live(home).iter().filter(|e| e.kind != VETO_REPEAT).map(|e| e.ms).min()
294}
295
296pub fn backfill_path(home: &Path) -> PathBuf {
297 home.join("stats").join("backfill.jsonl")
298}
299
300fn lines(path: &Path) -> Vec<serde_json::Value> {
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 backfill(home: &Path) -> usize {
310 let path = backfill_path(home);
311 if path.exists() {
312 return 0;
313 }
314 let cutoff = live_since(home).unwrap_or_else(crate::learn::now_ms);
315 let mut out = derive_history(home);
316 out.retain(|e| e.ms > 0 && e.ms < cutoff);
317 out.sort_by_key(|e| e.ms);
318 let text: String = out.iter().map(|e| serde_json::to_string(e).unwrap_or_default() + "\n").collect();
319 if std::fs::create_dir_all(path.parent().unwrap_or(home)).is_err() || std::fs::write(&path, text).is_err() {
320 return 0;
321 }
322 out.len()
323}
324
325fn derive_history(home: &Path) -> Vec<Event> {
326 use serde_json::Value;
327 let s = |v: &Value, k: &str| v[k].as_str().unwrap_or_default().to_string();
328 let ms = |v: &Value, k: &str| v[k].as_i64().unwrap_or(0);
329 let ids = |v: &Value, k: &str| v[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(String::from).collect::<Vec<_>>();
330 let mut out = Vec::new();
331 let mut who: std::collections::HashMap<String, String> = std::collections::HashMap::new();
333 for e in lines(&home.join("episodes.jsonl")) {
334 let tool = s(&e, "rejected_tool");
335 if !s(&e, "engineer").is_empty() {
336 who.insert(s(&e, "session"), s(&e, "engineer"));
337 }
338 let snippet = excerpt(&s(&e, "correction"), FEED_CHARS);
339 if s(&e, "kind") == crate::veto::KIND {
341 out.push(Event {
342 ms: ms(&e, "ms"),
343 kind: VETO.into(),
344 session: s(&e, "session"),
345 engineer: s(&e, "engineer"),
346 repo: s(&e, "repo"),
347 text: snippet,
348 ..Default::default()
349 });
350 continue;
351 }
352 let answer = s(&e, "via") == crate::learn::ANSWER;
353 out.push(Event {
354 ms: ms(&e, "ms"),
355 kind: CORRECTION.into(),
356 session: s(&e, "session"),
357 engineer: s(&e, "engineer"),
358 repo: s(&e, "repo"),
359 why: if answer {
360 ANSWERED.into()
361 } else if tool.is_empty() {
362 "pushback".into()
363 } else {
364 "rejected_tool".into()
365 },
366 text: if tool.is_empty() || answer { snippet } else { format!("{tool}: {snippet}") },
367 source: source(&s(&e, "harness"), &s(&e, "harness_version")),
368 ..Default::default()
369 });
370 }
371 let engineer = |session: &str| who.get(session).cloned().unwrap_or_default();
372 let mut call: std::collections::HashMap<(String, &str, String, i64), usize> = std::collections::HashMap::new();
376 for o in lines(&home.join("outcomes.jsonl")) {
377 if o["channel"] == crate::outcome::PLACEBO {
379 continue;
380 }
381 let live = o["channel"] == "live";
382 let kind = match o["kind"].as_str().unwrap_or_default() {
383 crate::outcome::ASKED if o["channel"] == "early" || o["channel"] == "queued" => continue,
385 crate::outcome::ASKED if live => LIVE_ASK,
386 crate::outcome::ASKED => BLOCK,
387 crate::outcome::CHANGED => CHANGED,
388 crate::outcome::IGNORED => IGNORED,
389 crate::outcome::CORRECTED => CORRECTED,
390 crate::outcome::NOT_CORRECTED => NOT_CORRECTED,
391 crate::outcome::REGRETTED => REGRETTED,
392 crate::outcome::TESTED => TESTED,
393 crate::outcome::UNCLEAR | crate::outcome::REMAINS | crate::outcome::NOT_ARISEN => continue,
395 _ => continue,
396 };
397 let lessons = ids(&o, "lessons");
398 let session = s(&o, "session");
399 let asks = kind == BLOCK || kind == LIVE_ASK;
400 let key = (session.clone(), kind, s(&o, "channel"), ms(&o, "ms"));
401 let merges = [BLOCK, LIVE_ASK, CHANGED, IGNORED].contains(&kind);
402 if merges
403 && let Some(&i) = call.get(&key)
404 && let Some(e) = out.get_mut(i)
405 {
406 e.n += if asks { lessons.len() as u64 } else { 0 };
407 e.lessons.extend(lessons);
408 continue;
409 }
410 if merges {
411 call.insert(key, out.len());
412 }
413 out.push(Event {
414 ms: ms(&o, "ms"),
415 kind: kind.into(),
416 engineer: engineer(&session),
417 session,
418 repo: s(&o, "repo"),
419 n: if asks { lessons.len() as u64 } else { 0 },
420 lessons,
421 channel: s(&o, "channel"),
422 ..Default::default()
423 });
424 }
425 let mut seen = std::collections::HashSet::new();
427 for l in lines(&home.join("lessons.jsonl")) {
428 let id = s(&l, "id");
429 if id.is_empty() || !seen.insert(id.clone()) {
430 continue;
431 }
432 let source = Some(s(&l, "source")).filter(|x| !x.is_empty()).unwrap_or_else(|| "session".into());
433 out.push(Event {
434 ms: ms(&l, "origin_ms"),
435 kind: LESSON_NEW.into(),
436 engineer: ids(&l, "engineers").into_iter().next().unwrap_or_default(),
437 repo: s(&l, "repo"),
438 lessons: vec![id],
439 why: source,
440 ..Default::default()
441 });
442 }
443 let mut queen: BTreeMap<String, Value> = BTreeMap::new();
444 for q in lines(&home.join("queen").join("queue.jsonl")) {
445 queen.insert(s(&q, "id"), q);
446 }
447 for q in queen.values() {
448 let base = || Event { engineer: s(q, "engineer"), session: s(q, "session"), repo: s(q, "repo"), lessons: vec![s(q, "id")], ..Default::default() };
449 out.push(Event { ms: ms(q, "ms"), kind: QUEEN_QUEUED.into(), text: excerpt(&s(q, "question"), FEED_CHARS), ..base() });
450 if let Some(at) = q["answered_ms"].as_i64() {
451 let dur = u64::try_from(at - ms(q, "ms")).unwrap_or(0);
452 out.push(Event {
453 ms: at,
454 kind: QUEEN_ANSWERED.into(),
455 dur_ms: dur,
456 engineer: s(q, "answered_by"),
457 text: excerpt(&s(q, "answer"), FEED_CHARS),
458 ..base()
459 });
460 }
461 if let Some(at) = q["dismissed_ms"].as_i64() {
462 out.push(Event { ms: at, kind: QUEEN_DISMISSED.into(), text: s(q, "dismissed"), ..base() });
463 }
464 }
465 for (file, kind) in [("delivered.jsonl", QUEEN_DELIVERED), ("skips.jsonl", QUEEN_SKIPPED)] {
466 for m in lines(&home.join("queen").join(file)) {
467 let session = s(&m, "session");
468 out.push(Event { ms: ms(&m, "ms"), kind: kind.into(), engineer: engineer(&session), session, lessons: vec![s(&m, "id")], ..Default::default() });
469 }
470 }
471 for a in lines(&home.join("assimilated.jsonl")) {
472 out.push(Event {
473 ms: ms(&a, "ms"),
474 kind: ASSIMILATED.into(),
475 repo: s(&a, "source"),
476 n: ids(&a, "lessons").len() as u64,
477 usd: a["cost_usd"].as_f64().unwrap_or(0.0),
478 ..Default::default()
479 });
480 }
481 for (file, why) in [("experts.jsonl", "expert"), ("facets.jsonl", "facets")] {
482 for c in lines(&home.join(file)) {
483 let session = s(&c, "session");
484 out.push(Event {
485 ms: ms(&c, "ms"),
486 kind: MODEL_CALL.into(),
487 engineer: engineer(&session),
488 session,
489 repo: s(&c, "repo"),
490 usd: c["cost_usd"].as_f64().unwrap_or(0.0),
491 why: why.into(),
492 ..Default::default()
493 });
494 }
495 }
496 for (file, kind) in [(home.join("mesh").join("directives.jsonl"), STEER_ISSUED), (home.join("steer").join("inbox.jsonl"), STEER_DELIVERED)] {
499 for d in lines(&file) {
500 out.push(Event {
501 ms: ms(&d, "ms"),
502 kind: kind.into(),
503 engineer: s(&d, "engineer"),
504 why: s(&d, "kind"),
505 text: excerpt(&s(&d, "text"), FEED_CHARS),
506 ..Default::default()
507 });
508 }
509 }
510 for d in lines(&crate::steer::dropped_path(home)) {
511 out.push(Event { ms: ms(&d, "ms"), kind: STEER_DROPPED.into(), engineer: s(&d, "engineer"), why: s(&d, "why"), ..Default::default() });
512 }
513 out.extend(turns_from_state(&home.join("state"), &who));
514 out
515}
516
517fn turns_from_state(dir: &Path, who: &std::collections::HashMap<String, String>) -> Vec<Event> {
520 let mut out = Vec::new();
521 let Ok(rd) = std::fs::read_dir(dir) else { return out };
522 for f in rd.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "ctx")) {
523 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 {
524 continue;
525 };
526 let session = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
527 let base =
528 || Event { ms: at.as_millis() as i64, session: session.clone(), engineer: who.get(&session).cloned().unwrap_or_default(), ..Default::default() };
529 for line in std::fs::read_to_string(&f).unwrap_or_default().lines() {
530 if let Some(score) = line.strip_prefix("#borg-skip ") {
531 out.push(Event { kind: TURN_END.into(), ..base() });
532 out.push(Event { kind: GATE_SKIP.into(), why: score.trim().into(), ..base() });
533 } else if let Some(cost) = line.strip_prefix("#borg-predict-cost ") {
534 out.push(Event { kind: TURN_END.into(), ..base() });
535 out.push(Event { kind: CHECK.into(), usd: cost.trim().parse().unwrap_or(0.0), ..base() });
536 }
537 }
538 }
539 out
540}
541
542pub fn node(home: &Path) -> String {
545 let path = home.join("stats").join("node");
546 if let Ok(id) = std::fs::read_to_string(&path)
547 && !id.trim().is_empty()
548 {
549 return id.trim().to_string();
550 }
551 let id = fresh_id();
555 let _ = std::fs::create_dir_all(path.parent().unwrap_or(home));
556 let tmp = path.with_extension(format!("{id}.tmp"));
557 let linked = std::fs::write(&tmp, &id).and_then(|()| std::fs::hard_link(&tmp, &path));
558 let _ = std::fs::remove_file(&tmp);
559 match linked {
560 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
561 std::fs::read_to_string(&path).map(|s| s.trim().to_string()).ok().filter(|s| !s.is_empty()).unwrap_or(id)
562 }
563 _ => id,
564 }
565}
566
567fn fresh_id() -> String {
568 let mut bytes = [0u8; 8];
569 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
570 use std::io::Read;
571 let _ = f.read_exact(&mut bytes);
572 }
573 if bytes == [0; 8] {
574 use std::hash::{BuildHasher, Hasher};
575 let mut h = std::collections::hash_map::RandomState::new().build_hasher();
576 h.write_i64(crate::learn::now_ms());
577 h.write_u32(std::process::id());
578 bytes = h.finish().to_le_bytes();
579 }
580 bytes.iter().map(|b| format!("{b:02x}")).collect()
581}
582
583pub fn day(ms: i64) -> String {
585 let s = crate::install::utc(ms);
586 format!("{}-{}-{}", &s[0..4], &s[4..6], &s[6..8])
587}
588
589pub fn days_between(first: &str, last: &str) -> Vec<String> {
591 let (Some(a), Some(b)) = (day_number(first), day_number(last)) else { return Vec::new() };
592 (a..=b).map(|d| day(d * crate::clock::DAY_MS)).collect()
593}
594
595fn day_number(date: &str) -> Option<i64> {
597 let mut it = date.split('-').map(|p| p.parse::<i64>().ok());
598 let (y, m, d) = (it.next()??, it.next()??, it.next()??);
599 Some(crate::clock::days_from_civil(y, m, d))
600}
601
602#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
604pub struct Said {
605 pub corrections: u64,
609 pub vetoes: u64,
611}
612
613pub fn per_session(events: &[Event]) -> BTreeMap<String, Said> {
617 let mut out: BTreeMap<String, Said> = BTreeMap::new();
618 for e in events.iter().filter(|e| !e.session.is_empty()) {
619 match e.kind.as_str() {
620 CORRECTION if e.why != "regret" && e.why != crate::learn::ANSWER => out.entry(e.session.clone()).or_default().corrections += 1,
621 VETO => out.entry(e.session.clone()).or_default().vetoes += 1,
622 _ => {}
623 }
624 }
625 out
626}
627
628pub fn fold(node: &str, events: &[Event]) -> BTreeMap<String, Bucket> {
630 let mut out: BTreeMap<String, Bucket> = BTreeMap::new();
631 for e in events {
632 let day = day(e.ms);
633 let id = format!("stat:{node}:{}:{day}", e.engineer);
634 let b = out.entry(id.clone()).or_insert_with(|| Bucket { id, day, node: node.into(), engineer: e.engineer.clone(), ..Default::default() });
635 let kind = if e.kind == CORRECTION && e.why == crate::learn::ANSWER { ANSWERED } else { e.kind.as_str() };
637 *b.counts.entry(kind.to_string()).or_default() += 1;
638 if e.n > 0 {
639 *b.counts.entry(format!("{}_n", e.kind)).or_default() += e.n;
640 }
641 b.usd += e.usd;
642 if e.usd > 0.0 {
643 *b.usd_by.entry(e.kind.clone()).or_default() += e.usd;
644 }
645 if e.usd > 0.0 && (e.kind == CHECK || e.kind == LIVE_CHECK) {
647 let mode = if e.mode.is_empty() { "hook" } else { e.mode.as_str() };
648 *b.usd_mode.entry(mode.into()).or_default() += e.usd;
649 }
650 if !e.channel.is_empty() {
651 *b.channels.entry(format!("{}@{}", e.kind, e.channel)).or_default() += 1;
652 }
653 b.dur_ms += e.dur_ms;
654 for l in &e.lessons {
655 *b.lessons.entry(l.clone()).or_default().entry(e.kind.clone()).or_default() += 1;
656 }
657 }
658 out
659}
660
661pub fn rollups(home: &Path) -> BTreeMap<String, Bucket> {
665 let mut out: BTreeMap<String, (Bucket, [u8; 32])> = BTreeMap::new();
666 for l in std::fs::read_to_string(rollups_path(home)).unwrap_or_default().lines() {
667 let Ok(b) = serde_json::from_str::<Bucket>(l) else { continue };
668 let h = *blake3::hash(l.as_bytes()).as_bytes();
669 if out.get(&b.id).is_none_or(|(old, oh)| (b.version, h) > (old.version, *oh)) {
670 out.insert(b.id.clone(), (b, h));
671 }
672 }
673 out.into_iter().map(|(id, (b, _))| (id, b)).collect()
674}
675
676pub fn rollup(home: &Path) -> usize {
679 backfill(home);
680 let evs = events(home);
682 if evs.is_empty() {
683 return 0;
684 }
685 let me = node(home);
686 let published = rollups(home);
687 let personal = crate::reach::personal_ids(home);
689 let fresh: Vec<Bucket> = fold(&me, &evs)
690 .into_values()
691 .filter_map(|mut b| {
692 b.lessons.retain(|id, _| !personal.contains(id));
693 let old = published.get(&b.id);
694 if old.is_some_and(|o| o.same(&b)) {
695 return None;
696 }
697 b.version = old.map_or(1, |o| o.version + 1);
698 Some(b)
699 })
700 .collect();
701 if fresh.is_empty() {
702 return 0;
703 }
704 let lines: String = fresh.iter().map(|b| serde_json::to_string(b).unwrap_or_default() + "\n").collect();
705 let path = rollups_path(home);
706 let ok = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| f.write_all(lines.as_bytes()));
707 if ok.is_ok() { fresh.len() } else { 0 }
708}
709
710pub fn current(home: &Path) -> Vec<Bucket> {
713 backfill(home);
714 let me = node(home);
715 let mut all: BTreeMap<String, Bucket> = rollups(home).into_iter().filter(|(_, b)| b.node != me).collect();
716 all.extend(fold(&me, &events(home)));
717 all.into_values().collect()
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn corrections_and_vetoes_count_per_session_merged_or_not() {
726 let ev = |kind: &str, session: &str, why: &str| Event { kind: kind.into(), session: session.into(), why: why.into(), ..Default::default() };
727 let events = [
728 ev(CORRECTION, "a", "pushback"),
729 ev(CORRECTION, "a", "rejected_tool"),
730 ev(CORRECTION, "a", "regret"),
731 ev(CORRECTION, "a", crate::learn::ANSWER),
732 ev(VETO, "a", "prompt"),
733 ev(VETO, "b", "cli"),
734 ev(CHANGED, "b", ""),
735 ev(CORRECTION, "", "pushback"),
736 ];
737 let got = per_session(&events);
738 assert_eq!(got["a"], Said { corrections: 2, vetoes: 1 }, "an answer to the drone's own question is no correction");
739 assert_eq!(got["b"], Said { corrections: 0, vetoes: 1 });
740 assert_eq!(got.len(), 2);
741 let buckets = fold("n", &events);
743 let b = buckets.values().next().unwrap();
744 assert_eq!((b.counts[CORRECTION], b.counts[ANSWERED]), (4, 1));
745 }
746
747 #[test]
748 fn the_history_derived_from_episodes_tells_answers_apart() {
749 let home = std::env::temp_dir().join(format!("borg-stats-answer-{}", std::process::id()));
750 let _ = std::fs::remove_dir_all(&home);
751 std::fs::create_dir_all(&home).unwrap();
752 let eps = [
753 serde_json::json!({"id":"e1","ms":1,"repo":"r","session":"s","task":"t","agent":"Which backoff?","rejected_tool":null,"correction":"Exponential","via":"answer"}),
754 serde_json::json!({"id":"e2","ms":2,"repo":"r","session":"s","task":"t","agent":"a","rejected_tool":null,"correction":"no, not like that"}),
755 ];
756 std::fs::write(home.join("episodes.jsonl"), eps.iter().map(|e| e.to_string() + "\n").collect::<String>()).unwrap();
757 let whys: Vec<String> = derive_history(&home).into_iter().filter(|e| e.kind == CORRECTION).map(|e| e.why).collect();
758 assert_eq!(whys, [ANSWERED, "pushback"]);
759 let _ = std::fs::remove_dir_all(&home);
760 }
761
762 #[test]
763 fn concurrent_first_calls_agree_on_one_node() {
764 for round in 0..20 {
767 let home = std::env::temp_dir().join(format!("borg-stats-node-{}-{round}", std::process::id()));
768 let _ = std::fs::remove_dir_all(&home);
769 let ids: Vec<String> = std::thread::scope(|s| {
770 let hs: Vec<_> = (0..16).map(|_| s.spawn(|| node(&home))).collect();
771 hs.into_iter().map(|h| h.join().unwrap()).collect()
772 });
773 assert!(ids.iter().all(|id| *id == ids[0] && id.len() == 16), "{ids:?}");
774 assert_eq!(node(&home), ids[0]);
775 let _ = std::fs::remove_dir_all(&home);
776 }
777 }
778
779 fn tmp(tag: &str) -> PathBuf {
780 let d = std::env::temp_dir().join(format!("borg-stats-{tag}-{}", std::process::id()));
781 let _ = std::fs::remove_dir_all(&d);
782 std::fs::create_dir_all(&d).unwrap();
783 d
784 }
785
786 fn ev(kind: &str, ms: i64, engineer: &str, lessons: &[&str], n: u64) -> Event {
787 Event { engineer: engineer.into(), lessons: lessons.iter().map(|s| s.to_string()).collect(), n, ..Event::new(kind, ms) }
788 }
789
790 const DAY: i64 = crate::clock::DAY_MS;
792
793 #[test]
794 fn days_between_fills_every_day() {
795 assert_eq!(days_between("2026-02-27", "2026-03-02"), ["2026-02-27", "2026-02-28", "2026-03-01", "2026-03-02"]);
796 assert_eq!(days_between("1970-01-01", "1970-01-01"), ["1970-01-01"]);
797 assert!(days_between("x", "1970-01-01").is_empty());
798 }
799
800 #[test]
801 fn day_is_utc_date() {
802 assert_eq!(day(0), "1970-01-01");
803 assert_eq!(day(DAY - 1), "1970-01-01");
804 assert_eq!(day(DAY), "1970-01-02");
805 }
806
807 #[test]
808 fn fold_counts_per_engineer_and_day() {
809 let evs = [
810 ev(TURN_END, 10, "a@x", &[], 0),
811 ev(CHECK, 20, "a@x", &[], 7),
812 ev(BLOCK, 30, "a@x", &["L1", "L2"], 2),
813 ev(TURN_END, DAY + 5, "a@x", &[], 0),
814 ev(TURN_END, 40, "b@x", &[], 0),
815 ];
816 let b = fold("n1", &evs);
817 assert_eq!(b.len(), 3, "two days for a, one for b");
818 let a = &b["stat:n1:a@x:1970-01-01"];
819 assert_eq!((a.counts[TURN_END], a.counts[CHECK], a.counts["check_n"], a.counts["block_n"]), (1, 1, 7, 2));
820 assert_eq!(a.lessons["L1"][BLOCK], 1);
821 }
822
823 #[test]
824 fn fold_splits_check_cost_by_mode_and_asks_by_channel() {
825 let with = |kind: &str, usd: f64, mode: &str, channel: &str| Event { usd, mode: mode.into(), channel: channel.into(), ..ev(kind, 10, "a@x", &[], 0) };
826 let evs = [
827 with(CHECK, 0.05, "", ""),
828 with(CHECK, 0.04, "live", ""),
829 with(LIVE_CHECK, 0.03, "live", ""),
830 with(MODEL_CALL, 0.5, "live", ""),
831 with(BLOCK, 0.0, "live", "stop"),
832 with(LIVE_ASK, 0.0, "live", "live"),
833 with(CHANGED, 0.0, "", "live"),
834 ];
835 let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
836 assert!((b.usd_mode["hook"] - 0.05).abs() < 1e-9 && (b.usd_mode["live"] - 0.07).abs() < 1e-9, "{:?}", b.usd_mode);
837 assert_eq!(b.usd_mode.len(), 2, "only the conscience's checks: {:?}", b.usd_mode);
838 assert_eq!((b.channels["block@stop"], b.channels["live_ask@live"], b.channels["changed@live"]), (1, 1, 1));
839 assert_eq!((b.counts[CHECK], b.counts[LIVE_CHECK]), (2, 1), "live checks are counted apart from turn-end checks");
840 }
841
842 #[test]
843 fn rollup_versions_only_on_change_and_is_idempotent() {
844 let home = tmp("rollup");
845 record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
846 assert_eq!(rollup(&home), 1);
847 assert_eq!(rollup(&home), 0, "nothing changed: nothing appended");
848 record(&home, &ev(GATE_SKIP, 11, "a@x", &[], 0));
849 assert_eq!(rollup(&home), 1);
850 let r = rollups(&home);
851 let b = r.values().next().unwrap();
852 assert_eq!((b.version, b.counts[TURN_END], b.counts[GATE_SKIP]), (2, 1, 1));
853 let text = std::fs::read_to_string(rollups_path(&home)).unwrap();
854 assert!(!text.contains("session"), "no session ids leave the machine");
855 }
856
857 #[test]
858 fn current_prefers_own_fresh_counts_and_keeps_peers() {
859 let home = tmp("current");
860 record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
861 rollup(&home);
862 record(&home, &ev(TURN_END, 12, "a@x", &[], 0));
863 let peer = Bucket {
864 id: "stat:other:b@x:1970-01-01".into(),
865 version: 3,
866 day: "1970-01-01".into(),
867 node: "other".into(),
868 engineer: "b@x".into(),
869 ..Default::default()
870 };
871 let mut f = std::fs::OpenOptions::new().append(true).open(rollups_path(&home)).unwrap();
872 writeln!(f, "{}", serde_json::to_string(&peer).unwrap()).unwrap();
873 let cur = current(&home);
874 assert_eq!(cur.len(), 2);
875 let mine = cur.iter().find(|b| b.engineer == "a@x").unwrap();
876 assert_eq!(mine.counts[TURN_END], 2, "own counts are folded fresh, not the stale rollup");
877 }
878
879 #[test]
882 fn rollups_break_version_ties_by_hash_whatever_the_order() {
883 let bucket = |n: u64| {
884 let mut b = Bucket { id: "stat:n:a@x:1970-01-01".into(), version: 2, ..Default::default() };
885 b.counts.insert(TURN_END.into(), n);
886 serde_json::to_string(&b).unwrap()
887 };
888 let (a, b) = (bucket(1), bucket(2));
889 let shown = |name: &str, first: &str, second: &str| {
890 let home = tmp(name);
891 std::fs::write(rollups_path(&home), format!("{first}\n{second}\n")).unwrap();
892 rollups(&home).into_values().next().unwrap().counts[TURN_END]
893 };
894 assert_eq!(shown("ties-ab", &a, &b), shown("ties-ba", &b, &a));
895 }
896
897 #[test]
898 fn record_fails_open() {
899 let home = tmp("open");
900 std::fs::write(home.join("stats"), "a file where the directory should be").unwrap();
901 record(&home, &ev(TURN_END, 1, "a@x", &[], 0));
902 assert!(events(&home).is_empty());
903 }
904
905 #[test]
906 fn backfill_derives_history_before_live_counting_once() {
907 let home = tmp("backfill");
908 let w = |f: &str, lines: &[serde_json::Value]| {
909 let p = home.join(f);
910 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
911 std::fs::write(p, lines.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
912 };
913 use serde_json::json;
914 w(
915 "episodes.jsonl",
916 &[
917 json!({"id":"e1","ms":100,"repo":"app","session":"s1","task":"t","agent":"a","rejected_tool":"Bash","correction":"no, not like that","engineer":"a@x"}),
918 json!({"id":"e2","ms":5000,"repo":"app","session":"s2","task":"t","agent":"a","rejected_tool":null,"correction":"after live counting began","engineer":"a@x"}),
919 ],
920 );
921 w(
922 "outcomes.jsonl",
923 &[
924 json!({"kind":"asked","session":"s1","lessons":["L1","L2"],"ms":110,"repo":"app"}),
925 json!({"kind":"asked","session":"s1","lessons":["L3"],"ms":112,"repo":"app","channel":"early"}),
927 json!({"kind":"asked","session":"s1","lessons":["L4"],"ms":114,"repo":"app","channel":"queued"}),
928 json!({"kind":"asked_then_changed","session":"s1","lessons":["L1"],"ms":120,"repo":"app"}),
929 ],
930 );
931 w(
932 "lessons.jsonl",
933 &[json!({"id":"L1","origin_ms":90,"question":"q","version":1,"engineers":["a@x"]}), json!({"id":"L1","origin_ms":90,"question":"q2","version":2})],
934 );
935 w("queen/queue.jsonl", &[json!({"id":"Q1","ms":130,"question":"which?","engineer":"a@x","answered_ms":190,"answer":"this","answered_by":"a@x"})]);
936 record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
937 let n = backfill(&home);
938 let all = read_events(&backfill_path(&home));
939 assert_eq!(n, all.len());
940 let kinds: Vec<&str> = all.iter().map(|e| e.kind.as_str()).collect();
941 assert_eq!(kinds, [LESSON_NEW, CORRECTION, BLOCK, CHANGED, QUEEN_QUEUED, QUEEN_ANSWERED], "ordered by time, the later correction left out");
942 let c = &all[1];
943 assert_eq!((c.why.as_str(), c.text.as_str(), c.engineer.as_str()), ("rejected_tool", "Bash: no, not like that", "a@x"));
944 assert_eq!(all[3].engineer, "a@x", "outcomes take the session's engineer from its corrections");
945 assert_eq!(all[5].dur_ms, 60, "time to answer");
946 assert_eq!(backfill(&home), 0, "once");
947 assert_eq!(events(&home).len(), all.len() + 1, "history and live events together");
948 }
949
950 #[test]
955 fn backfill_counts_one_event_per_call_as_live_counting_does() {
956 let home = tmp("backfill-call");
957 use serde_json::json;
958 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});
959 let log = [
960 rec("asked", &["L1", "L2"], 110, "stop", "c1"),
961 rec("asked", &["L3"], 110, "stop", "c2"),
962 rec("asked", &["L4"], 120, "live", "c3"),
963 rec("asked", &["L5"], 120, "live", "c4"),
964 rec("asked_then_changed", &["L1"], 130, "stop", "c1"),
965 rec("asked_then_changed", &["L3"], 130, "stop", "c2"),
966 rec("asked", &["L6"], 140, "stop", "c5"),
967 rec("asked_not_corrected", &["L2"], 150, "stop", "c1"),
968 rec("asked_not_corrected", &["L6"], 150, "stop", "c5"),
969 ];
970 std::fs::write(home.join("outcomes.jsonl"), log.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
971 record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
972 backfill(&home);
973 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();
974 let row = |kind: &str, ms: i64, n: u64, lessons: &[&str]| (kind.to_string(), ms, n, lessons.iter().map(|l| l.to_string()).collect::<Vec<_>>());
975 assert_eq!(
976 all,
977 [
978 row(BLOCK, 110, 3, &["L1", "L2", "L3"]),
979 row(LIVE_ASK, 120, 2, &["L4", "L5"]),
980 row(CHANGED, 130, 0, &["L1", "L3"]),
981 row(BLOCK, 140, 1, &["L6"]),
982 row(NOT_CORRECTED, 150, 0, &["L2"]),
983 row(NOT_CORRECTED, 150, 0, &["L6"]),
984 ]
985 );
986 }
987
988 #[test]
989 fn node_id_is_stable() {
990 let home = tmp("node");
991 let a = node(&home);
992 assert_eq!(a.len(), 16);
993 assert_eq!(node(&home), a);
994 }
995}