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.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +168 -5
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +202 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +324 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. 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)