ctxoptimizer 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. ctxoptimizer/__init__.py +5 -0
  2. ctxoptimizer/__main__.py +14 -0
  3. ctxoptimizer/admin_security.py +130 -0
  4. ctxoptimizer/analysis/__init__.py +1 -0
  5. ctxoptimizer/analysis/baseline.py +69 -0
  6. ctxoptimizer/analysis/break_even.py +49 -0
  7. ctxoptimizer/analysis/drift.py +151 -0
  8. ctxoptimizer/analysis/edits.py +148 -0
  9. ctxoptimizer/analysis/markers.py +34 -0
  10. ctxoptimizer/analysis/recommend.py +114 -0
  11. ctxoptimizer/analysis/segments.py +57 -0
  12. ctxoptimizer/analysis/session.py +62 -0
  13. ctxoptimizer/analysis/state.py +239 -0
  14. ctxoptimizer/analysis/tokens.py +62 -0
  15. ctxoptimizer/analysis/waste.py +322 -0
  16. ctxoptimizer/cli.py +873 -0
  17. ctxoptimizer/config.py +512 -0
  18. ctxoptimizer/dashboard/__init__.py +1 -0
  19. ctxoptimizer/dashboard/api.py +537 -0
  20. ctxoptimizer/dashboard/service.py +157 -0
  21. ctxoptimizer/dashboard/static/.gitkeep +1 -0
  22. ctxoptimizer/dashboard/templates/.gitkeep +1 -0
  23. ctxoptimizer/dashboard/templates/doctor.html +46 -0
  24. ctxoptimizer/dashboard/templates/live.html +91 -0
  25. ctxoptimizer/dashboard/templates/overview.html +172 -0
  26. ctxoptimizer/dashboard/templates/pricing.html +115 -0
  27. ctxoptimizer/dashboard/templates/request.html +111 -0
  28. ctxoptimizer/dashboard/templates/session.html +67 -0
  29. ctxoptimizer/dashboard/templates/settings.html +79 -0
  30. ctxoptimizer/dashboard/templates/table.html +56 -0
  31. ctxoptimizer/dashboard/templates/transforms.html +118 -0
  32. ctxoptimizer/dashboard/templates/waste.html +120 -0
  33. ctxoptimizer/dashboard/views.py +429 -0
  34. ctxoptimizer/data/cache_limits.json +23 -0
  35. ctxoptimizer/data/default_config.toml +185 -0
  36. ctxoptimizer/data/pricing.json +421 -0
  37. ctxoptimizer/doctor.py +129 -0
  38. ctxoptimizer/guard.py +139 -0
  39. ctxoptimizer/ids.py +73 -0
  40. ctxoptimizer/log.py +73 -0
  41. ctxoptimizer/mcp.py +124 -0
  42. ctxoptimizer/models.py +145 -0
  43. ctxoptimizer/pipeline.py +126 -0
  44. ctxoptimizer/profiles.py +152 -0
  45. ctxoptimizer/providers/__init__.py +37 -0
  46. ctxoptimizer/providers/anthropic.py +105 -0
  47. ctxoptimizer/providers/base.py +191 -0
  48. ctxoptimizer/providers/gemini.py +143 -0
  49. ctxoptimizer/providers/ollama.py +133 -0
  50. ctxoptimizer/providers/openai_chat.py +135 -0
  51. ctxoptimizer/providers/openai_responses.py +121 -0
  52. ctxoptimizer/proxy.py +665 -0
  53. ctxoptimizer/routing.py +259 -0
  54. ctxoptimizer/server.py +319 -0
  55. ctxoptimizer/service.py +150 -0
  56. ctxoptimizer/telemetry/__init__.py +1 -0
  57. ctxoptimizer/telemetry/blobs.py +57 -0
  58. ctxoptimizer/telemetry/expand.py +62 -0
  59. ctxoptimizer/telemetry/pricing.py +168 -0
  60. ctxoptimizer/telemetry/redact.py +53 -0
  61. ctxoptimizer/telemetry/sse.py +145 -0
  62. ctxoptimizer/telemetry/store.py +829 -0
  63. ctxoptimizer/telemetry/usage.py +435 -0
  64. ctxoptimizer/transforms/__init__.py +87 -0
  65. ctxoptimizer/transforms/ansi.py +21 -0
  66. ctxoptimizer/transforms/anthropic_cache.py +100 -0
  67. ctxoptimizer/transforms/backref.py +74 -0
  68. ctxoptimizer/transforms/base.py +93 -0
  69. ctxoptimizer/transforms/cache_serve.py +146 -0
  70. ctxoptimizer/transforms/dedupe_lines.py +69 -0
  71. ctxoptimizer/transforms/eligibility.py +24 -0
  72. ctxoptimizer/transforms/openai_hints.py +84 -0
  73. ctxoptimizer/transforms/progress.py +52 -0
  74. ctxoptimizer/transforms/stale_result_elision.py +109 -0
  75. ctxoptimizer/transforms/truncate.py +100 -0
  76. ctxoptimizer/transforms/volatile.py +177 -0
  77. ctxoptimizer/transforms/whitespace.py +25 -0
  78. ctxoptimizer-0.4.1.dist-info/METADATA +196 -0
  79. ctxoptimizer-0.4.1.dist-info/RECORD +83 -0
  80. ctxoptimizer-0.4.1.dist-info/WHEEL +4 -0
  81. ctxoptimizer-0.4.1.dist-info/entry_points.txt +3 -0
  82. ctxoptimizer-0.4.1.dist-info/licenses/LICENSE +21 -0
  83. ctxoptimizer-0.4.1.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,114 @@
