use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum TelemetryError {
#[error("telemetry I/O: {0}")]
Io(#[from] io::Error),
#[error("telemetry serialization: {0}")]
Serde(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, TelemetryError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Shape {
Prose,
NumberedList,
BulletList,
CodeBlock,
MarkdownTable,
NestedObject,
FlatObject,
ArrayOfObjects,
ArrayOfPrimitives,
Empty,
#[default]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Layer {
L0,
L1,
L2,
#[default]
L3,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PipelineEvent {
pub session_hash: String,
pub tool_call_id_hash: String,
pub tool_name_anon: String,
pub endpoint_class: String,
pub response_chars: u64,
pub shape: Shape,
#[serde(default)]
pub inner_formats: Vec<String>,
pub content_sha_prefix_hex: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path_hash: Option<String>,
pub is_dedup_hit: bool,
pub layer_used: Layer,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_id: Option<String>,
pub tokens_baseline: u32,
pub tokens_final: u32,
pub context_partition: u32,
pub is_sidechain: bool,
pub ts_ms: i64,
#[serde(default = "default_sample_rate")]
pub sample_rate_applied: f32,
#[serde(default, skip_serializing_if = "is_false")]
pub enricher_prefetched: bool,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub enricher_predicted_cost_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enricher_decline_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cited_in_next_n_turns: Option<bool>,
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_zero_u32(n: &u32) -> bool {
*n == 0
}
fn is_zero_u64(n: &u64) -> bool {
*n == 0
}
fn default_sample_rate() -> f32 {
1.0
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionSummary {
pub session_hash: String,
pub total_events: u64,
pub dedup_hit_rate: f32,
pub l1_hit_rate: f32,
pub l2_hit_rate: f32,
pub avg_response_chars: f32,
pub compaction_count: u32,
pub total_baseline_tokens: u64,
pub total_final_tokens: u64,
pub savings_pct: f32,
pub duration_sec: f32,
pub ended_at_ms: i64,
pub sample_rate_applied: f32,
#[serde(default)]
pub enrichment: EnrichmentEffectiveness,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct EnrichmentEffectiveness {
pub total_prefetches: u32,
pub cited_prefetches: u32,
pub total_declines: u32,
pub late_invoked_after_decline: u32,
pub cost_overrun_count: u32,
pub total_predictions: u32,
pub net_prediction_error_tokens: i64,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub inference_calls_saved_prefetch: u32,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub inference_calls_saved_dedup: u32,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub inference_calls_saved_fail_fast: u32,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub inference_tokens_saved: u64,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub prefetch_dispatched: u32,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub prefetch_won_race: u32,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub prefetch_wasted: u32,
}
impl EnrichmentEffectiveness {
pub fn prefetch_hit_rate(&self) -> Option<f32> {
(self.total_prefetches > 0)
.then(|| self.cited_prefetches as f32 / self.total_prefetches as f32)
}
pub fn decline_recall_loss(&self) -> Option<f32> {
(self.total_declines > 0)
.then(|| self.late_invoked_after_decline as f32 / self.total_declines as f32)
}
pub fn cost_overrun_rate(&self) -> Option<f32> {
(self.total_predictions > 0)
.then(|| self.cost_overrun_count as f32 / self.total_predictions as f32)
}
pub fn total_calls_saved(&self) -> u32 {
self.inference_calls_saved_prefetch
.saturating_add(self.inference_calls_saved_dedup)
.saturating_add(self.inference_calls_saved_fail_fast)
}
pub fn accumulate(&mut self, ev: &PipelineEvent) {
if ev.enricher_prefetched {
self.total_prefetches = self.total_prefetches.saturating_add(1);
self.total_predictions = self.total_predictions.saturating_add(1);
let predicted = ev.enricher_predicted_cost_tokens as i64;
let actual = ev.tokens_baseline as i64;
self.net_prediction_error_tokens = self
.net_prediction_error_tokens
.saturating_add(actual - predicted);
if predicted > 0 && actual * 10 >= predicted * 13 {
self.cost_overrun_count = self.cost_overrun_count.saturating_add(1);
}
if matches!(ev.cited_in_next_n_turns, Some(true)) {
self.cited_prefetches = self.cited_prefetches.saturating_add(1);
self.inference_calls_saved_prefetch =
self.inference_calls_saved_prefetch.saturating_add(1);
self.inference_tokens_saved = self
.inference_tokens_saved
.saturating_add(ev.tokens_baseline as u64);
}
}
if ev.is_dedup_hit {
self.inference_calls_saved_dedup = self.inference_calls_saved_dedup.saturating_add(1);
self.inference_tokens_saved = self
.inference_tokens_saved
.saturating_add(ev.tokens_baseline as u64);
}
if ev.enricher_decline_reason.is_some() {
self.total_declines = self.total_declines.saturating_add(1);
}
}
pub fn record_fail_fast_skip(&mut self, predicted_cost_tokens: u32) {
self.inference_calls_saved_fail_fast =
self.inference_calls_saved_fail_fast.saturating_add(1);
self.inference_tokens_saved = self
.inference_tokens_saved
.saturating_add(predicted_cost_tokens as u64);
}
pub fn record_prefetch_dispatched(&mut self) {
self.prefetch_dispatched = self.prefetch_dispatched.saturating_add(1);
}
pub fn record_prefetch_won_race(&mut self) {
self.prefetch_won_race = self.prefetch_won_race.saturating_add(1);
}
pub fn record_prefetch_wasted(&mut self) {
self.prefetch_wasted = self.prefetch_wasted.saturating_add(1);
}
pub fn prefetch_race_win_rate(&self) -> Option<f32> {
(self.prefetch_dispatched > 0)
.then(|| self.prefetch_won_race as f32 / self.prefetch_dispatched as f32)
}
pub fn prefetch_waste_rate(&self) -> Option<f32> {
(self.prefetch_dispatched > 0)
.then(|| self.prefetch_wasted as f32 / self.prefetch_dispatched as f32)
}
pub fn report(&self) -> String {
let hit = self
.prefetch_hit_rate()
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "n/a".into());
let loss = self
.decline_recall_loss()
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "n/a".into());
let overrun = self
.cost_overrun_rate()
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "n/a".into());
let race = self
.prefetch_race_win_rate()
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "n/a".into());
let waste = self
.prefetch_waste_rate()
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "n/a".into());
format!(
"prefetch_hit={hit} decline_recall_loss={loss} cost_overrun={overrun} \
race_win={race} waste={waste} \
calls_saved={saved} (prefetch={pf}, dedup={dd}, fail_fast={ff}) \
tokens_saved={ts} prefetches={p} dispatched={dp} \
declines={d} predictions={pr}",
saved = self.total_calls_saved(),
pf = self.inference_calls_saved_prefetch,
dd = self.inference_calls_saved_dedup,
ff = self.inference_calls_saved_fail_fast,
ts = self.inference_tokens_saved,
p = self.total_prefetches,
dp = self.prefetch_dispatched,
d = self.total_declines,
pr = self.total_predictions,
)
}
}
pub trait TelemetrySink: Send + Sync {
fn record(&self, event: &PipelineEvent) -> Result<()>;
fn record_summary(&self, _summary: &SessionSummary) -> Result<()> {
Ok(())
}
fn flush(&self) -> Result<()> {
Ok(())
}
}
pub struct JsonlSink {
path: PathBuf,
writer: Mutex<BufWriter<File>>,
}
impl JsonlSink {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
path,
writer: Mutex::new(BufWriter::new(file)),
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl TelemetrySink for JsonlSink {
fn record(&self, event: &PipelineEvent) -> Result<()> {
let line = serde_json::to_string(event)?;
let mut w = self.writer.lock().expect("telemetry writer mutex poisoned");
w.write_all(line.as_bytes())?;
w.write_all(b"\n")?;
Ok(())
}
fn record_summary(&self, summary: &SessionSummary) -> Result<()> {
let wrapped = serde_json::json!({
"type": "session_summary",
"data": summary,
});
let line = serde_json::to_string(&wrapped)?;
let mut w = self.writer.lock().expect("telemetry writer mutex poisoned");
w.write_all(line.as_bytes())?;
w.write_all(b"\n")?;
Ok(())
}
fn flush(&self) -> Result<()> {
self.writer
.lock()
.expect("telemetry writer mutex poisoned")
.flush()?;
Ok(())
}
}
#[derive(Default)]
pub struct NullSink;
impl TelemetrySink for NullSink {
fn record(&self, _event: &PipelineEvent) -> Result<()> {
Ok(())
}
}
#[derive(Default)]
pub struct MemorySink {
events: Mutex<Vec<PipelineEvent>>,
summaries: Mutex<Vec<SessionSummary>>,
}
impl MemorySink {
pub fn new() -> Self {
Self::default()
}
pub fn events(&self) -> Vec<PipelineEvent> {
self.events.lock().unwrap().clone()
}
pub fn summaries(&self) -> Vec<SessionSummary> {
self.summaries.lock().unwrap().clone()
}
pub fn len(&self) -> usize {
self.events.lock().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl TelemetrySink for MemorySink {
fn record(&self, event: &PipelineEvent) -> Result<()> {
self.events.lock().unwrap().push(event.clone());
Ok(())
}
fn record_summary(&self, summary: &SessionSummary) -> Result<()> {
self.summaries.lock().unwrap().push(summary.clone());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
fn sample_event() -> PipelineEvent {
PipelineEvent {
session_hash: "sess0001".into(),
tool_call_id_hash: "tc0001".into(),
tool_name_anon: "Read".into(),
endpoint_class: "Read".into(),
response_chars: 1234,
shape: Shape::NumberedList,
inner_formats: vec![],
content_sha_prefix_hex: "0123456789abcdef".into(),
file_path_hash: Some("fpath001".into()),
is_dedup_hit: false,
layer_used: Layer::L3,
template_id: None,
tokens_baseline: 308,
tokens_final: 308,
context_partition: 0,
is_sidechain: false,
ts_ms: 1_700_000_000_000,
sample_rate_applied: 1.0,
enricher_prefetched: false,
enricher_predicted_cost_tokens: 0,
enricher_decline_reason: None,
cited_in_next_n_turns: None,
}
}
#[test]
fn memory_sink_captures_events() {
let sink = MemorySink::new();
let e = sample_event();
sink.record(&e).unwrap();
assert_eq!(sink.len(), 1);
assert_eq!(sink.events()[0].tool_call_id_hash, "tc0001");
}
#[test]
fn null_sink_is_noop() {
let sink = NullSink;
let e = sample_event();
sink.record(&e).unwrap();
}
#[test]
fn jsonl_sink_appends_line() {
let tmp = tempfile();
{
let sink = JsonlSink::open(&tmp).unwrap();
sink.record(&sample_event()).unwrap();
sink.flush().unwrap();
}
let body = std::fs::read_to_string(&tmp).unwrap();
assert_eq!(body.lines().count(), 1);
let deserialized: PipelineEvent = serde_json::from_str(body.trim()).unwrap();
assert_eq!(deserialized.tokens_baseline, 308);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn jsonl_sink_survives_multiple_writes() {
let tmp = tempfile();
{
let sink = JsonlSink::open(&tmp).unwrap();
for i in 0..10 {
let mut e = sample_event();
e.tokens_baseline = i * 10;
sink.record(&e).unwrap();
}
sink.flush().unwrap();
}
let body = std::fs::read_to_string(&tmp).unwrap();
assert_eq!(body.lines().count(), 10);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn jsonl_sink_supports_summary_tag() {
let tmp = tempfile();
{
let sink = JsonlSink::open(&tmp).unwrap();
sink.record(&sample_event()).unwrap();
let summary = SessionSummary {
session_hash: "sess0001".into(),
total_events: 10,
dedup_hit_rate: 0.35,
savings_pct: 0.35,
ended_at_ms: 1_700_000_100_000,
sample_rate_applied: 1.0,
..Default::default()
};
sink.record_summary(&summary).unwrap();
sink.flush().unwrap();
}
let body = std::fs::read_to_string(&tmp).unwrap();
assert_eq!(body.lines().count(), 2);
assert!(body.contains("\"session_summary\""));
std::fs::remove_file(&tmp).ok();
}
#[test]
fn concurrent_writes_are_serialized() {
let tmp = tempfile();
{
let sink = Arc::new(JsonlSink::open(&tmp).unwrap());
let mut handles = vec![];
for i in 0..8 {
let sink = Arc::clone(&sink);
handles.push(thread::spawn(move || {
let mut e = sample_event();
e.tool_call_id_hash = format!("tc{i:04}");
for _ in 0..25 {
sink.record(&e).unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
sink.flush().unwrap();
}
let body = std::fs::read_to_string(&tmp).unwrap();
assert_eq!(body.lines().count(), 200);
for line in body.lines() {
let _: PipelineEvent = serde_json::from_str(line).unwrap();
}
std::fs::remove_file(&tmp).ok();
}
#[test]
fn schema_is_forward_compatible() {
let legacy = r#"{
"session_hash": "s",
"tool_call_id_hash": "t",
"tool_name_anon": "Read",
"endpoint_class": "Read",
"response_chars": 0,
"shape": "prose",
"content_sha_prefix_hex": "",
"is_dedup_hit": false,
"layer_used": "L3",
"tokens_baseline": 0,
"tokens_final": 0,
"context_partition": 0,
"is_sidechain": false,
"ts_ms": 0
}"#;
let parsed: PipelineEvent = serde_json::from_str(legacy).unwrap();
assert_eq!(parsed.sample_rate_applied, 1.0); assert!(parsed.inner_formats.is_empty());
assert!(parsed.file_path_hash.is_none());
}
fn tempfile() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("devboy_tele_test_{pid}_{n}.jsonl"))
}
#[test]
fn memory_sink_accessors() {
let sink = MemorySink::new();
assert!(sink.is_empty());
assert_eq!(sink.len(), 0);
sink.record(&sample_event()).unwrap();
assert!(!sink.is_empty());
assert_eq!(sink.len(), 1);
sink.flush().unwrap();
}
#[test]
fn memory_sink_captures_summaries() {
let sink = MemorySink::new();
let summary = SessionSummary {
session_hash: "abcd".into(),
total_events: 7,
savings_pct: 0.33,
..Default::default()
};
sink.record_summary(&summary).unwrap();
assert_eq!(sink.summaries().len(), 1);
assert_eq!(sink.summaries()[0].total_events, 7);
}
#[test]
fn jsonl_sink_path_getter() {
let tmp = tempfile();
let sink = JsonlSink::open(&tmp).unwrap();
assert_eq!(sink.path(), tmp.as_path());
std::fs::remove_file(&tmp).ok();
}
#[test]
fn jsonl_sink_creates_parent_dirs() {
let parent =
std::env::temp_dir().join(format!("devboy_tele_nested_{}", std::process::id()));
let tmp = parent.join("deep/sub/events.jsonl");
assert!(!tmp.parent().unwrap().exists());
let sink = JsonlSink::open(&tmp).unwrap();
sink.record(&sample_event()).unwrap();
sink.flush().unwrap();
assert!(tmp.exists());
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn shape_and_layer_defaults() {
assert_eq!(Shape::default(), Shape::Unknown);
assert_eq!(Layer::default(), Layer::L3);
}
#[test]
fn shape_serde_snake_case() {
let j = serde_json::to_string(&Shape::MarkdownTable).unwrap();
assert_eq!(j, "\"markdown_table\"");
let parsed: Shape = serde_json::from_str("\"array_of_objects\"").unwrap();
assert_eq!(parsed, Shape::ArrayOfObjects);
}
#[test]
fn null_sink_flush_is_noop() {
let sink = NullSink;
sink.flush().unwrap();
}
#[test]
fn telemetry_error_display() {
let io_err = TelemetryError::Io(std::io::Error::other("boom"));
let msg = format!("{io_err}");
assert!(msg.contains("telemetry"));
}
#[test]
fn enrichment_rates_are_none_when_no_activity() {
let e = EnrichmentEffectiveness::default();
assert!(e.prefetch_hit_rate().is_none());
assert!(e.decline_recall_loss().is_none());
assert!(e.cost_overrun_rate().is_none());
assert!(e.report().contains("n/a"));
}
#[test]
fn prefetch_hit_rate_handles_zero_and_partial_hits() {
let mut e = EnrichmentEffectiveness {
total_prefetches: 10,
cited_prefetches: 7,
..Default::default()
};
assert_eq!(e.prefetch_hit_rate(), Some(0.7));
e.cited_prefetches = 0;
assert_eq!(e.prefetch_hit_rate(), Some(0.0));
}
#[test]
fn decline_recall_loss_metric() {
let e = EnrichmentEffectiveness {
total_declines: 20,
late_invoked_after_decline: 3,
..Default::default()
};
let rate = e.decline_recall_loss().unwrap();
assert!((rate - 0.15).abs() < 1e-6);
}
#[test]
fn cost_overrun_rate_metric() {
let e = EnrichmentEffectiveness {
total_predictions: 100,
cost_overrun_count: 12,
..Default::default()
};
let rate = e.cost_overrun_rate().unwrap();
assert!((rate - 0.12).abs() < 1e-6);
}
#[test]
fn report_format_is_human_readable() {
let e = EnrichmentEffectiveness {
total_prefetches: 10,
cited_prefetches: 7,
total_declines: 20,
late_invoked_after_decline: 2,
cost_overrun_count: 3,
total_predictions: 30,
..Default::default()
};
let r = e.report();
assert!(r.contains("70.0%"), "expected prefetch_hit=70.0%, got {r}");
assert!(
r.contains("10.0%"),
"expected decline_recall_loss=10.0%, got {r}"
);
assert!(r.contains("10.0%"), "expected cost_overrun=10.0%, got {r}");
}
#[test]
fn pipeline_event_skips_default_enricher_fields_on_serialise() {
let evt = sample_event();
let json = serde_json::to_string(&evt).unwrap();
assert!(!json.contains("enricher_prefetched"));
assert!(!json.contains("enricher_predicted_cost_tokens"));
assert!(!json.contains("enricher_decline_reason"));
assert!(!json.contains("cited_in_next_n_turns"));
}
#[test]
fn pipeline_event_round_trips_with_enricher_fields_populated() {
let mut evt = sample_event();
evt.enricher_prefetched = true;
evt.enricher_predicted_cost_tokens = 540;
evt.enricher_decline_reason = Some("budget".into());
evt.cited_in_next_n_turns = Some(true);
let json = serde_json::to_string(&evt).unwrap();
let back: PipelineEvent = serde_json::from_str(&json).unwrap();
assert!(back.enricher_prefetched);
assert_eq!(back.enricher_predicted_cost_tokens, 540);
assert_eq!(back.enricher_decline_reason.as_deref(), Some("budget"));
assert_eq!(back.cited_in_next_n_turns, Some(true));
}
#[test]
fn total_calls_saved_sums_three_buckets() {
let e = EnrichmentEffectiveness {
inference_calls_saved_prefetch: 7,
inference_calls_saved_dedup: 12,
inference_calls_saved_fail_fast: 3,
..Default::default()
};
assert_eq!(e.total_calls_saved(), 22);
}
#[test]
fn accumulate_dedup_hit_increments_dedup_bucket_and_tokens() {
let mut e = EnrichmentEffectiveness::default();
let mut ev = sample_event();
ev.is_dedup_hit = true;
ev.tokens_baseline = 800;
ev.tokens_final = 9;
e.accumulate(&ev);
assert_eq!(e.inference_calls_saved_dedup, 1);
assert_eq!(e.inference_tokens_saved, 800);
assert_eq!(e.total_calls_saved(), 1);
assert_eq!(e.total_prefetches, 0);
assert_eq!(e.total_predictions, 0);
}
#[test]
fn accumulate_cited_prefetch_increments_prefetch_bucket() {
let mut e = EnrichmentEffectiveness::default();
let mut ev = sample_event();
ev.enricher_prefetched = true;
ev.enricher_predicted_cost_tokens = 500;
ev.tokens_baseline = 540;
ev.cited_in_next_n_turns = Some(true);
e.accumulate(&ev);
assert_eq!(e.total_prefetches, 1);
assert_eq!(e.cited_prefetches, 1);
assert_eq!(e.inference_calls_saved_prefetch, 1);
assert_eq!(e.inference_tokens_saved, 540);
assert_eq!(e.cost_overrun_count, 0); }
#[test]
fn accumulate_uncited_prefetch_does_not_count_as_saved() {
let mut e = EnrichmentEffectiveness::default();
let mut ev = sample_event();
ev.enricher_prefetched = true;
ev.cited_in_next_n_turns = Some(false);
ev.tokens_baseline = 200;
e.accumulate(&ev);
assert_eq!(e.total_prefetches, 1);
assert_eq!(e.cited_prefetches, 0);
assert_eq!(e.inference_calls_saved_prefetch, 0);
assert_eq!(e.inference_tokens_saved, 0);
}
#[test]
fn accumulate_overrun_counts_when_actual_exceeds_130_percent() {
let mut e = EnrichmentEffectiveness::default();
let mut ev = sample_event();
ev.enricher_prefetched = true;
ev.enricher_predicted_cost_tokens = 100;
ev.tokens_baseline = 200; e.accumulate(&ev);
assert_eq!(e.cost_overrun_count, 1);
assert_eq!(e.net_prediction_error_tokens, 100);
}
#[test]
fn accumulate_decline_reason_increments_declines() {
let mut e = EnrichmentEffectiveness::default();
let mut ev = sample_event();
ev.enricher_decline_reason = Some("budget".into());
e.accumulate(&ev);
assert_eq!(e.total_declines, 1);
}
#[test]
fn record_fail_fast_skip_increments_counter_and_tokens() {
let mut e = EnrichmentEffectiveness::default();
e.record_fail_fast_skip(75);
e.record_fail_fast_skip(75);
assert_eq!(e.inference_calls_saved_fail_fast, 2);
assert_eq!(e.inference_tokens_saved, 150);
assert_eq!(e.total_calls_saved(), 2);
}
#[test]
fn report_includes_calls_saved_and_tokens_saved() {
let e = EnrichmentEffectiveness {
total_prefetches: 10,
cited_prefetches: 7,
inference_calls_saved_prefetch: 7,
inference_calls_saved_dedup: 12,
inference_calls_saved_fail_fast: 3,
inference_tokens_saved: 12_345,
..Default::default()
};
let r = e.report();
assert!(r.contains("calls_saved=22"), "report missing total: {r}");
assert!(
r.contains("prefetch=7") && r.contains("dedup=12") && r.contains("fail_fast=3"),
"report missing per-bucket breakdown: {r}"
);
assert!(
r.contains("tokens_saved=12345"),
"report missing tokens_saved: {r}"
);
}
#[test]
fn enrichment_skips_zero_savings_fields_on_serialise() {
let e = EnrichmentEffectiveness::default();
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains("inference_calls_saved_prefetch"));
assert!(!json.contains("inference_calls_saved_dedup"));
assert!(!json.contains("inference_calls_saved_fail_fast"));
assert!(!json.contains("inference_tokens_saved"));
}
#[test]
fn enrichment_round_trips_with_savings_populated() {
let e = EnrichmentEffectiveness {
inference_calls_saved_prefetch: 4,
inference_calls_saved_dedup: 9,
inference_calls_saved_fail_fast: 2,
inference_tokens_saved: 8_400,
..Default::default()
};
let json = serde_json::to_string(&e).unwrap();
let back: EnrichmentEffectiveness = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
#[test]
fn record_prefetch_dispatched_increments_counter() {
let mut e = EnrichmentEffectiveness::default();
e.record_prefetch_dispatched();
e.record_prefetch_dispatched();
e.record_prefetch_dispatched();
assert_eq!(e.prefetch_dispatched, 3);
}
#[test]
fn race_win_rate_returns_some_only_when_dispatched() {
let e0 = EnrichmentEffectiveness::default();
assert!(e0.prefetch_race_win_rate().is_none());
let e = EnrichmentEffectiveness {
prefetch_dispatched: 10,
prefetch_won_race: 7,
..Default::default()
};
let rate = e.prefetch_race_win_rate().unwrap();
assert!((rate - 0.7).abs() < 1e-6);
}
#[test]
fn waste_rate_separates_dispatched_from_total_prefetches() {
let e = EnrichmentEffectiveness {
total_prefetches: 12,
prefetch_dispatched: 10, prefetch_wasted: 4, ..Default::default()
};
let rate = e.prefetch_waste_rate().unwrap();
assert!((rate - 0.4).abs() < 1e-6);
assert!(e.prefetch_race_win_rate().unwrap().abs() < 1e-6); }
#[test]
fn report_includes_race_and_waste_when_dispatched() {
let e = EnrichmentEffectiveness {
total_prefetches: 10,
prefetch_dispatched: 10,
prefetch_won_race: 6,
prefetch_wasted: 2,
..Default::default()
};
let r = e.report();
assert!(r.contains("race_win=60.0%"), "report missing race_win: {r}");
assert!(r.contains("waste=20.0%"), "report missing waste: {r}");
assert!(
r.contains("dispatched=10"),
"report missing dispatched: {r}"
);
}
#[test]
fn race_fields_skip_serialise_when_zero() {
let e = EnrichmentEffectiveness::default();
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains("prefetch_dispatched"));
assert!(!json.contains("prefetch_won_race"));
assert!(!json.contains("prefetch_wasted"));
}
#[test]
fn race_fields_round_trip_when_populated() {
let e = EnrichmentEffectiveness {
prefetch_dispatched: 12,
prefetch_won_race: 8,
prefetch_wasted: 3,
..Default::default()
};
let json = serde_json::to_string(&e).unwrap();
let back: EnrichmentEffectiveness = serde_json::from_str(&json).unwrap();
assert_eq!(back, e);
}
}