oc_chat
Loading...
Searching...
No Matches
stats.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/stats.py
4
5"""
6Real-time statistics tracking for the OC Chat enhanced terminal UI.
7
8Purpose
9-------
10Provides the presentation-side statistics tracker that backs the
11enhanced terminal header with real-time turn / session / context-usage
12counters. Relocated here from ``oc_chat.core.stats`` by plan 153: the
13tracker's sole owner and mutator is :class:`~oc_ui.terminal.enhanced_presenter.EnhancedPresenter`,
14so it belongs in the ``oc_ui`` adapter layer, not in the harness core.
15Keeping it here removes the ``oc_ui -> oc_chat.core.stats`` import edge
16named as a boundary residual in plan 151 §8.
17
18Responsibilities
19-------------
20- StatisticsTracker: Main class for tracking all metrics
21- Token formatting utilities for K/MB/GB display
22- Color-coded statistics for different metric types
23- Real-time updates during sessions
24
25Diagnostics
26-----------
27Domain: OC_UI.STATS
28Levels:
29 L2 — lifecycle events
30 L3 — semantic details
31 L4 — deep tracing
32
33Contracts
34---------
35- All statistics are thread-safe and atomic
36- Token counts are formatted with proper units
37- Percentages are always displayed with % symbol
38- Time values are formatted with appropriate units
39- This module imports stdlib + oc_diagnostics only; it MUST NOT import
40 ``oc_chat`` (the plan-153 boundary the DAG guard asserts).
41
42Dependencies
43------------
44- oc_diagnostics (structured debug logging via _dbg)
45"""
46
47from __future__ import annotations
48import threading
49from typing import Dict, List, NamedTuple
50from dataclasses import dataclass, field
51
52from oc_diagnostics import _dbg
53
54DEFAULT_CONTEXT_WINDOW_SIZE: int = 128_000
55# OCT-LINT: disable=no_hardcoded_secrets reason="Docstring prose (context-window cap description) mis-flagged as a high-entropy string; not a secret"
56"""Default context-window token cap for the enhanced UI's context meter.
57
58UI-local fallback that mirrors the harness default of the same name in
59``oc_chat.core.stats`` (consumed by ``cli.py``). The presenter always
60overrides it via :meth:`StatisticsTracker.set_context_window_size` right
61after construction — this constant only governs the meter before the
62first bind. Kept UI-local so this module carries no ``oc_chat`` import.
63"""
64
65
66@dataclass
68 """Token usage statistics."""
69 prompt: int = 0
70 completion: int = 0
71 total: int = 0
72 cache_hits: int = 0
73 cache_hit_rate: float = 0.0
74 duration_ms: int = 0
75
77 self,
78 usage_data: dict | int,
79 completion: int = 0,
80 cache_hits: int = 0,
81 duration_ms: int = 0,
82 ) -> None:
83 """Add token usage from an API response dictionary or counters."""
84 if isinstance(usage_data, dict):
85 prompt = usage_data.get('prompt_tokens', 0)
86 completion = usage_data.get('completion_tokens', 0)
87 cache_hits = usage_data.get('cache_hit_tokens', 0)
88 duration = usage_data.get('dur_ms', 0)
89 else:
90 prompt = usage_data
91 duration = duration_ms
92
93 self.prompt += prompt
94 self.completion += completion
95 self.total += prompt + completion
96 self.cache_hits += cache_hits
97 self.duration_ms = duration
98
99 if self.total > 0:
100 self.cache_hit_rate = (self.cache_hits / self.total) * 100
101
102 def format_tokens(self, count: int) -> str:
103 """Format token count with appropriate units."""
104 if count < 1024:
105 return f"{count}"
106 elif count < 1_048_576:
107 return f"{count // 1024}K"
108 elif count < 1_073_741_824:
109 return f"{count / 1_048_576:.1f}M"
110 else:
111 return f"{count / 1_073_741_824:.1f}G"
112
113
114@dataclass
116 """Timing and performance statistics."""
117 duration_ms: int = 0
118 tool_calls: int = 0
119 iteration_count: int = 0
120
121 def add_timing(self, duration_ms: int, tool_calls: int = 0) -> None:
122 """Add timing information from a turn."""
123 self.duration_ms = duration_ms
124 self.tool_calls += tool_calls
125
126
127@dataclass
129 """Session-level statistics.
130
131 Wall-clock session duration is NOT tracked here (plan 147 D1) — the
132 old ``update_session_totals`` accumulator wrote the same
133 ``timing.duration_ms`` field ``TimingStats.add_timing`` overwrites,
134 a genuine overwrite-vs-accumulate conflict with zero readers. Real
135 session-elapsed timing now lives on
136 :class:`~oc_ui.terminal.input.TurnStats` (monotonic, process-local)
137 and reaches the enhanced header via :class:`~oc_ui.terminal.statusline.StatusBarState`.
138 """
139 tokens: TokenStats = field(default_factory=TokenStats)
140 timing: TimingStats = field(default_factory=TimingStats)
141
142
143@dataclass
145 """Agent/sub-loop tracking statistics."""
146 active_agents: int = 0
147 total_agents_launched: int = 0
148 sub_loops_running: int = 0
149 total_sub_loops_launched: int = 0
150
151 def add_agent(self) -> None:
152 """Add a new agent to tracking."""
153 self.active_agents += 1
154 self.total_agents_launched += 1
155
156 def add_sub_loop(self) -> None:
157 """Add a new sub-loop to tracking."""
158 self.sub_loops_running += 1
160
161 def finish_agent(self) -> None:
162 """Mark an agent as finished."""
163 if self.active_agents > 0:
164 self.active_agents -= 1
165
166 def finish_sub_loop(self) -> None:
167 """Mark a sub-loop as finished."""
168 if self.sub_loops_running > 0:
169 self.sub_loops_running -= 1
170
171
173 """Main statistics tracker for OC Chat enhanced UI."""
174
175 def __init__(self) -> None:
176 func = "StatisticsTracker.__init__"
177 self._lock = threading.Lock()
181 self._context_usage = 0.0 # Percentage of context window used
182 self._max_context_size = DEFAULT_CONTEXT_WINDOW_SIZE
184 _dbg("OC_UI.STATS", func, "initialized", 2)
185
187 self,
188 prompt: int = 0,
189 completion: int = 0,
190 cache_hits: int = 0,
191 duration_ms: int = 0,
192 tool_calls: int = 0,
193 **aliases,
194 ) -> None:
195 """Update statistics for a completed turn."""
196 func = "update_turn_stats"
197 prompt = aliases.pop("prompt_tokens", prompt)
198 completion = aliases.pop("completion_tokens", completion)
199 cache_hits = aliases.pop("cache_hit_tokens", cache_hits)
200 cache_hits = aliases.pop("prompt_cached", cache_hits)
201 with self._lock:
202 self._turn_stats.add_usage(prompt, completion, cache_hits, duration_ms)
203 self._session_stats.tokens.add_usage(prompt, completion, cache_hits)
204 self._session_stats.timing.add_timing(duration_ms, tool_calls)
205 self._session_stats.timing.iteration_count += 1
206 self._last_turn_tool_calls = tool_calls
207 _dbg("OC_UI.STATS", func,
208 f"turn: prompt={prompt}, completion={completion}, "
209 f"cache_hits={cache_hits}, duration={duration_ms}ms, tools={tool_calls}", 3)
210
211 def update_context_usage(self, used_tokens: int) -> None:
212 """Update context window usage percentage."""
213 func = "update_context_usage"
214 with self._lock:
215 if self._max_context_size > 0:
216 self._context_usage = min(100.0, (used_tokens / self._max_context_size) * 100)
217 _dbg("OC_UI.STATS", func, f"context: {used_tokens}/{self._max_context_size} = {self._context_usage:.1f}%", 3)
218
219 def add_agent(self) -> None:
220 """Add a new agent to tracking."""
221 func = "add_agent"
222 with self._lock:
224 _dbg("OC_UI.STATS", func, f"active_agents={self._agent_stats.active_agents}", 3)
225
226 def finish_agent(self) -> None:
227 """Mark an agent as finished."""
228 func = "finish_agent"
229 with self._lock:
231 _dbg("OC_UI.STATS", func, f"active_agents={self._agent_stats.active_agents}", 3)
232
233 def add_sub_loop(self) -> None:
234 """Add a new sub-loop to tracking."""
235 func = "add_sub_loop"
236 with self._lock:
238 _dbg("OC_UI.STATS", func, f"sub_loops_running={self._agent_stats.sub_loops_running}", 3)
239
240 def finish_sub_loop(self) -> None:
241 """Mark a sub-loop as finished."""
242 func = "finish_sub_loop"
243 with self._lock:
245 _dbg("OC_UI.STATS", func, f"sub_loops_running={self._agent_stats.sub_loops_running}", 3)
246
247 def get_stats_snapshot(self) -> Dict[str, any]:
248 """Get a snapshot of all current statistics."""
249 with self._lock:
250 agents = {
251 "active": self._agent_stats.active_agents,
252 "total_launched": self._agent_stats.total_agents_launched,
253 "sub_loops_running": self._agent_stats.sub_loops_running,
254 "total_sub_loops": self._agent_stats.total_sub_loops_launched,
255 }
256 context = {
257 "usage_percent": self._context_usage,
258 "max_size": self._max_context_size,
259 }
260 last_turn = {
261 "prompt": self._turn_stats.prompt,
262 "completion": self._turn_stats.completion,
263 "total": self._turn_stats.total,
264 "cache_hit_rate": self._turn_stats.cache_hit_rate,
265 "duration_ms": self._turn_stats.duration_ms,
266 "tool_calls": self._last_turn_tool_calls,
267 }
268 return {
269 "agents": agents,
270 "context": context,
271 "last_turn": last_turn,
272 "session": {
273 "tokens": {
274 "prompt": self._session_stats.tokens.prompt,
275 "completion": self._session_stats.tokens.completion,
276 "total": self._session_stats.tokens.total,
277 "cache_hit_rate": self._session_stats.tokens.cache_hit_rate,
278 },
279 "timing": {
280 "duration_ms": self._session_stats.timing.duration_ms,
281 "tool_calls": self._session_stats.timing.tool_calls,
282 "iteration_count": self._session_stats.timing.iteration_count,
283 },
284 "agents": agents,
285 "context": context,
286 "last_turn": last_turn,
287 }
288 }
289
290 def set_context_window_size(self, max_size: int) -> None:
291 """Set the maximum context window size."""
292 func = "set_context_window_size"
293 with self._lock:
294 self._max_context_size = max_size
295 _dbg("OC_UI.STATS", func, f"max_context_size={max_size}", 3)
Dict[str, any] get_stats_snapshot(self)
Definition stats.py:247
None set_context_window_size(self, int max_size)
Definition stats.py:290
None update_context_usage(self, int used_tokens)
Definition stats.py:211
None update_turn_stats(self, int prompt=0, int completion=0, int cache_hits=0, int duration_ms=0, int tool_calls=0, **aliases)
Definition stats.py:194
None add_timing(self, int duration_ms, int tool_calls=0)
Definition stats.py:121
str format_tokens(self, int count)
Definition stats.py:102
None add_usage(self, dict|int usage_data, int completion=0, int cache_hits=0, int duration_ms=0)
Definition stats.py:82