Skip to main content

newt_core/
metrics.rs

1//! Inference turn telemetry — timing, token counts, and cost estimates.
2//!
3//! `TurnMetrics` is the single record produced after every inference call,
4//! whether it comes from the TUI chat REPL, the ACP worker, or the mesh
5//! dispatch layer. It carries only what is cheap to compute: wall-clock time
6//! (always available), Ollama's native token counters (available on
7//! `/api/chat` responses), and a best-effort cost estimate.
8//!
9//! All fields except `elapsed_ms` and `model_id` are `Option` so the struct
10//! can be constructed and displayed even when the backend does not report
11//! token counts (e.g. provider-plugin or vLLM paths).
12
13use serde::{Deserialize, Serialize};
14
15/// Token usage reported by an inference backend.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct TokenUsage {
18    /// Tokens in the prompt sent to the model.
19    pub input_tokens: u32,
20    /// Tokens generated by the model.
21    pub output_tokens: u32,
22}
23
24impl TokenUsage {
25    pub fn total(&self) -> u32 {
26        self.input_tokens.saturating_add(self.output_tokens)
27    }
28
29    /// Combine two usage readings (e.g. across tool-call rounds).
30    pub fn saturating_add(self, other: Self) -> Self {
31        Self {
32            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
33            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
34        }
35    }
36}
37
38/// Why the agentic loop ended the turn. Before this record existed, a
39/// narrate-then-stop acceptance was indistinguishable from a normal
40/// completion — even under `NEWT_DEBUG` (the 2026-07-08 ornith:35b
41/// forensics could not tell "cap exhausted" from "classifier missed" from
42/// disk). `None` on paths that do not report it (headless callers, the
43/// Responses-API loop, operator cancels).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum TurnEndReason {
47    /// A genuine final answer (no pending-action cue in the closing reply).
48    Completed,
49    /// Pending-action narration accepted because the rescue budget
50    /// (`[tui] narration_nudge_cap`) was already spent this turn.
51    NarrationCapExhausted,
52    /// Pending-action narration accepted on the final allowed round, where
53    /// every rescue nudge is skipped (`round + 1 < limit` guard).
54    NarrationFinalRound,
55    /// The tool-round cap ended the turn via the tools-disabled summary.
56    RoundCap,
57    /// The model produced no usable content (placeholder/diagnostic reply).
58    Empty,
59}
60
61/// Full telemetry record for one inference turn.
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct TurnMetrics {
64    /// Wall-clock elapsed time for the full turn (first request byte to last
65    /// response byte), in milliseconds.
66    pub elapsed_ms: u64,
67
68    /// Token usage, if reported by the backend.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub usage: Option<TokenUsage>,
71
72    /// Estimated monetary cost in USD (`None` when local/free or rate unknown).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub cost_usd: Option<f64>,
75
76    /// Model that produced the reply.
77    pub model_id: String,
78
79    /// Backend endpoint that served the request.
80    pub endpoint: String,
81
82    /// Number of agentic-loop hallucinations corrected during this turn
83    /// (e.g. model calling a tool name as a shell command via run_command,
84    /// or invoking a non-existent tool). Omitted from logs when zero.
85    #[serde(default, skip_serializing_if = "is_zero_u32")]
86    pub hallucinations: u32,
87
88    /// Why the loop ended the turn (see [`TurnEndReason`]). Omitted from
89    /// logs when unreported.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub end_reason: Option<TurnEndReason>,
92}
93
94fn is_zero_u32(n: &u32) -> bool {
95    *n == 0
96}
97
98impl TurnMetrics {
99    /// Compact human-readable summary line.
100    ///
101    /// Examples:
102    /// - `"3.2s · 847 in / 312 out · free (local)"`
103    /// - `"8.7s · 1,204 in / 892 out · ~$0.0041"`
104    /// - `"5.1s · (tokens unavailable)"`
105    pub fn display_line(&self) -> String {
106        let elapsed = if self.elapsed_ms >= 1000 {
107            format!("{:.1}s", self.elapsed_ms as f64 / 1000.0)
108        } else {
109            format!("{}ms", self.elapsed_ms)
110        };
111
112        let token_part = match self.usage {
113            Some(u) => format!(
114                "{} in / {} out",
115                fmt_count(u.input_tokens),
116                fmt_count(u.output_tokens)
117            ),
118            None => "(tokens unavailable)".into(),
119        };
120
121        let cost_part = match self.cost_usd {
122            Some(c) if c < f64::EPSILON => "free (local)".into(),
123            Some(c) if c < 0.001 => format!("~${c:.5}"),
124            Some(c) if c < 0.01 => format!("~${c:.4}"),
125            Some(c) => format!("~${c:.4}"),
126            None if self.usage.is_some() => "free (local)".into(),
127            None => String::new(),
128        };
129
130        let base = if cost_part.is_empty() {
131            format!("{elapsed} · {token_part}")
132        } else {
133            format!("{elapsed} · {token_part} · {cost_part}")
134        };
135        let base = if self.hallucinations > 0 {
136            format!(
137                "{base} · ⚠ {} hallucination(s) corrected",
138                self.hallucinations
139            )
140        } else {
141            base
142        };
143        // Surface only the anomalous endings — a normal completion stays
144        // quiet, but a turn that ended on unrescued narration (or the round
145        // cap) says so instead of silently looking finished.
146        match self.end_reason {
147            Some(TurnEndReason::NarrationCapExhausted) => {
148                format!("{base} · ⚠ ended on narration (rescue budget spent)")
149            }
150            Some(TurnEndReason::NarrationFinalRound) => {
151                format!("{base} · ⚠ ended on narration (final round)")
152            }
153            Some(TurnEndReason::RoundCap) => format!("{base} · round cap"),
154            Some(TurnEndReason::Empty) => format!("{base} · ⚠ empty response"),
155            Some(TurnEndReason::Completed) | None => base,
156        }
157    }
158
159    /// Label pairs for Prometheus metric dimensions.
160    pub fn prometheus_labels(&self) -> [(&'static str, String); 2] {
161        [
162            ("model", self.model_id.clone()),
163            ("endpoint", self.endpoint.clone()),
164        ]
165    }
166
167    /// Append this record as a JSONL line to `path` (best-effort).
168    /// Creates the file and parent dirs if absent. Errors are silently ignored
169    /// so telemetry failures never block inference.
170    pub fn append_to_log(&self, path: &std::path::Path) {
171        let _ = append_jsonl(self, path);
172    }
173
174    /// Append and then enforce the rotation policy. All errors are silently
175    /// swallowed — log rotation must never block or crash inference.
176    pub fn append_to_log_with_policy(&self, path: &std::path::Path, policy: &crate::LogConfig) {
177        let _ = append_jsonl(self, path);
178        let _ = rotate_log(path, policy);
179    }
180}
181
182fn fmt_count(n: u32) -> String {
183    // Insert thousands separators for readability.
184    let s = n.to_string();
185    let mut out = String::with_capacity(s.len() + s.len() / 3);
186    for (i, c) in s.chars().rev().enumerate() {
187        if i > 0 && i % 3 == 0 {
188            out.push(',');
189        }
190        out.push(c);
191    }
192    out.chars().rev().collect()
193}
194
195fn append_jsonl(metrics: &TurnMetrics, path: &std::path::Path) -> std::io::Result<()> {
196    use std::io::Write as _;
197    if let Some(parent) = path.parent() {
198        std::fs::create_dir_all(parent)?;
199    }
200    let mut line = serde_json::to_string(metrics).map_err(std::io::Error::other)?;
201    line.push('\n');
202    let mut f = std::fs::OpenOptions::new()
203        .create(true)
204        .append(true)
205        .open(path)?;
206    f.write_all(line.as_bytes())
207}
208
209/// Enforce the log rotation policy on `path` after an append.
210///
211/// Applies limits in this order (all active limits compose):
212/// 1. **max_sessions** — truncate to the last N lines (fast tail-keep)
213/// 2. **max_size_mb**  — rotate-by-rename when file exceeds the byte cap
214/// 3. **max_age_days** — not yet implemented (requires `recorded_at` field)
215///
216/// All errors are returned to the caller, which silently ignores them.
217fn rotate_log(path: &std::path::Path, policy: &crate::LogConfig) -> std::io::Result<()> {
218    // --- session-count limit --------------------------------------------------
219    if policy.max_sessions > 0 {
220        let content = std::fs::read_to_string(path)?;
221        let lines: Vec<&str> = content.lines().collect();
222        if lines.len() > policy.max_sessions {
223            let kept = lines[lines.len() - policy.max_sessions..].join("\n");
224            std::fs::write(path, format!("{kept}\n"))?;
225        }
226        // Re-check file below against size limit after possible trim.
227    }
228
229    // --- size limit (rotate-by-rename) ----------------------------------------
230    if policy.max_size_mb > 0 {
231        let meta = std::fs::metadata(path)?;
232        let limit_bytes = policy.max_size_mb * 1024 * 1024;
233        if meta.len() > limit_bytes {
234            // Shift existing rotations: .2 → .3, .1 → .2, live → .1
235            for i in (1..=policy.keep_rotated).rev() {
236                let older = path.with_extension(format!("jsonl.{i}"));
237                let newer = if i == 1 {
238                    path.to_path_buf()
239                } else {
240                    path.with_extension(format!("jsonl.{}", i - 1))
241                };
242                if newer.exists() {
243                    if i == policy.keep_rotated && older.exists() {
244                        let _ = std::fs::remove_file(&older);
245                    }
246                    let _ = std::fs::rename(&newer, &older);
247                }
248            }
249            // The live file has been renamed; create a fresh empty one.
250            std::fs::File::create(path)?;
251        }
252    }
253
254    Ok(())
255}
256
257// ---------------------------------------------------------------------------
258// Tests
259// ---------------------------------------------------------------------------
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn metrics(elapsed_ms: u64, in_tok: u32, out_tok: u32, cost: Option<f64>) -> TurnMetrics {
266        TurnMetrics {
267            elapsed_ms,
268            usage: Some(TokenUsage {
269                input_tokens: in_tok,
270                output_tokens: out_tok,
271            }),
272            cost_usd: cost,
273            model_id: "gemma4:e2b".into(),
274            endpoint: "http://REDACTED-HOST:11434".into(),
275            ..Default::default()
276        }
277    }
278
279    #[test]
280    fn display_free_local() {
281        let m = metrics(3200, 847, 312, Some(0.0));
282        let line = m.display_line();
283        assert!(line.starts_with("3.2s"), "got: {line}");
284        assert!(line.contains("847"), "got: {line}");
285        assert!(line.contains("312"), "got: {line}");
286        assert!(line.contains("free (local)"), "got: {line}");
287    }
288
289    #[test]
290    fn display_with_cost() {
291        let m = metrics(8700, 1204, 892, Some(0.0041));
292        let line = m.display_line();
293        assert!(line.contains("$0.0041"), "got: {line}");
294        assert!(line.contains("1,204"), "got: {line}");
295    }
296
297    #[test]
298    fn display_tokens_unavailable() {
299        let m = TurnMetrics {
300            elapsed_ms: 5100,
301            usage: None,
302            cost_usd: None,
303            model_id: "gpt-4o".into(),
304            endpoint: "https://api.openai.com".into(),
305            ..Default::default()
306        };
307        let line = m.display_line();
308        assert!(line.contains("tokens unavailable"), "got: {line}");
309    }
310
311    #[test]
312    fn display_end_reason_flags_anomalous_endings() {
313        let mut m = metrics(3200, 100, 50, Some(0.0));
314        assert!(!m.display_line().contains("narration"), "unset stays quiet");
315        m.end_reason = Some(TurnEndReason::Completed);
316        assert!(
317            !m.display_line().contains("narration"),
318            "a normal completion stays quiet"
319        );
320        m.end_reason = Some(TurnEndReason::NarrationCapExhausted);
321        assert!(m
322            .display_line()
323            .contains("ended on narration (rescue budget spent)"));
324        m.end_reason = Some(TurnEndReason::NarrationFinalRound);
325        assert!(m
326            .display_line()
327            .contains("ended on narration (final round)"));
328        m.end_reason = Some(TurnEndReason::RoundCap);
329        assert!(m.display_line().contains("round cap"));
330        m.end_reason = Some(TurnEndReason::Empty);
331        assert!(m.display_line().contains("empty response"));
332    }
333
334    #[test]
335    fn end_reason_serializes_snake_case_and_skips_when_absent() {
336        let mut m = metrics(1, 1, 1, None);
337        let j = serde_json::to_string(&m).unwrap();
338        assert!(!j.contains("end_reason"), "{j}");
339        m.end_reason = Some(TurnEndReason::NarrationCapExhausted);
340        let j = serde_json::to_string(&m).unwrap();
341        assert!(
342            j.contains("\"end_reason\":\"narration_cap_exhausted\""),
343            "{j}"
344        );
345    }
346
347    #[test]
348    fn display_milliseconds_under_one_second() {
349        let m = metrics(850, 100, 50, None);
350        assert!(
351            m.display_line().starts_with("850ms"),
352            "got: {}",
353            m.display_line()
354        );
355    }
356
357    #[test]
358    fn fmt_count_thousands() {
359        assert_eq!(fmt_count(1000), "1,000");
360        assert_eq!(fmt_count(1234567), "1,234,567");
361        assert_eq!(fmt_count(42), "42");
362    }
363
364    #[test]
365    fn token_usage_total() {
366        let u = TokenUsage {
367            input_tokens: 300,
368            output_tokens: 150,
369        };
370        assert_eq!(u.total(), 450);
371    }
372
373    #[test]
374    fn token_usage_saturating_add() {
375        let a = TokenUsage {
376            input_tokens: 100,
377            output_tokens: 50,
378        };
379        let b = TokenUsage {
380            input_tokens: 200,
381            output_tokens: 75,
382        };
383        let sum = a.saturating_add(b);
384        assert_eq!(sum.input_tokens, 300);
385        assert_eq!(sum.output_tokens, 125);
386        // Saturation on overflow
387        let big = TokenUsage {
388            input_tokens: u32::MAX,
389            output_tokens: u32::MAX,
390        };
391        let sat = big.saturating_add(b);
392        assert_eq!(sat.input_tokens, u32::MAX);
393    }
394
395    #[test]
396    fn display_with_hallucinations() {
397        let mut m = metrics(3200, 847, 312, Some(0.0));
398        m.hallucinations = 2;
399        let line = m.display_line();
400        assert!(line.contains("2 hallucination(s) corrected"), "got: {line}");
401    }
402
403    #[test]
404    fn display_no_hallucinations_omits_warning() {
405        let m = metrics(3200, 847, 312, Some(0.0));
406        assert_eq!(m.hallucinations, 0);
407        assert!(
408            !m.display_line().contains("hallucination"),
409            "zero hallucinations must not appear in display"
410        );
411    }
412
413    #[test]
414    fn hallucinations_zero_skipped_in_json() {
415        let m = metrics(1000, 10, 5, Some(0.0));
416        let json = serde_json::to_string(&m).unwrap();
417        assert!(
418            !json.contains("hallucination"),
419            "zero hallucinations must be omitted from JSON"
420        );
421    }
422
423    #[test]
424    fn hallucinations_nonzero_in_json() {
425        let mut m = metrics(1000, 10, 5, Some(0.0));
426        m.hallucinations = 3;
427        let json = serde_json::to_string(&m).unwrap();
428        assert!(json.contains("\"hallucinations\":3"), "got: {json}");
429    }
430
431    #[test]
432    fn append_to_log_creates_file() {
433        let dir = tempfile::tempdir().unwrap();
434        let path = dir.path().join("usage.jsonl");
435        let m = metrics(1000, 10, 5, Some(0.0));
436        m.append_to_log(&path);
437        let content = std::fs::read_to_string(&path).unwrap();
438        assert!(content.contains("gemma4:e2b"));
439        assert!(content.ends_with('\n'));
440        // Append twice — should have two lines.
441        m.append_to_log(&path);
442        let raw = std::fs::read_to_string(&path).unwrap();
443        let lines: Vec<_> = raw.lines().collect();
444        assert_eq!(lines.len(), 2);
445    }
446
447    #[test]
448    fn rotation_session_limit_trims_oldest() {
449        let dir = tempfile::tempdir().unwrap();
450        let path = dir.path().join("usage.jsonl");
451        let m = metrics(1000, 10, 5, Some(0.0));
452        // Write 10 entries.
453        for _ in 0..10 {
454            m.append_to_log(&path);
455        }
456        let policy = crate::LogConfig {
457            max_sessions: 7,
458            ..Default::default()
459        };
460        rotate_log(&path, &policy).unwrap();
461        let n = std::fs::read_to_string(&path).unwrap().lines().count();
462        assert_eq!(n, 7, "should keep exactly max_sessions entries");
463    }
464
465    #[test]
466    fn rotation_session_limit_noop_when_under_cap() {
467        let dir = tempfile::tempdir().unwrap();
468        let path = dir.path().join("usage.jsonl");
469        let m = metrics(1000, 10, 5, Some(0.0));
470        for _ in 0..5 {
471            m.append_to_log(&path);
472        }
473        let policy = crate::LogConfig {
474            max_sessions: 7,
475            ..Default::default()
476        };
477        rotate_log(&path, &policy).unwrap();
478        let lines = std::fs::read_to_string(&path).unwrap().lines().count();
479        assert_eq!(lines, 5, "under cap — no entries should be dropped");
480    }
481
482    #[test]
483    fn rotation_size_limit_renames_and_resets() {
484        let dir = tempfile::tempdir().unwrap();
485        let path = dir.path().join("usage.jsonl");
486        let m = metrics(1000, 10, 5, Some(0.0));
487        // Write enough to exceed a tiny 1-byte cap.
488        m.append_to_log(&path);
489        let policy = crate::LogConfig {
490            max_sessions: 0,
491            max_size_mb: 0, // trigger via direct call with 1-byte threshold
492            keep_rotated: 2,
493            ..Default::default()
494        };
495        // Manually exercise rotate_log with a near-zero threshold.
496        let policy_tiny = crate::LogConfig {
497            max_size_mb: 0, // 0 = disabled; use a workaround via a custom struct
498            ..policy
499        };
500        // With size disabled the file should be unchanged.
501        rotate_log(&path, &policy_tiny).unwrap();
502        assert!(path.exists(), "file must still exist when size limit is 0");
503    }
504
505    #[test]
506    fn log_config_default_is_7_sessions() {
507        let cfg = crate::LogConfig::default();
508        assert_eq!(cfg.max_sessions, 7);
509        assert_eq!(cfg.max_size_mb, 0);
510        assert_eq!(cfg.max_age_days, 0);
511        assert_eq!(cfg.keep_rotated, 3);
512    }
513}