cctally 1.7.0 → 1.7.1

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.
@@ -0,0 +1,361 @@
1
+ """Display-timezone primitives — the datetime render chokepoint.
2
+
3
+ Pure-fn layer (no I/O at import time): holds every helper that resolves a
4
+ display tz from CLI args / config, localizes a datetime through that
5
+ resolution, and formats it for human display. `format_display_dt` is the
6
+ chokepoint per CLAUDE.md — all human-displayed datetimes route through it.
7
+
8
+ `bin/cctally` re-exports every symbol below so internal call sites resolve
9
+ unchanged. Future pure layers (alerts payload, render, aggregators) import
10
+ the chokepoint from here directly via `_load_sibling("_lib_display_tz")`,
11
+ so they stay pure without back-importing `cctally`.
12
+
13
+ Module-level flags `_DISPLAY_TZ_BAD_CONFIG_WARNED` and
14
+ `_DISPLAY_TZ_RESOLVE_WARNED` move with their owning functions; the
15
+ `global` declarations work fine in the new module. A private `_eprint`
16
+ duplicates `bin/cctally:eprint` (two-line stderr helper) so this pure
17
+ layer carries zero back-imports per the split design's §5.3 contract.
18
+
19
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import datetime as dt
25
+ import os
26
+ import sys
27
+ from typing import Any
28
+
29
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
30
+
31
+
32
+ def _eprint(*args: Any) -> None:
33
+ print(*args, file=sys.stderr)
34
+
35
+
36
+ def _local_tz_name() -> str:
37
+ """Return an IANA timezone name if resolvable, else a best-effort fallback.
38
+
39
+ Used in Codex command title banners ("(Timezone: <name>)"). Upstream
40
+ uses IANA names (e.g. "Asia/Jerusalem"); we mirror when possible.
41
+ """
42
+ # Preferred: TZ env var if IANA-looking ("/" in the value).
43
+ tz = os.environ.get("TZ", "")
44
+ if tz and "/" in tz:
45
+ return tz
46
+ try:
47
+ tz_path = os.readlink("/etc/localtime")
48
+ if "zoneinfo/" in tz_path:
49
+ return tz_path.split("zoneinfo/", 1)[1]
50
+ except (OSError, ValueError):
51
+ pass
52
+ # Fallback: time.tzname[0] (e.g. "IST"); may be non-IANA.
53
+ try:
54
+ import time as _time
55
+ return _time.tzname[0] or ""
56
+ except Exception:
57
+ return ""
58
+
59
+
60
+ def _resolve_tz(tz_name: str | None, *, strict_iana: bool = False, fallback: Any = None) -> Any:
61
+ """Return ZoneInfo(tz_name) or ``fallback``. Callers have already validated
62
+ the tz via _parse_cli_date_range; this is a defensive re-resolve that
63
+ falls back to ``fallback`` on any error so aggregators never crash.
64
+
65
+ With ``strict_iana=True``, names lacking a "/" (e.g. bare "UTC") are
66
+ rejected up-front and ``fallback`` is returned instead — mirrors
67
+ ``_local_tz_name``'s gating to avoid the bare-UTC gotcha (see CLAUDE.md).
68
+ Default behavior (strict_iana=False, fallback=None) preserves the prior
69
+ contract for existing callers.
70
+ """
71
+ if not tz_name:
72
+ return fallback
73
+ if strict_iana and "/" not in tz_name:
74
+ return fallback
75
+ try:
76
+ return ZoneInfo(tz_name)
77
+ except (ZoneInfoNotFoundError, ValueError, OSError):
78
+ return fallback
79
+
80
+
81
+ DISPLAY_TZ_DEFAULT = "local"
82
+ _DISPLAY_TZ_BAD_CONFIG_WARNED = False # one-shot warning flag
83
+
84
+
85
+ def normalize_display_tz_value(raw: "str | None") -> str:
86
+ """Canonicalize a display-tz value to "local" | "utc" | <IANA>.
87
+
88
+ - None / "" / case-insensitive "local" -> "local"
89
+ - case-insensitive "utc" -> "utc"
90
+ - anything else: must be a valid IANA name (validated via _resolve_tz
91
+ with strict_iana=True). Returns the trimmed value verbatim.
92
+ Raises ValueError on invalid input.
93
+ """
94
+ if raw is None:
95
+ return "local"
96
+ s = str(raw).strip()
97
+ if not s:
98
+ return "local"
99
+ low = s.lower()
100
+ if low == "local":
101
+ return "local"
102
+ if low == "utc":
103
+ return "utc"
104
+ sentinel = object()
105
+ if _resolve_tz(s, strict_iana=True, fallback=sentinel) is sentinel:
106
+ raise ValueError(f"invalid IANA zone: {s!r}")
107
+ return s
108
+
109
+
110
+ def _config_has_explicit_display_tz(config: "dict | None") -> bool:
111
+ """Return True iff `config["display"]["tz"]` is present and non-None.
112
+
113
+ Used by codex command tz-name resolution (F2): we need to distinguish
114
+ "user pinned a display tz" from "default 'local' fallback because no
115
+ config block is set" so upstream's `--timezone` can still apply in the
116
+ drop-in-parity case where neither --tz nor display.tz was specified.
117
+ """
118
+ if not isinstance(config, dict):
119
+ return False
120
+ block = config.get("display")
121
+ return isinstance(block, dict) and block.get("tz") is not None
122
+
123
+
124
+ def get_display_tz_pref(config: "dict | None") -> str:
125
+ """Read config['display']['tz']; default DISPLAY_TZ_DEFAULT.
126
+
127
+ Malformed value -> fall back to default with one-shot stderr warning.
128
+ Never raises.
129
+ """
130
+ global _DISPLAY_TZ_BAD_CONFIG_WARNED
131
+ if not isinstance(config, dict):
132
+ return DISPLAY_TZ_DEFAULT
133
+ block = config.get("display")
134
+ if not isinstance(block, dict):
135
+ return DISPLAY_TZ_DEFAULT
136
+ raw = block.get("tz")
137
+ if raw is None:
138
+ return DISPLAY_TZ_DEFAULT
139
+ try:
140
+ return normalize_display_tz_value(raw)
141
+ except ValueError:
142
+ if not _DISPLAY_TZ_BAD_CONFIG_WARNED:
143
+ _eprint(
144
+ f"warning: ignoring malformed display.tz {raw!r} in config; "
145
+ f"using {DISPLAY_TZ_DEFAULT!r}"
146
+ )
147
+ _DISPLAY_TZ_BAD_CONFIG_WARNED = True
148
+ return DISPLAY_TZ_DEFAULT
149
+
150
+
151
+ def resolve_display_tz(args: argparse.Namespace,
152
+ config: "dict | None") -> "ZoneInfo | None":
153
+ """Returns ZoneInfo for fixed zones, None for "local" (caller does
154
+ bare astimezone()). Precedence: --tz flag > config > default.
155
+ Invalid --tz values are normalized via normalize_display_tz_value
156
+ (which raises ValueError -- caller is responsible for catching at
157
+ argparse-time via the shared _argparse_tz type=callable).
158
+ """
159
+ flag = getattr(args, "tz", None)
160
+ if flag is not None and str(flag).strip() != "":
161
+ canonical = normalize_display_tz_value(flag) # may raise
162
+ else:
163
+ canonical = get_display_tz_pref(config)
164
+ if canonical == "local":
165
+ return None
166
+ if canonical == "utc":
167
+ return ZoneInfo("Etc/UTC")
168
+ return ZoneInfo(canonical)
169
+
170
+
171
+ def display_tz_label(localized: dt.datetime) -> str:
172
+ """Returns the suffix label for an already-localized datetime.
173
+
174
+ Prefer ``localized.tzname()`` if alphanumeric and len <= 5; otherwise
175
+ fall back to a numeric offset via ``%z`` trimmed to "+HH" or "+HHMM".
176
+ Works uniformly across local / utc / explicit IANA -- caller has
177
+ already done astimezone() before passing in.
178
+ """
179
+ if localized.tzinfo is None:
180
+ # Defensive: shouldn't happen because callers localize first
181
+ localized = localized.replace(tzinfo=dt.timezone.utc)
182
+ name = localized.tzname() or ""
183
+ if (name
184
+ and name.replace("+", "").replace("-", "").isalnum()
185
+ and len(name) <= 5
186
+ and any(c.isalpha() for c in name)):
187
+ return name
188
+ # Numeric fallback. %z gives "+HHMM"; trim trailing "00" -> "+HH".
189
+ raw = localized.strftime("%z")
190
+ if not raw:
191
+ return "UTC"
192
+ if raw.endswith("00") and len(raw) == 5:
193
+ return raw[:-2] # "+0500" -> "+05"
194
+ # Insert colon for readability when minutes != 00: "+0530" -> "+05:30"
195
+ if len(raw) == 5:
196
+ return f"{raw[:3]}:{raw[3:]}"
197
+ return raw
198
+
199
+
200
+ def _localize(d: dt.datetime, tz: "ZoneInfo | None") -> dt.datetime:
201
+ """Localize a tz-aware datetime through the resolved display tz.
202
+
203
+ Mirrors ``format_display_dt``'s tz handling for callers that need a
204
+ localized ``dt.datetime`` (not a formatted string) — e.g. when the
205
+ same instant is fed to multiple ``strftime`` formats. ``tz=None``
206
+ falls back to host local via bare ``astimezone()``.
207
+ """
208
+ return d.astimezone(tz) if tz is not None else d.astimezone()
209
+
210
+
211
+ _DISPLAY_TZ_RESOLVE_WARNED = False # one-shot host-zone-fallback warning
212
+
213
+
214
+ def _resolve_display_tz_obj(config: dict) -> ZoneInfo:
215
+ """Resolve config.display.tz to a concrete ZoneInfo object.
216
+
217
+ Single source of truth used by ``_compute_display_block`` and the
218
+ dashboard snapshot builders (``_tui_build_snapshot``,
219
+ ``_handle_get_block_detail``). Local-fallback case emits a one-shot
220
+ warning via ``_DISPLAY_TZ_RESOLVE_WARNED``. Returns a ZoneInfo
221
+ (never None).
222
+ """
223
+ global _DISPLAY_TZ_RESOLVE_WARNED
224
+ pref = get_display_tz_pref(config)
225
+ if pref == "local":
226
+ host_iana = _local_tz_name()
227
+ if host_iana and "/" in host_iana:
228
+ return ZoneInfo(host_iana)
229
+ if not _DISPLAY_TZ_RESOLVE_WARNED:
230
+ _eprint(
231
+ "warning: display.tz='local' but host IANA zone could "
232
+ "not be resolved; using Etc/UTC. Set TZ to an IANA "
233
+ "name (e.g. America/New_York) to fix."
234
+ )
235
+ _DISPLAY_TZ_RESOLVE_WARNED = True
236
+ return ZoneInfo("Etc/UTC")
237
+ if pref == "utc":
238
+ return ZoneInfo("Etc/UTC")
239
+ try:
240
+ return ZoneInfo(pref)
241
+ except Exception:
242
+ # Defense in depth -- pref was already canonicalized via
243
+ # normalize_display_tz_value, but if a stale config slipped
244
+ # through, fall back rather than crash.
245
+ if not _DISPLAY_TZ_RESOLVE_WARNED:
246
+ _eprint(
247
+ f"warning: display.tz={pref!r} could not be loaded as "
248
+ f"a ZoneInfo; using Etc/UTC"
249
+ )
250
+ _DISPLAY_TZ_RESOLVE_WARNED = True
251
+ return ZoneInfo("Etc/UTC")
252
+
253
+
254
+ def _apply_display_tz_override(
255
+ config: dict,
256
+ override: "str | None",
257
+ ) -> dict:
258
+ """Return a shallow-copied config with `display.tz` substituted from
259
+ the override (F3 fix).
260
+
261
+ `--tz` on `cctally dashboard` should win over the persisted
262
+ `config.display.tz` for the lifetime of the server. Plumbing the
263
+ canonicalized string here lets every reader that already calls
264
+ `load_config()` (envelope builder, snapshot builder, block-detail
265
+ handler) pick up the override by routing the loaded config through
266
+ this thin wrapper -- without changing their existing
267
+ `_resolve_display_tz_obj(load_config())` shape.
268
+
269
+ Override semantics: a canonical token from
270
+ ``normalize_display_tz_value`` (``"local"`` / ``"utc"`` / IANA
271
+ name). ``None`` means "no override; let config win" -- the input
272
+ config is returned unchanged. Always returns a fresh dict so callers
273
+ can't mutate the passed-in config via the result.
274
+ """
275
+ if override is None:
276
+ return config
277
+ out = dict(config)
278
+ out["display"] = dict(out.get("display") or {})
279
+ out["display"]["tz"] = override
280
+ out["display"]["pinned"] = True
281
+ return out
282
+
283
+
284
+ def _compute_display_block(config: dict, generated_at: dt.datetime) -> dict:
285
+ """Compute the dashboard envelope's ``display`` block.
286
+
287
+ Resolves ``config.display.tz`` to a CONCRETE IANA name server-side
288
+ (F1 fix per spec) so the browser never has to guess "local". Also
289
+ computes the offset label and signed offset seconds at the snapshot
290
+ timestamp, so the client can label times consistently with the
291
+ server-rendered week_lbl / block labels.
292
+
293
+ Resolution flows through ``_resolve_display_tz_obj`` -- the same
294
+ helper that ``_tui_build_snapshot`` and ``_handle_get_block_detail``
295
+ use, so all three sites share warn-once stderr semantics.
296
+ """
297
+ pref = get_display_tz_pref(config)
298
+ resolved_obj = _resolve_display_tz_obj(config)
299
+ resolved_iana = resolved_obj.key
300
+
301
+ if generated_at.tzinfo is None:
302
+ generated_at = generated_at.replace(tzinfo=dt.timezone.utc)
303
+ localized = generated_at.astimezone(resolved_obj)
304
+ offset = localized.utcoffset()
305
+ offset_seconds = int(offset.total_seconds()) if offset is not None else 0
306
+ block: dict = {
307
+ "tz": pref,
308
+ "resolved_tz": resolved_iana,
309
+ "offset_label": display_tz_label(localized),
310
+ "offset_seconds": offset_seconds,
311
+ }
312
+ # F3: surface --tz-pin so the React client can render a read-only
313
+ # state for the Settings UI when the operator launched the server
314
+ # with an explicit --tz override. Override application leaves the
315
+ # `pinned` key on `config["display"]`; we forward it as a runtime
316
+ # signal (NOT persisted into config.json -- POST /api/settings is
317
+ # blocked under pin in this mode).
318
+ display_cfg = config.get("display") if isinstance(config, dict) else None
319
+ if isinstance(display_cfg, dict) and display_cfg.get("pinned"):
320
+ block["pinned"] = True
321
+ return block
322
+
323
+
324
+ def format_display_dt(value: "str | dt.datetime",
325
+ tz: "ZoneInfo | None",
326
+ *, fmt: str, suffix: bool = True) -> str:
327
+ """Targeted-swap chokepoint. Naive value treated as UTC.
328
+
329
+ Output: "<strftime fmt> <suffix>" when suffix=True, else just the
330
+ strftime. Suffix is computed from the localized datetime via
331
+ display_tz_label.
332
+ """
333
+ if isinstance(value, str):
334
+ s = value.replace("Z", "+00:00")
335
+ parsed = dt.datetime.fromisoformat(s)
336
+ else:
337
+ parsed = value
338
+ if parsed.tzinfo is None:
339
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
340
+ localized = parsed.astimezone(tz) if tz is not None else parsed.astimezone()
341
+ body = localized.strftime(fmt)
342
+ if not suffix:
343
+ return body
344
+ return f"{body} {display_tz_label(localized)}"
345
+
346
+
347
+ def _argparse_tz(value: str) -> str:
348
+ """argparse ``type=`` callable for ``--tz`` on any subcommand.
349
+
350
+ Canonicalizes via ``normalize_display_tz_value``: returns "local",
351
+ "utc", or a verbatim IANA name. Bad input raises
352
+ ``argparse.ArgumentTypeError`` so argparse formats a standard
353
+ ``error: argument --tz: ...`` message and exits 2.
354
+ """
355
+ try:
356
+ return normalize_display_tz_value(value)
357
+ except ValueError:
358
+ raise argparse.ArgumentTypeError(
359
+ f"invalid timezone {value!r} -- expected 'local', 'utc', "
360
+ f"or an IANA name"
361
+ )
@@ -49,6 +49,17 @@ class DoctorState:
49
49
  cache_entries_count: Optional[int]
50
50
  cache_last_entry_at: Optional[dt.datetime]
51
51
  claude_jsonl_present: bool
52
+ # Forked-bucket invariant counts (data.forked_buckets check).
53
+ # Keys: "usage", "cost", "milestones" — each maps to the count of
54
+ # rows in the respective table where ``week_start_at IS NOT NULL``
55
+ # AND ``week_start_date != substr(week_start_at, 1, 10)``. None
56
+ # means the stats.db couldn't be opened to check; the migration
57
+ # ``004_heal_forked_week_start_date_buckets`` auto-merges any
58
+ # detected rows on the next ``open_db()``, so a non-zero count
59
+ # here indicates either (a) the migration is gated as
60
+ # skipped/failed/pending or (b) a buggy writer slipped through
61
+ # after the migration ran.
62
+ forked_bucket_counts: Optional[dict]
52
63
  codex_entries_count: Optional[int]
53
64
  codex_last_entry_at: Optional[dt.datetime]
54
65
  codex_jsonl_present: bool
@@ -502,6 +513,52 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
502
513
  )
503
514
 
504
515
 
516
+ def _check_data_forked_buckets(s: DoctorState) -> CheckResult:
517
+ """Invariant: for every row with ``week_start_at IS NOT NULL``,
518
+ ``week_start_date == substr(week_start_at, 1, 10)``.
519
+
520
+ Pair with migration ``004_heal_forked_week_start_date_buckets``,
521
+ which auto-merges any detected rows on the next ``open_db()``. A
522
+ non-zero count here means either (a) the migration is gated as
523
+ skipped/failed/pending or (b) a buggy writer slipped through
524
+ after the migration ran. Either way the user has a fork that
525
+ needs attention.
526
+ """
527
+ counts = s.forked_bucket_counts
528
+ if counts is None:
529
+ return CheckResult(
530
+ id="data.forked_buckets", title="Forked week buckets",
531
+ severity="fail", summary="state unavailable",
532
+ remediation="Check stats.db opens (`cctally db status`)",
533
+ details={"reason": "gather returned None"},
534
+ )
535
+ total = sum(int(counts.get(k, 0)) for k in ("usage", "cost", "milestones"))
536
+ if total == 0:
537
+ return CheckResult(
538
+ id="data.forked_buckets", title="Forked week buckets",
539
+ severity="ok", summary="none",
540
+ remediation=None,
541
+ details=dict(counts),
542
+ )
543
+ parts = [
544
+ f"{counts.get(k, 0)} {k}"
545
+ for k in ("usage", "cost", "milestones")
546
+ if counts.get(k, 0)
547
+ ]
548
+ return CheckResult(
549
+ id="data.forked_buckets", title="Forked week buckets",
550
+ severity="fail",
551
+ summary=f"{total} forked row(s): {', '.join(parts)}",
552
+ remediation=(
553
+ "Run any cctally command to trigger the auto-heal migration "
554
+ "(`004_heal_forked_week_start_date_buckets`); if it's already "
555
+ "applied, see `cctally db status`."
556
+ ),
557
+ details=dict(counts),
558
+ )
559
+
560
+
561
+
505
562
  _LOOPBACK_HOSTS = frozenset({"loopback", "127.0.0.1", "::1", "localhost"})
506
563
 
507
564
 
@@ -728,6 +785,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
728
785
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
729
786
  ("data.cache_sync_state", "_check_data_cache_sync_state"),
730
787
  ("data.codex_cache", "_check_data_codex_cache"),
788
+ ("data.forked_buckets", "_check_data_forked_buckets"),
731
789
  )),
732
790
  ("safety", "Safety", (
733
791
  ("safety.dashboard_bind", "_check_safety_dashboard_bind"),
@@ -0,0 +1,82 @@
1
+ """5-hour window canonical-key primitives.
2
+
3
+ Pure-fn layer (no I/O at import time): holds the two jitter-tolerant
4
+ floors that route every 5h-window identity decision through a single
5
+ granularity. `_canonical_5h_window_key` is the epoch-int chokepoint and
6
+ `_floor_to_ten_minutes` the datetime equivalent — CLAUDE.md's "5h window
7
+ key MUST go through `_canonical_5h_window_key`" invariant lives here.
8
+
9
+ Both helpers share `_FIVE_HOUR_JITTER_FLOOR_SECONDS = 600` so neither can
10
+ drift independently; the regression `bin/cctally-5h-canonical-test`
11
+ pins the cross-shape equivalence (epoch-int → datetime → epoch round-trip
12
+ matches the modulo floor on 600-aligned base epochs).
13
+
14
+ `bin/cctally` re-exports every symbol below so internal call sites and
15
+ SourceFileLoader-based tests/fixtures (`tests/test_blocks_recorded_anchor`,
16
+ `tests/test_five_hour_block_selector`, `tests/test_five_hour_blocks_json`,
17
+ `tests/test_five_hour_breakdown`, `bin/cctally-5h-canonical-test`,
18
+ `bin/cctally-record-usage-selfheal-test`) resolve unchanged. No
19
+ cross-sibling dependencies — this is a true leaf in the sibling graph.
20
+
21
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import datetime as dt
26
+
27
+
28
+ _FIVE_HOUR_JITTER_FLOOR_SECONDS = 600 # 10 minutes; tolerance band for resets_at jitter
29
+
30
+
31
+ def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
32
+ """Floor a datetime to the previous 10-minute boundary.
33
+
34
+ Anthropic ``rate_limits.5h.resets_at`` arrives via the status line
35
+ with capture jitter and occasional transient bogus values that
36
+ differ from the real reset by tens of minutes (a brief mid-window
37
+ glitch sitting alongside the genuine reset). A 10-minute floor
38
+ collapses fine-grained jitter into shared buckets while leaving
39
+ truly distinct windows separable; structural conflicts that survive
40
+ the floor are resolved downstream by
41
+ ``_select_non_overlapping_recorded_windows``.
42
+ """
43
+ minute_bucket = _FIVE_HOUR_JITTER_FLOOR_SECONDS // 60
44
+ return d.replace(
45
+ minute=(d.minute // minute_bucket) * minute_bucket,
46
+ second=0, microsecond=0,
47
+ )
48
+
49
+
50
+ def _canonical_5h_window_key(
51
+ resets_at_epoch: int,
52
+ prior_epoch: int | None = None,
53
+ prior_key: int | None = None,
54
+ ) -> int:
55
+ """Floor a 5h-window resets_at epoch to a jitter-tolerant canonical key.
56
+
57
+ Anthropic's status-line API jitters resets_at by ~seconds within the same
58
+ physical 5h window. Any code identifying 'this 5h window' across consecutive
59
+ fetches MUST derive its key via this function. Floor granularity matches
60
+ _floor_to_ten_minutes (the same tolerance already used for weekly_reset_events).
61
+
62
+ Required invariant: two ``record-usage`` calls with ``resets_at`` differing
63
+ by ≤ 599 seconds MUST resolve to the same window key. A pure modulo floor
64
+ cannot satisfy this when the two epochs straddle a 600-second bucket
65
+ boundary (e.g. 1746014999 → 1746014400 vs. 1746015000 → 1746015000, a
66
+ 1-second delta producing different keys).
67
+
68
+ The optional ``prior_epoch`` / ``prior_key`` arguments close that gap: when
69
+ callers can supply the most-recent stored sample's raw ``five_hour_resets_at``
70
+ and its persisted ``five_hour_window_key``, the function reuses ``prior_key``
71
+ whenever ``|resets_at_epoch - prior_epoch| < _FIVE_HOUR_JITTER_FLOOR_SECONDS``
72
+ — boundary-straddling jitter then collapses to the first-seen anchor instead
73
+ of forking a new key. With no anchor (or with the anchor too far away to be
74
+ the same physical window), falls back to the pure floor.
75
+ """
76
+ if (
77
+ prior_epoch is not None
78
+ and prior_key is not None
79
+ and abs(resets_at_epoch - prior_epoch) < _FIVE_HOUR_JITTER_FLOOR_SECONDS
80
+ ):
81
+ return prior_key
82
+ return resets_at_epoch - (resets_at_epoch % _FIVE_HOUR_JITTER_FLOOR_SECONDS)