1
+ """Ranked recommendations from measured evidence (SPEC section 13.7).
2
+
3
+ P2 scope: a deterministic, advisory list. Nothing is auto-applied.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from ctxoptimizer.models import WasteFinding
11
+
12
+
13
+ def recommendations(
14
+ findings: list[WasteFinding],
15
+ *,
16
+ cache_serve_enabled: bool = False,
17
+ volatile_normalizer_enabled: bool = False,
18
+ transforms_enabled: bool = True,
19
+ ) -> list[dict[str, Any]]:
20
+ grouped: dict[str, list[WasteFinding]] = {}
21
+ for finding in findings:
22
+ grouped.setdefault(finding.detector, []).append(finding)
23
+
24
+ result: list[dict[str, Any]] = []
25
+ rereads = grouped.get("redundant_reread", [])
26
+ if rereads and not cache_serve_enabled:
27
+ tokens = sum(item.est_tokens_wasted or 0 for item in rereads)
28
+ result.append(
29
+ {
30
+ "id": "enable_cache_serve",
31
+ "title": "Enable cache_serve for repeated file reads",
32
+ "evidence": f"{len(rereads)} redundant re-read finding(s)",
33
+ "est_tokens": tokens,
34
+ "label": "sim.",
35
+ "action": "set transforms.cache_serve.enabled = true",
36
+ }
37
+ )
38
+ drift = grouped.get("recurring_drift", [])
39
+ if drift and not volatile_normalizer_enabled:
40
+ tokens = sum(item.est_tokens_wasted or 0 for item in drift)
41
+ result.append(
42
+ {
43
+ "id": "enable_volatile_normalizer",
44
+ "title": "Stabilize volatile system-prompt tokens",
45
+ "evidence": f"{len(drift)} recurring drift finding(s)",
46
+ "est_tokens": tokens,
47
+ "label": "sim.",
48
+ "action": "investigate timestamps/UUIDs in the system prompt",
49
+ }
50
+ )
51
+ if grouped.get("unbatched_fanout"):
52
+ result.append(
53
+ {
54
+ "id": "batch_reads",
55
+ "title": "Batch consecutive read calls",
56
+ "evidence": f"{len(grouped['unbatched_fanout'])} fan-out finding(s)",
57
+ "est_tokens": 0,
58
+ "label": "est.",
59
+ "action": "advisory: issue independent reads together",
60
+ }
61
+ )
62
+ if grouped.get("runaway_session"):
63
+ result.append(
64
+ {
65
+ "id": "investigate_runaway",
66
+ "title": "Investigate long-running unattended sessions",
67
+ "evidence": f"{len(grouped['runaway_session'])} runaway finding(s)",
68
+ "est_tokens": sum(
69
+ item.est_tokens_wasted or 0 for item in grouped["runaway_session"]
70
+ ),
71
+ "label": "est.",
72
+ "action": "advisory: stop idle agents before resuming",
73
+ }
74
+ )
75
+ if grouped.get("schema_bloat"):
76
+ result.append(
77
+ {
78
+ "id": "prune_mcp_schemas",
79
+ "title": "Prune unused tool schemas",
80
+ "evidence": f"{len(grouped['schema_bloat'])} unused schema finding(s)",
81
+ "est_tokens": sum(item.est_tokens_wasted or 0 for item in grouped["schema_bloat"]),
82
+ "label": "sim.",
83
+ "action": "advisory: remove MCP tools the agent never calls",
84
+ }
85
+ )
86
+ if grouped.get("churn_detector"):
87
+ result.append(
88
+ {
89
+ "id": "investigate_churn",
90
+ "title": "Investigate revert/whitespace edit churn",
91
+ "evidence": f"{len(grouped['churn_detector'])} churn finding(s)",
92
+ "est_tokens": sum(
93
+ item.est_tokens_wasted or 0 for item in grouped["churn_detector"]
94
+ ),
95
+ "label": "est.",
96
+ "action": "advisory: fix instructions or formatter loops",
97
+ }
98
+ )
99
+ if not transforms_enabled:
100
+ result.append(
101
+ {
102
+ "id": "enable_transforms",
103
+ "title": "Enable the deterministic transform pipeline",
104
+ "evidence": "pipeline disabled in config",
105
+ "est_tokens": 0,
106
+ "label": "sim.",
107
+ "action": "set mode = active after a shadow run",
108
+ }
109
+ )
110
+ result.sort(key=lambda item: int(item["est_tokens"]), reverse=True)
111
+ return result
112
+
113
+
114
+ __all__ = ["recommendations"]
@@ -0,0 +1,57 @@
1
+ """Canonical segments and content hashing (SPEC section 13.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from hashlib import blake2b
8
+ from typing import Any, Literal
9
+
10
+ import orjson
11
+
12
+ from ctxoptimizer.analysis.tokens import estimate_tokens
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Segment:
17
+ kind: Literal["tool_def", "system", "message"]
18
+ msg_index: int | None
19
+ block_index: int | None
20
+ hash: bytes
21
+ est_tokens: int
22
+ has_marker: bool
23
+
24
+
25
+ def block_hash(block: dict[str, Any]) -> bytes:
26
+ """Stable 8-byte hash of a block, ignoring cache markers."""
27
+ clean = {key: value for key, value in block.items() if key != "cache_control"}
28
+ encoded = orjson.dumps(clean, option=orjson.OPT_SORT_KEYS)
29
+ return blake2b(encoded, digest_size=8).digest()
30
+
31
+
32
+ def block_tokens(block: dict[str, Any]) -> int:
33
+ for key in ("text", "content"):
34
+ value = block.get(key)
35
+ if isinstance(value, str):
36
+ return estimate_tokens(value)
37
+ return max(1, math.ceil(len(orjson.dumps(block)) / 3.5))
38
+
39
+
40
+ def make_segment(
41
+ kind: Literal["tool_def", "system", "message"],
42
+ block: dict[str, Any],
43
+ *,
44
+ msg_index: int | None = None,
45
+ block_index: int | None = None,
46
+ ) -> Segment:
47
+ return Segment(
48
+ kind=kind,
49
+ msg_index=msg_index,
50
+ block_index=block_index,
51
+ hash=block_hash(block),
52
+ est_tokens=block_tokens(block),
53
+ has_marker="cache_control" in block,
54
+ )
55
+
56
+
57
+ __all__ = ["Segment", "block_hash", "block_tokens", "make_segment"]
@@ -0,0 +1,62 @@
1
+ """Session and project identification (SPEC section 13.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from hashlib import blake2b, sha256
8
+ from typing import Any
9
+
10
+ from ctxoptimizer.models import Provider
11
+ from ctxoptimizer.providers.base import ProviderAdapter
12
+
13
+ _WORKDIR_PATTERNS = (
14
+ re.compile(r"Working directory:\s*(\S+)"),
15
+ re.compile(r"<cwd>(.*?)</cwd>"),
16
+ re.compile(r"Primary working directory:\s*(\S+)"),
17
+ )
18
+
19
+
20
+ def key_fingerprint(headers: Mapping[str, str]) -> str:
21
+ """Stable fingerprint of the client credential; the raw key is never kept."""
22
+ value = headers.get("x-api-key") or headers.get("authorization") or ""
23
+ if not value:
24
+ return "anonymous"
25
+ return sha256(value.encode("utf-8")).hexdigest()[:12]
26
+
27
+
28
+ def derive_session_id(
29
+ provider: Provider,
30
+ headers: Mapping[str, str],
31
+ payload: dict[str, Any],
32
+ adapter: ProviderAdapter,
33
+ ) -> str:
34
+ system = adapter.system_text(payload)[:4096]
35
+ tool_names = "|".join(adapter.tool_names(payload))
36
+ first_user = adapter.first_user_text(payload)[:2048]
37
+ system_hash = blake2b((system + tool_names).encode("utf-8"), digest_size=8).hexdigest()
38
+ user_hash = blake2b(first_user.encode("utf-8"), digest_size=8).hexdigest()
39
+ material = "|".join((provider.value, key_fingerprint(headers), system_hash, user_hash))
40
+ return sha256(material.encode("utf-8")).hexdigest()[:16]
41
+
42
+
43
+ def project_from_text(text: str) -> str | None:
44
+ for pattern in _WORKDIR_PATTERNS:
45
+ match = pattern.search(text)
46
+ if match:
47
+ value = match.group(1).strip().rstrip("/\\")
48
+ if value:
49
+ return value.replace("\\", "/").split("/")[-1]
50
+ return None
51
+
52
+
53
+ def project_from_payload(payload: dict[str, Any], adapter: ProviderAdapter) -> str | None:
54
+ return project_from_text(f"{adapter.system_text(payload)}\n{adapter.first_user_text(payload)}")
55
+
56
+
57
+ __all__ = [
58
+ "derive_session_id",
59
+ "key_fingerprint",
60
+ "project_from_payload",
61
+ "project_from_text",
62
+ ]
@@ -0,0 +1,239 @@
1
+ """Per-session analysis state: drift + waste findings (P2 wiring)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from hashlib import sha256
7
+
8
+ from ctxoptimizer.analysis.baseline import CacheSim, simulate
9
+ from ctxoptimizer.analysis.drift import DriftEvent, detect_drift
10
+ from ctxoptimizer.analysis.markers import MarkerQuality, marker_quality
11
+ from ctxoptimizer.analysis.segments import Segment
12
+ from ctxoptimizer.analysis.tokens import estimate_tokens
13
+ from ctxoptimizer.analysis.waste import (
14
+ EditObservation,
15
+ HashObservation,
16
+ churn_edits,
17
+ identical_outputs,
18
+ recurring_drift,
19
+ redundant_rereads,
20
+ runaway_session,
21
+ schema_bloat,
22
+ )
23
+ from ctxoptimizer.models import WasteFinding
24
+ from ctxoptimizer.transforms.ansi import clean as ansi_clean
25
+ from ctxoptimizer.transforms.progress import clean as progress_clean
26
+
27
+
28
+ @dataclass
29
+ class SessionState:
30
+ hashes: dict[bytes, HashObservation] = field(default_factory=dict)
31
+ observations: list[HashObservation] = field(default_factory=list)
32
+ segments: list[Segment] | None = None
33
+ last_ts: float | None = None
34
+ turns: int = 0
35
+ drift_events: list[DriftEvent] = field(default_factory=list)
36
+ used_tools: set[str] = field(default_factory=set)
37
+ last_edits: dict[str, EditObservation] = field(default_factory=dict)
38
+ schema_reported: set[str] = field(default_factory=set)
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class AnalysisResult:
43
+ drift: DriftEvent | None
44
+ findings: list[WasteFinding]
45
+ marker_quality: MarkerQuality
46
+ baseline: CacheSim
47
+
48
+
49
+ class AnalysisState:
50
+ """Bounded in-memory analysis for the dashboard and CLI."""
51
+
52
+ def __init__(self, max_sessions: int = 256, max_findings: int = 500) -> None:
53
+ self.max_sessions = max_sessions
54
+ self.max_findings = max_findings
55
+ self._sessions: dict[str, SessionState] = {}
56
+ self._findings: list[WasteFinding] = []
57
+
58
+ def observe(
59
+ self,
60
+ *,
61
+ session_id: str,
62
+ project: str | None,
63
+ ts: float,
64
+ segments: list[Segment],
65
+ observations: list[HashObservation],
66
+ model: str,
67
+ config_hash: str,
68
+ ttl_s: float = 300.0,
69
+ tool_schemas: list[tuple[str, int]] | None = None,
70
+ used_tools: list[str] | None = None,
71
+ edits: list[EditObservation] | None = None,
72
+ churn_enabled: bool = False,
73
+ schema_bloat_enabled: bool = True,
74
+ schema_min_requests: int = 10,
75
+ cache_policy: str = "none",
76
+ ) -> AnalysisResult:
77
+ state = self._sessions.get(session_id)
78
+ if state is None:
79
+ if len(self._sessions) >= self.max_sessions:
80
+ self._sessions.pop(next(iter(self._sessions)))
81
+ state = SessionState()
82
+ self._sessions[session_id] = state
83
+
84
+ drift = detect_drift(
85
+ state.segments,
86
+ segments,
87
+ previous_ts=state.last_ts,
88
+ current_ts=ts,
89
+ ttl_s=ttl_s,
90
+ previous_params=model,
91
+ current_params=model,
92
+ previous_config_hash=config_hash,
93
+ current_config_hash=config_hash,
94
+ previous_model=model,
95
+ current_model=model,
96
+ )
97
+ baseline = simulate(
98
+ cache_policy,
99
+ state.segments,
100
+ state.last_ts,
101
+ segments,
102
+ ts,
103
+ ttl_s=ttl_s,
104
+ )
105
+ if drift is not None:
106
+ state.drift_events.append(drift)
107
+
108
+ findings: list[WasteFinding] = []
109
+ findings.extend(
110
+ redundant_rereads(
111
+ observations, state.hashes, session_id=session_id, project=project, ts=ts
112
+ )
113
+ )
114
+ findings.extend(
115
+ identical_outputs(
116
+ [*state.observations, *observations],
117
+ session_id=session_id,
118
+ project=project,
119
+ ts=ts,
120
+ )
121
+ )
122
+ runaway = runaway_session(
123
+ state.last_ts,
124
+ ts,
125
+ state.turns,
126
+ session_id=session_id,
127
+ project=project,
128
+ tokens=sum(item.tokens for item in observations),
129
+ )
130
+ if runaway is not None:
131
+ findings.append(runaway)
132
+ if drift is not None:
133
+ findings.extend(
134
+ recurring_drift(state.drift_events, session_id=session_id, project=project, ts=ts)
135
+ )
136
+ if used_tools:
137
+ state.used_tools.update(used_tools)
138
+ if schema_bloat_enabled and tool_schemas:
139
+ request_count = state.turns + 1
140
+ for name, tokens in tool_schemas:
141
+ if name in state.used_tools or name in state.schema_reported:
142
+ continue
143
+ bloat = schema_bloat(
144
+ name,
145
+ tokens,
146
+ request_count,
147
+ session_id=session_id,
148
+ project=project,
149
+ ts=ts,
150
+ min_requests=schema_min_requests,
151
+ )
152
+ if bloat is not None:
153
+ findings.append(bloat)
154
+ state.schema_reported.add(name)
155
+ if churn_enabled and edits:
156
+ findings.extend(
157
+ churn_edits(
158
+ edits,
159
+ state.last_edits,
160
+ session_id=session_id,
161
+ project=project,
162
+ ts=ts,
163
+ )
164
+ )
165
+
166
+ for observation in observations:
167
+ state.hashes[observation.hash] = observation
168
+ state.observations.extend(observations)
169
+ state.observations = state.observations[-2000:]
170
+ state.segments = segments
171
+ state.last_ts = ts
172
+ state.turns += 1
173
+ self._findings.extend(findings)
174
+ self._findings = self._dedupe(self._findings)[-self.max_findings :]
175
+ return AnalysisResult(
176
+ drift=drift,
177
+ findings=findings,
178
+ marker_quality=marker_quality(segments),
179
+ baseline=baseline,
180
+ )
181
+
182
+ def findings(self) -> list[WasteFinding]:
183
+ return list(self._findings)
184
+
185
+ @staticmethod
186
+ def _dedupe(findings: list[WasteFinding]) -> list[WasteFinding]:
187
+ by_key: dict[tuple[str, bytes], WasteFinding] = {}
188
+ for finding in findings:
189
+ key = (finding.detector, finding.key_hash)
190
+ existing = by_key.get(key)
191
+ if existing is None or existing.status not in {"new", "ack"}:
192
+ by_key[key] = finding
193
+ else:
194
+ existing.occurrences += finding.occurrences
195
+ existing.last_seen_ts = max(existing.last_seen_ts, finding.last_seen_ts)
196
+ existing.est_tokens_wasted = max(
197
+ existing.est_tokens_wasted or 0,
198
+ finding.est_tokens_wasted or 0,
199
+ )
200
+ existing.evidence = finding.evidence
201
+ return list(by_key.values())
202
+
203
+ def set_status(self, finding_id: str, status: str) -> bool:
204
+ allowed = {"new", "ack", "fixed", "skipped"}
205
+ if status not in allowed:
206
+ return False
207
+ for finding in self._findings:
208
+ if finding.finding_id == finding_id:
209
+ finding.status = status # type: ignore[assignment]
210
+ return True
211
+ return False
212
+
213
+ def clear(self) -> None:
214
+ self._sessions.clear()
215
+ self._findings.clear()
216
+
217
+
218
+ def observations_from_texts(
219
+ texts: list[tuple[str | None, str | None, str]],
220
+ ) -> list[HashObservation]:
221
+ observations: list[HashObservation] = []
222
+ for _tool_use_id, tool_name, text in texts:
223
+ sigma = progress_clean(ansi_clean(text))
224
+ observations.append(
225
+ HashObservation(
226
+ hash=sha256(sigma.encode("utf-8")).digest(),
227
+ tokens=estimate_tokens(text),
228
+ tool_name=tool_name,
229
+ )
230
+ )
231
+ return observations
232
+
233
+
234
+ __all__ = [
235
+ "AnalysisResult",
236
+ "AnalysisState",
237
+ "SessionState",
238
+ "observations_from_texts",
239
+ ]
@@ -0,0 +1,62 @@
1
+ """Token estimation and calibration (SPEC section 12.0.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.util
7
+ import math
8
+ from functools import lru_cache
9
+ from typing import Any
10
+
11
+
12
+ def _load_tiktoken() -> Any | None:
13
+ if importlib.util.find_spec("tiktoken") is None:
14
+ return None
15
+ try:
16
+ return importlib.import_module("tiktoken")
17
+ except Exception:
18
+ return None
19
+
20
+
21
+ _tiktoken = _load_tiktoken()
22
+
23
+
24
+ @lru_cache(maxsize=2)
25
+ def _encoding(name: str) -> Any:
26
+ if _tiktoken is None:
27
+ raise RuntimeError("tiktoken is not available")
28
+ return _tiktoken.get_encoding(name)
29
+
30
+
31
+ def estimate_tokens(text: str, family: str = "generic") -> int:
32
+ """Estimate tokens with tiktoken when available, else a stable heuristic."""
33
+ if not text:
34
+ return 0
35
+ if _tiktoken is not None:
36
+ encoding_name = "o200k_base" if family == "openai" else "cl100k_base"
37
+ try:
38
+ return max(1, len(_encoding(encoding_name).encode(text)))
39
+ except Exception:
40
+ return max(1, math.ceil(len(text) / 3.5))
41
+ return max(1, math.ceil(len(text) / 3.5))
42
+
43
+
44
+ class Calibrator:
45
+ """Exponential moving average of actual/estimated tokens per family."""
46
+
47
+ def __init__(self, alpha: float = 0.1) -> None:
48
+ self.alpha = alpha
49
+ self.ratios: dict[str, float] = {}
50
+
51
+ def update(self, family: str, actual: int, estimated: int) -> None:
52
+ if estimated <= 0 or actual <= 0:
53
+ return
54
+ ratio = actual / estimated
55
+ previous = self.ratios.get(family, 1.0)
56
+ self.ratios[family] = (1 - self.alpha) * previous + self.alpha * ratio
57
+
58
+ def apply(self, family: str, estimate: int) -> int:
59
+ return max(0, round(estimate * self.ratios.get(family, 1.0)))
60
+
61
+
62
+ __all__ = ["Calibrator", "estimate_tokens"]