cctally 1.68.0 → 1.69.0
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.
- package/CHANGELOG.md +56 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +644 -107
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +168 -5
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +303 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +118 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +298 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +202 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +324 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Pure provider-neutral quota interpretation primitives.
|
|
2
|
+
|
|
3
|
+
This module deliberately owns no provider I/O. Adapters turn physical cache
|
|
4
|
+
rows into :class:`QuotaObservation` objects; this kernel then keeps the logical
|
|
5
|
+
identity, history selection, freshness, blocks, forecasts, crossings, and
|
|
6
|
+
threshold decisions deterministic.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Iterable, Literal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
FUTURE_CLOCK_SKEW_SECONDS = 300
|
|
19
|
+
_SOURCE_PATH_KEY_PREFIX = b"cctally-source-path-v1\0"
|
|
20
|
+
_RULE_FINGERPRINT_PREFIX = b"cctally-quota-alert-rule-v1\0"
|
|
21
|
+
|
|
22
|
+
FreshnessState = Literal["fresh", "stale", "future", "unavailable"]
|
|
23
|
+
ForecastStatus = Literal[
|
|
24
|
+
"ok", "insufficient-history", "unavailable", "stale", "future",
|
|
25
|
+
]
|
|
26
|
+
ThresholdKind = Literal["actual", "projected"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _require_aware(value: dt.datetime, field_name: str) -> None:
|
|
30
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
31
|
+
raise ValueError(f"{field_name} must be timezone-aware")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _integer_percent(value: float) -> int:
|
|
35
|
+
"""Return a percent HWM without exposing binary float-floor drift."""
|
|
36
|
+
return math.floor(value + 1e-9)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class QuotaWindowIdentity:
|
|
41
|
+
"""One root-qualified native quota window identity.
|
|
42
|
+
|
|
43
|
+
``limit_id`` and ``limit_name`` are provider display metadata. They are
|
|
44
|
+
intentionally excluded from equality and hashing: labels never merge root,
|
|
45
|
+
slot, duration, or logical-limit identities, and a label-only change never
|
|
46
|
+
re-arms alert rules. They remain present on observations so a metadata
|
|
47
|
+
change remains an interpreted history point.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
source: str
|
|
51
|
+
source_root_key: str
|
|
52
|
+
logical_limit_key: str
|
|
53
|
+
observed_slot: str
|
|
54
|
+
window_minutes: int
|
|
55
|
+
limit_id: str | None = field(default=None, compare=False)
|
|
56
|
+
limit_name: str | None = field(default=None, compare=False)
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
for name in ("source", "source_root_key", "logical_limit_key", "observed_slot"):
|
|
60
|
+
if not isinstance(getattr(self, name), str) or not getattr(self, name):
|
|
61
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
62
|
+
if not isinstance(self.window_minutes, int) or isinstance(self.window_minutes, bool):
|
|
63
|
+
raise ValueError("window_minutes must be a positive integer")
|
|
64
|
+
if self.window_minutes <= 0:
|
|
65
|
+
raise ValueError("window_minutes must be a positive integer")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class QuotaObservation:
|
|
70
|
+
"""One validated physical observation after provider-specific parsing."""
|
|
71
|
+
|
|
72
|
+
identity: QuotaWindowIdentity
|
|
73
|
+
captured_at: dt.datetime
|
|
74
|
+
used_percent: float
|
|
75
|
+
resets_at: dt.datetime
|
|
76
|
+
source_path: str
|
|
77
|
+
line_offset: int
|
|
78
|
+
plan_type: str | None = None
|
|
79
|
+
individual_limit_json: str | None = None
|
|
80
|
+
reached_type: str | None = None
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
_require_aware(self.captured_at, "captured_at")
|
|
84
|
+
_require_aware(self.resets_at, "resets_at")
|
|
85
|
+
if not isinstance(self.used_percent, (int, float)) or isinstance(self.used_percent, bool):
|
|
86
|
+
raise ValueError("used_percent must be a number")
|
|
87
|
+
if not math.isfinite(self.used_percent) or not 0 <= self.used_percent <= 100:
|
|
88
|
+
raise ValueError("used_percent must be between 0 and 100")
|
|
89
|
+
if not isinstance(self.source_path, str) or not self.source_path.startswith("/"):
|
|
90
|
+
raise ValueError("source_path must be a canonical absolute path")
|
|
91
|
+
if not isinstance(self.line_offset, int) or isinstance(self.line_offset, bool) or self.line_offset < 0:
|
|
92
|
+
raise ValueError("line_offset must be a non-negative integer")
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def captured_at_utc(self) -> dt.datetime:
|
|
96
|
+
"""Compatibility spelling for physical-cache adapter code."""
|
|
97
|
+
return self.captured_at
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def resets_at_utc(self) -> dt.datetime:
|
|
101
|
+
"""Compatibility spelling for physical-cache adapter code."""
|
|
102
|
+
return self.resets_at
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class QuotaHistory:
|
|
107
|
+
"""All physical evidence and deduplicated interpreted points for one identity."""
|
|
108
|
+
|
|
109
|
+
identity: QuotaWindowIdentity
|
|
110
|
+
physical_observations: tuple[QuotaObservation, ...]
|
|
111
|
+
observations: tuple[QuotaObservation, ...]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class QuotaBlock:
|
|
116
|
+
"""A native reset block scoped to exactly one logical quota identity."""
|
|
117
|
+
|
|
118
|
+
identity: QuotaWindowIdentity
|
|
119
|
+
resets_at: dt.datetime
|
|
120
|
+
nominal_start_at: dt.datetime
|
|
121
|
+
observations: tuple[QuotaObservation, ...]
|
|
122
|
+
first_observed_at: dt.datetime
|
|
123
|
+
last_observed_at: dt.datetime
|
|
124
|
+
first_percent: float
|
|
125
|
+
current_percent: float
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class QuotaPercentMilestone:
|
|
130
|
+
"""The first observed physical crossing for an integer percent HWM."""
|
|
131
|
+
|
|
132
|
+
percent: int
|
|
133
|
+
captured_at: dt.datetime
|
|
134
|
+
observation: QuotaObservation
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class QuotaFreshness:
|
|
139
|
+
"""Freshness of locally retained physical evidence, not provider-live state."""
|
|
140
|
+
|
|
141
|
+
state: FreshnessState
|
|
142
|
+
captured_at: dt.datetime | None
|
|
143
|
+
age_seconds: int | None
|
|
144
|
+
stale_after_seconds: int | None
|
|
145
|
+
source: str = "local-rollout"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class QuotaForecast:
|
|
150
|
+
"""One reset-scoped duration-agnostic quota forecast."""
|
|
151
|
+
|
|
152
|
+
status: ForecastStatus
|
|
153
|
+
current_percent: float | None
|
|
154
|
+
rate_percent_per_hour: float | None
|
|
155
|
+
projected_percent: float | None
|
|
156
|
+
resets_at: dt.datetime | None
|
|
157
|
+
remaining_seconds: int | None
|
|
158
|
+
sample_count: int
|
|
159
|
+
sample_span_seconds: int
|
|
160
|
+
confidence: Literal["high", "medium", "low"] | None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class QuotaRule:
|
|
165
|
+
"""An exact source/root/logical-limit threshold override."""
|
|
166
|
+
|
|
167
|
+
source: str
|
|
168
|
+
source_root_key: str
|
|
169
|
+
logical_limit_key: str
|
|
170
|
+
actual_thresholds: tuple[int, ...]
|
|
171
|
+
projected_thresholds: tuple[int, ...]
|
|
172
|
+
|
|
173
|
+
def __post_init__(self) -> None:
|
|
174
|
+
for name in ("source", "source_root_key", "logical_limit_key"):
|
|
175
|
+
if not isinstance(getattr(self, name), str) or not getattr(self, name):
|
|
176
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
177
|
+
object.__setattr__(self, "actual_thresholds", validate_thresholds(self.actual_thresholds))
|
|
178
|
+
object.__setattr__(self, "projected_thresholds", validate_thresholds(self.projected_thresholds))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class ResolvedQuotaRule:
|
|
183
|
+
"""Thresholds selected for one identity before durable alert evaluation."""
|
|
184
|
+
|
|
185
|
+
actual_thresholds: tuple[int, ...]
|
|
186
|
+
projected_thresholds: tuple[int, ...]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class QuotaThresholdDecision:
|
|
191
|
+
"""A threshold currently satisfied by actual or projected quota usage."""
|
|
192
|
+
|
|
193
|
+
kind: ThresholdKind
|
|
194
|
+
threshold: int
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def identity_sort_key(identity: QuotaWindowIdentity) -> tuple[str, str, str, str, int]:
|
|
198
|
+
"""Return the stable full logical identity ordering used by every selector."""
|
|
199
|
+
return (
|
|
200
|
+
identity.source,
|
|
201
|
+
identity.source_root_key,
|
|
202
|
+
identity.logical_limit_key,
|
|
203
|
+
identity.observed_slot,
|
|
204
|
+
identity.window_minutes,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def physical_order_key(observation: QuotaObservation) -> tuple[dt.datetime, dt.datetime, str, int]:
|
|
209
|
+
"""The frozen total order for physical rows within one identity."""
|
|
210
|
+
return (
|
|
211
|
+
observation.captured_at,
|
|
212
|
+
observation.resets_at,
|
|
213
|
+
observation.source_path,
|
|
214
|
+
observation.line_offset,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def logical_value_tuple(observation: QuotaObservation) -> tuple[object, ...]:
|
|
219
|
+
"""The exact interpreted-point duplicate tuple from the approved design."""
|
|
220
|
+
identity = observation.identity
|
|
221
|
+
return (
|
|
222
|
+
identity.source,
|
|
223
|
+
identity.source_root_key,
|
|
224
|
+
identity.logical_limit_key,
|
|
225
|
+
identity.observed_slot,
|
|
226
|
+
identity.window_minutes,
|
|
227
|
+
identity.limit_id,
|
|
228
|
+
identity.limit_name,
|
|
229
|
+
observation.used_percent,
|
|
230
|
+
observation.resets_at,
|
|
231
|
+
observation.plan_type,
|
|
232
|
+
observation.individual_limit_json,
|
|
233
|
+
observation.reached_type,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def source_path_key(source_path: str) -> str:
|
|
238
|
+
"""Return the privacy-safe stable source path key mandated for quota JSON."""
|
|
239
|
+
if not isinstance(source_path, str) or not source_path.startswith("/"):
|
|
240
|
+
raise ValueError("source_path must be a canonical absolute path")
|
|
241
|
+
return hashlib.sha256(_SOURCE_PATH_KEY_PREFIX + source_path.encode("utf-8")).hexdigest()[:32]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def build_history(observations: Iterable[QuotaObservation]) -> tuple[QuotaHistory, ...]:
|
|
245
|
+
"""Partition physical observations and collapse exact logical duplicates.
|
|
246
|
+
|
|
247
|
+
The first row in the total physical order represents an exact duplicate
|
|
248
|
+
run. All rows remain in ``physical_observations`` so freshness can use the
|
|
249
|
+
last physical capture even when history has only one interpreted point.
|
|
250
|
+
"""
|
|
251
|
+
by_identity: dict[QuotaWindowIdentity, list[QuotaObservation]] = {}
|
|
252
|
+
for observation in observations:
|
|
253
|
+
by_identity.setdefault(observation.identity, []).append(observation)
|
|
254
|
+
|
|
255
|
+
result: list[QuotaHistory] = []
|
|
256
|
+
for identity in sorted(by_identity, key=identity_sort_key):
|
|
257
|
+
physical = tuple(sorted(by_identity[identity], key=physical_order_key))
|
|
258
|
+
interpreted: list[QuotaObservation] = []
|
|
259
|
+
previous_value: tuple[object, ...] | None = None
|
|
260
|
+
for observation in physical:
|
|
261
|
+
value = logical_value_tuple(observation)
|
|
262
|
+
if not interpreted or value != previous_value:
|
|
263
|
+
interpreted.append(observation)
|
|
264
|
+
previous_value = value
|
|
265
|
+
result.append(QuotaHistory(
|
|
266
|
+
identity=identity,
|
|
267
|
+
physical_observations=physical,
|
|
268
|
+
observations=tuple(interpreted),
|
|
269
|
+
))
|
|
270
|
+
return tuple(result)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def latest_physical_observation(observations: Iterable[QuotaObservation]) -> QuotaObservation | None:
|
|
274
|
+
"""Select the latest local physical capture with deterministic tie breaking."""
|
|
275
|
+
values = tuple(observations)
|
|
276
|
+
return max(values, key=physical_order_key) if values else None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _single_identity_observations(
|
|
280
|
+
observations: Iterable[QuotaObservation],
|
|
281
|
+
) -> tuple[QuotaObservation, ...]:
|
|
282
|
+
values = tuple(observations)
|
|
283
|
+
if len({observation.identity for observation in values}) > 1:
|
|
284
|
+
raise ValueError("selector requires observations for exactly one quota identity")
|
|
285
|
+
return values
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def select_baseline(
|
|
289
|
+
observations: Iterable[QuotaObservation], as_of: dt.datetime,
|
|
290
|
+
) -> QuotaObservation | None:
|
|
291
|
+
"""Return the last ordered observation that was already captured at ``as_of``."""
|
|
292
|
+
_require_aware(as_of, "as_of")
|
|
293
|
+
values = _single_identity_observations(observations)
|
|
294
|
+
eligible = [
|
|
295
|
+
observation for observation in values
|
|
296
|
+
if observation.captured_at <= as_of
|
|
297
|
+
]
|
|
298
|
+
return max(eligible, key=physical_order_key) if eligible else None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_blocks(observations: Iterable[QuotaObservation]) -> tuple[QuotaBlock, ...]:
|
|
302
|
+
"""Segment deduplicated interpreted history at each native reset boundary."""
|
|
303
|
+
by_block: dict[tuple[QuotaWindowIdentity, dt.datetime], list[QuotaObservation]] = {}
|
|
304
|
+
for history in build_history(observations):
|
|
305
|
+
for observation in history.observations:
|
|
306
|
+
by_block.setdefault((history.identity, observation.resets_at), []).append(observation)
|
|
307
|
+
|
|
308
|
+
blocks: list[QuotaBlock] = []
|
|
309
|
+
for (identity, resets_at), points in sorted(
|
|
310
|
+
by_block.items(), key=lambda item: (identity_sort_key(item[0][0]), item[0][1]),
|
|
311
|
+
):
|
|
312
|
+
ordered = tuple(sorted(points, key=physical_order_key))
|
|
313
|
+
first = ordered[0]
|
|
314
|
+
last = ordered[-1]
|
|
315
|
+
blocks.append(QuotaBlock(
|
|
316
|
+
identity=identity,
|
|
317
|
+
resets_at=resets_at,
|
|
318
|
+
nominal_start_at=resets_at - dt.timedelta(minutes=identity.window_minutes),
|
|
319
|
+
observations=ordered,
|
|
320
|
+
first_observed_at=first.captured_at,
|
|
321
|
+
last_observed_at=last.captured_at,
|
|
322
|
+
first_percent=first.used_percent,
|
|
323
|
+
current_percent=last.used_percent,
|
|
324
|
+
))
|
|
325
|
+
return tuple(blocks)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def percent_milestones(block: QuotaBlock) -> tuple[QuotaPercentMilestone, ...]:
|
|
329
|
+
"""Return post-baseline integer crossings using a non-decreasing HWM."""
|
|
330
|
+
observations = iter(block.observations)
|
|
331
|
+
first = next(observations, None)
|
|
332
|
+
if first is None:
|
|
333
|
+
return ()
|
|
334
|
+
high_water = _integer_percent(first.used_percent)
|
|
335
|
+
milestones: list[QuotaPercentMilestone] = []
|
|
336
|
+
for observation in observations:
|
|
337
|
+
current = _integer_percent(observation.used_percent)
|
|
338
|
+
if current <= high_water:
|
|
339
|
+
continue
|
|
340
|
+
for percent in range(high_water + 1, current + 1):
|
|
341
|
+
milestones.append(QuotaPercentMilestone(
|
|
342
|
+
percent=percent,
|
|
343
|
+
captured_at=observation.captured_at,
|
|
344
|
+
observation=observation,
|
|
345
|
+
))
|
|
346
|
+
high_water = current
|
|
347
|
+
return tuple(milestones)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def stale_after_seconds(window_minutes: int) -> int:
|
|
351
|
+
"""Return the native-duration freshness bound frozen by the S2 design."""
|
|
352
|
+
if not isinstance(window_minutes, int) or isinstance(window_minutes, bool) or window_minutes <= 0:
|
|
353
|
+
raise ValueError("window_minutes must be a positive integer")
|
|
354
|
+
return max(900, min((window_minutes * 60) // 10, 3600))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def quota_freshness(
|
|
358
|
+
observations: Iterable[QuotaObservation], as_of: dt.datetime,
|
|
359
|
+
) -> QuotaFreshness:
|
|
360
|
+
"""Classify latest physical local-rollout evidence independently of baseline use."""
|
|
361
|
+
_require_aware(as_of, "as_of")
|
|
362
|
+
latest = latest_physical_observation(_single_identity_observations(observations))
|
|
363
|
+
if latest is None:
|
|
364
|
+
return QuotaFreshness(
|
|
365
|
+
state="unavailable", captured_at=None, age_seconds=None,
|
|
366
|
+
stale_after_seconds=None,
|
|
367
|
+
)
|
|
368
|
+
age_seconds = int((as_of - latest.captured_at).total_seconds())
|
|
369
|
+
stale_after = stale_after_seconds(latest.identity.window_minutes)
|
|
370
|
+
if age_seconds < -FUTURE_CLOCK_SKEW_SECONDS:
|
|
371
|
+
state: FreshnessState = "future"
|
|
372
|
+
elif age_seconds > stale_after:
|
|
373
|
+
state = "stale"
|
|
374
|
+
else:
|
|
375
|
+
state = "fresh"
|
|
376
|
+
return QuotaFreshness(
|
|
377
|
+
state=state,
|
|
378
|
+
captured_at=latest.captured_at,
|
|
379
|
+
age_seconds=age_seconds,
|
|
380
|
+
stale_after_seconds=stale_after,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _null_forecast(status: ForecastStatus) -> QuotaForecast:
|
|
385
|
+
return QuotaForecast(
|
|
386
|
+
status=status,
|
|
387
|
+
current_percent=None,
|
|
388
|
+
rate_percent_per_hour=None,
|
|
389
|
+
projected_percent=None,
|
|
390
|
+
resets_at=None,
|
|
391
|
+
remaining_seconds=None,
|
|
392
|
+
sample_count=0,
|
|
393
|
+
sample_span_seconds=0,
|
|
394
|
+
confidence=None,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _single_identity_history(observations: Iterable[QuotaObservation]) -> QuotaHistory | None:
|
|
399
|
+
history = build_history(observations)
|
|
400
|
+
if not history:
|
|
401
|
+
return None
|
|
402
|
+
if len(history) != 1:
|
|
403
|
+
raise ValueError("forecast requires observations for exactly one quota identity")
|
|
404
|
+
return history[0]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def forecast_quota(
|
|
408
|
+
observations: Iterable[QuotaObservation], as_of: dt.datetime,
|
|
409
|
+
) -> QuotaForecast:
|
|
410
|
+
"""Fit a reset-scoped quota forecast from positive adjacent intervals only."""
|
|
411
|
+
_require_aware(as_of, "as_of")
|
|
412
|
+
history = _single_identity_history(observations)
|
|
413
|
+
if history is None:
|
|
414
|
+
return _null_forecast("unavailable")
|
|
415
|
+
|
|
416
|
+
freshness = quota_freshness(history.physical_observations, as_of)
|
|
417
|
+
baseline = select_baseline(history.observations, as_of)
|
|
418
|
+
if baseline is None:
|
|
419
|
+
return _null_forecast("future" if freshness.state == "future" else "unavailable")
|
|
420
|
+
|
|
421
|
+
points = tuple(
|
|
422
|
+
observation for observation in history.observations
|
|
423
|
+
if observation.resets_at == baseline.resets_at and observation.captured_at <= as_of
|
|
424
|
+
)
|
|
425
|
+
points = tuple(sorted(points, key=physical_order_key))
|
|
426
|
+
usable_elapsed_seconds = 0.0
|
|
427
|
+
positive_delta_percent = 0.0
|
|
428
|
+
sample_count = 0
|
|
429
|
+
for prior, current in zip(points, points[1:]):
|
|
430
|
+
elapsed_seconds = (current.captured_at - prior.captured_at).total_seconds()
|
|
431
|
+
delta_percent = current.used_percent - prior.used_percent
|
|
432
|
+
if elapsed_seconds > 0 and delta_percent > 0:
|
|
433
|
+
usable_elapsed_seconds += elapsed_seconds
|
|
434
|
+
positive_delta_percent += delta_percent
|
|
435
|
+
sample_count += 1
|
|
436
|
+
|
|
437
|
+
remaining_seconds = max(0, int((baseline.resets_at - as_of).total_seconds()))
|
|
438
|
+
if usable_elapsed_seconds > 0:
|
|
439
|
+
rate = positive_delta_percent / (usable_elapsed_seconds / 3600)
|
|
440
|
+
projected = min(100.0, max(
|
|
441
|
+
baseline.used_percent,
|
|
442
|
+
baseline.used_percent + rate * (remaining_seconds / 3600),
|
|
443
|
+
))
|
|
444
|
+
native_window_seconds = baseline.identity.window_minutes * 60
|
|
445
|
+
if sample_count >= 4 and usable_elapsed_seconds >= native_window_seconds * 0.25:
|
|
446
|
+
confidence: Literal["high", "medium", "low"] = "high"
|
|
447
|
+
elif sample_count >= 2 and usable_elapsed_seconds >= native_window_seconds * 0.10:
|
|
448
|
+
confidence = "medium"
|
|
449
|
+
else:
|
|
450
|
+
confidence = "low"
|
|
451
|
+
else:
|
|
452
|
+
rate = None
|
|
453
|
+
projected = None
|
|
454
|
+
confidence = None
|
|
455
|
+
|
|
456
|
+
if freshness.state == "future":
|
|
457
|
+
status: ForecastStatus = "future"
|
|
458
|
+
elif freshness.state == "stale":
|
|
459
|
+
status = "stale"
|
|
460
|
+
elif sample_count == 0:
|
|
461
|
+
status = "insufficient-history"
|
|
462
|
+
else:
|
|
463
|
+
status = "ok"
|
|
464
|
+
return QuotaForecast(
|
|
465
|
+
status=status,
|
|
466
|
+
current_percent=baseline.used_percent,
|
|
467
|
+
rate_percent_per_hour=rate,
|
|
468
|
+
projected_percent=projected,
|
|
469
|
+
resets_at=baseline.resets_at,
|
|
470
|
+
remaining_seconds=remaining_seconds,
|
|
471
|
+
sample_count=sample_count,
|
|
472
|
+
sample_span_seconds=int(usable_elapsed_seconds),
|
|
473
|
+
confidence=confidence,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def validate_thresholds(values: Iterable[int]) -> tuple[int, ...]:
|
|
478
|
+
"""Validate the frozen ordered-unique quota threshold grammar."""
|
|
479
|
+
result = tuple(values)
|
|
480
|
+
if any(not isinstance(value, int) or isinstance(value, bool) for value in result):
|
|
481
|
+
raise ValueError("thresholds must be ordered unique integers from 1 through 100")
|
|
482
|
+
if any(value < 1 or value > 100 for value in result):
|
|
483
|
+
raise ValueError("thresholds must be integers from 1 through 100")
|
|
484
|
+
if tuple(sorted(set(result))) != result:
|
|
485
|
+
raise ValueError("thresholds must be ordered unique integers from 1 through 100")
|
|
486
|
+
return result
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def resolve_quota_rule(
|
|
490
|
+
identity: QuotaWindowIdentity,
|
|
491
|
+
*,
|
|
492
|
+
default_actual_thresholds: Iterable[int],
|
|
493
|
+
default_projected_thresholds: Iterable[int],
|
|
494
|
+
rules: Iterable[QuotaRule],
|
|
495
|
+
) -> ResolvedQuotaRule:
|
|
496
|
+
"""Resolve one exact root-qualified override or the validated defaults."""
|
|
497
|
+
defaults = ResolvedQuotaRule(
|
|
498
|
+
actual_thresholds=validate_thresholds(default_actual_thresholds),
|
|
499
|
+
projected_thresholds=validate_thresholds(default_projected_thresholds),
|
|
500
|
+
)
|
|
501
|
+
matches = [
|
|
502
|
+
rule for rule in rules
|
|
503
|
+
if (rule.source, rule.source_root_key, rule.logical_limit_key) == (
|
|
504
|
+
identity.source, identity.source_root_key, identity.logical_limit_key,
|
|
505
|
+
)
|
|
506
|
+
]
|
|
507
|
+
if len(matches) > 1:
|
|
508
|
+
raise ValueError("quota rules must be unique by source, source_root_key, and logical_limit_key")
|
|
509
|
+
if not matches:
|
|
510
|
+
return defaults
|
|
511
|
+
match = matches[0]
|
|
512
|
+
return ResolvedQuotaRule(
|
|
513
|
+
actual_thresholds=match.actual_thresholds,
|
|
514
|
+
projected_thresholds=match.projected_thresholds,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def quota_rule_fingerprint(
|
|
519
|
+
identity: QuotaWindowIdentity,
|
|
520
|
+
resolved: ResolvedQuotaRule,
|
|
521
|
+
*,
|
|
522
|
+
global_enabled: bool,
|
|
523
|
+
quota_enabled: bool,
|
|
524
|
+
) -> str:
|
|
525
|
+
"""Return the exact canonical resolved-rule SHA-256 fingerprint."""
|
|
526
|
+
actual = validate_thresholds(resolved.actual_thresholds)
|
|
527
|
+
projected = validate_thresholds(resolved.projected_thresholds)
|
|
528
|
+
payload = {
|
|
529
|
+
"actualThresholds": list(actual),
|
|
530
|
+
"globalEnabled": global_enabled,
|
|
531
|
+
"identity": {
|
|
532
|
+
"logicalLimitKey": identity.logical_limit_key,
|
|
533
|
+
"source": identity.source,
|
|
534
|
+
"sourceRootKey": identity.source_root_key,
|
|
535
|
+
},
|
|
536
|
+
"projectedThresholds": list(projected),
|
|
537
|
+
"quotaEnabled": quota_enabled,
|
|
538
|
+
}
|
|
539
|
+
encoded = json.dumps(
|
|
540
|
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
541
|
+
).encode("utf-8")
|
|
542
|
+
return hashlib.sha256(_RULE_FINGERPRINT_PREFIX + encoded).hexdigest()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def quota_threshold_decisions(
|
|
546
|
+
*,
|
|
547
|
+
current_percent: float | None,
|
|
548
|
+
projected_percent: float | None,
|
|
549
|
+
actual_thresholds: Iterable[int],
|
|
550
|
+
projected_thresholds: Iterable[int],
|
|
551
|
+
) -> tuple[QuotaThresholdDecision, ...]:
|
|
552
|
+
"""Return one fire-once candidate per threshold, preferring actual claims."""
|
|
553
|
+
actual = validate_thresholds(actual_thresholds)
|
|
554
|
+
projected = validate_thresholds(projected_thresholds)
|
|
555
|
+
decisions: list[QuotaThresholdDecision] = []
|
|
556
|
+
claimed: set[int] = set()
|
|
557
|
+
if current_percent is not None:
|
|
558
|
+
for threshold in actual:
|
|
559
|
+
if current_percent >= threshold:
|
|
560
|
+
decisions.append(QuotaThresholdDecision(kind="actual", threshold=threshold))
|
|
561
|
+
claimed.add(threshold)
|
|
562
|
+
if projected_percent is not None:
|
|
563
|
+
for threshold in projected:
|
|
564
|
+
if projected_percent >= threshold and threshold not in claimed:
|
|
565
|
+
decisions.append(QuotaThresholdDecision(kind="projected", threshold=threshold))
|
|
566
|
+
return tuple(decisions)
|