cctally 1.112.0 → 1.114.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 (58) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -5
  3. package/bin/_cctally_cache.py +1683 -485
  4. package/bin/_cctally_core.py +223 -1
  5. package/bin/_cctally_dashboard.py +96 -18
  6. package/bin/_cctally_dashboard_perf.py +128 -9
  7. package/bin/_cctally_dashboard_sources.py +267 -204
  8. package/bin/_cctally_db.py +861 -18
  9. package/bin/_cctally_diagnosis_sources.py +22 -4
  10. package/bin/_cctally_doctor.py +136 -18
  11. package/bin/_cctally_five_hour.py +2 -2
  12. package/bin/_cctally_forecast.py +27 -6
  13. package/bin/_cctally_journal.py +67 -5
  14. package/bin/_cctally_milestone_history.py +82 -9
  15. package/bin/_cctally_percent_breakdown.py +3 -2
  16. package/bin/_cctally_quota.py +201 -33
  17. package/bin/_cctally_record.py +14 -0
  18. package/bin/_cctally_rederive.py +23 -9
  19. package/bin/_cctally_source_analytics.py +60 -15
  20. package/bin/_cctally_statusline.py +17 -0
  21. package/bin/_cctally_store.py +80 -5
  22. package/bin/_cctally_tui.py +4 -3
  23. package/bin/_cctally_weekrefs.py +128 -21
  24. package/bin/_lib_cache_report.py +35 -6
  25. package/bin/_lib_codex_conversation.py +32 -0
  26. package/bin/_lib_codex_conversation_query.py +164 -30
  27. package/bin/_lib_conversation_query.py +95 -56
  28. package/bin/_lib_conversation_retention.py +1605 -640
  29. package/bin/_lib_cost_provenance.py +53 -2
  30. package/bin/_lib_doctor.py +263 -69
  31. package/bin/_lib_fmt.py +11 -0
  32. package/bin/_lib_merge_gate.py +469 -51
  33. package/bin/_lib_perf.py +27 -1
  34. package/bin/_lib_pricing.py +209 -8
  35. package/bin/_lib_pricing_check.py +20 -4
  36. package/bin/_lib_reclaim_planner.py +792 -0
  37. package/bin/_lib_rederive.py +225 -77
  38. package/bin/_lib_snapshot_cache.py +21 -0
  39. package/bin/_lib_sqlite_close.py +81 -0
  40. package/bin/_lib_subscription_weeks.py +100 -17
  41. package/bin/_lib_tick_stats.py +217 -17
  42. package/bin/_lib_wal_checkpoint.py +1077 -0
  43. package/bin/_lib_write_budget.py +483 -0
  44. package/bin/_lib_write_io.py +703 -0
  45. package/bin/cctally +8 -0
  46. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  47. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  48. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  49. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  50. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  51. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  52. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  53. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  54. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  55. package/dashboard/static/dashboard.html +2 -2
  56. package/package.json +6 -1
  57. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  58. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -0,0 +1,483 @@
