cctally 1.22.2 → 1.22.3

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,450 @@
1
+ """WeekRef / reset-event cluster (impure-glue sibling).
2
+
3
+ Holds the seven DB-touching WeekRef helpers + the two reset-drop-threshold
4
+ constants that drive mid-week reset-event detection:
5
+ `_get_canonical_boundary_for_date`, `get_recent_weeks`,
6
+ `_apply_reset_events_to_weekrefs`, `_backfill_week_reset_events`,
7
+ `_week_ref_has_reset_event`, `_compute_cost_for_weekref`,
8
+ `_apply_overlap_clamp_to_weekrefs`, `_RESET_PCT_DROP_THRESHOLD`,
9
+ `_FIVE_HOUR_RESET_PCT_DROP_THRESHOLD`.
10
+
11
+ These operate on the `WeekRef` type and take `sqlite3.Connection` — the
12
+ IMPURE counterpart to the PURE `SubWeek` math in `_lib_subscription_weeks.py`
13
+ (which owns `_apply_reset_events_to_subweeks` / `_apply_overlap_clamp_to_subweeks`).
14
+
15
+ Honest *name* imports are KERNEL-ONLY (`_cctally_core`). The three
16
+ cctally-ns re-exports this module needs — `_floor_to_hour` (of `_lib_blocks`),
17
+ `_clamp_end_ats_to_next_start` (of `_lib_subscription_weeks`), and
18
+ `_sum_cost_for_range` (defined in `bin/cctally`) — are reached via the
19
+ call-time `c = _cctally()` accessor so test monkeypatches through the
20
+ `cctally` namespace are preserved. (No `for c in ...` row-loop in this
21
+ cluster → the accessor binds the conventional `c`.)
22
+
23
+ bin/cctally eager-re-exports all 7 functions + 2 constants; consumers reach
24
+ them via `c.` (forecast/percent_breakdown/view_models/tui/core/diff_kernel)
25
+ or bare `def`-shims (record.py). No consumer source edits.
26
+
27
+ Spec: docs/superpowers/specs/2026-06-01-extract-weekrefs-5h-backfill-design.md
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import datetime as dt
32
+ import sqlite3
33
+ import sys
34
+ from dataclasses import replace
35
+
36
+ from _cctally_core import (
37
+ WeekRef,
38
+ _canonicalize_optional_iso,
39
+ make_week_ref,
40
+ parse_iso_datetime,
41
+ )
42
+
43
+
44
+ def _cctally():
45
+ """Resolve the current `cctally` module at call-time."""
46
+ return sys.modules["cctally"]
47
+
48
+
49
+ def _get_canonical_boundary_for_date(
50
+ conn: sqlite3.Connection,
51
+ week_start_date_str: str,
52
+ ) -> tuple[str | None, str | None]:
53
+ """Return the first established (week_start_at, week_end_at) for a week."""
54
+ row = conn.execute(
55
+ """
56
+ SELECT week_start_at, week_end_at
57
+ FROM weekly_usage_snapshots
58
+ WHERE week_start_date = ?
59
+ AND week_start_at IS NOT NULL AND week_start_at != ''
60
+ AND week_end_at IS NOT NULL AND week_end_at != ''
61
+ ORDER BY captured_at_utc ASC, id ASC
62
+ LIMIT 1
63
+ """,
64
+ (week_start_date_str,),
65
+ ).fetchone()
66
+ if row:
67
+ start_at = _canonicalize_optional_iso(row["week_start_at"], "weekStartAt")
68
+ end_at = _canonicalize_optional_iso(row["week_end_at"], "weekEndAt")
69
+ if start_at and end_at:
70
+ return start_at, end_at
71
+ return None, None
72
+
73
+
74
+ def get_recent_weeks(conn: sqlite3.Connection, limit: int) -> list[WeekRef]:
75
+ rows = conn.execute(
76
+ """
77
+ SELECT week_start_date, MAX(week_end_date) AS week_end_date
78
+ FROM (
79
+ SELECT week_start_date, week_end_date FROM weekly_usage_snapshots
80
+ UNION ALL
81
+ SELECT week_start_date, week_end_date FROM weekly_cost_snapshots
82
+ )
83
+ GROUP BY week_start_date
84
+ ORDER BY week_start_date DESC
85
+ LIMIT ?
86
+ """,
87
+ (limit,),
88
+ ).fetchall()
89
+
90
+ refs: list[WeekRef] = []
91
+ for row in rows:
92
+ date_str = row["week_start_date"]
93
+ canon_start, canon_end = _get_canonical_boundary_for_date(conn, date_str)
94
+ try:
95
+ ref = make_week_ref(
96
+ week_start_date=date_str,
97
+ week_end_date=row["week_end_date"],
98
+ week_start_at=canon_start,
99
+ week_end_at=canon_end,
100
+ )
101
+ except ValueError:
102
+ continue
103
+ refs.append(ref)
104
+ # Reset-event boundary override runs BEFORE the generic overlap clamp.
105
+ # After the override, pre/post-reset refs are contiguous at the reset
106
+ # moment, so the clamp becomes a no-op for them; for installs with no
107
+ # reset events the clamp still does all the work it did before.
108
+ return _apply_overlap_clamp_to_weekrefs(
109
+ _apply_reset_events_to_weekrefs(conn, refs)
110
+ )
111
+
112
+
113
+ def _apply_reset_events_to_weekrefs(
114
+ conn: sqlite3.Connection, refs: list[WeekRef]
115
+ ) -> list[WeekRef]:
116
+ """Override API-derived boundaries with reset-event effective moments.
117
+
118
+ For each row in week_reset_events:
119
+ - A ref whose week_end_at matches `old_week_end_at` was the PRE-reset
120
+ week: its API-declared end is in the future but Anthropic cut it
121
+ early. Override ref.week_end_at = effective_reset_at_utc so display
122
+ shows the real cut-off.
123
+ - A ref whose week_end_at matches `new_week_end_at` is the POST-reset
124
+ week: its API-derived start (= new resets_at - 7d) backdates into
125
+ the pre-reset week. Override ref.week_start_at = effective_reset_at_utc
126
+ so the new week starts at the actual reset moment.
127
+ - **In-place credit (v1.7.2 round-3, Bug B).** Detected via the row
128
+ shape ``old_week_end_at == effective_reset_at_utc`` (the live and
129
+ backfill detection paths both write this shape — see
130
+ ``test_event_row_old_is_effective_not_cur_end``). For these events,
131
+ the credited week's ref matches ``new_week_end_at`` (the original
132
+ resets_at is unchanged), so the post-credit override above
133
+ rewrites ``week_start_at`` to ``effective``. But the pre-credit
134
+ segment of the SAME week — where the user spent the bulk of their
135
+ usage before the credit — is dropped, because no other ref in
136
+ ``refs`` carries ``week_end_at == effective``. Synthesize a
137
+ pre-credit ref alongside the post-credit one: its
138
+ ``week_start_at`` stays at the ref's original API-derived value,
139
+ its ``week_end_at`` becomes ``effective`` (closes the pre-credit
140
+ segment). Credited weeks render as TWO trend rows downstream.
141
+
142
+ The ref's `week_start` (date) and `key` fields are intentionally left at
143
+ the API-derived values — they're the lookup keys for
144
+ weekly_usage_snapshots / weekly_cost_snapshots. Only the display-facing
145
+ `week_start_at` / `week_end_at` (and the derived `week_end` date) shift.
146
+ Both the pre-credit and post-credit synthesized refs share the same
147
+ `key` so downstream per-segment readers
148
+ (``cmd_percent_breakdown`` / dashboard milestone panel) can still
149
+ filter milestones by ``reset_event_id`` against the same lookup keys.
150
+ """
151
+ events = conn.execute(
152
+ "SELECT old_week_end_at, new_week_end_at, effective_reset_at_utc "
153
+ "FROM week_reset_events"
154
+ ).fetchall()
155
+ if not events:
156
+ return refs
157
+ pre_map = {e["old_week_end_at"]: e["effective_reset_at_utc"] for e in events}
158
+ post_map = {e["new_week_end_at"]: e["effective_reset_at_utc"] for e in events}
159
+ # In-place credit events have `old == effective` (the row shape the
160
+ # live + backfill detection paths agree on). Project the set of
161
+ # `new_week_end_at` values for those events so we can detect them
162
+ # while iterating refs and split the credited week into TWO refs.
163
+ in_place_credit_new_ends: set[str] = {
164
+ e["new_week_end_at"]
165
+ for e in events
166
+ if e["old_week_end_at"] == e["effective_reset_at_utc"]
167
+ }
168
+ out: list[WeekRef] = []
169
+ for ref in refs:
170
+ new_ref = ref
171
+ if ref.week_end_at and ref.week_end_at in pre_map:
172
+ reset_at = pre_map[ref.week_end_at]
173
+ try:
174
+ reset_dt = parse_iso_datetime(reset_at, "reset_event.effective")
175
+ # internal fallback: host-local intentional
176
+ new_end_date = (reset_dt - dt.timedelta(seconds=1)).astimezone().date()
177
+ new_ref = replace(new_ref, week_end_at=reset_at, week_end=new_end_date)
178
+ except ValueError:
179
+ pass
180
+ if ref.week_end_at and ref.week_end_at in post_map:
181
+ reset_at = post_map[ref.week_end_at]
182
+ # In-place credit: synthesize a pre-credit ref FIRST so it
183
+ # lands in `out` before the post-credit ref. The pre-credit
184
+ # ref keeps the ORIGINAL API-derived week_start_at; only its
185
+ # week_end_at shifts to `effective`. The post-credit ref
186
+ # (constructed below via the standard `replace`) carries
187
+ # week_start_at = effective, week_end_at = original.
188
+ # Order: pre-credit BEFORE post-credit so chronological
189
+ # iteration in cmd_report's trend table renders them
190
+ # naturally (older segment above the newer one in DESC
191
+ # ordering: post-credit is "more recent" so the post-credit
192
+ # row should come FIRST in the DESC list — but the original
193
+ # ref was already in DESC position, and we insert pre-credit
194
+ # AFTER the post-credit. Concretely: post-credit takes the
195
+ # ref's original slot; pre-credit goes one slot later).
196
+ if ref.week_end_at in in_place_credit_new_ends:
197
+ try:
198
+ reset_dt = parse_iso_datetime(
199
+ reset_at, "reset_event.effective"
200
+ )
201
+ pre_end_date = (
202
+ # internal fallback: host-local intentional
203
+ reset_dt - dt.timedelta(seconds=1)
204
+ ).astimezone().date()
205
+ pre_credit_ref = replace(
206
+ ref,
207
+ week_end_at=reset_at,
208
+ week_end=pre_end_date,
209
+ )
210
+ except ValueError:
211
+ pre_credit_ref = None
212
+ else:
213
+ pre_credit_ref = None
214
+ new_ref = replace(new_ref, week_start_at=reset_at)
215
+ out.append(new_ref)
216
+ if pre_credit_ref is not None:
217
+ out.append(pre_credit_ref)
218
+ continue
219
+ out.append(new_ref)
220
+ return out
221
+
222
+
223
+ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
224
+ """One-shot scan over historical snapshots to synthesize reset events
225
+ for past mid-week resets the tool lived through before this feature
226
+ shipped. Idempotent via UNIQUE(old_week_end_at, new_week_end_at) +
227
+ INSERT OR IGNORE — safe to re-run, safe to ship alongside the DDL.
228
+
229
+ Rule mirrors the runtime detection in cmd_record_usage: when a new
230
+ week_end_at arrives in a snapshot whose captured_at_utc is still
231
+ BEFORE the prior week's end, that's a mid-week reset. Boundary ISO
232
+ strings get canonicalized via `_canonicalize_optional_iso` and the
233
+ effective reset moment is floored to the hour via `_floor_to_hour`
234
+ so minute/second-level Anthropic jitter ("in X hr Y min" relative-text
235
+ drift) doesn't masquerade as a reset.
236
+ """
237
+ c = _cctally()
238
+ try:
239
+ rows = conn.execute(
240
+ "SELECT captured_at_utc, week_end_at, weekly_percent "
241
+ "FROM weekly_usage_snapshots "
242
+ "WHERE week_end_at IS NOT NULL "
243
+ "ORDER BY captured_at_utc ASC, id ASC"
244
+ ).fetchall()
245
+ except sqlite3.DatabaseError:
246
+ return
247
+ # Canonicalized (hour-rounded) previous end; stored canonical form is
248
+ # what WeekRef.week_end_at carries after make_week_ref, so maps in
249
+ # _apply_reset_events_to_weekrefs stay joinable without extra parsing.
250
+ prior_end = None
251
+ prior_pct: float | None = None
252
+ for row in rows:
253
+ cur_end_raw = row["week_end_at"]
254
+ cur_pct = row["weekly_percent"]
255
+ if not cur_end_raw:
256
+ continue
257
+ try:
258
+ cur_end = _canonicalize_optional_iso(cur_end_raw, "backfill.cur")
259
+ except ValueError:
260
+ continue
261
+ if cur_end is None:
262
+ continue
263
+ if prior_end and cur_end != prior_end:
264
+ try:
265
+ prior_end_dt = parse_iso_datetime(prior_end, "backfill.prior")
266
+ captured_dt = parse_iso_datetime(row["captured_at_utc"], "backfill.cap")
267
+ except ValueError:
268
+ prior_end = cur_end
269
+ prior_pct = cur_pct
270
+ continue
271
+ # Real mid-week reset needs three signals:
272
+ # 1. Boundary shifted (already checked).
273
+ # 2. Prior boundary was still in the future (not natural rollover).
274
+ # 3. weekly_percent dropped substantially (prior_pct - cur_pct
275
+ # >= RESET_PCT_DROP_THRESHOLD). Filters out API jitter where
276
+ # Anthropic briefly reported a different reset_at but usage
277
+ # stayed roughly the same.
278
+ if (
279
+ captured_dt < prior_end_dt
280
+ and prior_pct is not None and cur_pct is not None
281
+ and (float(prior_pct) - float(cur_pct)) >= _RESET_PCT_DROP_THRESHOLD
282
+ ):
283
+ # Floor to the hour so the display boundary lands on the
284
+ # natural hour mark (Anthropic's reset times are always
285
+ # hour-aligned, and users think of weeks in hour-mark
286
+ # units). A reset at 18:08Z becomes 18:00Z in the event
287
+ # row, rendering as "21:00" local instead of "21:08".
288
+ effective_iso = c._floor_to_hour(captured_dt).isoformat(timespec="seconds")
289
+ conn.execute(
290
+ "INSERT OR IGNORE INTO week_reset_events "
291
+ "(detected_at_utc, old_week_end_at, new_week_end_at, "
292
+ " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
293
+ (row["captured_at_utc"], prior_end, cur_end, effective_iso),
294
+ )
295
+ elif prior_end and cur_end == prior_end:
296
+ # In-place credit branch (v1.7.2). Mirrors the live detection
297
+ # in cmd_record_usage: same end_at across two captures + ≥25pp
298
+ # drop in weekly_percent + prior end still in the future at
299
+ # captured_dt → Anthropic-issued goodwill credit. One event
300
+ # row with old == new == cur_end, effective = floor_to_hour
301
+ # of the captured_at when the drop was first observed.
302
+ try:
303
+ prior_end_dt = parse_iso_datetime(prior_end, "backfill.prior")
304
+ captured_dt = parse_iso_datetime(row["captured_at_utc"], "backfill.cap")
305
+ except ValueError:
306
+ prior_end = cur_end
307
+ prior_pct = cur_pct
308
+ continue
309
+ if (
310
+ captured_dt < prior_end_dt
311
+ and prior_pct is not None and cur_pct is not None
312
+ and (float(prior_pct) - float(cur_pct)) >= _RESET_PCT_DROP_THRESHOLD
313
+ ):
314
+ # Pre-check on ``new_week_end_at`` (mirrors the live
315
+ # detection path's pre-check). Necessary because the
316
+ # UNIQUE(old, new) constraint alone WON'T dedup against
317
+ # legacy/broken-shape rows: pre-fix DBs may have
318
+ # ``(cur_end, cur_end)`` rows for the same credit that
319
+ # the new shape writes as ``(effective_iso, cur_end)``.
320
+ # Without this pre-check, the backfill writes a second
321
+ # row for the same credit on every open_db() call after
322
+ # upgrade. (See round-2 review Bug 1.)
323
+ already = conn.execute(
324
+ "SELECT 1 FROM week_reset_events "
325
+ "WHERE new_week_end_at = ? LIMIT 1",
326
+ (cur_end,),
327
+ ).fetchone()
328
+ if already is not None:
329
+ prior_end = cur_end
330
+ prior_pct = cur_pct
331
+ continue
332
+ # Canonicalize to UTC before isoformat so the stored
333
+ # offset is `+00:00`, matching the live detection path
334
+ # (cmd_record_usage uses now_utc which is already UTC).
335
+ # parse_iso_datetime returns .astimezone() (host-local
336
+ # fallback at bin/cctally:_local_tz_name gate); without
337
+ # this normalization, non-UTC hosts would store the
338
+ # column as e.g. `+03:00`, breaking lex comparisons
339
+ # downstream (CLAUDE.md gotcha: 5h-block cross-reset
340
+ # comparisons go through unixepoch(), NOT lex
341
+ # BETWEEN/</>; the reset-aware DB clamp here applies
342
+ # the same rule). The reset-aware clamp now wraps both
343
+ # sides with unixepoch() (Bug 2 fix), but a canonical
344
+ # UTC offset on write is the right defense-in-depth.
345
+ effective_iso = (
346
+ c._floor_to_hour(captured_dt.astimezone(dt.timezone.utc))
347
+ .isoformat(timespec="seconds")
348
+ )
349
+ # Row shape: old=effective_iso, new=cur_end (distinct
350
+ # values). See the live-detection site in
351
+ # bin/_cctally_record.py for the full rationale; in
352
+ # short, old==new collapses the credited week to a
353
+ # zero-width window in _apply_reset_events_to_weekrefs.
354
+ conn.execute(
355
+ "INSERT OR IGNORE INTO week_reset_events "
356
+ "(detected_at_utc, old_week_end_at, new_week_end_at, "
357
+ " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
358
+ (row["captured_at_utc"], effective_iso, cur_end, effective_iso),
359
+ )
360
+ prior_end = cur_end
361
+ prior_pct = cur_pct
362
+ # Flush implicit transaction so callers using explicit BEGIN
363
+ # (e.g. _backfill_five_hour_blocks) don't trip "cannot start a
364
+ # transaction within a transaction".
365
+ conn.commit()
366
+
367
+
368
+ # Minimum weekly_percent drop that counts as a goodwill reset (percentage
369
+ # points). Real resets zero out usage, so the drop is large; transient API
370
+ # flaps show similar percents on both sides. 25pp catches both the known
371
+ # 86→0 and 34→0 cases while filtering 35→33-style jitter.
372
+ _RESET_PCT_DROP_THRESHOLD = 25.0
373
+
374
+ # In-place 5h-credit threshold. Mirrors `_RESET_PCT_DROP_THRESHOLD` but
375
+ # scaled down for the 5h dimension: typical 5h usage stays under ~10pp in
376
+ # a single block, so a 5pp drop sits well above natural variation while
377
+ # proportionally being a larger signal than 25pp is on the weekly scale.
378
+ # See spec docs/superpowers/specs/2026-05-16-5h-in-place-credit-detection.md
379
+ # §2.1 (Q1) for rationale.
380
+ _FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = 5.0
381
+
382
+
383
+ def _week_ref_has_reset_event(
384
+ conn: sqlite3.Connection, ref: WeekRef
385
+ ) -> bool:
386
+ """Return True if `ref`'s effective boundaries were rewritten by a
387
+ reset event (the ref went through _apply_reset_events_to_weekrefs
388
+ and either its start or end now equals some effective_reset_at_utc).
389
+ Lets cost callers bypass the weekly_cost_snapshots cache (which was
390
+ computed over API-derived range) and recompute live over the
391
+ effective range instead.
392
+ """
393
+ if not ref.week_start_at and not ref.week_end_at:
394
+ return False
395
+ row = conn.execute(
396
+ "SELECT 1 FROM week_reset_events "
397
+ "WHERE effective_reset_at_utc IN (?, ?) LIMIT 1",
398
+ (ref.week_start_at, ref.week_end_at),
399
+ ).fetchone()
400
+ return row is not None
401
+
402
+
403
+ def _compute_cost_for_weekref(ref: WeekRef) -> float | None:
404
+ """Live-compute USD cost over `ref`'s (possibly reset-adjusted) range
405
+ straight from session_entries. Mirrors what cmd_sync_week writes into
406
+ weekly_cost_snapshots, minus the cache write — used for reset-affected
407
+ weeks where the cached range disagrees with the effective range.
408
+ """
409
+ c = _cctally()
410
+ if not ref.week_start_at or not ref.week_end_at:
411
+ return None
412
+ try:
413
+ start = parse_iso_datetime(ref.week_start_at, "weekRef.week_start_at")
414
+ end = parse_iso_datetime(ref.week_end_at, "weekRef.week_end_at")
415
+ except ValueError:
416
+ return None
417
+ if end <= start:
418
+ return 0.0
419
+ return c._sum_cost_for_range(start, end, mode="auto")
420
+
421
+
422
+ def _apply_overlap_clamp_to_weekrefs(refs: list[WeekRef]) -> list[WeekRef]:
423
+ """Clamp each WeekRef's end to the next WeekRef's start on overlap.
424
+
425
+ Caller-visible effect: report --weeks output (and its --json
426
+ weekEndAt / weekEndDate) now reflects the true observed week end
427
+ instead of the stale week_end_at captured from Anthropic's
428
+ --resets-at at week-start. See _clamp_end_ats_to_next_start for
429
+ the underlying signal. Input order is preserved (caller contract:
430
+ get_recent_weeks returns DESC by week_start_date).
431
+
432
+ Only refs with both week_start_at and week_end_at participate in
433
+ clamping; date-only refs (pre-boundary-tracking rows) pass through.
434
+ """
435
+ c = _cctally()
436
+ candidates = [(i, r) for i, r in enumerate(refs) if r.week_start_at and r.week_end_at]
437
+ if len(candidates) < 2:
438
+ return refs
439
+ candidates.sort(key=lambda ir: ir[1].week_start_at) # type: ignore[arg-type,return-value]
440
+ pairs: list[tuple[str | None, str | None]] = [(r.week_start_at, r.week_end_at) for _, r in candidates]
441
+ new_ends = c._clamp_end_ats_to_next_start(pairs)
442
+ out = list(refs)
443
+ for (idx, cur), new_end in zip(candidates, new_ends):
444
+ if new_end is None or new_end == cur.week_end_at:
445
+ continue
446
+ new_end_dt = parse_iso_datetime(new_end, "week.end_at (clamped)")
447
+ # internal fallback: host-local intentional
448
+ new_end_date = (new_end_dt - dt.timedelta(seconds=1)).astimezone().date()
449
+ out[idx] = replace(cur, week_end_at=new_end, week_end=new_end_date)
450
+ return out