codex-meter 0.3.0__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.
codex_meter/config.py ADDED
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ from codex_meter.models import RuntimeOptions
8
+ from codex_meter.pricing import normalize_service_tier
9
+ from codex_meter.timeutil import load_timezone, local_timezone, parse_datetime
10
+
11
+ DEFAULT_SESSION_ROOT = Path.home() / ".codex" / "sessions"
12
+ DEFAULT_STATE_DB = Path.home() / ".codex" / "state_5.sqlite"
13
+ DEFAULT_CODEX_CONFIG = Path.home() / ".codex" / "config.toml"
14
+ USER_CONFIG = Path.home() / ".config" / "codex-meter" / "config.toml"
15
+ LOCAL_CONFIG = Path(".codex-meter.toml")
16
+
17
+
18
+ def load_config(explicit_path: Path | None = None) -> dict:
19
+ paths = [USER_CONFIG, LOCAL_CONFIG]
20
+ if explicit_path:
21
+ paths.append(explicit_path)
22
+ loaded: dict = {}
23
+ for path in paths:
24
+ if path is None:
25
+ continue
26
+ expanded = path.expanduser()
27
+ if not expanded.exists():
28
+ continue
29
+ try:
30
+ loaded |= tomllib.loads(expanded.read_text())
31
+ except (OSError, tomllib.TOMLDecodeError) as exc:
32
+ raise ValueError(f"Could not load config {expanded}: {exc}") from exc
33
+ return loaded
34
+
35
+
36
+ def cfg(config: dict, key: str, value, default):
37
+ if value != default:
38
+ return value
39
+ return config.get(key, default)
40
+
41
+
42
+ def cfg_path(config: dict, key: str, value: Path | None, default: Path) -> Path:
43
+ if value is not None:
44
+ return Path(value).expanduser()
45
+ return Path(config.get(key, default)).expanduser()
46
+
47
+
48
+ def cfg_optional_path(config: dict, key: str, value: Path | None) -> Path | None:
49
+ if value is not None:
50
+ return Path(value).expanduser()
51
+ configured = config.get(key)
52
+ return Path(configured).expanduser() if configured else None
53
+
54
+
55
+ def cfg_bool(config: dict, key: str, value: bool, default: bool) -> bool:
56
+ if value != default:
57
+ return bool(value)
58
+ return bool(config.get(key, default))
59
+
60
+
61
+ def validate_choice(name: str, value: str, allowed: set[str]) -> str:
62
+ if value not in allowed:
63
+ choices = ", ".join(sorted(allowed))
64
+ raise ValueError(f"{name} must be one of: {choices}")
65
+ return value
66
+
67
+
68
+ def build_options(
69
+ *,
70
+ since: str | None = None,
71
+ until: str | None = None,
72
+ days: float | None = None,
73
+ timezone: str = "local",
74
+ session_root: Path | None = None,
75
+ state_db: Path | None = None,
76
+ codex_config: Path | None = None,
77
+ config: Path | None = None,
78
+ pricing_mode: str = "model",
79
+ service_tier: str = "auto",
80
+ unknown_service_tier: str = "current-config",
81
+ tier_overrides: Path | None = None,
82
+ rates_file: Path | None = None,
83
+ no_dedupe: bool = False,
84
+ no_parse_cache: bool = False,
85
+ default_model: str = "gpt-5.5",
86
+ show_prompts: bool = False,
87
+ offline: bool = True,
88
+ compact: bool = False,
89
+ width: int | None = None,
90
+ top_threads: int = 10,
91
+ ) -> RuntimeOptions:
92
+ loaded = load_config(config)
93
+ end = parse_datetime(until, dt.datetime.now(tz=local_timezone()))
94
+ if since:
95
+ start = parse_datetime(since)
96
+ elif days is not None:
97
+ if days <= 0:
98
+ raise ValueError("--days must be greater than 0")
99
+ start = end - dt.timedelta(days=days)
100
+ else:
101
+ default_days = float(loaded.get("default_days", 30))
102
+ if default_days <= 0:
103
+ raise ValueError("default_days must be greater than 0")
104
+ start = end - dt.timedelta(days=default_days)
105
+ if start >= end:
106
+ raise ValueError("--since/--start must be before --until/--end")
107
+
108
+ timezone_value = str(cfg(loaded, "timezone", timezone, "local"))
109
+ load_timezone(timezone_value)
110
+ pricing_mode_value = validate_choice(
111
+ "--pricing-mode",
112
+ str(cfg(loaded, "pricing_mode", pricing_mode, "model")),
113
+ {"flat", "model"},
114
+ )
115
+ service_tier_value = str(cfg(loaded, "service_tier", service_tier, "auto"))
116
+ if service_tier_value != "auto":
117
+ service_tier_value = normalize_service_tier(service_tier_value)
118
+ service_tier_value = validate_choice(
119
+ "--service-tier",
120
+ service_tier_value,
121
+ {"auto", "fast", "standard"},
122
+ )
123
+ unknown_service_tier_value = str(
124
+ cfg(loaded, "unknown_service_tier", unknown_service_tier, "current-config")
125
+ )
126
+ if unknown_service_tier_value != "current-config":
127
+ unknown_service_tier_value = normalize_service_tier(unknown_service_tier_value)
128
+ unknown_service_tier_value = validate_choice(
129
+ "--unknown-service-tier",
130
+ unknown_service_tier_value,
131
+ {"current-config", "fast", "standard"},
132
+ )
133
+ top_threads_value = int(cfg(loaded, "top_threads", top_threads, 10))
134
+ if top_threads_value <= 0:
135
+ raise ValueError("--top-threads must be greater than 0")
136
+ width_value = cfg(loaded, "width", width, None)
137
+ if width_value is not None:
138
+ width_value = int(width_value)
139
+ if width_value <= 0:
140
+ raise ValueError("--width must be greater than 0")
141
+ no_dedupe_value = cfg_bool(loaded, "no_dedupe", no_dedupe, False)
142
+ no_parse_cache_value = cfg_bool(loaded, "no_parse_cache", no_parse_cache, False)
143
+
144
+ return RuntimeOptions(
145
+ session_root=cfg_path(loaded, "session_root", session_root, DEFAULT_SESSION_ROOT),
146
+ state_db=cfg_path(loaded, "state_db", state_db, DEFAULT_STATE_DB),
147
+ config_path=cfg_path(loaded, "codex_config", codex_config, DEFAULT_CODEX_CONFIG),
148
+ start=start,
149
+ end=end,
150
+ timezone=timezone_value,
151
+ pricing_mode=pricing_mode_value,
152
+ service_tier=service_tier_value,
153
+ unknown_service_tier=unknown_service_tier_value,
154
+ tier_overrides=cfg_optional_path(loaded, "tier_overrides", tier_overrides),
155
+ rates_file=cfg_optional_path(loaded, "rates_file", rates_file),
156
+ dedupe=not no_dedupe_value,
157
+ parse_cache=not no_parse_cache_value,
158
+ default_model=str(cfg(loaded, "default_model", default_model, "gpt-5.5")),
159
+ show_prompts=cfg_bool(loaded, "show_prompts", show_prompts, False),
160
+ offline=cfg_bool(loaded, "offline", offline, True),
161
+ compact=cfg_bool(loaded, "compact", compact, False),
162
+ width=width_value,
163
+ top_threads=top_threads_value,
164
+ )
@@ -0,0 +1,339 @@
1
+ """Export formats: monthly receipts (markdown/html) and Grafana dashboard JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import html
7
+ import json
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+
11
+ from codex_meter.models import Aggregate
12
+
13
+ DEFAULT_DASHBOARD_TITLE = "Codex Meter"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ReceiptInputs:
18
+ month: str # YYYY-MM
19
+ totals: Aggregate
20
+ by_model: list[Aggregate]
21
+ top_sessions: list[Aggregate]
22
+ top_projects: list[Aggregate]
23
+ generated_at: dt.datetime
24
+ tier_sources: dict[str, int] | None = None
25
+ insights: list[str] | None = None
26
+ warning_count: int = 0
27
+ pricing_status: str = "exact"
28
+ pricing_warnings: list[str] | None = None
29
+
30
+
31
+ def month_bounds(month: str, tz: dt.tzinfo) -> tuple[dt.datetime, dt.datetime]:
32
+ """Parse 'YYYY-MM' into [start, end) local-tz datetimes."""
33
+ try:
34
+ year, month_num = month.split("-")
35
+ year_int = int(year)
36
+ month_int = int(month_num)
37
+ except ValueError as exc:
38
+ raise ValueError(f"Invalid --month {month!r}; expected YYYY-MM") from exc
39
+ if not 1 <= month_int <= 12:
40
+ raise ValueError(f"Month must be 1..12, got {month_int}")
41
+ start = dt.datetime(year_int, month_int, 1, tzinfo=tz)
42
+ if month_int == 12:
43
+ end = dt.datetime(year_int + 1, 1, 1, tzinfo=tz)
44
+ else:
45
+ end = dt.datetime(year_int, month_int + 1, 1, tzinfo=tz)
46
+ return start, end
47
+
48
+
49
+ def _format_money(value: float) -> str:
50
+ return f"${value:,.2f}"
51
+
52
+
53
+ def _format_credits(value: float) -> str:
54
+ return f"{value:,.2f}"
55
+
56
+
57
+ def _format_int(value: int) -> str:
58
+ return f"{value:,}"
59
+
60
+
61
+ def render_receipt_markdown(payload: ReceiptInputs) -> str:
62
+ lines: list[str] = []
63
+ lines.append(f"# Codex Meter Receipt — {payload.month}")
64
+ lines.append("")
65
+ lines.append(f"Generated: {payload.generated_at.isoformat()}")
66
+ lines.append("")
67
+ lines.append("## Totals")
68
+ lines.append("")
69
+ lines.append("| Metric | Value |")
70
+ lines.append("| --- | ---: |")
71
+ lines.append(f"| Credits | {_format_credits(payload.totals.costs.adjusted_credits)} |")
72
+ lines.append(f"| API $ | {_format_money(payload.totals.costs.api_dollars)} |")
73
+ cache_savings = _format_credits(payload.totals.cache_savings.adjusted_credits)
74
+ lines.append(
75
+ f"| Cache savings | {cache_savings} credits / "
76
+ f"{_format_money(payload.totals.cache_savings.api_dollars)} |"
77
+ )
78
+ lines.append(f"| Events | {_format_int(payload.totals.totals.events)} |")
79
+ lines.append(f"| Tokens | {_format_int(payload.totals.totals.total_tokens)} |")
80
+ lines.append(
81
+ f"| Input tokens | {_format_int(payload.totals.totals.input_tokens)} (cached "
82
+ f"{_format_int(payload.totals.totals.cached_input_tokens)}) |"
83
+ )
84
+ lines.append(f"| Output tokens | {_format_int(payload.totals.totals.output_tokens)} |")
85
+ lines.append(
86
+ f"| Reasoning tokens | {_format_int(payload.totals.totals.reasoning_output_tokens)} |"
87
+ )
88
+ if payload.tier_sources:
89
+ sources = ", ".join(
90
+ f"{key}={value:,}" for key, value in sorted(payload.tier_sources.items())
91
+ )
92
+ lines.append(f"| Tier sources | {sources} |")
93
+ if payload.warning_count:
94
+ lines.append(f"| Parser warnings | {_format_int(payload.warning_count)} |")
95
+ if payload.pricing_status != "exact" or payload.pricing_warnings:
96
+ warnings = "; ".join(payload.pricing_warnings or []) or payload.pricing_status
97
+ lines.append(f"| Pricing status | {payload.pricing_status}: {warnings} |")
98
+ lines.append("")
99
+ if payload.insights:
100
+ lines.append("## Insights")
101
+ lines.append("")
102
+ for insight in payload.insights:
103
+ lines.append(f"- {insight}")
104
+ lines.append("")
105
+ lines.extend(_section_table("Models", payload.by_model))
106
+ lines.extend(_section_table("Top sessions", payload.top_sessions))
107
+ lines.extend(_section_table("Top projects", payload.top_projects))
108
+ return "\n".join(lines) + "\n"
109
+
110
+
111
+ def _section_table(title: str, rows: Iterable[Aggregate]) -> list[str]:
112
+ rendered = list(rows)
113
+ if not rendered:
114
+ return [f"## {title}", "", "_No data for this month._", ""]
115
+ lines: list[str] = [f"## {title}", ""]
116
+ lines.append("| Label | Credits | API $ | Events | Tokens |")
117
+ lines.append("| --- | ---: | ---: | ---: | ---: |")
118
+ for row in rendered:
119
+ lines.append(
120
+ "| "
121
+ + " | ".join(
122
+ [
123
+ row.label.replace("|", "\\|"),
124
+ _format_credits(row.costs.adjusted_credits),
125
+ _format_money(row.costs.api_dollars),
126
+ _format_int(row.totals.events),
127
+ _format_int(row.totals.total_tokens),
128
+ ]
129
+ )
130
+ + " |"
131
+ )
132
+ lines.append("")
133
+ return lines
134
+
135
+
136
+ def render_receipt_html(payload: ReceiptInputs) -> str:
137
+ sections: list[str] = []
138
+ sections.append(f"<h1>Codex Meter Receipt — {html.escape(payload.month)}</h1>")
139
+ sections.append(f"<p><em>Generated: {payload.generated_at.isoformat()}</em></p>")
140
+ sections.append("<h2>Totals</h2>")
141
+ sections.append(
142
+ "<table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>"
143
+ + "".join(
144
+ f"<tr><td>{name}</td><td>{value}</td></tr>"
145
+ for name, value in (
146
+ ("Credits", _format_credits(payload.totals.costs.adjusted_credits)),
147
+ ("API $", _format_money(payload.totals.costs.api_dollars)),
148
+ (
149
+ "Cache savings",
150
+ f"{_format_credits(payload.totals.cache_savings.adjusted_credits)} credits / "
151
+ f"{_format_money(payload.totals.cache_savings.api_dollars)}",
152
+ ),
153
+ ("Events", _format_int(payload.totals.totals.events)),
154
+ ("Tokens", _format_int(payload.totals.totals.total_tokens)),
155
+ ("Input tokens", _format_int(payload.totals.totals.input_tokens)),
156
+ ("Cached input", _format_int(payload.totals.totals.cached_input_tokens)),
157
+ ("Output tokens", _format_int(payload.totals.totals.output_tokens)),
158
+ ("Reasoning tokens", _format_int(payload.totals.totals.reasoning_output_tokens)),
159
+ (
160
+ "Tier sources",
161
+ ", ".join(
162
+ f"{key}={value:,}"
163
+ for key, value in sorted((payload.tier_sources or {}).items())
164
+ )
165
+ or "—",
166
+ ),
167
+ ("Parser warnings", _format_int(payload.warning_count)),
168
+ (
169
+ "Pricing status",
170
+ (f"{payload.pricing_status}: {'; '.join(payload.pricing_warnings or [])}")
171
+ if payload.pricing_status != "exact" or payload.pricing_warnings
172
+ else "exact",
173
+ ),
174
+ )
175
+ )
176
+ + "</tbody></table>"
177
+ )
178
+ if payload.insights:
179
+ sections.append(
180
+ "<h2>Insights</h2><ul>"
181
+ + "".join(f"<li>{html.escape(item)}</li>" for item in payload.insights)
182
+ + "</ul>"
183
+ )
184
+ sections.append(_html_section("Models", payload.by_model))
185
+ sections.append(_html_section("Top sessions", payload.top_sessions))
186
+ sections.append(_html_section("Top projects", payload.top_projects))
187
+ body = "\n".join(sections)
188
+ return (
189
+ '<!doctype html><html><head><meta charset="utf-8">'
190
+ f"<title>Codex Meter Receipt — {html.escape(payload.month)}</title>"
191
+ "<style>"
192
+ "body{font-family:system-ui,sans-serif;max-width:920px;margin:2rem auto;padding:0 1rem;}"
193
+ "table{border-collapse:collapse;width:100%;margin-bottom:1.5rem;}"
194
+ "th,td{border:1px solid #ddd;padding:.4rem .6rem;}"
195
+ "th{background:#f5f5f5;text-align:left;}"
196
+ "td:last-child,th:last-child{text-align:right;}"
197
+ "</style></head><body>"
198
+ f"{body}"
199
+ "</body></html>"
200
+ )
201
+
202
+
203
+ def _html_section(title: str, rows: Iterable[Aggregate]) -> str:
204
+ rendered = list(rows)
205
+ if not rendered:
206
+ return f"<h2>{html.escape(title)}</h2><p><em>No data for this month.</em></p>"
207
+ body = "".join(
208
+ "<tr>"
209
+ f"<td>{html.escape(row.label)}</td>"
210
+ f"<td>{_format_credits(row.costs.adjusted_credits)}</td>"
211
+ f"<td>{_format_money(row.costs.api_dollars)}</td>"
212
+ f"<td>{_format_int(row.totals.events)}</td>"
213
+ f"<td>{_format_int(row.totals.total_tokens)}</td>"
214
+ "</tr>"
215
+ for row in rendered
216
+ )
217
+ return (
218
+ f"<h2>{html.escape(title)}</h2>"
219
+ "<table><thead><tr>"
220
+ "<th>Label</th><th>Credits</th><th>API $</th><th>Events</th><th>Tokens</th>"
221
+ "</tr></thead><tbody>"
222
+ f"{body}"
223
+ "</tbody></table>"
224
+ )
225
+
226
+
227
+ def grafana_dashboard(title: str = DEFAULT_DASHBOARD_TITLE, datasource: str = "Prometheus") -> dict:
228
+ """Generate a Grafana dashboard JSON tied to codex-meter Prometheus metrics."""
229
+ return {
230
+ "annotations": {"list": []},
231
+ "editable": True,
232
+ "schemaVersion": 39,
233
+ "tags": ["codex-meter"],
234
+ "title": title,
235
+ "timezone": "",
236
+ "time": {"from": "now-24h", "to": "now"},
237
+ "refresh": "30s",
238
+ "panels": [
239
+ _stat_panel(
240
+ id_=1,
241
+ title="Credits used (current 5h)",
242
+ expr="codex_meter_credits_used",
243
+ unit="none",
244
+ datasource=datasource,
245
+ grid={"h": 5, "w": 6, "x": 0, "y": 0},
246
+ ),
247
+ _stat_panel(
248
+ id_=2,
249
+ title="Burn rate (credits/hour)",
250
+ expr="codex_meter_burn_per_hour",
251
+ unit="none",
252
+ datasource=datasource,
253
+ grid={"h": 5, "w": 6, "x": 6, "y": 0},
254
+ ),
255
+ _gauge_panel(
256
+ id_=3,
257
+ title="Primary window %",
258
+ expr='codex_meter_window_used_percent{window="primary"}',
259
+ datasource=datasource,
260
+ grid={"h": 5, "w": 6, "x": 12, "y": 0},
261
+ ),
262
+ _gauge_panel(
263
+ id_=4,
264
+ title="Secondary window %",
265
+ expr='codex_meter_window_used_percent{window="secondary"}',
266
+ datasource=datasource,
267
+ grid={"h": 5, "w": 6, "x": 18, "y": 0},
268
+ ),
269
+ _timeseries_panel(
270
+ id_=5,
271
+ title="Tokens by model / tier / kind",
272
+ expr="sum by (model, tier, kind) (codex_meter_tokens_total)",
273
+ datasource=datasource,
274
+ grid={"h": 10, "w": 24, "x": 0, "y": 5},
275
+ ),
276
+ ],
277
+ }
278
+
279
+
280
+ def _stat_panel(
281
+ *,
282
+ id_: int,
283
+ title: str,
284
+ expr: str,
285
+ unit: str,
286
+ datasource: str,
287
+ grid: dict,
288
+ ) -> dict:
289
+ return {
290
+ "id": id_,
291
+ "type": "stat",
292
+ "title": title,
293
+ "gridPos": grid,
294
+ "datasource": datasource,
295
+ "fieldConfig": {"defaults": {"unit": unit, "decimals": 2}},
296
+ "targets": [{"expr": expr, "refId": "A"}],
297
+ }
298
+
299
+
300
+ def _gauge_panel(*, id_: int, title: str, expr: str, datasource: str, grid: dict) -> dict:
301
+ return {
302
+ "id": id_,
303
+ "type": "gauge",
304
+ "title": title,
305
+ "gridPos": grid,
306
+ "datasource": datasource,
307
+ "fieldConfig": {
308
+ "defaults": {
309
+ "unit": "percent",
310
+ "min": 0,
311
+ "max": 100,
312
+ "thresholds": {
313
+ "mode": "absolute",
314
+ "steps": [
315
+ {"value": 0, "color": "green"},
316
+ {"value": 80, "color": "orange"},
317
+ {"value": 100, "color": "red"},
318
+ ],
319
+ },
320
+ }
321
+ },
322
+ "targets": [{"expr": expr, "refId": "A"}],
323
+ }
324
+
325
+
326
+ def _timeseries_panel(*, id_: int, title: str, expr: str, datasource: str, grid: dict) -> dict:
327
+ return {
328
+ "id": id_,
329
+ "type": "timeseries",
330
+ "title": title,
331
+ "gridPos": grid,
332
+ "datasource": datasource,
333
+ "fieldConfig": {"defaults": {"unit": "none"}},
334
+ "targets": [{"expr": expr, "refId": "A"}],
335
+ }
336
+
337
+
338
+ def render_grafana_dashboard(title: str = DEFAULT_DASHBOARD_TITLE) -> str:
339
+ return json.dumps(grafana_dashboard(title=title), indent=2) + "\n"
@@ -0,0 +1,92 @@
1
+ """Forecast helpers: linear and EWMA projection, confidence band.
2
+
3
+ Pure functions over a list of per-day numeric samples (credits, dollars, tokens —
4
+ unit-agnostic). I/O lives in the CLI layer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import statistics
11
+ from dataclasses import dataclass
12
+
13
+ DEFAULT_EWMA_ALPHA = 0.3
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Projection:
18
+ unit: str
19
+ days_analyzed: int
20
+ daily_mean: float
21
+ daily_stdev: float
22
+ days_remaining: int
23
+ linear_total: float
24
+ ewma_total: float
25
+ linear_low: float
26
+ linear_high: float
27
+ cap: float | None
28
+ days_to_cap: float | None
29
+
30
+
31
+ def daily_mean(daily_values: list[float]) -> float:
32
+ if not daily_values:
33
+ return 0.0
34
+ return statistics.fmean(daily_values)
35
+
36
+
37
+ def daily_stdev(daily_values: list[float]) -> float:
38
+ if len(daily_values) < 2:
39
+ return 0.0
40
+ return statistics.pstdev(daily_values)
41
+
42
+
43
+ def ewma(daily_values: list[float], alpha: float = DEFAULT_EWMA_ALPHA) -> float:
44
+ """Exponentially-weighted moving average. Newer days weighted more heavily."""
45
+ if not daily_values:
46
+ return 0.0
47
+ if not 0 < alpha <= 1:
48
+ raise ValueError("ewma alpha must be in (0, 1]")
49
+ smoothed = daily_values[0]
50
+ for value in daily_values[1:]:
51
+ smoothed = alpha * value + (1 - alpha) * smoothed
52
+ return smoothed
53
+
54
+
55
+ def project(
56
+ daily_values: list[float],
57
+ days_remaining: int,
58
+ *,
59
+ unit: str = "credits",
60
+ cap: float | None = None,
61
+ alpha: float = DEFAULT_EWMA_ALPHA,
62
+ ) -> Projection:
63
+ """Project the rolling-sum total `days_remaining` days into the future.
64
+
65
+ `linear_total` uses the simple mean; `ewma_total` uses an exponentially
66
+ weighted mean that reacts faster to recent acceleration.
67
+ """
68
+ if days_remaining < 0:
69
+ raise ValueError("days_remaining must be non-negative")
70
+ mean = daily_mean(daily_values)
71
+ sigma = daily_stdev(daily_values)
72
+ ewma_rate = ewma(daily_values, alpha)
73
+ linear_total = mean * days_remaining
74
+ ewma_total = ewma_rate * days_remaining
75
+ # 1σ band scales with sqrt(N) under iid assumption.
76
+ band = sigma * math.sqrt(days_remaining) if days_remaining > 0 else 0.0
77
+ days_to_cap: float | None = None
78
+ if cap is not None and mean > 0:
79
+ days_to_cap = max(0.0, cap / mean)
80
+ return Projection(
81
+ unit=unit,
82
+ days_analyzed=len(daily_values),
83
+ daily_mean=mean,
84
+ daily_stdev=sigma,
85
+ days_remaining=days_remaining,
86
+ linear_total=linear_total,
87
+ ewma_total=ewma_total,
88
+ linear_low=max(0.0, linear_total - band),
89
+ linear_high=linear_total + band,
90
+ cap=cap,
91
+ days_to_cap=days_to_cap,
92
+ )
@@ -0,0 +1,38 @@
1
+ """Small humanization helpers — formatting and redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ REDACTION_LIMIT = 72
8
+
9
+
10
+ def format_int(value: int) -> str:
11
+ return f"{value:,}"
12
+
13
+
14
+ def compact_number(value: float, prefix: str = "") -> str:
15
+ sign = "-" if value < 0 else ""
16
+ amount = abs(float(value))
17
+ for threshold, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")):
18
+ if amount >= threshold:
19
+ return f"{sign}{prefix}{amount / threshold:.3g}{suffix}"
20
+ if amount.is_integer():
21
+ return f"{sign}{prefix}{int(amount):,}"
22
+ return f"{sign}{prefix}{amount:,.2f}"
23
+
24
+
25
+ def redact(text: str, show: bool, limit: int = REDACTION_LIMIT) -> str:
26
+ """Collapse whitespace; truncate with ellipsis when `show` is False."""
27
+ clean = " ".join(str(text).split())
28
+ if show or not clean or len(clean) <= limit:
29
+ return clean
30
+ return clean[: limit - 3] + "..."
31
+
32
+
33
+ def short_table_label(text: str) -> str:
34
+ """Prefer scannable labels for dense terminal tables."""
35
+ clean = " ".join(str(text).split())
36
+ if clean.startswith(("/", "~")):
37
+ return Path(clean).name or clean
38
+ return clean