cctally 1.88.2 → 1.89.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.
@@ -0,0 +1,274 @@
1
+ """Pure kernel for the Codex quota change ledger (public issue omrikais/cctally#5).
2
+
3
+ Spec:
4
+ ``docs/superpowers/specs/2026-07-31-codex-hook-incremental-quota-reconcile-design.md``
5
+ §1-§2.
6
+
7
+ Three key spaces meet here and confusing any two of them is silent:
8
+
9
+ * **Raw coordinates** are what the SQLite triggers record and what the loader's
10
+ exact filter matches: the stored ``(source_root_key, logical_limit_key,
11
+ observed_slot, window_minutes, canonical-or-raw reset)`` of one row image.
12
+ Nothing is interpreted, because interpretation is population-dependent (the
13
+ account fold reads every observation of a window) and cannot be expressed in
14
+ SQL at all.
15
+
16
+ * **The loading unit** is the raw group closed over the ``window_minutes`` snap.
17
+ The provider occasionally reports a weekly window as ``10081``, and that one
18
+ minute lives in BOTH the limit key and a column of its own, so two raw groups
19
+ can interpret into one window. Loading a raw group alone would then hand the
20
+ projector a PARTIAL population — a wrong block, not a stale one. The loading
21
+ unit is also the reverse map persisted as ``quota_window_blocks.
22
+ physical_group_key`` and the unit the per-group digest is taken over.
23
+
24
+ * **The interpreted identity** is what ``QuotaObservation`` carries after the
25
+ read path snaps the length and rewrites the limit key for a model-scoped pool.
26
+ One loading unit can contain several interpreted identities (a Spark window
27
+ and an ordinary one), which is fine and deliberate: the unit is a partition of
28
+ the root's observations, so loading it whole gives every identity inside it
29
+ its complete population.
30
+
31
+ The bridge between the last two is ``strip_model_pool``. A model-scoped
32
+ interpreted key is the ordinary key with a ``modelPool`` member added, so
33
+ removing that member recovers the loading unit's key — which is what lets a
34
+ block stamp its own unit without the loader having to carry raw coordinates
35
+ alongside every observation.
36
+
37
+ Everything here is pure: no SQLite, no clock, no I/O.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import datetime as dt
42
+ import hashlib
43
+ import json
44
+ from typing import Iterable, Mapping, Sequence
45
+
46
+ from _lib_jsonl import (
47
+ _codex_canonical_json,
48
+ codex_snap_equivalent_limit_keys,
49
+ codex_snap_equivalent_window_minutes,
50
+ snap_codex_window_minutes,
51
+ snap_window_minutes,
52
+ )
53
+
54
+
55
+ #: The ledger's row-image column suffixes, in group order.
56
+ LEDGER_GROUP_SUFFIXES = (
57
+ "source_root_key",
58
+ "logical_limit_key",
59
+ "observed_slot",
60
+ "window_minutes",
61
+ "resets_at_utc",
62
+ "canonical_resets_at_utc",
63
+ )
64
+
65
+ #: Field separator for the serialized group key. A unit separator cannot occur
66
+ #: in a canonical-JSON limit key, an ISO timestamp, a slot name or a root key,
67
+ #: so the join is unambiguous without escaping.
68
+ _KEY_SEPARATOR = "\x1f"
69
+
70
+
71
+ def _text(value: object) -> str | None:
72
+ if value is None:
73
+ return None
74
+ text = str(value)
75
+ return text if text.strip() else None
76
+
77
+
78
+ def _side_group(row: Mapping[str, object], prefix: str) -> tuple | None:
79
+ """One row image's raw group, or ``None`` when it cannot form one.
80
+
81
+ A side with no root, slot, limit key, length or reset is a row the loader
82
+ would skip anyway (its required-text guard drops it before it can become a
83
+ quota identity), so there is nothing for the projector to expand.
84
+ """
85
+ root = _text(row.get(prefix + "source_root_key"))
86
+ limit_key = _text(row.get(prefix + "logical_limit_key"))
87
+ slot = _text(row.get(prefix + "observed_slot"))
88
+ minutes = row.get(prefix + "window_minutes")
89
+ # COALESCE, exactly as the loader's predicate does: a row whose anchor was
90
+ # never resolved falls back to its raw reset on every read path, so the
91
+ # group coordinate has to fall back with it.
92
+ reset = (
93
+ _text(row.get(prefix + "canonical_resets_at_utc"))
94
+ or _text(row.get(prefix + "resets_at_utc"))
95
+ )
96
+ if None in (root, limit_key, slot, reset) or minutes is None:
97
+ return None
98
+ try:
99
+ minutes_int = int(minutes)
100
+ except (TypeError, ValueError):
101
+ return None
102
+ return (root, limit_key, slot, minutes_int, reset)
103
+
104
+
105
+ def expand_dirty_groups(
106
+ ledger_rows: Iterable[Mapping[str, object]],
107
+ ) -> frozenset[tuple]:
108
+ """Map ledger entries to the union of their old and new raw group coordinates.
109
+
110
+ Both sides, always. A semantic UPDATE can move rows BETWEEN groups, so the
111
+ pass has to re-materialize the new group AND sweep the old one — a group
112
+ that has lost all its members is swept to nothing, and that only works if
113
+ the sweep knows where the rows came from. An insert contributes only a new
114
+ side and a delete only an old one, which falls out of the same rule rather
115
+ than needing a case per op.
116
+ """
117
+ groups: set[tuple] = set()
118
+ for row in ledger_rows:
119
+ for prefix in ("old_", "new_"):
120
+ group = _side_group(row, prefix)
121
+ if group is not None:
122
+ groups.add(group)
123
+ return frozenset(groups)
124
+
125
+
126
+ def snap_equivalent_raw_groups(groups: Iterable[tuple]) -> frozenset[tuple]:
127
+ """Close a raw group set over the ``window_minutes`` snap.
128
+
129
+ Rows are STORED under whichever spelling the provider sent, so the exact
130
+ filter has to ask for each of them by name. The set is bounded at nine per
131
+ input group (three equivalent limit keys x three equivalent lengths) and
132
+ contains the input itself, so a non-snappable group resolves to exactly
133
+ itself. Asking for a combination no row carries costs one indexed miss.
134
+
135
+ The cross product rather than a zip is deliberate: a key whose
136
+ ``windowMinutes`` member disagrees with the column (only reachable by hand
137
+ repair) would otherwise fall outside its own closure.
138
+ """
139
+ widened: set[tuple] = set()
140
+ for root, limit_key, slot, minutes, reset in groups:
141
+ for key in codex_snap_equivalent_limit_keys(limit_key):
142
+ for equivalent in codex_snap_equivalent_window_minutes(minutes):
143
+ widened.add((root, key, slot, int(equivalent), reset))
144
+ return frozenset(widened)
145
+
146
+
147
+ def strip_model_pool(logical_limit_key: str) -> str:
148
+ """Return the limit key with any ``modelPool`` member removed.
149
+
150
+ A model-scoped interpreted key is the ordinary key plus that member, so
151
+ dropping it recovers the loading unit's key — the value a ledger entry's raw
152
+ coordinates snap to. Fails OPEN on shape: a key this cannot parse is
153
+ returned exactly as it arrived rather than rebuilt from guessed members,
154
+ which keeps a hand-written or legacy key comparing equal to itself.
155
+ """
156
+ if not isinstance(logical_limit_key, str):
157
+ return logical_limit_key
158
+ try:
159
+ payload = json.loads(logical_limit_key)
160
+ except (json.JSONDecodeError, TypeError, ValueError):
161
+ return logical_limit_key
162
+ if not isinstance(payload, dict) or "modelPool" not in payload:
163
+ return logical_limit_key
164
+ payload.pop("modelPool")
165
+ try:
166
+ return _codex_canonical_json(payload)
167
+ except (TypeError, ValueError): # pragma: no cover - non-serializable member
168
+ return logical_limit_key
169
+
170
+
171
+ def normalize_reset(value: object) -> str:
172
+ """One spelling for one instant.
173
+
174
+ The cache retains whatever the provider sent — ``2026-07-01T05:00:00Z`` from
175
+ one writer, ``…+00:00`` from another — and every SQL comparison in this
176
+ subsystem wraps the column in ``unixepoch()`` for exactly that reason. The
177
+ loading-unit key is a TEXT key compared with ``=``, so it has no such
178
+ escape: a ledger entry naming the ``Z`` spelling and a block stamping the
179
+ ``+00:00`` one would be two different units, the scoped sweep would look for
180
+ a key nothing wrote, and a vanished window's block would survive.
181
+
182
+ Fails OPEN: a value this cannot parse is returned as text, so it still
183
+ compares equal to itself.
184
+ """
185
+ text = str(value)
186
+ try:
187
+ parsed = dt.datetime.fromisoformat(text.replace("Z", "+00:00"))
188
+ except ValueError:
189
+ return text
190
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
191
+ return text
192
+ return parsed.astimezone(dt.timezone.utc).isoformat()
193
+
194
+
195
+ def loading_unit_from_raw(group: Sequence) -> tuple:
196
+ """The loading unit one raw group belongs to."""
197
+ root, limit_key, slot, minutes, reset = group
198
+ return (
199
+ str(root),
200
+ snap_window_minutes(str(limit_key)),
201
+ str(slot),
202
+ int(snap_codex_window_minutes(int(minutes))),
203
+ normalize_reset(reset),
204
+ )
205
+
206
+
207
+ def loading_unit_from_identity(
208
+ *,
209
+ source_root_key: str,
210
+ logical_limit_key: str,
211
+ observed_slot: str,
212
+ window_minutes: int,
213
+ canonical_reset_iso: str,
214
+ ) -> tuple:
215
+ """The loading unit an INTERPRETED identity belongs to.
216
+
217
+ The identity's length is already snapped and its key already carries any
218
+ model pool, so the only transform left is removing that pool member. Both
219
+ this and ``loading_unit_from_raw`` must land on the same value for the same
220
+ physical rows — that identity is what lets a block record its own unit while
221
+ a ledger entry names the same unit from raw coordinates alone.
222
+ """
223
+ return (
224
+ str(source_root_key),
225
+ strip_model_pool(str(logical_limit_key)),
226
+ str(observed_slot),
227
+ int(window_minutes),
228
+ normalize_reset(canonical_reset_iso),
229
+ )
230
+
231
+
232
+ def physical_group_key_text(unit: Sequence) -> str:
233
+ """Serialize a loading unit for storage and for an SQL ``IN`` match."""
234
+ root, limit_key, slot, minutes, reset = unit
235
+ return _KEY_SEPARATOR.join(
236
+ (str(root), str(limit_key), str(slot), str(int(minutes)), str(reset)))
237
+
238
+
239
+ def group_digest(tuples: Iterable[Sequence]) -> str:
240
+ """Digest one loading unit's observation tuples.
241
+
242
+ The tuple shape is the whole-root signature's, unchanged, so a group's
243
+ contribution is exactly the rows it owns.
244
+ """
245
+ encoded = json.dumps(
246
+ sorted([list(item) for item in tuples]),
247
+ ensure_ascii=False, separators=(",", ":"),
248
+ ).encode("utf-8")
249
+ return hashlib.sha256(encoded).hexdigest()
250
+
251
+
252
+ def compose_root_signature(pairs: Iterable[tuple[str, str]]) -> str:
253
+ """Compose a root's physical signature from its per-group digests.
254
+
255
+ This is what makes the signature ASSOCIATIVE. The previous value was a
256
+ sha256 over the sorted per-observation tuples of a whole root, which a
257
+ bounded pass physically cannot reproduce — and the measured alternatives
258
+ were already rejected at 0.70s exact and 0.23s inexact against a 500ms
259
+ budget. Digesting the root's sorted ``(group key, group digest)`` pairs
260
+ instead is O(groups): 608 on the real store, against 211K observations.
261
+
262
+ The VALUE differs from the old whole-root digest; the SEMANTICS do not. It
263
+ stays an exact-equality function of the physical evidence alone, so
264
+ ``_stats_projection_signatures_match`` and the cache certificate keep meaning
265
+ what they mean today, and a bounded pass and a whole-history pass over the
266
+ same store must produce the same string. The transition is covered by the
267
+ interpretation-version bump, which invalidates every certificate written
268
+ under the old scheme.
269
+ """
270
+ encoded = json.dumps(
271
+ sorted({(str(key), str(digest)) for key, digest in pairs}),
272
+ ensure_ascii=False, separators=(",", ":"),
273
+ ).encode("utf-8")
274
+ return hashlib.sha256(encoded).hexdigest()
@@ -45,6 +45,7 @@ reason.
45
45
  from __future__ import annotations
46
46
 
47
47
  import datetime as dt
48
+ import hashlib
48
49
  import os
49
50
  import sqlite3
50
51
  import threading
@@ -97,6 +98,14 @@ class SnapshotSignature(NamedTuple):
97
98
  # an account SWITCH with zero new ingested rows, so the idle short-circuit is
98
99
  # left and the `active` marker rebuilds on the next tick.
99
100
  accounts_digest: str = ""
101
+ # public #5: the hook's budgeted-ingest backlog record, which feeds the
102
+ # Codex envelope's `ingest_backlog` field. A tick whose budgeted walk
103
+ # consumed only deduped or non-`token_count` bytes moves NO other cache leg,
104
+ # so without this the backlog changes with nothing to publish it and the
105
+ # hero's "totals will rise" note goes stale or missing until some unrelated
106
+ # Codex mutation happens along. Empty when nothing is owed (the writer
107
+ # DELETEs the key at zero), so a fully-ingested store is byte-neutral.
108
+ codex_ingest_backlog_sig: str = ""
100
109
 
101
110
 
102
111
  def _max_id(conn: sqlite3.Connection, table: str) -> int:
@@ -156,6 +165,32 @@ def _codex_physical_mutation_seq(conn: sqlite3.Connection) -> int:
156
165
  return 0
157
166
 
158
167
 
168
+ def _codex_ingest_backlog_sig(conn: sqlite3.Connection) -> str:
169
+ """Digest of the ``codex_ingest_backlog`` record, ``""`` when absent (#5).
170
+
171
+ The same O(1) ``cache_meta`` KV read as the mutation counters above — one
172
+ primary-key lookup on a table with a handful of rows, so this stays off the
173
+ per-tick cost that #268 removed.
174
+
175
+ A DIGEST rather than the parsed counts: the whole record is what the
176
+ envelope publishes (``files``, ``bytes`` and the one-hour ``since`` clock),
177
+ and hashing the stored bytes covers every field including any added later,
178
+ without teaching the signature the record's shape. The writer DELETEs the
179
+ key once the backlog drains, so ``""`` is both "never had one" and
180
+ "finished" — which is what keeps the version string byte-identical to the
181
+ pre-#5 one for the overwhelming majority of installs.
182
+ """
183
+ try:
184
+ row = conn.execute(
185
+ "SELECT value FROM cache_meta WHERE key='codex_ingest_backlog'"
186
+ ).fetchone()
187
+ except sqlite3.Error:
188
+ return ""
189
+ if row is None or not row[0]:
190
+ return ""
191
+ return hashlib.sha256(str(row[0]).encode("utf-8")).hexdigest()[:16]
192
+
193
+
159
194
  def _reset_sig(conn: sqlite3.Connection) -> tuple[int, int]:
160
195
  """Change-signal over the two reset-event tables combined (spec §3).
161
196
 
@@ -207,6 +242,7 @@ def compute_signature(
207
242
  codex_physical_mutation_seq=_codex_physical_mutation_seq(cache_conn),
208
243
  codex_stats_digest=str(codex_stats_digest),
209
244
  accounts_digest=str(accounts_digest),
245
+ codex_ingest_backlog_sig=_codex_ingest_backlog_sig(cache_conn),
210
246
  )
211
247
 
212
248
 
package/bin/cctally CHANGED
@@ -1053,6 +1053,7 @@ sync_claude_conversations = _cctally_cache.sync_claude_conversations
1053
1053
  sync_codex_conversations = _cctally_cache.sync_codex_conversations
1054
1054
  _reset_orphan_warning_throttle = _cctally_cache._reset_orphan_warning_throttle
1055
1055
  cmd_cache_sync = _cctally_cache.cmd_cache_sync
1056
+ cmd_codex_replay_drain_internal = _cctally_cache.cmd_codex_replay_drain_internal
1056
1057
 
1057
1058
 
1058
1059
  # Record-usage / hook-tick hot-path subsystem — the runtime path that
@@ -1452,6 +1453,7 @@ cmd_codex_session = _cctally_codex.cmd_codex_session
1452
1453
  # adapter while retaining the existing Codex accounting commands unchanged.
1453
1454
  _cctally_quota = _load_sibling("_cctally_quota")
1454
1455
  reconcile_codex_quota_projection = _cctally_quota.reconcile_codex_quota_projection
1456
+ cmd_codex_quota_verify_internal = _cctally_quota.cmd_codex_quota_verify_internal
1455
1457
  cmd_codex_quota_history = _cctally_quota.cmd_codex_quota_history
1456
1458
  cmd_codex_quota_statusline = _cctally_quota.cmd_codex_quota_statusline
1457
1459
  cmd_codex_quota_forecast = _cctally_quota.cmd_codex_quota_forecast
@@ -3457,9 +3459,10 @@ def _post_command_update_hooks(command: str | None, args) -> None:
3457
3459
  # (it runs before the wrapper sets CCTALLY_DATA_DIR, so APP_DIR is
3458
3460
  # still cctally-dev at this point). Same rationale class as doctor.
3459
3461
  return
3460
- if command in ("_update-check", "_telemetry-beat"):
3461
- # Both hidden workers are detached and have already done their one job
3462
- # in their own command handler; neither must re-enter this hook.
3462
+ if command in ("_update-check", "_telemetry-beat", "_codex-quota-verify",
3463
+ "_codex-replay-drain"):
3464
+ # All four hidden workers are detached and have already done their one job
3465
+ # in their own command handler; none must re-enter this hook.
3463
3466
  # Without the guard the ``_update-check`` worker would fall through to
3464
3467
  # the telemetry gate below and (throttle-bounded) spawn a
3465
3468
  # ``_telemetry-beat`` — a worker spawning another worker. And