aiuse 2.1.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.
- aiuse/__init__.py +3 -0
- aiuse/__main__.py +3 -0
- aiuse/ai_stub.py +13 -0
- aiuse/analysis/__init__.py +5 -0
- aiuse/analysis/history.py +355 -0
- aiuse/analysis/pace.py +117 -0
- aiuse/analysis/use_or_lose.py +793 -0
- aiuse/cli.py +615 -0
- aiuse/collectors/__init__.py +13 -0
- aiuse/collectors/base.py +82 -0
- aiuse/collectors/codexbar.py +461 -0
- aiuse/collectors/cswap.py +431 -0
- aiuse/collectors/runner.py +492 -0
- aiuse/collectors/tokscale.py +118 -0
- aiuse/config.py +457 -0
- aiuse/models.py +363 -0
- aiuse/report.py +1206 -0
- aiuse/tui/__init__.py +58 -0
- aiuse/tui/app.py +117 -0
- aiuse/tui/builders.py +201 -0
- aiuse-2.1.0.dist-info/METADATA +331 -0
- aiuse-2.1.0.dist-info/RECORD +26 -0
- aiuse-2.1.0.dist-info/WHEEL +5 -0
- aiuse-2.1.0.dist-info/entry_points.txt +3 -0
- aiuse-2.1.0.dist-info/licenses/LICENSE +21 -0
- aiuse-2.1.0.dist-info/top_level.txt +1 -0
aiuse/__init__.py
ADDED
aiuse/__main__.py
ADDED
aiuse/ai_stub.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Compatibility stub: ``ai`` invokes the same entrypoint as ``aiuse``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> int:
|
|
7
|
+
from aiuse.cli import main as aiuse_main
|
|
8
|
+
|
|
9
|
+
return aiuse_main()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Snapshot persistence and consumption-rate learning (Phase 4 — experimental)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from aiuse.models import Snapshot, utcnow
|
|
12
|
+
|
|
13
|
+
_DEFAULT_SNAPSHOT_DIR = "~/.cache/aiuse/snapshots"
|
|
14
|
+
_DEFAULT_RETENTION_DAYS = 90
|
|
15
|
+
_DEFAULT_MIN_SNAPSHOTS = 2
|
|
16
|
+
_DEFAULT_LOOKBACK_DAYS = 7
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def snapshot_dir() -> Path:
|
|
20
|
+
return Path(os.path.expanduser(_DEFAULT_SNAPSHOT_DIR))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def save_snapshot(snapshot: Snapshot, alerts: list[Any]) -> Path:
|
|
24
|
+
path = snapshot_dir()
|
|
25
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
path.chmod(0o700)
|
|
27
|
+
# Microseconds keep same-second runs unique and still sort lexicographically.
|
|
28
|
+
ts = snapshot.collected_at.strftime("%Y-%m-%dT%H%M%S.%fZ")
|
|
29
|
+
filepath = path / f"{ts}.json"
|
|
30
|
+
n = 1
|
|
31
|
+
while filepath.exists():
|
|
32
|
+
filepath = path / f"{ts}-{n}.json"
|
|
33
|
+
n += 1
|
|
34
|
+
payload = {
|
|
35
|
+
"collected_at": snapshot.collected_at.isoformat(),
|
|
36
|
+
"accounts": [a.to_dict() for a in snapshot.accounts],
|
|
37
|
+
"alerts": [a.to_dict() for a in alerts],
|
|
38
|
+
}
|
|
39
|
+
text = json.dumps(payload, indent=2, default=str) + "\n"
|
|
40
|
+
fd = os.open(filepath, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
41
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
42
|
+
handle.write(text)
|
|
43
|
+
return filepath
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_recent_snapshots(
|
|
47
|
+
*, retention_days: int = _DEFAULT_RETENTION_DAYS, max_count: int = 30
|
|
48
|
+
) -> list[dict[str, Any]]:
|
|
49
|
+
directory = snapshot_dir()
|
|
50
|
+
if not directory.is_dir():
|
|
51
|
+
return []
|
|
52
|
+
cutoff = utcnow() - timedelta(days=retention_days)
|
|
53
|
+
snapshots: list[dict[str, Any]] = []
|
|
54
|
+
for entry in sorted(directory.iterdir(), reverse=True):
|
|
55
|
+
if not entry.is_file() or not entry.suffix.lower() == ".json":
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(entry.read_text(encoding="utf-8"))
|
|
59
|
+
except (json.JSONDecodeError, OSError):
|
|
60
|
+
continue
|
|
61
|
+
ts_str = data.get("collected_at")
|
|
62
|
+
if ts_str:
|
|
63
|
+
try:
|
|
64
|
+
collected = datetime.fromisoformat(ts_str)
|
|
65
|
+
if collected.tzinfo is None:
|
|
66
|
+
collected = collected.replace(tzinfo=timezone.utc)
|
|
67
|
+
if collected < cutoff:
|
|
68
|
+
continue
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
snapshots.append(data)
|
|
72
|
+
if len(snapshots) >= max_count:
|
|
73
|
+
break
|
|
74
|
+
return snapshots
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _account_window_key(account: dict[str, Any], window: dict[str, Any]) -> str:
|
|
78
|
+
provider = str(account.get("provider", "")).lower()
|
|
79
|
+
acct = str(account.get("account", "")).lower()
|
|
80
|
+
label = str(window.get("label", "")).lower()
|
|
81
|
+
resets = window.get("resets_at") or ""
|
|
82
|
+
return f"{provider}|{acct}|{label}|{resets}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def compute_learned_burn_rates(
|
|
86
|
+
*,
|
|
87
|
+
current: Snapshot,
|
|
88
|
+
retention_days: int = _DEFAULT_RETENTION_DAYS,
|
|
89
|
+
min_snapshots: int = _DEFAULT_MIN_SNAPSHOTS,
|
|
90
|
+
) -> dict[str, tuple[float, int]]:
|
|
91
|
+
"""Return ``{'provider:duration_kind': (avg_fraction_per_day, sample_count)}``.
|
|
92
|
+
|
|
93
|
+
``avg_fraction_per_day`` is remaining-percent consumed per day / 100
|
|
94
|
+
(e.g. 0.30 means ~30% of the window per day).
|
|
95
|
+
|
|
96
|
+
Pairs are weighted by elapsed time so multi-minute noise does not dominate
|
|
97
|
+
day-scale samples. Snapshot pairs that straddle a window reset contribute a
|
|
98
|
+
reconstructed tail-of-cycle point instead of being dropped.
|
|
99
|
+
"""
|
|
100
|
+
history = load_recent_snapshots(retention_days=retention_days)
|
|
101
|
+
if len(history) < min_snapshots:
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
provider_window_burns: dict[str, list[tuple[float, float]]] = {}
|
|
105
|
+
|
|
106
|
+
now = utcnow()
|
|
107
|
+
for prev_data in history:
|
|
108
|
+
ts_str = prev_data.get("collected_at", "")
|
|
109
|
+
try:
|
|
110
|
+
prev_time = datetime.fromisoformat(ts_str)
|
|
111
|
+
if prev_time.tzinfo is None:
|
|
112
|
+
prev_time = prev_time.replace(tzinfo=timezone.utc)
|
|
113
|
+
except ValueError:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
time_delta_days = max(0.01, (now - prev_time).total_seconds() / 86400.0)
|
|
117
|
+
if time_delta_days > retention_days:
|
|
118
|
+
continue
|
|
119
|
+
# Weight by elapsed days (capped at 1) so 3-minute pairs barely matter.
|
|
120
|
+
weight = min(time_delta_days, 1.0)
|
|
121
|
+
|
|
122
|
+
for prev_account in prev_data.get("accounts") or []:
|
|
123
|
+
provider = str(prev_account.get("provider", "")).lower()
|
|
124
|
+
for prev_window in prev_account.get("windows") or []:
|
|
125
|
+
prev_remaining = prev_window.get("remaining_percent")
|
|
126
|
+
if prev_remaining is None:
|
|
127
|
+
prev_remaining = _remaining_from_used(prev_window.get("used_percent"))
|
|
128
|
+
if prev_remaining is None:
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
prev_remaining_f = float(prev_remaining)
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
current_remaining = _find_current_remaining(
|
|
136
|
+
current, prev_account, prev_window, match_resets=True
|
|
137
|
+
)
|
|
138
|
+
burn_rate: float | None = None
|
|
139
|
+
pair_weight = weight
|
|
140
|
+
|
|
141
|
+
if current_remaining is not None:
|
|
142
|
+
consumed = prev_remaining_f - current_remaining
|
|
143
|
+
if consumed > 0:
|
|
144
|
+
burn_rate = consumed / time_delta_days
|
|
145
|
+
elif current_remaining > prev_remaining_f:
|
|
146
|
+
# Same resets_at match but remaining rose — ignore.
|
|
147
|
+
continue
|
|
148
|
+
else:
|
|
149
|
+
continue
|
|
150
|
+
else:
|
|
151
|
+
# No same-cycle match: try same label (possible reset).
|
|
152
|
+
loose = _find_current_remaining(
|
|
153
|
+
current, prev_account, prev_window, match_resets=False
|
|
154
|
+
)
|
|
155
|
+
if loose is None or loose <= prev_remaining_f:
|
|
156
|
+
continue
|
|
157
|
+
# Reset between snapshots: attribute remaining at prev as
|
|
158
|
+
# consumption closed out over prev → previous resets_at.
|
|
159
|
+
resets_raw = prev_window.get("resets_at")
|
|
160
|
+
days_to_reset = time_delta_days
|
|
161
|
+
if resets_raw:
|
|
162
|
+
try:
|
|
163
|
+
resets_at = datetime.fromisoformat(str(resets_raw).replace("Z", "+00:00"))
|
|
164
|
+
if resets_at.tzinfo is None:
|
|
165
|
+
resets_at = resets_at.replace(tzinfo=timezone.utc)
|
|
166
|
+
if prev_time < resets_at <= now:
|
|
167
|
+
days_to_reset = max(0.01, (resets_at - prev_time).total_seconds() / 86400.0)
|
|
168
|
+
except ValueError:
|
|
169
|
+
pass
|
|
170
|
+
burn_rate = prev_remaining_f / days_to_reset
|
|
171
|
+
pair_weight = min(days_to_reset, 1.0)
|
|
172
|
+
|
|
173
|
+
if burn_rate is None:
|
|
174
|
+
continue
|
|
175
|
+
window_minutes = prev_window.get("window_minutes")
|
|
176
|
+
duration_key = _duration_key(window_minutes)
|
|
177
|
+
if duration_key:
|
|
178
|
+
pk = f"{provider}:{duration_key}"
|
|
179
|
+
provider_window_burns.setdefault(pk, []).append((burn_rate, pair_weight))
|
|
180
|
+
|
|
181
|
+
rates: dict[str, tuple[float, int]] = {}
|
|
182
|
+
for pk, burns in provider_window_burns.items():
|
|
183
|
+
if len(burns) < 2:
|
|
184
|
+
continue
|
|
185
|
+
total_w = sum(w for _, w in burns)
|
|
186
|
+
if total_w <= 0:
|
|
187
|
+
continue
|
|
188
|
+
avg_burn_pct = sum(b * w for b, w in burns) / total_w
|
|
189
|
+
rates[pk] = (avg_burn_pct / 100.0, len(burns))
|
|
190
|
+
return rates
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def compute_learned_flexibility(
|
|
194
|
+
*,
|
|
195
|
+
current: Snapshot,
|
|
196
|
+
retention_days: int = _DEFAULT_RETENTION_DAYS,
|
|
197
|
+
min_snapshots: int = _DEFAULT_MIN_SNAPSHOTS,
|
|
198
|
+
) -> dict[str, float]:
|
|
199
|
+
rates = compute_learned_burn_rates(
|
|
200
|
+
current=current,
|
|
201
|
+
retention_days=retention_days,
|
|
202
|
+
min_snapshots=min_snapshots,
|
|
203
|
+
)
|
|
204
|
+
return {k: _burn_rate_to_flexibility(rate * 100.0) for k, (rate, _n) in rates.items()}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _burn_rate_to_flexibility(burn_rate_pct_per_day: float) -> float:
|
|
208
|
+
if burn_rate_pct_per_day >= 80:
|
|
209
|
+
return 1.0
|
|
210
|
+
if burn_rate_pct_per_day >= 40:
|
|
211
|
+
return 0.7 + 0.3 * (burn_rate_pct_per_day - 40) / 40
|
|
212
|
+
if burn_rate_pct_per_day >= 10:
|
|
213
|
+
return 0.25 + 0.45 * (burn_rate_pct_per_day - 10) / 30
|
|
214
|
+
if burn_rate_pct_per_day >= 2:
|
|
215
|
+
return 0.05 + 0.20 * (burn_rate_pct_per_day - 2) / 8
|
|
216
|
+
return 0.0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def merge_learned_flexibility(
|
|
220
|
+
base_flex: float,
|
|
221
|
+
provider: str,
|
|
222
|
+
duration_kind: str | None,
|
|
223
|
+
learned: dict[str, float],
|
|
224
|
+
) -> float:
|
|
225
|
+
if not duration_kind or not learned:
|
|
226
|
+
return base_flex
|
|
227
|
+
key = f"{provider.lower().replace(' ', '-')}:{duration_kind}"
|
|
228
|
+
learned_flex = learned.get(key)
|
|
229
|
+
# Exact provider match only — never blend another provider's rate for the
|
|
230
|
+
# same duration bucket (Grok weekly ≠ Codex weekly).
|
|
231
|
+
if learned_flex is None:
|
|
232
|
+
return base_flex
|
|
233
|
+
return 0.3 * learned_flex + 0.7 * base_flex
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def chronic_waste_summary(
|
|
237
|
+
*,
|
|
238
|
+
current: Snapshot,
|
|
239
|
+
retention_days: int = _DEFAULT_RETENTION_DAYS,
|
|
240
|
+
) -> list[dict[str, Any]]:
|
|
241
|
+
history = load_recent_snapshots(retention_days=retention_days)
|
|
242
|
+
if len(history) < _DEFAULT_MIN_SNAPSHOTS:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
# samples: list of (resets_at_key, remaining) — at most one per cycle
|
|
246
|
+
wasted: dict[str, dict[str, Any]] = {}
|
|
247
|
+
|
|
248
|
+
for prev_data in history[:7]:
|
|
249
|
+
ts_str = prev_data.get("collected_at", "")
|
|
250
|
+
try:
|
|
251
|
+
prev_time = datetime.fromisoformat(ts_str)
|
|
252
|
+
if prev_time.tzinfo is None:
|
|
253
|
+
prev_time = prev_time.replace(tzinfo=timezone.utc)
|
|
254
|
+
except ValueError:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
for prev_account in prev_data.get("accounts") or []:
|
|
258
|
+
provider = str(prev_account.get("provider", "")).lower()
|
|
259
|
+
for prev_window in prev_account.get("windows") or []:
|
|
260
|
+
window_minutes = prev_window.get("window_minutes")
|
|
261
|
+
if not window_minutes or window_minutes > 360:
|
|
262
|
+
continue
|
|
263
|
+
prev_remaining = prev_window.get("remaining_percent")
|
|
264
|
+
if prev_remaining is None:
|
|
265
|
+
prev_remaining = _remaining_from_used(prev_window.get("used_percent"))
|
|
266
|
+
if prev_remaining is None:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
key = f"{provider}:{prev_window.get('label', '')}"
|
|
270
|
+
resets_key = str(prev_window.get("resets_at") or "") or f"unknown:{ts_str}"
|
|
271
|
+
bucket = wasted.setdefault(
|
|
272
|
+
key,
|
|
273
|
+
{
|
|
274
|
+
"provider": provider,
|
|
275
|
+
"label": prev_window.get("label", ""),
|
|
276
|
+
"by_reset": {}, # resets_at -> remaining (most recent wins)
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
# history is newest-first; keep first sample per resets_at
|
|
280
|
+
if resets_key not in bucket["by_reset"]:
|
|
281
|
+
bucket["by_reset"][resets_key] = float(prev_remaining)
|
|
282
|
+
|
|
283
|
+
result: list[dict[str, Any]] = []
|
|
284
|
+
for key, data in wasted.items():
|
|
285
|
+
by_reset: dict[str, float] = data["by_reset"]
|
|
286
|
+
if len(by_reset) < 2:
|
|
287
|
+
continue
|
|
288
|
+
samples = list(by_reset.values())
|
|
289
|
+
avg = sum(samples) / len(samples)
|
|
290
|
+
result.append(
|
|
291
|
+
{
|
|
292
|
+
"provider": data["provider"],
|
|
293
|
+
"label": data["label"],
|
|
294
|
+
"avg_remaining_pct": round(avg, 1),
|
|
295
|
+
"sample_count": len(samples),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
result.sort(key=lambda x: x["avg_remaining_pct"], reverse=True)
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _remaining_from_used(used: Any) -> float | None:
|
|
303
|
+
if used is None:
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
return max(0.0, 100.0 - float(used))
|
|
307
|
+
except (TypeError, ValueError):
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _find_current_remaining(
|
|
312
|
+
snapshot: Snapshot,
|
|
313
|
+
prev_account: dict[str, Any],
|
|
314
|
+
prev_window: dict[str, Any],
|
|
315
|
+
*,
|
|
316
|
+
match_resets: bool = True,
|
|
317
|
+
) -> float | None:
|
|
318
|
+
prev_provider = str(prev_account.get("provider", "")).lower()
|
|
319
|
+
prev_account_id = str(prev_account.get("account", "")).lower()
|
|
320
|
+
prev_label = str(prev_window.get("label", "")).lower()
|
|
321
|
+
prev_resets = prev_window.get("resets_at") or ""
|
|
322
|
+
|
|
323
|
+
for acc in snapshot.accounts:
|
|
324
|
+
if acc.provider.lower() != prev_provider:
|
|
325
|
+
continue
|
|
326
|
+
if (acc.account or "").lower() != prev_account_id:
|
|
327
|
+
continue
|
|
328
|
+
for w in acc.windows:
|
|
329
|
+
if w.label.lower() != prev_label:
|
|
330
|
+
continue
|
|
331
|
+
if match_resets and prev_resets:
|
|
332
|
+
w_resets = w.resets_at.isoformat() if w.resets_at else ""
|
|
333
|
+
if w_resets != prev_resets:
|
|
334
|
+
continue
|
|
335
|
+
val = w.remaining()
|
|
336
|
+
if val is not None:
|
|
337
|
+
return val
|
|
338
|
+
return _remaining_from_used(w.used_percent)
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _duration_key(window_minutes: Any) -> str | None:
|
|
343
|
+
if window_minutes is None:
|
|
344
|
+
return None
|
|
345
|
+
try:
|
|
346
|
+
m = int(window_minutes)
|
|
347
|
+
except (TypeError, ValueError):
|
|
348
|
+
return None
|
|
349
|
+
if m <= 360:
|
|
350
|
+
return "5h"
|
|
351
|
+
if m <= 10080:
|
|
352
|
+
return "weekly"
|
|
353
|
+
if m <= 44640:
|
|
354
|
+
return "monthly"
|
|
355
|
+
return None
|
aiuse/analysis/pace.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Pure pace-math for Phase 2 scoring (not wired into analyze_use_or_lose yet)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from aiuse.models import PaceProfile, QuotaWindow, classify_window_minutes, nominal_window_minutes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compute_pace(
|
|
11
|
+
window: QuotaWindow,
|
|
12
|
+
*,
|
|
13
|
+
now: datetime,
|
|
14
|
+
learned_rate_per_day: float | None = None, # fraction/day, e.g. 0.30 == 30%/day
|
|
15
|
+
learned_sample_count: int = 0,
|
|
16
|
+
e_min: float = 0.05,
|
|
17
|
+
) -> PaceProfile | None:
|
|
18
|
+
remaining = window.remaining()
|
|
19
|
+
if remaining is None:
|
|
20
|
+
return None
|
|
21
|
+
used_fraction = (100.0 - remaining) / 100.0
|
|
22
|
+
|
|
23
|
+
kind = classify_window_minutes(window.window_minutes)
|
|
24
|
+
duration_minutes = window.window_minutes or nominal_window_minutes(kind)
|
|
25
|
+
confidence = "measured" if window.window_minutes else ("inferred" if duration_minutes else "low")
|
|
26
|
+
|
|
27
|
+
if not window.resets_at or not duration_minutes:
|
|
28
|
+
return PaceProfile(
|
|
29
|
+
elapsed_fraction=None,
|
|
30
|
+
used_fraction=used_fraction,
|
|
31
|
+
pace_ratio=None,
|
|
32
|
+
projected_used_fraction=None,
|
|
33
|
+
projected_waste_fraction=None,
|
|
34
|
+
projected_waste_usd=None,
|
|
35
|
+
projected_exhaust_at=None,
|
|
36
|
+
confidence="low",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
t_left_days = max(0.0, (window.resets_at - now).total_seconds() / 86400.0)
|
|
40
|
+
d_days = duration_minutes / 1440.0
|
|
41
|
+
elapsed = min(1.0, max(0.0, 1.0 - t_left_days / d_days))
|
|
42
|
+
|
|
43
|
+
r_now = used_fraction / (max(elapsed, e_min) * d_days) # fraction/day
|
|
44
|
+
if learned_rate_per_day is not None and learned_sample_count > 0:
|
|
45
|
+
lam = learned_sample_count / (learned_sample_count + 2.0)
|
|
46
|
+
r_hat = (1 - lam) * r_now + lam * learned_rate_per_day
|
|
47
|
+
else:
|
|
48
|
+
r_hat = r_now
|
|
49
|
+
|
|
50
|
+
projected_used = min(1.0, used_fraction + r_hat * t_left_days)
|
|
51
|
+
waste = 1.0 - projected_used
|
|
52
|
+
exhaust_at = now + timedelta(days=(1.0 - used_fraction) / r_hat) if r_hat > 1e-9 else None
|
|
53
|
+
|
|
54
|
+
return PaceProfile(
|
|
55
|
+
elapsed_fraction=elapsed,
|
|
56
|
+
used_fraction=used_fraction,
|
|
57
|
+
pace_ratio=used_fraction / max(elapsed, e_min),
|
|
58
|
+
projected_used_fraction=projected_used,
|
|
59
|
+
projected_waste_fraction=waste,
|
|
60
|
+
projected_waste_usd=None, # filled in by the caller once it knows the plan price
|
|
61
|
+
projected_exhaust_at=exhaust_at,
|
|
62
|
+
confidence=confidence,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def classify_pace(
|
|
67
|
+
pace: PaceProfile,
|
|
68
|
+
*,
|
|
69
|
+
resets_at: datetime | None,
|
|
70
|
+
waste_alert_fraction: float,
|
|
71
|
+
min_elapsed_fraction: float,
|
|
72
|
+
conserve_min_lead_hours: float,
|
|
73
|
+
has_learned_rate: bool,
|
|
74
|
+
) -> str:
|
|
75
|
+
"""Returns 'conserve' | 'burn' | 'on_pace' | 'unknown'."""
|
|
76
|
+
if pace.projected_waste_fraction is None and pace.projected_exhaust_at is None:
|
|
77
|
+
return "unknown"
|
|
78
|
+
# Too early in the window (no learned rate) → do not trust burn/conserve yet.
|
|
79
|
+
if (
|
|
80
|
+
pace.elapsed_fraction is not None
|
|
81
|
+
and pace.elapsed_fraction < min_elapsed_fraction
|
|
82
|
+
and not has_learned_rate
|
|
83
|
+
):
|
|
84
|
+
return "on_pace"
|
|
85
|
+
if pace.projected_exhaust_at and resets_at:
|
|
86
|
+
if pace.projected_exhaust_at < resets_at - timedelta(hours=conserve_min_lead_hours):
|
|
87
|
+
return "conserve"
|
|
88
|
+
if pace.projected_waste_fraction is None:
|
|
89
|
+
return "unknown"
|
|
90
|
+
if pace.projected_waste_fraction >= waste_alert_fraction:
|
|
91
|
+
return "burn"
|
|
92
|
+
return "on_pace"
|
|
93
|
+
|
|
94
|
+
def governing_partition(windows: list[QuotaWindow]) -> tuple[QuotaWindow | None, list[QuotaWindow]]:
|
|
95
|
+
"""Longest-duration window with usable remaining() governs; the rest are children.
|
|
96
|
+
|
|
97
|
+
When durations tie (e.g. Cursor Included/Auto/API all monthly), prefer a
|
|
98
|
+
window whose label looks like the overall included bar, then list order.
|
|
99
|
+
"""
|
|
100
|
+
scored = [
|
|
101
|
+
(
|
|
102
|
+
w.window_minutes
|
|
103
|
+
or nominal_window_minutes(classify_window_minutes(w.window_minutes))
|
|
104
|
+
or 0,
|
|
105
|
+
0 if "included" in (w.label or "").casefold() else 1,
|
|
106
|
+
w,
|
|
107
|
+
)
|
|
108
|
+
for w in windows
|
|
109
|
+
if w.remaining() is not None
|
|
110
|
+
]
|
|
111
|
+
if not scored:
|
|
112
|
+
return None, list(windows)
|
|
113
|
+
# Longest minutes first; among ties, included (rank 0) before others.
|
|
114
|
+
scored.sort(key=lambda pair: (-pair[0], pair[1]))
|
|
115
|
+
governing = scored[0][2]
|
|
116
|
+
children = [w for w in windows if w is not governing]
|
|
117
|
+
return governing, children
|