cctally 1.85.0 → 1.86.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.
@@ -995,6 +995,7 @@ def _doctor_gather_state_impl(
995
995
  codex_last_entry_at = None
996
996
  codex_project_metadata_health = None
997
997
  codex_project_metadata_error = None
998
+ codex_null_reset_anchors = 0
998
999
  try:
999
1000
  if _cache_probe_allowed and _cctally_core.CACHE_DB_PATH.exists():
1000
1001
  conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
@@ -1008,6 +1009,18 @@ def _doctor_gather_state_impl(
1008
1009
  codex_last_entry_at = parse_iso_datetime(
1009
1010
  row[1], "codex_session_entries.timestamp_utc",
1010
1011
  ).astimezone(dt.timezone.utc)
1012
+ try:
1013
+ row = conn.execute(
1014
+ "SELECT COUNT(*) FROM quota_window_snapshots "
1015
+ "WHERE source = 'codex' "
1016
+ "AND canonical_resets_at_utc IS NULL"
1017
+ ).fetchone()
1018
+ if row and row[0] is not None:
1019
+ codex_null_reset_anchors = int(row[0])
1020
+ except sqlite3.OperationalError:
1021
+ # Pre-anchor cache shapes have no column to inspect. Their
1022
+ # pending migration is reported by the DB checks instead.
1023
+ pass
1011
1024
  # Keep the health probe on the existing read-only cache
1012
1025
  # connection. A failed probe is health evidence, not an
1013
1026
  # empty corpus: the kernel renders it as a distinct FAIL.
@@ -1645,6 +1658,8 @@ def _doctor_gather_state_impl(
1645
1658
  cctally_version = (
1646
1659
  cctally_version_tuple[0] if cctally_version_tuple else "unknown"
1647
1660
  )
1661
+ accounts_state = _gather_accounts_state(now_utc)
1662
+ accounts_state["codex_null_reset_anchors"] = codex_null_reset_anchors
1648
1663
 
1649
1664
  return _lib_doctor.DoctorState(
1650
1665
  symlink_state=symlink_state,
@@ -1742,7 +1757,7 @@ def _doctor_gather_state_impl(
1742
1757
  journal_heal_incidents=journal_heal_incidents,
1743
1758
  journal_writer_guard=journal_writer_guard,
1744
1759
  # Multi-account attribution legs (#341).
1745
- accounts_state=_gather_accounts_state(now_utc),
1760
+ accounts_state=accounts_state,
1746
1761
  cache_repair_marker=cache_repair_marker,
1747
1762
  backup_sync_state=backup_sync_state,
1748
1763
  )
@@ -1489,6 +1489,8 @@ def _resolve_obs_anchor(resolver, rec: dict) -> "str | None":
1489
1489
  source_root_key=root, observed_slot=slot, logical_limit_key=key,
1490
1490
  window_minutes=p.get("window_minutes"),
1491
1491
  resets_at_utc=p.get("resets_at_utc"),
1492
+ source_path=p.get("source_path"),
1493
+ line_offset=p.get("line_offset"),
1492
1494
  )
1493
1495
  except Exception: # pragma: no cover — never fail an ingest over a label
1494
1496
  return None
@@ -1523,6 +1525,9 @@ def _apply_quota_records(cache, records) -> None:
1523
1525
  for rec in records:
1524
1526
  covered, decided = oracle.resolve(rec)
1525
1527
  anchor = _resolve_obs_anchor(anchors, rec)
1528
+ if anchors is not None:
1529
+ anchors.apply_pending_merges()
1530
+ anchors.mark_file_committed()
1526
1531
  row_values = _quota_snapshot_values(rec, anchor)
1527
1532
  if not has_anchor:
1528
1533
  row_values = row_values[:-1]
@@ -966,7 +966,9 @@ def _evaluate_quota_alerts(
966
966
  return queued
967
967
 
968
968
 
969
- def _reanchor_terminal_events_sql(key_slots: int, minute_slots: int) -> str:
969
+ def _reanchor_terminal_events_sql(
970
+ key_slots: int, minute_slots: int, reset_slots: int,
971
+ ) -> str:
970
972
  # `UPDATE OR IGNORE`, not a plain UPDATE: if this identity already carries an
971
973
  # anchored row at the same threshold, moving the jittered twin onto it would
972
974
  # violate the UNIQUE key. OR IGNORE SKIPS that move (it does not delete the
@@ -978,6 +980,11 @@ def _reanchor_terminal_events_sql(key_slots: int, minute_slots: int) -> str:
978
980
  # transaction.
979
981
  keys = ",".join(f":key{i}" for i in range(key_slots))
980
982
  minutes = ",".join(f":min{i}" for i in range(minute_slots))
983
+ member_epochs = ",".join(f":reset{i}" for i in range(reset_slots))
984
+ member_clause = (
985
+ f" OR unixepoch(resets_at_utc) IN ({member_epochs})"
986
+ if member_epochs else ""
987
+ )
981
988
  return (
982
989
  "UPDATE OR IGNORE quota_threshold_events "
983
990
  " SET resets_at_utc = :anchor, "
@@ -989,7 +996,9 @@ def _reanchor_terminal_events_sql(key_slots: int, minute_slots: int) -> str:
989
996
  f" AND observed_slot = :slot AND window_minutes IN ({minutes}) "
990
997
  " AND (resets_at_utc <> :anchor OR logical_limit_key <> :limit_key "
991
998
  " OR window_minutes <> :minutes) "
992
- " AND abs(unixepoch(resets_at_utc) - unixepoch(:anchor)) <= :tolerance"
999
+ " AND ("
1000
+ " abs(unixepoch(resets_at_utc) - unixepoch(:anchor)) <= :tolerance"
1001
+ f"{member_clause})"
993
1002
  )
994
1003
 
995
1004
 
@@ -1020,15 +1029,24 @@ def _reanchor_terminal_events(conn: sqlite3.Connection, block) -> None:
1020
1029
  re-materialization (they share this body), and is idempotent: a row already
1021
1030
  on the canonical identity is excluded by the three-way `<>` guard.
1022
1031
 
1023
- Bounded on both axes by the tolerances that produced the canonical identity —
1024
- 600s on the reset, ±1 minute on the length — so it can only ever collapse
1025
- rows the canonicalization itself merged. Two genuinely different cycles are
1026
- five hours or seven days apart, and a `10200` window is a different window,
1027
- not jitter.
1032
+ The reset match accepts either the original 600-second anchor neighbourhood
1033
+ or an exact raw reset retained by this block. The latter is required by
1034
+ #425's transitive component closure: an endpoint can be farther than 600s
1035
+ from the first-sight anchor while still joining it through retained bridge
1036
+ observations. Exact membership keeps the widened reach evidence-bound; a
1037
+ genuinely different cycle is never inferred from distance alone. The length
1038
+ axis remains bounded by its ±1 minute snap.
1028
1039
  """
1029
1040
  identity = block.identity
1030
1041
  keys = codex_snap_equivalent_limit_keys(identity.logical_limit_key)
1031
1042
  minutes = codex_snap_equivalent_window_minutes(identity.window_minutes)
1043
+ membership_evidence = (
1044
+ block.physical_observations or block.observations
1045
+ )
1046
+ reset_epochs = sorted({
1047
+ int(observation.resets_at.timestamp())
1048
+ for observation in membership_evidence
1049
+ })
1032
1050
  params: dict[str, object] = {
1033
1051
  "anchor": _utc_iso(block.resets_at),
1034
1052
  "source": identity.source,
@@ -1041,8 +1059,13 @@ def _reanchor_terminal_events(conn: sqlite3.Connection, block) -> None:
1041
1059
  }
1042
1060
  params.update({f"key{i}": value for i, value in enumerate(keys)})
1043
1061
  params.update({f"min{i}": value for i, value in enumerate(minutes)})
1062
+ params.update({
1063
+ f"reset{i}": value for i, value in enumerate(reset_epochs)})
1044
1064
  conn.execute(
1045
- _reanchor_terminal_events_sql(len(keys), len(minutes)), params)
1065
+ _reanchor_terminal_events_sql(
1066
+ len(keys), len(minutes), len(reset_epochs)),
1067
+ params,
1068
+ )
1046
1069
 
1047
1070
 
1048
1071
  def _apply_quota_projection_rows(
@@ -702,6 +702,7 @@ def maybe_record_milestone(
702
702
 
703
703
  # Threshold crossed — sync cost before recording so the milestone
704
704
  # captures up-to-date cumulative cost, not a stale snapshot.
705
+ cost_synced = True
705
706
  try:
706
707
  if retained_selection is None:
707
708
  retained_selection = _cctally().WeekSelection(
@@ -740,7 +741,13 @@ def maybe_record_milestone(
740
741
  retained_selection=retained_selection,
741
742
  )
742
743
  except Exception as exc:
743
- eprint(f"[milestone] cost sync failed, using latest available: {exc}")
744
+ # The snapshot read below would now return a row from an EARLIER
745
+ # crossing, so recording here stamps that older cumulative onto
746
+ # this threshold — a write-once row with a $0.00 marginal and a
747
+ # fabricated $/1%. Fall through only far enough to reach the
748
+ # skip guard on the snapshot branch.
749
+ cost_synced = False
750
+ eprint(f"[milestone] cost sync failed: {exc}")
744
751
 
745
752
  week_start = dt.date.fromisoformat(week_start_date)
746
753
  week_end = dt.date.fromisoformat(week_end_date)
@@ -762,17 +769,36 @@ def maybe_record_milestone(
762
769
  effective_ref = adjusted[0]
763
770
 
764
771
  if _week_ref_has_reset_event(conn, effective_ref):
765
- live_cost = _compute_cost_for_weekref(
766
- effective_ref,
767
- account_key=account_key,
768
- as_of=as_of,
769
- )
772
+ import _cctally_cache # fail-closed attribution guard (#341)
773
+ try:
774
+ live_cost = _compute_cost_for_weekref(
775
+ effective_ref,
776
+ account_key=account_key,
777
+ as_of=as_of,
778
+ )
779
+ except _cctally_cache.AccountAttributionUnavailable as exc:
780
+ # Same contract the budget ladder already holds (#341 Task 4):
781
+ # an account-scoped read that fell into the fail-closed guard
782
+ # SKIPS this tick and fires on the next healthy one. Never
783
+ # re-raised — on the passed-conn (ingest) path a bare raise
784
+ # would abort the whole cycle over a transient lock.
785
+ eprint("[milestone] account attribution unavailable, "
786
+ f"skipping this crossing: {exc}")
787
+ return
770
788
  if live_cost is None:
771
789
  eprint("[milestone] could not compute effective-range cost, skipping")
772
790
  return
773
791
  cumulative_cost = live_cost
774
792
  cost_snapshot_id = 0 # no snapshot row to anchor against
775
793
  else:
794
+ if not cost_synced:
795
+ # The latest snapshot predates this crossing. Milestones are
796
+ # write-once, so a stale cumulative here is permanent; skip
797
+ # instead. The next observation still sees current_floor >
798
+ # max_existing and records the crossing with a real cost.
799
+ eprint("[milestone] skipping this crossing — its cost would "
800
+ "come from a snapshot taken before the crossing")
801
+ return
776
802
  # Account-scoped read (#341 P2-1): the cost snapshot was just
777
803
  # materialized under `account_key`, so scope the read to it — the
778
804
  # merged (account-blind) read would return another account's row on
@@ -387,6 +387,9 @@ class CodexBucketUsage:
387
387
  period_end_at: dt.datetime | None = None
388
388
  used_pct: float | None = None
389
389
  dollar_per_pct: float | None = None
390
+ # #424: owning accounts for a pooled native weekly period. Empty for
391
+ # calendar buckets, focused account children, and undecorated providers.
392
+ account_keys: tuple[str, ...] = ()
390
393
 
391
394
 
392
395
  @dataclass
@@ -2607,6 +2607,33 @@ def _check_accounts_attribution(s: DoctorState) -> CheckResult:
2607
2607
  )
2608
2608
 
2609
2609
 
2610
+ def _check_accounts_codex_reset_anchors(s: DoctorState) -> CheckResult:
2611
+ """Surface Codex quota rows that landed after migration 032 with no anchor."""
2612
+ st = s.accounts_state or {}
2613
+ try:
2614
+ null_rows = int(st.get("codex_null_reset_anchors") or 0)
2615
+ except (TypeError, ValueError):
2616
+ null_rows = 0
2617
+ details = {"null_anchor_rows": null_rows}
2618
+ if null_rows > 0:
2619
+ return CheckResult(
2620
+ id="accounts.codex_reset_anchors",
2621
+ title="Codex reset anchors",
2622
+ severity="warn",
2623
+ summary=f"{null_rows} Codex quota observation(s) lack a canonical reset anchor",
2624
+ remediation="Run `cctally cache-sync --source codex --rebuild`",
2625
+ details=details,
2626
+ )
2627
+ return CheckResult(
2628
+ id="accounts.codex_reset_anchors",
2629
+ title="Codex reset anchors",
2630
+ severity="ok",
2631
+ summary="Codex reset anchors complete",
2632
+ remediation=None,
2633
+ details=details,
2634
+ )
2635
+
2636
+
2610
2637
  _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
2611
2638
  ("install", "Install", (
2612
2639
  ("install.mode", "_check_install_dev_mode"),
@@ -2667,6 +2694,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
2667
2694
  ("accounts.registry", "_check_accounts_registry"),
2668
2695
  ("accounts.freshness", "_check_accounts_freshness"),
2669
2696
  ("accounts.attribution", "_check_accounts_attribution"),
2697
+ ("accounts.codex_reset_anchors", "_check_accounts_codex_reset_anchors"),
2670
2698
  )),
2671
2699
  ("pricing", "Pricing", (
2672
2700
  ("pricing.coverage", "_check_pricing_coverage"),
package/bin/_lib_quota.py CHANGED
@@ -78,11 +78,11 @@ def resolve_reset_anchor(
78
78
  anchor already in ``anchors`` or the observation's own raw value, never a
79
79
  recomputed centroid. Assignment picks the NEAREST anchor within tolerance,
80
80
  breaking a tie on the earlier anchor, so for a given anchor set the answer
81
- does not depend on the order the anchors were established in. (The anchor
82
- SET can still depend on arrival order in the pathological case of a chain of
83
- observations each within tolerance of its neighbour but not of the first;
84
- real jitter is seconds wide, so a real cluster collapses to one anchor under
85
- every order.)
81
+ does not depend on the order the anchors were established in. The anchor
82
+ SET can still depend on arrival order in the chain-of-neighbours case. This
83
+ is retained as migration 032's reference rule; production ingest uses
84
+ :class:`ResetAnchorComponents` after #425 real-data evidence proved those
85
+ chains occur.
86
86
  """
87
87
  _require_aware(raw_reset, "raw_reset")
88
88
  if (isinstance(anchors, ResetAnchorIndex)
@@ -121,7 +121,9 @@ class ResetAnchorIndex:
121
121
  nearest within tolerance, ties broken on the earlier anchor, first sight
122
122
  wins, the anchor never moves, and the pathological chain-of-neighbours
123
123
  order-dependence that docstring documents is preserved exactly, because the
124
- anchor SET is still whatever the caller established in whatever order.
124
+ anchor SET is still whatever the caller established in whatever order. This
125
+ class remains the exact migration-032 oracle; current ingest uses
126
+ ``ResetAnchorComponents``.
125
127
 
126
128
  Only the LOOKUP changes. The linear scan did a full ``datetime`` subtraction
127
129
  against every established anchor, and a 5h group accumulates ~1,750 anchors
@@ -204,6 +206,156 @@ class ResetAnchorIndex:
204
206
  return len(self._order)
205
207
 
206
208
 
209
+ class ResetAnchorComponents:
210
+ """Tolerance-connected raw-reset components with first-sight anchors.
211
+
212
+ ``ResetAnchorIndex`` intentionally preserves the original migration-032
213
+ rule: compare a raw reset only with already-established anchors. Real data
214
+ proved that rule can split a chain whose adjacent members are all within
215
+ tolerance. This index retains every distinct raw reset as evidence and
216
+ unions adjacent members transitively. The member with the smallest stable
217
+ physical ``order_key`` remains the completed component's canonical anchor,
218
+ independent of filesystem traversal or journal batch arrival. Callers that
219
+ omit an order key retain insertion-order behavior for compatibility.
220
+
221
+ ``add`` returns both the winning anchor and any formerly independent
222
+ component anchors retired by the union. Writers use the retired set to
223
+ converge rows materialized before a later bridge observation arrived.
224
+ """
225
+
226
+ __slots__ = (
227
+ "_tolerance", "_buckets", "_parent", "_rank",
228
+ "_anchor", "_first_order", "_member_order", "_next_order",
229
+ )
230
+
231
+ def __init__(
232
+ self, raws: Iterable[dt.datetime] = (),
233
+ *, tolerance_seconds: int = CODEX_RESET_ANCHOR_TOLERANCE_SECONDS,
234
+ ) -> None:
235
+ if not isinstance(tolerance_seconds, int) or isinstance(
236
+ tolerance_seconds, bool):
237
+ raise ValueError("tolerance_seconds must be an int")
238
+ if tolerance_seconds <= 0:
239
+ raise ValueError("tolerance_seconds must be positive")
240
+ self._tolerance = tolerance_seconds
241
+ self._buckets: dict[int, list[dt.datetime]] = {}
242
+ self._parent: dict[dt.datetime, dt.datetime] = {}
243
+ self._rank: dict[dt.datetime, int] = {}
244
+ self._anchor: dict[dt.datetime, dt.datetime] = {}
245
+ self._first_order: dict[
246
+ dt.datetime, tuple[str, int, int]
247
+ ] = {}
248
+ self._member_order: dict[
249
+ dt.datetime, tuple[str, int, int]
250
+ ] = {}
251
+ self._next_order = 0
252
+ for raw in raws:
253
+ self.add(raw)
254
+
255
+ @property
256
+ def tolerance_seconds(self) -> int:
257
+ return self._tolerance
258
+
259
+ def _bucket(self, value: dt.datetime) -> int:
260
+ return int(value.timestamp() // self._tolerance)
261
+
262
+ def _find(self, value: dt.datetime) -> dt.datetime:
263
+ parent = self._parent[value]
264
+ if parent != value:
265
+ self._parent[value] = self._find(parent)
266
+ return self._parent[value]
267
+
268
+ def _union(
269
+ self, left: dt.datetime, right: dt.datetime,
270
+ ) -> dt.datetime:
271
+ left_root = self._find(left)
272
+ right_root = self._find(right)
273
+ if left_root == right_root:
274
+ return left_root
275
+ if self._rank[left_root] < self._rank[right_root]:
276
+ left_root, right_root = right_root, left_root
277
+ self._parent[right_root] = left_root
278
+ if self._rank[left_root] == self._rank[right_root]:
279
+ self._rank[left_root] += 1
280
+ if self._first_order[right_root] < self._first_order[left_root]:
281
+ self._anchor[left_root] = self._anchor[right_root]
282
+ self._first_order[left_root] = self._first_order[right_root]
283
+ del self._anchor[right_root]
284
+ del self._first_order[right_root]
285
+ return left_root
286
+
287
+ def add(
288
+ self, raw_reset: dt.datetime,
289
+ *, order_key: "tuple[str, int, int] | None" = None,
290
+ ) -> tuple[dt.datetime, tuple[dt.datetime, ...]]:
291
+ """Add one raw reset and return ``(anchor, retired_anchors)``."""
292
+ _require_aware(raw_reset, "raw_reset")
293
+ if order_key is None:
294
+ order_key = ("", self._next_order, 0)
295
+ if (
296
+ not isinstance(order_key, tuple) or len(order_key) != 3
297
+ or not isinstance(order_key[0], str)
298
+ or not isinstance(order_key[1], int)
299
+ or isinstance(order_key[1], bool)
300
+ or not isinstance(order_key[2], int)
301
+ or isinstance(order_key[2], bool)
302
+ ):
303
+ raise ValueError(
304
+ "order_key must be a (source_path, line_offset, row_id) tuple")
305
+ self._next_order += 1
306
+ if raw_reset in self._parent:
307
+ root = self._find(raw_reset)
308
+ previous = self._anchor[root]
309
+ if order_key < self._member_order[raw_reset]:
310
+ self._member_order[raw_reset] = order_key
311
+ if order_key < self._first_order[root]:
312
+ self._first_order[root] = order_key
313
+ self._anchor[root] = raw_reset
314
+ winner = self._anchor[root]
315
+ retired = (previous,) if previous != winner else ()
316
+ return winner, retired
317
+
318
+ probe = self._bucket(raw_reset)
319
+ neighbours: list[dt.datetime] = []
320
+ for bucket_id in (probe - 1, probe, probe + 1):
321
+ for candidate in self._buckets.get(bucket_id, ()):
322
+ if abs((raw_reset - candidate).total_seconds()) <= self._tolerance:
323
+ neighbours.append(candidate)
324
+
325
+ self._parent[raw_reset] = raw_reset
326
+ self._rank[raw_reset] = 0
327
+ self._anchor[raw_reset] = raw_reset
328
+ self._first_order[raw_reset] = order_key
329
+ self._member_order[raw_reset] = order_key
330
+ self._buckets.setdefault(probe, []).append(raw_reset)
331
+
332
+ prior_anchors = {raw_reset}
333
+ prior_anchors.update(
334
+ self._anchor[self._find(candidate)] for candidate in neighbours)
335
+ root = raw_reset
336
+ for candidate in neighbours:
337
+ root = self._union(root, candidate)
338
+ winner = self._anchor[self._find(root)]
339
+ return winner, tuple(sorted(prior_anchors - {winner}))
340
+
341
+ def canonical(self, raw_reset: dt.datetime) -> dt.datetime:
342
+ """Return the completed component's first-sight anchor."""
343
+ _require_aware(raw_reset, "raw_reset")
344
+ return self._anchor[self._find(raw_reset)]
345
+
346
+ def __contains__(self, anchor: object) -> bool:
347
+ if not isinstance(anchor, dt.datetime):
348
+ return False
349
+ return anchor in self._buckets.get(self._bucket(anchor), ())
350
+
351
+ def __iter__(self):
352
+ """Distinct raw-reset evidence in insertion order."""
353
+ return iter(self._parent)
354
+
355
+ def __len__(self) -> int:
356
+ return len(self._parent)
357
+
358
+
207
359
  @dataclass(frozen=True)
208
360
  class QuotaWindowIdentity:
209
361
  """One root-qualified native quota window identity.
@@ -313,6 +465,7 @@ class QuotaBlock:
313
465
  last_observed_at: dt.datetime
314
466
  first_percent: float
315
467
  current_percent: float
468
+ physical_observations: tuple[QuotaObservation, ...] = ()
316
469
 
317
470
 
318
471
  @dataclass(frozen=True)
@@ -577,7 +730,14 @@ def build_blocks(observations: Iterable[QuotaObservation]) -> tuple[QuotaBlock,
577
730
  ``QuotaBlock.resets_at``, which every renderer shows, is that same anchor.
578
731
  """
579
732
  by_block: dict[tuple[QuotaWindowIdentity, dt.datetime], list[QuotaObservation]] = {}
733
+ physical_by_block: dict[
734
+ tuple[QuotaWindowIdentity, dt.datetime], list[QuotaObservation]
735
+ ] = {}
580
736
  for history in build_history(observations):
737
+ for observation in history.physical_observations:
738
+ physical_by_block.setdefault(
739
+ (history.identity, observation.canonical_resets_at), []
740
+ ).append(observation)
581
741
  for observation in history.observations:
582
742
  by_block.setdefault(
583
743
  (history.identity, observation.canonical_resets_at), []
@@ -599,6 +759,10 @@ def build_blocks(observations: Iterable[QuotaObservation]) -> tuple[QuotaBlock,
599
759
  last_observed_at=last.captured_at,
600
760
  first_percent=first.used_percent,
601
761
  current_percent=last.used_percent,
762
+ physical_observations=tuple(sorted(
763
+ physical_by_block[(identity, resets_at)],
764
+ key=physical_order_key,
765
+ )),
602
766
  ))
603
767
  return tuple(blocks)
604
768