oc_chat
Loading...
Searching...
No Matches
metrics.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/benchmarks/metrics.py
4
5"""
6Pure metric extraction + aggregation for the benchmark suite.
7
8Purpose
9-------
10Turn a raw provider ``usage`` dict (the one ``presenter.show_usage``
11receives — the only place ``reasoning_tokens`` and DeepSeek's prefix-
12cache split survive; ``provider_plugin.UsageStats`` drops the former
13and the harness wants both) into a typed :class:`CallMetrics`, then
14aggregate calls → turns → run totals. **Hit and miss tokens are kept
15distinct and never summed** — cache-hit is ~120x cheaper on DeepSeek
16V4-pro, so collapsing them would erase the very economics the suite
17measures.
18
19All functions are pure: no I/O, no network, no global state. This is
20the hermetically-tested core (plan 136 §6 tests 1-2).
21
22Responsibilities
23----------------
24- :class:`CallMetrics` — one API call's token figures.
25- :class:`TurnMetrics` — one scripted turn (its calls + TTFT + tool
26 names + wall-clock + iteration/tool counts + cold/warm label).
27- :func:`extract_call_metrics` — raw usage dict → :class:`CallMetrics`.
28- :func:`aggregate_turn` / :func:`aggregate_run` — sum the token
29 fields, hit/miss kept separate.
30- :func:`derived_full_miss_cost` — the deterministic cold-turn cost
31 (``prompt_tokens x miss-rate``), Plan 135's win #2.
32
33Diagnostics
34-----------
35Domain: OC_CHAT.MAIN
36Levels:
37 L2 — lifecycle events
38 L3 — semantic details
39 L4 — deep tracing
40
41Contracts
42---------
43- ``extract_call_metrics`` never raises on a malformed/partial usage
44 dict — missing fields yield zeros, not ``KeyError``.
45- Cache hit and miss are independent fields; no function sums them.
46- Cache extraction delegates to the production
47 :func:`oc_chat.core.cache_usage.usage_prompt_cache_hit_miss` so the
48 numbers match the runtime exactly (and inherit its
49 DeepSeek-then-OpenAI fallback).
50
51Dependencies
52------------
53- oc_diagnostics (structured debug logging via _dbg)
54- oc_chat.core.cache_usage (usage_prompt_cache_hit_miss — read-only)
55"""
56from __future__ import annotations
57
58from dataclasses import dataclass, field
59
60from oc_diagnostics import _dbg
61
62from oc_chat.core.cache_usage import usage_prompt_cache_hit_miss
63
64_dbg("OC_CHAT.MAIN", "module", "metrics loaded", 4)
65
66
67@dataclass(frozen=True)
69 """Per-API-call token figures, normalised from the raw usage dict."""
70
71 prompt_tokens: int = 0
72 completion_tokens: int = 0
73 total_tokens: int = 0
74 reasoning_tokens: int = 0
75 cache_hit_tokens: int = 0
76 cache_miss_tokens: int = 0
77
78
79@dataclass
81 """One scripted turn's measurements.
82
83 ``ttft_ms`` is the first iteration's time-to-first-token (``None``
84 when an iteration emitted only tool calls — never fabricated).
85 ``cold_label`` is one of ``"cold_unprewarmed"`` / ``"prewarmed"``
86 for turn 1, or ``"warmed"`` for turns 2+ (warmup-aware, plan §3.3).
87 """
88
89 index: int = 0
90 cold_label: str = "warmed"
91 calls: list[CallMetrics] = field(default_factory=list)
92 ttft_ms: float | None = None
93 wall_clock_ms: float = 0.0
94 iterations: int = 0
95 main_loop_tool_names: list[str] = field(default_factory=list)
96 sub_loop_count: int = 0
97 sub_loop_tool_names_available: bool = False
98
99
100def _as_int(value: object) -> int:
101 """Coerce a usage value to int, treating ``None``/garbage as 0."""
102 try:
103 return int(value or 0)
104 except (TypeError, ValueError):
105 return 0
106
107
108def _reasoning_tokens(raw_usage: dict) -> int:
109 """Extract reasoning tokens (DeepSeek nests them under details).
110
111 Primary location: ``completion_tokens_details.reasoning_tokens``.
112 Fallback: a top-level ``reasoning_tokens`` if a provider emits one.
113 """
114 details = raw_usage.get("completion_tokens_details")
115 if isinstance(details, dict) and details.get("reasoning_tokens") is not None:
116 return _as_int(details.get("reasoning_tokens"))
117 return _as_int(raw_usage.get("reasoning_tokens"))
118
119
120def extract_call_metrics(raw_usage: dict | None) -> CallMetrics:
121 """Normalise a single raw provider ``usage`` dict into :class:`CallMetrics`.
122
123 Never raises: a ``None`` or partial dict yields zero-filled metrics.
124 Cache hit/miss come from the production helper so they match the
125 runtime; reasoning tokens come from the raw nested field that
126 ``UsageStats`` does not carry.
127 """
128 if not isinstance(raw_usage, dict):
129 return CallMetrics()
130 hit, miss = usage_prompt_cache_hit_miss(raw_usage)
131 return CallMetrics(
132 prompt_tokens=_as_int(raw_usage.get("prompt_tokens")),
133 completion_tokens=_as_int(raw_usage.get("completion_tokens")),
134 total_tokens=_as_int(raw_usage.get("total_tokens")),
135 reasoning_tokens=_reasoning_tokens(raw_usage),
136 cache_hit_tokens=int(hit),
137 cache_miss_tokens=int(miss),
138 )
139
140
141def aggregate_calls(calls: list[CallMetrics]) -> CallMetrics:
142 """Sum a list of :class:`CallMetrics`. Hit and miss stay separate."""
143 return CallMetrics(
144 prompt_tokens=sum(c.prompt_tokens for c in calls),
145 completion_tokens=sum(c.completion_tokens for c in calls),
146 total_tokens=sum(c.total_tokens for c in calls),
147 reasoning_tokens=sum(c.reasoning_tokens for c in calls),
148 cache_hit_tokens=sum(c.cache_hit_tokens for c in calls),
149 cache_miss_tokens=sum(c.cache_miss_tokens for c in calls),
150 )
151
152
153def aggregate_turn(turn: TurnMetrics) -> CallMetrics:
154 """Sum one turn's calls into a single :class:`CallMetrics`."""
155 return aggregate_calls(turn.calls)
156
157
158def aggregate_run(turns: list[TurnMetrics]) -> CallMetrics:
159 """Sum every call across every turn into the run total."""
160 all_calls: list[CallMetrics] = []
161 for turn in turns:
162 all_calls.extend(turn.calls)
163 return aggregate_calls(all_calls)
164
165
166def derived_full_miss_cost(prompt_tokens: int, miss_rate_per_mtok: float) -> float:
167 """The deterministic cold-turn cost: a full cache-miss of the prompt.
168
169 ``prompt_tokens x miss-rate`` (rate is $/Mtok). This follows purely
170 from prompt size, so it proves Plan 135's win #2 (a slim prompt is
171 cheaper on the guaranteed-cold first turn) regardless of whatever
172 server cache state the run actually hit.
173 """
174 return (max(0, int(prompt_tokens)) / 1_000_000.0) * float(miss_rate_per_mtok)
175
176
177__all__ = [
178 "CallMetrics",
179 "TurnMetrics",
180 "extract_call_metrics",
181 "aggregate_calls",
182 "aggregate_turn",
183 "aggregate_run",
184 "derived_full_miss_cost",
185]
int _as_int(object value)
Definition metrics.py:100
CallMetrics extract_call_metrics(dict|None raw_usage)
Definition metrics.py:120
int _reasoning_tokens(dict raw_usage)
Definition metrics.py:108
CallMetrics aggregate_calls(list[CallMetrics] calls)
Definition metrics.py:141
CallMetrics aggregate_turn(TurnMetrics turn)
Definition metrics.py:153
CallMetrics aggregate_run(list[TurnMetrics] turns)
Definition metrics.py:158
float derived_full_miss_cost(int prompt_tokens, float miss_rate_per_mtok)
Definition metrics.py:166