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";
50pub const VETO: &str = "veto";
53pub const VETO_REPEAT: &str = "veto_repeat";
56pub const LESSON_NEW: &str = "lesson_new";
58pub const LESSON_MERGED: &str = "lesson_merged";
59pub const REVIEW_FINDING: &str = "review_finding";
62pub const NOT_A_LESSON: &str = "not_a_lesson";
64pub const MODEL_CALL: &str = "model_call";
66pub const STEER_ISSUED: &str = "steer_issued";
69pub const STEER_DELIVERED: &str = "steer_delivered";
70pub const STEER_DROPPED: &str = "steer_dropped";
71pub const PAUSE_REFUSED: &str = "pause_refused";
72pub const MSG_SENT: &str = "msg_sent";
75pub const MSG_DELIVERED: &str = "msg_delivered";
76pub const MSG_DROPPED: &str = "msg_dropped";
77pub const QUEEN_QUEUED: &str = "queen_queued";
80pub const QUEEN_DELIVERED: &str = "queen_delivered";
81pub const QUEEN_ANSWERED: &str = "queen_answered";
82pub const QUEEN_SKIPPED: &str = "queen_skipped";
83pub const QUEEN_DISMISSED: &str = "queen_dismissed";
84pub const QUEEN_NO: &str = "queen_no";
88pub const QUEEN_STAMP: &str = "queen_stamp";
89pub const QUEEN_MISSED: &str = "queen_missed";
90pub const ASSIMILATED: &str = "assimilated";
92
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
94pub struct Event {
95 pub ms: i64,
96 pub kind: String,
97 #[serde(default, skip_serializing_if = "String::is_empty")]
98 pub session: String,
99 #[serde(default, skip_serializing_if = "String::is_empty")]
100 pub engineer: String,
101 #[serde(default, skip_serializing_if = "String::is_empty")]
102 pub repo: String,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub lessons: Vec<String>,
105 #[serde(default, skip_serializing_if = "is_zero")]
106 pub n: u64,
107 #[serde(default, skip_serializing_if = "is_zero_f")]
108 pub usd: f64,
109 #[serde(default, skip_serializing_if = "is_zero")]
110 pub dur_ms: u64,
111 #[serde(default, skip_serializing_if = "String::is_empty")]
112 pub why: String,
113 #[serde(default, skip_serializing_if = "String::is_empty")]
115 pub text: String,
116 #[serde(default, skip_serializing_if = "String::is_empty")]
118 pub mode: String,
119 #[serde(default, skip_serializing_if = "String::is_empty")]
122 pub channel: String,
123}
124
125fn is_zero(v: &u64) -> bool {
126 *v == 0
127}
128
129fn is_zero_f(v: &f64) -> bool {
130 *v == 0.0
131}
132
133impl Event {
134 pub fn new(kind: &str, ms: i64) -> Self {
136 let mode = std::env::var("BORG_MODE").map(|_| crate::live::current().name().to_string()).unwrap_or_default();
137 Event { kind: kind.into(), ms, mode, ..Default::default() }
138 }
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
143pub struct Bucket {
144 pub id: String,
145 pub version: u64,
146 pub day: String,
147 pub node: String,
148 pub engineer: String,
149 pub counts: BTreeMap<String, u64>,
151 pub usd: f64,
152 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
154 pub usd_by: BTreeMap<String, f64>,
155 pub dur_ms: u64,
156 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
158 pub lessons: BTreeMap<String, BTreeMap<String, u64>>,
159 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
162 pub usd_mode: BTreeMap<String, f64>,
163 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
165 pub channels: BTreeMap<String, u64>,
166}
167
168impl Bucket {
169 fn same(&self, other: &Bucket) -> bool {
171 self.counts == other.counts
172 && self.lessons == other.lessons
173 && self.channels == other.channels
174 && self.dur_ms == other.dur_ms
175 && (self.usd - other.usd).abs() < 1e-9
176 }
177
178 pub fn add(&mut self, other: &Bucket) {
180 for (k, v) in &other.counts {
181 *self.counts.entry(k.clone()).or_default() += v;
182 }
183 for (id, kinds) in &other.lessons {
184 let mine = self.lessons.entry(id.clone()).or_default();
185 for (k, v) in kinds {
186 *mine.entry(k.clone()).or_default() += v;
187 }
188 }
189 for (k, v) in &other.usd_by {
190 *self.usd_by.entry(k.clone()).or_default() += v;
191 }
192 for (k, v) in &other.usd_mode {
193 *self.usd_mode.entry(k.clone()).or_default() += v;
194 }
195 for (k, v) in &other.channels {
196 *self.channels.entry(k.clone()).or_default() += v;
197 }
198 self.usd += other.usd;
199 self.dur_ms += other.dur_ms;
200 }
201}
202
203pub fn events_path(home: &Path) -> PathBuf {
204 home.join("stats").join("events.jsonl")
205}
206
207pub fn rollups_path(home: &Path) -> PathBuf {
208 home.join("stats.jsonl")
209}
210
211pub fn record(home: &Path, ev: &Event) {
213 if !crate::learn::learning() {
214 return;
215 }
216 let _ = crate::learn::append_jsonl(&events_path(home), ev);
217}
218
219pub fn count(home: &Path, kind: &str, session: &str, fill: impl FnOnce(&mut Event)) {
222 let mut ev = Event::new(kind, crate::learn::now_ms());
223 ev.session = session.into();
224 fill(&mut ev);
225 if ev.engineer.is_empty() && !session.is_empty() {
226 ev.engineer = crate::presence::read(home, session).map(|p| p.engineer).unwrap_or_default();
227 }
228 record(home, &ev);
229}
230
231pub fn excerpt(text: &str, max: usize) -> String {
233 let one = text.split_whitespace().collect::<Vec<_>>().join(" ");
234 match one.char_indices().nth(max) {
235 Some((i, _)) => format!("{}…", &one[..i]),
236 None => one,
237 }
238}
239
240static PURPOSE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
241
242pub fn set_purpose(purpose: &'static str) {
244 let _ = PURPOSE.set(purpose);
245}
246
247pub fn purpose() -> &'static str {
248 PURPOSE.get().copied().unwrap_or("model")
249}
250
251fn read_events(path: &Path) -> Vec<Event> {
252 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
253}
254
255pub fn events(home: &Path) -> Vec<Event> {
257 let mut all = read_events(&backfill_path(home));
258 all.extend(read_events(&events_path(home)));
259 all
260}
261
262pub fn live(home: &Path) -> Vec<Event> {
264 read_events(&events_path(home))
265}
266
267pub fn live_since(home: &Path) -> Option<i64> {
271 live(home).iter().filter(|e| e.kind != VETO_REPEAT).map(|e| e.ms).min()
272}
273
274pub fn backfill_path(home: &Path) -> PathBuf {
275 home.join("stats").join("backfill.jsonl")
276}
277
278fn lines(path: &Path) -> Vec<serde_json::Value> {
279 std::fs::read_to_string(path).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
280}
281
282pub fn backfill(home: &Path) -> usize {
288 let path = backfill_path(home);
289 if path.exists() {
290 return 0;
291 }
292 let cutoff = live_since(home).unwrap_or_else(crate::learn::now_ms);
293 let mut out = derive_history(home);
294 out.retain(|e| e.ms > 0 && e.ms < cutoff);
295 out.sort_by_key(|e| e.ms);
296 let text: String = out.iter().map(|e| serde_json::to_string(e).unwrap_or_default() + "\n").collect();
297 if std::fs::create_dir_all(path.parent().unwrap_or(home)).is_err() || std::fs::write(&path, text).is_err() {
298 return 0;
299 }
300 out.len()
301}
302
303fn derive_history(home: &Path) -> Vec<Event> {
304 use serde_json::Value;
305 let s = |v: &Value, k: &str| v[k].as_str().unwrap_or_default().to_string();
306 let ms = |v: &Value, k: &str| v[k].as_i64().unwrap_or(0);
307 let ids = |v: &Value, k: &str| v[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(String::from).collect::<Vec<_>>();
308 let mut out = Vec::new();
309 let mut who: std::collections::HashMap<String, String> = std::collections::HashMap::new();
311 for e in lines(&home.join("episodes.jsonl")) {
312 let tool = s(&e, "rejected_tool");
313 if !s(&e, "engineer").is_empty() {
314 who.insert(s(&e, "session"), s(&e, "engineer"));
315 }
316 let snippet = excerpt(&s(&e, "correction"), 160);
317 if s(&e, "kind") == crate::veto::KIND {
319 out.push(Event {
320 ms: ms(&e, "ms"),
321 kind: VETO.into(),
322 session: s(&e, "session"),
323 engineer: s(&e, "engineer"),
324 repo: s(&e, "repo"),
325 text: snippet,
326 ..Default::default()
327 });
328 continue;
329 }
330 out.push(Event {
331 ms: ms(&e, "ms"),
332 kind: CORRECTION.into(),
333 session: s(&e, "session"),
334 engineer: s(&e, "engineer"),
335 repo: s(&e, "repo"),
336 why: if tool.is_empty() { "pushback".into() } else { "rejected_tool".into() },
337 text: if tool.is_empty() { snippet } else { format!("{tool}: {snippet}") },
338 ..Default::default()
339 });
340 }
341 let engineer = |session: &str| who.get(session).cloned().unwrap_or_default();
342 for o in lines(&home.join("outcomes.jsonl")) {
343 let live = o["channel"] == "live";
344 let kind = match o["kind"].as_str().unwrap_or_default() {
345 crate::outcome::ASKED if o["channel"] == "early" || o["channel"] == "queued" => continue,
347 crate::outcome::ASKED if live => LIVE_ASK,
348 crate::outcome::ASKED => BLOCK,
349 crate::outcome::CHANGED => CHANGED,
350 crate::outcome::IGNORED => IGNORED,
351 crate::outcome::CORRECTED => CORRECTED,
352 crate::outcome::NOT_CORRECTED => NOT_CORRECTED,
353 crate::outcome::REGRETTED => REGRETTED,
354 crate::outcome::TESTED => TESTED,
355 _ => continue,
356 };
357 let lessons = ids(&o, "lessons");
358 let session = s(&o, "session");
359 out.push(Event {
360 ms: ms(&o, "ms"),
361 kind: kind.into(),
362 engineer: engineer(&session),
363 session,
364 repo: s(&o, "repo"),
365 n: if kind == BLOCK || kind == LIVE_ASK { lessons.len() as u64 } else { 0 },
366 lessons,
367 channel: s(&o, "channel"),
368 ..Default::default()
369 });
370 }
371 let mut seen = std::collections::HashSet::new();
373 for l in lines(&home.join("lessons.jsonl")) {
374 let id = s(&l, "id");
375 if id.is_empty() || !seen.insert(id.clone()) {
376 continue;
377 }
378 let source = Some(s(&l, "source")).filter(|x| !x.is_empty()).unwrap_or_else(|| "session".into());
379 out.push(Event {
380 ms: ms(&l, "origin_ms"),
381 kind: LESSON_NEW.into(),
382 engineer: ids(&l, "engineers").into_iter().next().unwrap_or_default(),
383 repo: s(&l, "repo"),
384 lessons: vec![id],
385 why: source,
386 ..Default::default()
387 });
388 }
389 let mut queen: BTreeMap<String, Value> = BTreeMap::new();
390 for q in lines(&home.join("queen").join("queue.jsonl")) {
391 queen.insert(s(&q, "id"), q);
392 }
393 for q in queen.values() {
394 let base = || Event { engineer: s(q, "engineer"), session: s(q, "session"), repo: s(q, "repo"), lessons: vec![s(q, "id")], ..Default::default() };
395 out.push(Event { ms: ms(q, "ms"), kind: QUEEN_QUEUED.into(), text: excerpt(&s(q, "question"), 160), ..base() });
396 if let Some(at) = q["answered_ms"].as_i64() {
397 let dur = u64::try_from(at - ms(q, "ms")).unwrap_or(0);
398 out.push(Event { ms: at, kind: QUEEN_ANSWERED.into(), dur_ms: dur, engineer: s(q, "answered_by"), text: excerpt(&s(q, "answer"), 160), ..base() });
399 }
400 if let Some(at) = q["dismissed_ms"].as_i64() {
401 out.push(Event { ms: at, kind: QUEEN_DISMISSED.into(), text: s(q, "dismissed"), ..base() });
402 }
403 }
404 for (file, kind) in [("delivered.jsonl", QUEEN_DELIVERED), ("skips.jsonl", QUEEN_SKIPPED)] {
405 for m in lines(&home.join("queen").join(file)) {
406 let session = s(&m, "session");
407 out.push(Event { ms: ms(&m, "ms"), kind: kind.into(), engineer: engineer(&session), session, lessons: vec![s(&m, "id")], ..Default::default() });
408 }
409 }
410 for a in lines(&home.join("assimilated.jsonl")) {
411 out.push(Event {
412 ms: ms(&a, "ms"),
413 kind: ASSIMILATED.into(),
414 repo: s(&a, "source"),
415 n: ids(&a, "lessons").len() as u64,
416 usd: a["cost_usd"].as_f64().unwrap_or(0.0),
417 ..Default::default()
418 });
419 }
420 for (file, why) in [("experts.jsonl", "expert"), ("facets.jsonl", "facets")] {
421 for c in lines(&home.join(file)) {
422 let session = s(&c, "session");
423 out.push(Event {
424 ms: ms(&c, "ms"),
425 kind: MODEL_CALL.into(),
426 engineer: engineer(&session),
427 session,
428 repo: s(&c, "repo"),
429 usd: c["cost_usd"].as_f64().unwrap_or(0.0),
430 why: why.into(),
431 ..Default::default()
432 });
433 }
434 }
435 for (file, kind) in [(home.join("mesh").join("directives.jsonl"), STEER_ISSUED), (home.join("steer").join("inbox.jsonl"), STEER_DELIVERED)] {
438 for d in lines(&file) {
439 out.push(Event {
440 ms: ms(&d, "ms"),
441 kind: kind.into(),
442 engineer: s(&d, "engineer"),
443 why: s(&d, "kind"),
444 text: excerpt(&s(&d, "text"), 160),
445 ..Default::default()
446 });
447 }
448 }
449 for d in lines(&crate::steer::dropped_path(home)) {
450 out.push(Event { ms: ms(&d, "ms"), kind: STEER_DROPPED.into(), engineer: s(&d, "engineer"), why: s(&d, "why"), ..Default::default() });
451 }
452 out.extend(turns_from_state(&home.join("state"), &who));
453 out
454}
455
456fn turns_from_state(dir: &Path, who: &std::collections::HashMap<String, String>) -> Vec<Event> {
459 let mut out = Vec::new();
460 let Ok(rd) = std::fs::read_dir(dir) else { return out };
461 for f in rd.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "ctx")) {
462 let Some(at) = std::fs::metadata(&f).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) else {
463 continue;
464 };
465 let session = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
466 let base =
467 || Event { ms: at.as_millis() as i64, session: session.clone(), engineer: who.get(&session).cloned().unwrap_or_default(), ..Default::default() };
468 for line in std::fs::read_to_string(&f).unwrap_or_default().lines() {
469 if let Some(score) = line.strip_prefix("#borg-skip ") {
470 out.push(Event { kind: TURN_END.into(), ..base() });
471 out.push(Event { kind: GATE_SKIP.into(), why: score.trim().into(), ..base() });
472 } else if let Some(cost) = line.strip_prefix("#borg-predict-cost ") {
473 out.push(Event { kind: TURN_END.into(), ..base() });
474 out.push(Event { kind: CHECK.into(), usd: cost.trim().parse().unwrap_or(0.0), ..base() });
475 }
476 }
477 }
478 out
479}
480
481pub fn node(home: &Path) -> String {
484 let path = home.join("stats").join("node");
485 if let Ok(id) = std::fs::read_to_string(&path)
486 && !id.trim().is_empty()
487 {
488 return id.trim().to_string();
489 }
490 let id = fresh_id();
494 let _ = std::fs::create_dir_all(path.parent().unwrap_or(home));
495 let tmp = path.with_extension(format!("{id}.tmp"));
496 let linked = std::fs::write(&tmp, &id).and_then(|()| std::fs::hard_link(&tmp, &path));
497 let _ = std::fs::remove_file(&tmp);
498 match linked {
499 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
500 std::fs::read_to_string(&path).map(|s| s.trim().to_string()).ok().filter(|s| !s.is_empty()).unwrap_or(id)
501 }
502 _ => id,
503 }
504}
505
506fn fresh_id() -> String {
507 let mut bytes = [0u8; 8];
508 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
509 use std::io::Read;
510 let _ = f.read_exact(&mut bytes);
511 }
512 if bytes == [0; 8] {
513 use std::hash::{BuildHasher, Hasher};
514 let mut h = std::collections::hash_map::RandomState::new().build_hasher();
515 h.write_i64(crate::learn::now_ms());
516 h.write_u32(std::process::id());
517 bytes = h.finish().to_le_bytes();
518 }
519 bytes.iter().map(|b| format!("{b:02x}")).collect()
520}
521
522pub fn day(ms: i64) -> String {
524 let s = crate::install::utc(ms);
525 format!("{}-{}-{}", &s[0..4], &s[4..6], &s[6..8])
526}
527
528pub fn days_between(first: &str, last: &str) -> Vec<String> {
530 let (Some(a), Some(b)) = (day_number(first), day_number(last)) else { return Vec::new() };
531 (a..=b).map(|d| day(d * 86_400_000)).collect()
532}
533
534fn day_number(date: &str) -> Option<i64> {
536 let mut it = date.split('-').map(|p| p.parse::<i64>().ok());
537 let (y, m, d) = (it.next()??, it.next()??, it.next()??);
538 let y = if m <= 2 { y - 1 } else { y };
539 let era = y.div_euclid(400);
540 let yoe = y - era * 400;
541 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
542 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
543 Some(era * 146_097 + doe - 719_468)
544}
545
546pub fn fold(node: &str, events: &[Event]) -> BTreeMap<String, Bucket> {
548 let mut out: BTreeMap<String, Bucket> = BTreeMap::new();
549 for e in events {
550 let day = day(e.ms);
551 let id = format!("stat:{node}:{}:{day}", e.engineer);
552 let b = out.entry(id.clone()).or_insert_with(|| Bucket { id, day, node: node.into(), engineer: e.engineer.clone(), ..Default::default() });
553 *b.counts.entry(e.kind.clone()).or_default() += 1;
554 if e.n > 0 {
555 *b.counts.entry(format!("{}_n", e.kind)).or_default() += e.n;
556 }
557 b.usd += e.usd;
558 if e.usd > 0.0 {
559 *b.usd_by.entry(e.kind.clone()).or_default() += e.usd;
560 }
561 if e.usd > 0.0 && (e.kind == CHECK || e.kind == LIVE_CHECK) {
563 let mode = if e.mode.is_empty() { "hook" } else { e.mode.as_str() };
564 *b.usd_mode.entry(mode.into()).or_default() += e.usd;
565 }
566 if !e.channel.is_empty() {
567 *b.channels.entry(format!("{}@{}", e.kind, e.channel)).or_default() += 1;
568 }
569 b.dur_ms += e.dur_ms;
570 for l in &e.lessons {
571 *b.lessons.entry(l.clone()).or_default().entry(e.kind.clone()).or_default() += 1;
572 }
573 }
574 out
575}
576
577pub fn rollups(home: &Path) -> BTreeMap<String, Bucket> {
581 let mut out: BTreeMap<String, (Bucket, [u8; 32])> = BTreeMap::new();
582 for l in std::fs::read_to_string(rollups_path(home)).unwrap_or_default().lines() {
583 let Ok(b) = serde_json::from_str::<Bucket>(l) else { continue };
584 let h = *blake3::hash(l.as_bytes()).as_bytes();
585 if out.get(&b.id).is_none_or(|(old, oh)| (b.version, h) > (old.version, *oh)) {
586 out.insert(b.id.clone(), (b, h));
587 }
588 }
589 out.into_iter().map(|(id, (b, _))| (id, b)).collect()
590}
591
592pub fn rollup(home: &Path) -> usize {
595 backfill(home);
596 let evs = events(home);
598 if evs.is_empty() {
599 return 0;
600 }
601 let me = node(home);
602 let published = rollups(home);
603 let fresh: Vec<Bucket> = fold(&me, &evs)
604 .into_values()
605 .filter_map(|mut b| {
606 let old = published.get(&b.id);
607 if old.is_some_and(|o| o.same(&b)) {
608 return None;
609 }
610 b.version = old.map_or(1, |o| o.version + 1);
611 Some(b)
612 })
613 .collect();
614 if fresh.is_empty() {
615 return 0;
616 }
617 let lines: String = fresh.iter().map(|b| serde_json::to_string(b).unwrap_or_default() + "\n").collect();
618 let path = rollups_path(home);
619 let ok = std::fs::OpenOptions::new().create(true).append(true).open(&path).and_then(|mut f| f.write_all(lines.as_bytes()));
620 if ok.is_ok() { fresh.len() } else { 0 }
621}
622
623pub fn current(home: &Path) -> Vec<Bucket> {
626 backfill(home);
627 let me = node(home);
628 let mut all: BTreeMap<String, Bucket> = rollups(home).into_iter().filter(|(_, b)| b.node != me).collect();
629 all.extend(fold(&me, &events(home)));
630 all.into_values().collect()
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn concurrent_first_calls_agree_on_one_node() {
639 for round in 0..20 {
642 let home = std::env::temp_dir().join(format!("borg-stats-node-{}-{round}", std::process::id()));
643 let _ = std::fs::remove_dir_all(&home);
644 let ids: Vec<String> = std::thread::scope(|s| {
645 let hs: Vec<_> = (0..16).map(|_| s.spawn(|| node(&home))).collect();
646 hs.into_iter().map(|h| h.join().unwrap()).collect()
647 });
648 assert!(ids.iter().all(|id| *id == ids[0] && id.len() == 16), "{ids:?}");
649 assert_eq!(node(&home), ids[0]);
650 let _ = std::fs::remove_dir_all(&home);
651 }
652 }
653
654 fn tmp(tag: &str) -> PathBuf {
655 let d = std::env::temp_dir().join(format!("borg-stats-{tag}-{}", std::process::id()));
656 let _ = std::fs::remove_dir_all(&d);
657 std::fs::create_dir_all(&d).unwrap();
658 d
659 }
660
661 fn ev(kind: &str, ms: i64, engineer: &str, lessons: &[&str], n: u64) -> Event {
662 Event { engineer: engineer.into(), lessons: lessons.iter().map(|s| s.to_string()).collect(), n, ..Event::new(kind, ms) }
663 }
664
665 const DAY: i64 = 86_400_000;
666
667 #[test]
668 fn days_between_fills_every_day() {
669 assert_eq!(days_between("2026-02-27", "2026-03-02"), ["2026-02-27", "2026-02-28", "2026-03-01", "2026-03-02"]);
670 assert_eq!(days_between("1970-01-01", "1970-01-01"), ["1970-01-01"]);
671 assert!(days_between("x", "1970-01-01").is_empty());
672 }
673
674 #[test]
675 fn day_is_utc_date() {
676 assert_eq!(day(0), "1970-01-01");
677 assert_eq!(day(DAY - 1), "1970-01-01");
678 assert_eq!(day(DAY), "1970-01-02");
679 }
680
681 #[test]
682 fn fold_counts_per_engineer_and_day() {
683 let evs = [
684 ev(TURN_END, 10, "a@x", &[], 0),
685 ev(CHECK, 20, "a@x", &[], 7),
686 ev(BLOCK, 30, "a@x", &["L1", "L2"], 2),
687 ev(TURN_END, DAY + 5, "a@x", &[], 0),
688 ev(TURN_END, 40, "b@x", &[], 0),
689 ];
690 let b = fold("n1", &evs);
691 assert_eq!(b.len(), 3, "two days for a, one for b");
692 let a = &b["stat:n1:a@x:1970-01-01"];
693 assert_eq!((a.counts[TURN_END], a.counts[CHECK], a.counts["check_n"], a.counts["block_n"]), (1, 1, 7, 2));
694 assert_eq!(a.lessons["L1"][BLOCK], 1);
695 }
696
697 #[test]
698 fn fold_splits_check_cost_by_mode_and_asks_by_channel() {
699 let with = |kind: &str, usd: f64, mode: &str, channel: &str| Event { usd, mode: mode.into(), channel: channel.into(), ..ev(kind, 10, "a@x", &[], 0) };
700 let evs = [
701 with(CHECK, 0.05, "", ""),
702 with(CHECK, 0.04, "live", ""),
703 with(LIVE_CHECK, 0.03, "live", ""),
704 with(MODEL_CALL, 0.5, "live", ""),
705 with(BLOCK, 0.0, "live", "stop"),
706 with(LIVE_ASK, 0.0, "live", "live"),
707 with(CHANGED, 0.0, "", "live"),
708 ];
709 let b = &fold("n1", &evs)["stat:n1:a@x:1970-01-01"];
710 assert!((b.usd_mode["hook"] - 0.05).abs() < 1e-9 && (b.usd_mode["live"] - 0.07).abs() < 1e-9, "{:?}", b.usd_mode);
711 assert_eq!(b.usd_mode.len(), 2, "only the conscience's checks: {:?}", b.usd_mode);
712 assert_eq!((b.channels["block@stop"], b.channels["live_ask@live"], b.channels["changed@live"]), (1, 1, 1));
713 assert_eq!((b.counts[CHECK], b.counts[LIVE_CHECK]), (2, 1), "live checks are counted apart from turn-end checks");
714 }
715
716 #[test]
717 fn rollup_versions_only_on_change_and_is_idempotent() {
718 let home = tmp("rollup");
719 record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
720 assert_eq!(rollup(&home), 1);
721 assert_eq!(rollup(&home), 0, "nothing changed: nothing appended");
722 record(&home, &ev(GATE_SKIP, 11, "a@x", &[], 0));
723 assert_eq!(rollup(&home), 1);
724 let r = rollups(&home);
725 let b = r.values().next().unwrap();
726 assert_eq!((b.version, b.counts[TURN_END], b.counts[GATE_SKIP]), (2, 1, 1));
727 let text = std::fs::read_to_string(rollups_path(&home)).unwrap();
728 assert!(!text.contains("session"), "no session ids leave the machine");
729 }
730
731 #[test]
732 fn current_prefers_own_fresh_counts_and_keeps_peers() {
733 let home = tmp("current");
734 record(&home, &ev(TURN_END, 10, "a@x", &[], 0));
735 rollup(&home);
736 record(&home, &ev(TURN_END, 12, "a@x", &[], 0));
737 let peer = Bucket {
738 id: "stat:other:b@x:1970-01-01".into(),
739 version: 3,
740 day: "1970-01-01".into(),
741 node: "other".into(),
742 engineer: "b@x".into(),
743 ..Default::default()
744 };
745 let mut f = std::fs::OpenOptions::new().append(true).open(rollups_path(&home)).unwrap();
746 writeln!(f, "{}", serde_json::to_string(&peer).unwrap()).unwrap();
747 let cur = current(&home);
748 assert_eq!(cur.len(), 2);
749 let mine = cur.iter().find(|b| b.engineer == "a@x").unwrap();
750 assert_eq!(mine.counts[TURN_END], 2, "own counts are folded fresh, not the stale rollup");
751 }
752
753 #[test]
756 fn rollups_break_version_ties_by_hash_whatever_the_order() {
757 let bucket = |n: u64| {
758 let mut b = Bucket { id: "stat:n:a@x:1970-01-01".into(), version: 2, ..Default::default() };
759 b.counts.insert(TURN_END.into(), n);
760 serde_json::to_string(&b).unwrap()
761 };
762 let (a, b) = (bucket(1), bucket(2));
763 let shown = |name: &str, first: &str, second: &str| {
764 let home = tmp(name);
765 std::fs::write(rollups_path(&home), format!("{first}\n{second}\n")).unwrap();
766 rollups(&home).into_values().next().unwrap().counts[TURN_END]
767 };
768 assert_eq!(shown("ties-ab", &a, &b), shown("ties-ba", &b, &a));
769 }
770
771 #[test]
772 fn record_fails_open() {
773 let home = tmp("open");
774 std::fs::write(home.join("stats"), "a file where the directory should be").unwrap();
775 record(&home, &ev(TURN_END, 1, "a@x", &[], 0));
776 assert!(events(&home).is_empty());
777 }
778
779 #[test]
780 fn backfill_derives_history_before_live_counting_once() {
781 let home = tmp("backfill");
782 let w = |f: &str, lines: &[serde_json::Value]| {
783 let p = home.join(f);
784 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
785 std::fs::write(p, lines.iter().map(|l| l.to_string() + "\n").collect::<String>()).unwrap();
786 };
787 use serde_json::json;
788 w(
789 "episodes.jsonl",
790 &[
791 json!({"id":"e1","ms":100,"repo":"app","session":"s1","task":"t","agent":"a","rejected_tool":"Bash","correction":"no, not like that","engineer":"a@x"}),
792 json!({"id":"e2","ms":5000,"repo":"app","session":"s2","task":"t","agent":"a","rejected_tool":null,"correction":"after live counting began","engineer":"a@x"}),
793 ],
794 );
795 w(
796 "outcomes.jsonl",
797 &[
798 json!({"kind":"asked","session":"s1","lessons":["L1","L2"],"ms":110,"repo":"app"}),
799 json!({"kind":"asked","session":"s1","lessons":["L3"],"ms":112,"repo":"app","channel":"early"}),
801 json!({"kind":"asked","session":"s1","lessons":["L4"],"ms":114,"repo":"app","channel":"queued"}),
802 json!({"kind":"asked_then_changed","session":"s1","lessons":["L1"],"ms":120,"repo":"app"}),
803 ],
804 );
805 w(
806 "lessons.jsonl",
807 &[json!({"id":"L1","origin_ms":90,"question":"q","version":1,"engineers":["a@x"]}), json!({"id":"L1","origin_ms":90,"question":"q2","version":2})],
808 );
809 w("queen/queue.jsonl", &[json!({"id":"Q1","ms":130,"question":"which?","engineer":"a@x","answered_ms":190,"answer":"this","answered_by":"a@x"})]);
810 record(&home, &ev(TURN_END, 1000, "a@x", &[], 0));
811 let n = backfill(&home);
812 let all = read_events(&backfill_path(&home));
813 assert_eq!(n, all.len());
814 let kinds: Vec<&str> = all.iter().map(|e| e.kind.as_str()).collect();
815 assert_eq!(kinds, [LESSON_NEW, CORRECTION, BLOCK, CHANGED, QUEEN_QUEUED, QUEEN_ANSWERED], "ordered by time, the later correction left out");
816 let c = &all[1];
817 assert_eq!((c.why.as_str(), c.text.as_str(), c.engineer.as_str()), ("rejected_tool", "Bash: no, not like that", "a@x"));
818 assert_eq!(all[3].engineer, "a@x", "outcomes take the session's engineer from its corrections");
819 assert_eq!(all[5].dur_ms, 60, "time to answer");
820 assert_eq!(backfill(&home), 0, "once");
821 assert_eq!(events(&home).len(), all.len() + 1, "history and live events together");
822 }
823
824 #[test]
825 fn node_id_is_stable() {
826 let home = tmp("node");
827 let a = node(&home);
828 assert_eq!(a.len(), 16);
829 assert_eq!(node(&home), a);
830 }
831}