1
+ """Write-budget kernel for the dashboard's disk writes (#901 spec §5.6).
2
+
3
+ Stdlib-only leaf shared by the dashboard (`/api/debug/backend` `writeIo`), the
4
+ doctor check `performance.dashboard_disk_writes`, `dashboard-perf` and the
5
+ soak write-budget leg. Every function is pure: nothing here reads a clock, a
6
+ file, a counter or a database.
7
+
8
+ Two statistics with two windows:
9
+
10
+ * STEADY STATE (I2): a five-minute trailing window over one cumulative process
11
+ write counter. Bytes inside a marked deletion operation (its BEGIN to the
12
+ return of its COMMIT) are subtracted and reported beside it; everything else
13
+ counts, a reclaim chunk and a delayed checkpoint copy included (Q7).
14
+ * MAINTENANCE (I4): the durable charge ledger — charged bytes, never measured
15
+ bytes — over the current partial UTC hour plus the 24 complete hours before
16
+ it. A correct pacer can never exceed its allowance, so exceeding it means
17
+ pacing is broken.
18
+
19
+ The I2 caps are fixed and never move, and `LIMITS` equals them (Q20). The
20
+ §6.3 calibration (twice the largest healthy five-minute measurement of three
21
+ repetitions of each frozen workload, rounded up to whole MiB) is reported as a
22
+ diagnostic and never sets a limit: frozen inputs carry only the controlled
23
+ append, and the operator's real activity measured above it, within the caps.
24
+ A limit above a cap is refused by `validate_limits`; a measurement above a cap
25
+ means the outcome is not achieved, and that is escalated rather than absorbed.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ import datetime as dt
31
+
32
+ KiB = 1024
33
+ MiB = 1024 * KiB
34
+ GiB = 1024 * MiB
35
+ UTC = dt.timezone.utc
36
+ _NS = 1_000_000_000
37
+
38
+ #: I2 caps — FIXED by the specification.
39
+ CAP_BYTES_PER_MINUTE = 16 * MiB
40
+ CAP_BYTES_PER_PUBLICATION = 8 * MiB
41
+
42
+
43
+ @dataclasses.dataclass(frozen=True)
44
+ class BudgetLimits:
45
+ policy_version: int
46
+ bytes_per_minute: int
47
+ bytes_per_publication: int
48
+
49
+
50
+ def validate_limits(limits: BudgetLimits) -> BudgetLimits:
51
+ """Refuse a non-positive limit and any limit above its I2 cap."""
52
+ if limits.bytes_per_minute <= 0 or limits.bytes_per_publication <= 0:
53
+ raise ValueError("write-budget limits must be positive")
54
+ if limits.bytes_per_minute > CAP_BYTES_PER_MINUTE:
55
+ raise ValueError(
56
+ f"bytes_per_minute {limits.bytes_per_minute} exceeds the I2 cap "
57
+ f"{CAP_BYTES_PER_MINUTE}")
58
+ if limits.bytes_per_publication > CAP_BYTES_PER_PUBLICATION:
59
+ raise ValueError(
60
+ f"bytes_per_publication {limits.bytes_per_publication} exceeds "
61
+ f"the I2 cap {CAP_BYTES_PER_PUBLICATION}")
62
+ return limits
63
+
64
+
65
+ # ── Versioned limits: the I2 caps (Q20); calibration is diagnostic only ────
66
+ POLICY_VERSION = 1
67
+ LIMITS = validate_limits(BudgetLimits(
68
+ policy_version=POLICY_VERSION,
69
+ bytes_per_minute=16 * MiB,
70
+ bytes_per_publication=8 * MiB,
71
+ ))
72
+ # ───────────────────────────────────────────────────────────────────────────
73
+
74
+ WINDOW_SECONDS = 300
75
+ MIN_COUNTER_SAMPLES = 6
76
+ MIN_PUBLICATIONS = 10
77
+ MAX_SAMPLE_GAP_SECONDS = 60
78
+ EXCLUDED_TIME_MAX_FRACTION = 0.20
79
+ HYSTERESIS_SPACING_SECONDS = 60
80
+ CLEAR_FRACTION = 0.80
81
+
82
+ #: I4 allowance — the one home for the pacer and the statistic.
83
+ MAINTENANCE_RATE_BYTES_PER_MINUTE = 4 * MiB
84
+ MAINTENANCE_CREDIT_BYTES = 4 * MiB
85
+ LEDGER_BUCKETS = 25
86
+
87
+
88
+ @dataclasses.dataclass(frozen=True)
89
+ class CounterSample:
90
+ t_ns: int
91
+ bytes: "int | None"
92
+ publications: int = 0
93
+
94
+
95
+ @dataclasses.dataclass(frozen=True)
96
+ class DeletionInterval:
97
+ start_ns: int
98
+ end_ns: int
99
+ start_bytes: "int | None"
100
+ end_bytes: "int | None"
101
+ rows: int = 0
102
+ ended_wall_s: "int | None" = None
103
+
104
+
105
+ @dataclasses.dataclass(frozen=True)
106
+ class SteadyStatistic:
107
+ status: str # "qualified" | "insufficient" | "unavailable"
108
+ reasons: "tuple[str, ...]" = ()
109
+ window_seconds: "float | None" = None
110
+ bytes_written: "int | None" = None
111
+ bytes_per_minute: "int | None" = None
112
+ rate_qualified: bool = False
113
+ publications: "int | None" = None
114
+ mean_bytes_per_publication: "int | None" = None
115
+ publication_qualified: bool = False
116
+ excluded_operations: int = 0
117
+ excluded_rows: int = 0
118
+ excluded_bytes: int = 0
119
+ excluded_seconds: float = 0.0
120
+ last_excluded_wall_s: "int | None" = None
121
+ sample_count: int = 0
122
+
123
+
124
+ def steady_statistic(samples, deletions=(), *, now_ns, warm_admitted_ns,
125
+ counter_status="ok") -> SteadyStatistic:
126
+ """The trailing five-minute statistic, or the typed reason it has none."""
127
+ if counter_status != "ok":
128
+ return SteadyStatistic("unavailable", (str(counter_status),))
129
+ start_ns = now_ns - WINDOW_SECONDS * _NS
130
+ gap_ns = MAX_SAMPLE_GAP_SECONDS * _NS
131
+ if warm_admitted_ns is None or warm_admitted_ns > start_ns:
132
+ return SteadyStatistic("insufficient", ("warming_up",))
133
+ ordered = sorted(samples, key=lambda s: s.t_ns)
134
+ if any(s.bytes is None and start_ns - gap_ns <= s.t_ns <= now_ns
135
+ for s in ordered):
136
+ return SteadyStatistic("insufficient", ("invalid_samples",))
137
+ valid = [s for s in ordered if s.bytes is not None and s.t_ns <= now_ns]
138
+ base = None
139
+ for sample in valid:
140
+ if sample.t_ns <= start_ns:
141
+ base = sample
142
+ if base is None or start_ns - base.t_ns > gap_ns:
143
+ return SteadyStatistic("insufficient", ("insufficient_coverage",))
144
+ span = [s for s in valid if s.t_ns >= base.t_ns]
145
+ end = span[-1]
146
+ if now_ns - end.t_ns > gap_ns or any(
147
+ b.t_ns - a.t_ns > gap_ns for a, b in zip(span, span[1:])):
148
+ return SteadyStatistic("insufficient", ("insufficient_coverage",))
149
+ if len(span) < MIN_COUNTER_SAMPLES:
150
+ return SteadyStatistic("insufficient", ("insufficient_samples",))
151
+ if any(b.bytes < a.bytes for a, b in zip(span, span[1:])):
152
+ return SteadyStatistic("unavailable", ("counter_reset",))
153
+ excluded_bytes = 0
154
+ excluded_ns = 0
155
+ operations = 0
156
+ rows = 0
157
+ last_wall = None
158
+ for d in deletions:
159
+ overlap_ns = max(0, min(d.end_ns, end.t_ns) - max(d.start_ns, base.t_ns))
160
+ if d.start_bytes is not None and d.end_bytes is not None:
161
+ overlap_bytes = max(0, min(d.end_bytes, end.bytes)
162
+ - max(d.start_bytes, base.bytes))
163
+ else:
164
+ overlap_bytes = 0
165
+ if overlap_ns <= 0 and overlap_bytes <= 0:
166
+ continue
167
+ operations += 1
168
+ rows += max(0, int(d.rows))
169
+ excluded_ns += overlap_ns
170
+ excluded_bytes += overlap_bytes
171
+ if d.ended_wall_s is not None:
172
+ last_wall = max(last_wall or 0, int(d.ended_wall_s))
173
+ elapsed_ns = end.t_ns - base.t_ns
174
+ written = end.bytes - base.bytes - excluded_bytes
175
+ publications = end.publications - base.publications
176
+ common = dict(
177
+ window_seconds=elapsed_ns / _NS,
178
+ bytes_written=written,
179
+ bytes_per_minute=int(round(written * 60 * _NS / elapsed_ns)),
180
+ publications=publications,
181
+ mean_bytes_per_publication=(
182
+ int(round(written / publications)) if publications > 0 else None),
183
+ excluded_operations=operations,
184
+ excluded_rows=rows,
185
+ excluded_bytes=excluded_bytes,
186
+ excluded_seconds=excluded_ns / _NS,
187
+ last_excluded_wall_s=last_wall,
188
+ sample_count=len(span),
189
+ )
190
+ if excluded_ns > EXCLUDED_TIME_MAX_FRACTION * elapsed_ns:
191
+ return SteadyStatistic("insufficient", ("excluded_time_over_limit",),
192
+ **common)
193
+ return SteadyStatistic(
194
+ "qualified", (), rate_qualified=True,
195
+ publication_qualified=publications >= MIN_PUBLICATIONS, **common)
196
+
197
+
198
+ #: PR-8: the plain phrase for each reason `steady_statistic` gives no rate
199
+ #: or verdict for. Only `warming_up` is the first five minutes; the others
200
+ #: can hold on a dashboard that has run for hours. The doctor summary and the
201
+ #: `dashboard-perf` row share them.
202
+ STATISTIC_REASON_PHRASES = {
203
+ "warming_up": "no samples yet (needs 5 minutes)",
204
+ "insufficient_samples": "not enough samples in the last 5 minutes",
205
+ "insufficient_coverage": "the last 5 minutes were not fully sampled",
206
+ "invalid_samples": "a counter reading in the last 5 minutes failed",
207
+ "counter_reset": "the write counter went backwards; measuring again",
208
+ "excluded_time_over_limit": (
209
+ "transcript maintenance filled over 20% of the last 5 minutes"),
210
+ }
211
+
212
+
213
+ def statistic_reason_phrase(reason) -> str:
214
+ """The phrase for a statistic reason; an unknown one is named."""
215
+ phrase = STATISTIC_REASON_PHRASES.get(str(reason))
216
+ if phrase is not None:
217
+ return phrase
218
+ return f"not measured ({str(reason or 'unavailable').replace('_', ' ')})"
219
+
220
+
221
+ def _exceeds(stat, limits) -> "tuple[bool, bool]":
222
+ rate = bool(stat.rate_qualified and stat.bytes_per_minute is not None
223
+ and stat.bytes_per_minute > limits.bytes_per_minute)
224
+ per_pub = bool(stat.publication_qualified
225
+ and stat.mean_bytes_per_publication is not None
226
+ and stat.mean_bytes_per_publication
227
+ > limits.bytes_per_publication)
228
+ return rate, per_pub
229
+
230
+
231
+ def _below_clear(stat, limits) -> bool:
232
+ if not stat.rate_qualified or stat.bytes_per_minute is None:
233
+ return False
234
+ if stat.bytes_per_minute >= CLEAR_FRACTION * limits.bytes_per_minute:
235
+ return False
236
+ if stat.publication_qualified and stat.mean_bytes_per_publication is not None:
237
+ return (stat.mean_bytes_per_publication
238
+ < CLEAR_FRACTION * limits.bytes_per_publication)
239
+ return True
240
+
241
+
242
+ @dataclasses.dataclass(frozen=True)
243
+ class HysteresisState:
244
+ verdict: str = "ok" # "ok" | "over"
245
+ pending: "str | None" = None # "over" | "ok" | None
246
+ since_ns: "int | None" = None
247
+
248
+
249
+ def advance(state: HysteresisState, stat: SteadyStatistic, limits: BudgetLimits,
250
+ now_ns: int) -> HysteresisState:
251
+ """One evaluation. Only a qualified statistic moves the state; a
252
+ transition needs two qualifying evaluations at least a minute apart, and
253
+ any evaluation that disagrees cancels the pending candidate."""
254
+ if stat.status != "qualified":
255
+ return state
256
+ spacing = HYSTERESIS_SPACING_SECONDS * _NS
257
+ if state.verdict == "ok":
258
+ if not any(_exceeds(stat, limits)):
259
+ return HysteresisState("ok")
260
+ if state.pending == "over":
261
+ if now_ns - state.since_ns >= spacing:
262
+ return HysteresisState("over")
263
+ return state
264
+ return HysteresisState("ok", "over", now_ns)
265
+ if not _below_clear(stat, limits):
266
+ return HysteresisState("over")
267
+ if state.pending == "ok":
268
+ if now_ns - state.since_ns >= spacing:
269
+ return HysteresisState("ok")
270
+ return state
271
+ return HysteresisState("over", "ok", now_ns)
272
+
273
+
274
+ def steady_verdict(stat: SteadyStatistic, state: HysteresisState,
275
+ limits: BudgetLimits = LIMITS) -> "tuple[str, tuple[str, ...]]":
276
+ """`(verdict, reasons)` for the surfaces; verdict in VERDICTS."""
277
+ if stat.status == "unavailable":
278
+ return "unavailable", tuple(stat.reasons)
279
+ if stat.status == "insufficient":
280
+ return "insufficient", tuple(stat.reasons)
281
+ rate, per_pub = _exceeds(stat, limits)
282
+ reasons = []
283
+ if rate:
284
+ reasons.append("rate_over_limit")
285
+ if per_pub:
286
+ reasons.append("publication_over_limit")
287
+ if not stat.publication_qualified:
288
+ reasons.append("insufficient_publications")
289
+ if state.pending == "over":
290
+ reasons.append("pending_over")
291
+ elif state.pending == "ok":
292
+ reasons.append("pending_clear")
293
+ if not reasons:
294
+ reasons.append("within_limits")
295
+ return state.verdict, tuple(reasons)
296
+
297
+
298
+ VERDICTS = ("ok", "over", "insufficient", "unavailable")
299
+
300
+
301
+ # ── maintenance ledger and statistic ──────────────────────────────────────
302
+
303
+ @dataclasses.dataclass(frozen=True)
304
+ class LedgerBucket:
305
+ hour: dt.datetime
306
+ charged: int
307
+ largest: int
308
+
309
+
310
+ def hour_floor(t: dt.datetime) -> dt.datetime:
311
+ return t.astimezone(UTC).replace(minute=0, second=0, microsecond=0)
312
+
313
+
314
+ def iso_z(t: dt.datetime) -> str:
315
+ return t.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
316
+
317
+
318
+ def _parse_hour(value) -> "dt.datetime | None":
319
+ if not isinstance(value, str):
320
+ return None
321
+ try:
322
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
323
+ except ValueError:
324
+ return None
325
+ if parsed.tzinfo is None:
326
+ return None
327
+ return hour_floor(parsed)
328
+
329
+
330
+ def parse_ledger(raw) -> "tuple[LedgerBucket, ...]":
331
+ """A legacy, absent or malformed ledger reads as no charges."""
332
+ if not isinstance(raw, (list, tuple)):
333
+ return ()
334
+ buckets = {}
335
+ for entry in raw:
336
+ if not isinstance(entry, dict):
337
+ continue
338
+ hour = _parse_hour(entry.get("hour"))
339
+ charged = entry.get("charged")
340
+ largest = entry.get("largest", charged)
341
+ if (hour is None or isinstance(charged, bool)
342
+ or not isinstance(charged, int) or charged < 0
343
+ or isinstance(largest, bool) or not isinstance(largest, int)
344
+ or largest < 0):
345
+ continue
346
+ prior = buckets.get(hour)
347
+ if prior is not None:
348
+ charged += prior.charged
349
+ largest = max(largest, prior.largest)
350
+ buckets[hour] = LedgerBucket(hour, charged, largest)
351
+ return tuple(sorted(buckets.values(), key=lambda b: b.hour))
352
+
353
+
354
+ def serialize_ledger(buckets) -> "list[dict]":
355
+ return [{"hour": iso_z(b.hour), "charged": int(b.charged),
356
+ "largest": int(b.largest)} for b in buckets]
357
+
358
+
359
+ def ledger_add(raw, *, at_utc: dt.datetime, charge_bytes: int) -> "list[dict]":
360
+ """Add one operation's charge to the bucket of its start, then drop every
361
+ bucket more than 24 complete hours old (at most 25 remain)."""
362
+ hour = hour_floor(at_utc)
363
+ buckets = {b.hour: b for b in parse_ledger(raw)}
364
+ prior = buckets.get(hour)
365
+ charge = max(0, int(charge_bytes))
366
+ buckets[hour] = LedgerBucket(
367
+ hour,
368
+ (prior.charged if prior else 0) + charge,
369
+ max(prior.largest if prior else 0, charge),
370
+ )
371
+ floor = hour - dt.timedelta(hours=LEDGER_BUCKETS - 1)
372
+ kept = sorted((b for b in buckets.values() if b.hour >= floor),
373
+ key=lambda b: b.hour)[-LEDGER_BUCKETS:]
374
+ return serialize_ledger(kept)
375
+
376
+
377
+ @dataclasses.dataclass(frozen=True)
378
+ class MaintenanceStatistic:
379
+ charged_bytes: int
380
+ largest_charge_bytes: int
381
+ window_start: dt.datetime
382
+ window_end: dt.datetime
383
+ window_minutes: int
384
+ allowance_bytes: int
385
+ verdict: str
386
+
387
+ def as_wire(self) -> dict:
388
+ return {
389
+ "chargedBytes": self.charged_bytes,
390
+ "largestChargeBytes": self.largest_charge_bytes,
391
+ "windowStart": iso_z(self.window_start),
392
+ "windowEnd": iso_z(self.window_end),
393
+ "windowMinutes": self.window_minutes,
394
+ "allowanceBytes": self.allowance_bytes,
395
+ "verdict": self.verdict,
396
+ }
397
+
398
+ def as_details(self) -> dict:
399
+ return {
400
+ "charged_bytes": self.charged_bytes,
401
+ "largest_charge_bytes": self.largest_charge_bytes,
402
+ "window_start": iso_z(self.window_start),
403
+ "window_end": iso_z(self.window_end),
404
+ "window_minutes": self.window_minutes,
405
+ "allowance_bytes": self.allowance_bytes,
406
+ "verdict": self.verdict,
407
+ }
408
+
409
+
410
+ def maintenance_statistic(raw_ledger, now_utc: dt.datetime) -> MaintenanceStatistic:
411
+ """Charged bytes over the current partial UTC hour plus the 24 complete
412
+ hours before it, against 4 MiB x (L + 1) + the largest charge in it."""
413
+ now = now_utc.astimezone(UTC)
414
+ current = hour_floor(now)
415
+ start = current - dt.timedelta(hours=24)
416
+ minutes = 1440 + int((now - current).total_seconds() // 60)
417
+ inside = [b for b in parse_ledger(raw_ledger) if start <= b.hour <= current]
418
+ charged = sum(b.charged for b in inside)
419
+ largest = max((b.largest for b in inside), default=0)
420
+ allowance = MAINTENANCE_RATE_BYTES_PER_MINUTE * (minutes + 1) + largest
421
+ return MaintenanceStatistic(
422
+ charged_bytes=charged, largest_charge_bytes=largest,
423
+ window_start=start, window_end=now, window_minutes=minutes,
424
+ allowance_bytes=allowance,
425
+ verdict="over" if charged > allowance else "ok")
426
+
427
+
428
+ # ── operator wording (spec §5.7) ──────────────────────────────────────────
429
+
430
+ def _one_decimal(value: float, unit: str) -> str:
431
+ if abs(value) >= 100 or float(value).is_integer():
432
+ return f"{value:.0f} {unit}"
433
+ return f"{value:.1f} {unit}"
434
+
435
+
436
+ def format_mib(n) -> str:
437
+ """MiB with one decimal below 100 and whole numbers at or above it."""
438
+ return _one_decimal(n / MiB, "MiB")
439
+
440
+
441
+ def format_size(n) -> str:
442
+ """GiB from one GiB up, MiB below it (``format_mib``).
443
+
444
+ GiB carries three significant figures with trailing zeros dropped, the
445
+ precision spec §5.7 states (`allowance 5.98 GiB`, `charged 7.9 GiB`); one
446
+ decimal rounded a 6,122 MiB allowance up to `6.0 GiB` (#901 Amendment 19
447
+ PR-12)."""
448
+ if abs(n) >= GiB:
449
+ value = n / GiB
450
+ if abs(value) >= 100:
451
+ text = f"{value:.0f}"
452
+ elif abs(value) >= 10:
453
+ text = f"{value:.1f}"
454
+ else:
455
+ text = f"{value:.2f}"
456
+ if "." in text:
457
+ text = text.rstrip("0").rstrip(".")
458
+ return f"{text} GiB"
459
+ return format_mib(n)
460
+
461
+
462
+ def format_kib(n) -> str:
463
+ return f"{n / KiB:.1f} KiB"
464
+
465
+
466
+ def format_window_hm(minutes: int) -> str:
467
+ return f"{int(minutes) // 60} h {int(minutes) % 60} m"
468
+
469
+
470
+ def format_window_ms(seconds: float) -> str:
471
+ total = int(round(seconds))
472
+ return f"{total // 60}m {total % 60:02d}s"
473
+
474
+
475
+ def format_since(window_start: dt.datetime, window_end: dt.datetime) -> str:
476
+ start = window_start.astimezone(UTC)
477
+ end = window_end.astimezone(UTC)
478
+ clock = start.strftime("%H:%M")
479
+ if start.date() == end.date():
480
+ return f"{clock} UTC today"
481
+ if start.date() == end.date() - dt.timedelta(days=1):
482
+ return f"{clock} UTC yesterday"
483
+ return f"{clock} UTC {start.date().isoformat()}"