cctally 1.117.2 → 1.118.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.
- package/CHANGELOG.md +30 -0
- package/README.md +7 -3
- package/bin/_cctally_cache.py +2783 -175
- package/bin/_cctally_completion.py +106 -33
- package/bin/_cctally_core.py +69 -4
- package/bin/_cctally_dashboard.py +188 -13
- package/bin/_cctally_dashboard_conversation.py +5 -1
- package/bin/_cctally_dashboard_envelope.py +50 -19
- package/bin/_cctally_dashboard_sources.py +23 -1
- package/bin/_cctally_db.py +193 -153
- package/bin/_cctally_diagnosis_sources.py +63 -7
- package/bin/_cctally_doctor.py +865 -93
- package/bin/_cctally_five_hour.py +67 -50
- package/bin/_cctally_forecast.py +35 -13
- package/bin/_cctally_journal.py +186 -18
- package/bin/_cctally_milestone_history.py +859 -62
- package/bin/_cctally_oauth_samples.py +286 -15
- package/bin/_cctally_parser.py +2 -2
- package/bin/_cctally_quota.py +104 -12
- package/bin/_cctally_record.py +153 -29
- package/bin/_cctally_rederive.py +608 -68
- package/bin/_cctally_statusline.py +108 -81
- package/bin/_cctally_store.py +179 -38
- package/bin/_cctally_tui.py +892 -181
- package/bin/_cctally_update.py +32 -13
- package/bin/_lib_codex_conversation_query.py +52 -14
- package/bin/_lib_codex_drain_admission.py +224 -6
- package/bin/_lib_codex_metadata.py +39 -0
- package/bin/_lib_conversation_anon.py +15 -8
- package/bin/_lib_conversation_dispatch.py +7 -0
- package/bin/_lib_conversation_query.py +289 -70
- package/bin/_lib_dashboard_sources.py +37 -1
- package/bin/_lib_detector_access_registry.py +497 -26
- package/bin/_lib_doctor.py +85 -20
- package/bin/_lib_doctor_scan_index.py +321 -0
- package/bin/_lib_ingest_frontier.py +20 -1
- package/bin/_lib_oauth_samples.py +23 -7
- package/bin/_lib_pricing.py +146 -16
- package/bin/_lib_quota.py +19 -0
- package/bin/_lib_rederive.py +11 -6
- package/bin/_lib_retained_records.py +516 -0
- package/bin/_lib_snapshot_cache.py +10 -0
- package/bin/_lib_stale_proof.py +272 -43
- package/bin/_lib_stats_content_revision.py +831 -0
- package/bin/_lib_write_io.py +11 -4
- package/bin/cctally +4 -1
- package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
- package/dashboard/static/assets/{DoctorModal-B0DnU9d9.js → DoctorModal-D4QSq6uz.js} +1 -1
- package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-DSxXQSa-.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BleDpQqG.js → SourceDetailModal-NyyEfXmc.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CMqaAkSK.js → UpdateModal-CFLq9pT1.js} +1 -1
- package/dashboard/static/assets/{index-BjQV-0-D.js → index-BKTWH9JE.js} +2 -2
- package/dashboard/static/assets/{outlineNavigation-CO-VWBm7.js → outlineNavigation-BnGUwCS3.js} +3 -3
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-oqNsWzWV.js +0 -72
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +0 -1
|
@@ -24,14 +24,24 @@ host-local offset while boundaries are canonical UTC).
|
|
|
24
24
|
"""
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import bisect
|
|
27
28
|
import datetime as dt
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import math
|
|
32
|
+
import os
|
|
33
|
+
import secrets
|
|
28
34
|
import sqlite3
|
|
29
35
|
import sys
|
|
36
|
+
import threading
|
|
30
37
|
from dataclasses import replace
|
|
38
|
+
from typing import NamedTuple
|
|
31
39
|
|
|
32
40
|
from _cctally_core import (
|
|
33
41
|
_canonicalize_optional_iso, make_week_ref, parse_iso_datetime,
|
|
34
42
|
)
|
|
43
|
+
import _cctally_quota as _quota
|
|
44
|
+
import _lib_pricing
|
|
35
45
|
from _cctally_quota import assert_projection_readable, codex_quota_breakdown
|
|
36
46
|
from _lib_accounts import UNATTRIBUTED
|
|
37
47
|
from _lib_codex_pools import is_model_scoped_codex_quota
|
|
@@ -45,6 +55,7 @@ from _lib_json_envelope import _iso_z
|
|
|
45
55
|
from _lib_quota import QuotaWindowIdentity
|
|
46
56
|
|
|
47
57
|
import _lib_milestone_history as _mh
|
|
58
|
+
import _lib_stats_content_revision as _content_revision
|
|
48
59
|
|
|
49
60
|
UTC = dt.timezone.utc
|
|
50
61
|
|
|
@@ -374,35 +385,9 @@ def _claude_cycle_rows(conn: sqlite3.Connection, ref, *, account_key=None,
|
|
|
374
385
|
for its CURRENT cycle, so the index, the detail and the card read one
|
|
375
386
|
segment (spec 5.2 step 2) rather than re-deriving it from the cut instant.
|
|
376
387
|
"""
|
|
377
|
-
|
|
378
|
-
acct_m, _ = _account_predicate(account_key, "m.account_key")
|
|
379
|
-
acct_e, _ = _account_predicate(account_key, "e.account_key")
|
|
380
|
-
acct_r, _ = _account_predicate(account_key, "account_key")
|
|
388
|
+
acct_r, acct_p = _account_predicate(account_key, "account_key")
|
|
381
389
|
if cohort_id is None:
|
|
382
|
-
cohort_id =
|
|
383
|
-
if ref.week_start_at:
|
|
384
|
-
row = conn.execute(
|
|
385
|
-
"SELECT e.id FROM week_reset_events e "
|
|
386
|
-
"WHERE unixepoch(e.effective_reset_at_utc)=unixepoch(?)"
|
|
387
|
-
+ acct_e + " "
|
|
388
|
-
"AND (EXISTS ("
|
|
389
|
-
" SELECT 1 FROM weekly_usage_snapshots s "
|
|
390
|
-
" WHERE s.week_start_date=?" + acct_s + " AND ("
|
|
391
|
-
" unixepoch(s.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
392
|
-
" unixepoch(s.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
393
|
-
" )"
|
|
394
|
-
") OR EXISTS ("
|
|
395
|
-
" SELECT 1 FROM percent_milestones m "
|
|
396
|
-
" WHERE m.week_start_date=?" + acct_m + " AND ("
|
|
397
|
-
" unixepoch(m.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
398
|
-
" unixepoch(m.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
399
|
-
" )"
|
|
400
|
-
")) ORDER BY e.id DESC LIMIT 1",
|
|
401
|
-
(ref.week_start_at,) + acct_p + (ref.key,) + acct_p
|
|
402
|
-
+ (ref.key,) + acct_p,
|
|
403
|
-
).fetchone()
|
|
404
|
-
if row is not None:
|
|
405
|
-
cohort_id = int(row[0])
|
|
390
|
+
cohort_id = _claude_cycle_cohort_id(conn, ref, account_key=account_key)
|
|
406
391
|
return conn.execute(
|
|
407
392
|
"SELECT * FROM percent_milestones WHERE week_start_date=? "
|
|
408
393
|
"AND reset_event_id=?" + acct_r + " "
|
|
@@ -411,6 +396,42 @@ def _claude_cycle_rows(conn: sqlite3.Connection, ref, *, account_key=None,
|
|
|
411
396
|
).fetchall()
|
|
412
397
|
|
|
413
398
|
|
|
399
|
+
def _claude_cycle_cohort_id(conn: sqlite3.Connection, ref, *,
|
|
400
|
+
account_key=None) -> int:
|
|
401
|
+
"""The cycle's weekly milestone cohort (`percent_milestones.
|
|
402
|
+
reset_event_id`) as derived from its effective start: the newest reset
|
|
403
|
+
event effective at that instant that names this storage week, else ``0``.
|
|
404
|
+
Shared by the cohort read and the #1050 stamp, which scopes on it."""
|
|
405
|
+
acct_s, acct_p = _account_predicate(account_key, "s.account_key")
|
|
406
|
+
acct_m, _ = _account_predicate(account_key, "m.account_key")
|
|
407
|
+
acct_e, _ = _account_predicate(account_key, "e.account_key")
|
|
408
|
+
cohort_id = 0
|
|
409
|
+
if ref.week_start_at:
|
|
410
|
+
row = conn.execute(
|
|
411
|
+
"SELECT e.id FROM week_reset_events e "
|
|
412
|
+
"WHERE unixepoch(e.effective_reset_at_utc)=unixepoch(?)"
|
|
413
|
+
+ acct_e + " "
|
|
414
|
+
"AND (EXISTS ("
|
|
415
|
+
" SELECT 1 FROM weekly_usage_snapshots s "
|
|
416
|
+
" WHERE s.week_start_date=?" + acct_s + " AND ("
|
|
417
|
+
" unixepoch(s.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
418
|
+
" unixepoch(s.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
419
|
+
" )"
|
|
420
|
+
") OR EXISTS ("
|
|
421
|
+
" SELECT 1 FROM percent_milestones m "
|
|
422
|
+
" WHERE m.week_start_date=?" + acct_m + " AND ("
|
|
423
|
+
" unixepoch(m.week_end_at)=unixepoch(e.old_week_end_at) OR "
|
|
424
|
+
" unixepoch(m.week_end_at)=unixepoch(e.new_week_end_at)"
|
|
425
|
+
" )"
|
|
426
|
+
")) ORDER BY e.id DESC LIMIT 1",
|
|
427
|
+
(ref.week_start_at,) + acct_p + (ref.key,) + acct_p
|
|
428
|
+
+ (ref.key,) + acct_p,
|
|
429
|
+
).fetchone()
|
|
430
|
+
if row is not None:
|
|
431
|
+
cohort_id = int(row[0])
|
|
432
|
+
return cohort_id
|
|
433
|
+
|
|
434
|
+
|
|
414
435
|
def _five_hour_detail_revision(conn, start_iso, end_iso, *,
|
|
415
436
|
account_key=None) -> tuple[int, ...]:
|
|
416
437
|
"""Append-only 5h child revision for one cycle's intersecting blocks.
|
|
@@ -511,8 +532,100 @@ def _unavailable_join_component(conn, start_iso, end_iso,
|
|
|
511
532
|
return ("u" + _mh.compute_detail_stamp(*ids),)
|
|
512
533
|
|
|
513
534
|
|
|
535
|
+
class _ClaudeRevisionScopes:
|
|
536
|
+
"""The #1050 content-revision material of ONE index build (spec 4.6).
|
|
537
|
+
|
|
538
|
+
Read once per `build_claude_week_index`: the build's `ContentRevision`
|
|
539
|
+
and every scope row, in one read snapshot — the caller's transaction when
|
|
540
|
+
one is open (the detail route's), otherwise a short ``BEGIN`` …
|
|
541
|
+
``ROLLBACK`` of its own. The scope rows are strict to ``account_key`` when
|
|
542
|
+
the index is scoped and merged across every account (``unattributed``
|
|
543
|
+
included) otherwise, exactly as the index reads its rows.
|
|
544
|
+
|
|
545
|
+
A ``current`` store names the relevant revision of each cycle; any other
|
|
546
|
+
state (absent, partial, unsupported, unreadable) cannot certify a stamp,
|
|
547
|
+
so every entry of the build carries the same fresh nonce instead and the
|
|
548
|
+
client never serves a detail cached under an earlier one.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
def __init__(self, conn: sqlite3.Connection, *, account_key=None):
|
|
552
|
+
own = not conn.in_transaction
|
|
553
|
+
if own:
|
|
554
|
+
conn.execute("BEGIN")
|
|
555
|
+
try:
|
|
556
|
+
try:
|
|
557
|
+
revision = _content_revision.read_claude_content_revision(conn)
|
|
558
|
+
except _content_revision.StatsContentRevisionUnreadable:
|
|
559
|
+
revision = None
|
|
560
|
+
trusted = (revision is not None
|
|
561
|
+
and revision.state == _content_revision.STATE_CURRENT)
|
|
562
|
+
rows = (_content_revision.read_scope_rows(
|
|
563
|
+
conn, account_key=account_key) if trusted else [])
|
|
564
|
+
finally:
|
|
565
|
+
if own:
|
|
566
|
+
conn.rollback()
|
|
567
|
+
self.trusted = trusted
|
|
568
|
+
self.cycle_basis = revision.cycle_basis if trusted else None
|
|
569
|
+
self.nonce = None if trusted else secrets.token_hex(8)
|
|
570
|
+
self._keyed: dict = {"cohort": {}, "bucket": {}}
|
|
571
|
+
blocks, points = [], []
|
|
572
|
+
for row in rows:
|
|
573
|
+
if row.kind in self._keyed:
|
|
574
|
+
keyed = self._keyed[row.kind]
|
|
575
|
+
keyed[row.scope_key] = max(
|
|
576
|
+
keyed.get(row.scope_key, row.revision), row.revision)
|
|
577
|
+
elif row.start_epoch is None or row.end_epoch is None:
|
|
578
|
+
continue # an unparseable bound never matches, as in SQL
|
|
579
|
+
elif row.kind == "block":
|
|
580
|
+
blocks.append((row.start_epoch, row.end_epoch, row.revision))
|
|
581
|
+
elif row.kind == "reset_point":
|
|
582
|
+
points.append((row.start_epoch, row.revision))
|
|
583
|
+
blocks.sort()
|
|
584
|
+
points.sort()
|
|
585
|
+
self._blocks = blocks
|
|
586
|
+
self._block_starts = [b[0] for b in blocks]
|
|
587
|
+
self._points = points
|
|
588
|
+
self._point_epochs = [p[0] for p in points]
|
|
589
|
+
|
|
590
|
+
def component(self, conn, ref, cohort_id, start_iso, end_iso) -> tuple:
|
|
591
|
+
"""The stamp component for one cycle: ``("c" + …,)`` over the MAX
|
|
592
|
+
revision of its cohort, its bucket, every block intersecting
|
|
593
|
+
``[start, end)`` and every reset point in ``[start, end]``; ``()``
|
|
594
|
+
when no scope is relevant and the store was never published or
|
|
595
|
+
repaired (so such a cycle keeps its bytes); ``("u" + nonce,)`` on an
|
|
596
|
+
untrusted store."""
|
|
597
|
+
if not self.trusted:
|
|
598
|
+
return ("u" + self.nonce,)
|
|
599
|
+
relevant = [
|
|
600
|
+
rev for rev in (
|
|
601
|
+
self._keyed["cohort"].get(f"{ref.key}|{cohort_id}"),
|
|
602
|
+
self._keyed["bucket"].get(ref.key),
|
|
603
|
+
) if rev is not None
|
|
604
|
+
]
|
|
605
|
+
if (self._blocks or self._points) and start_iso and end_iso:
|
|
606
|
+
start, end = conn.execute(
|
|
607
|
+
"SELECT unixepoch(?), unixepoch(?)",
|
|
608
|
+
(start_iso, end_iso)).fetchone()
|
|
609
|
+
if start is not None and end is not None:
|
|
610
|
+
# Blocks starting before the cycle's end, then ending after
|
|
611
|
+
# its start; reset points inside the closed interval.
|
|
612
|
+
upto = bisect.bisect_left(self._block_starts, end)
|
|
613
|
+
relevant.extend(rev for _s, b_end, rev in self._blocks[:upto]
|
|
614
|
+
if b_end > start)
|
|
615
|
+
lo = bisect.bisect_left(self._point_epochs, start)
|
|
616
|
+
hi = bisect.bisect_right(self._point_epochs, end)
|
|
617
|
+
relevant.extend(rev for _e, rev in self._points[lo:hi])
|
|
618
|
+
if not relevant and self.cycle_basis is None:
|
|
619
|
+
return ()
|
|
620
|
+
return ("c" + _mh.compute_detail_stamp(
|
|
621
|
+
self.cycle_basis, max(relevant, default=None)),)
|
|
622
|
+
|
|
623
|
+
|
|
514
624
|
def _index_entry(conn, ref, current_cycle_key, tz, *, unavailable_rows,
|
|
515
|
-
decorated=False, account_key=None,
|
|
625
|
+
revision_scopes, decorated=False, account_key=None,
|
|
626
|
+
cohort_id=None) -> dict:
|
|
627
|
+
if cohort_id is None:
|
|
628
|
+
cohort_id = _claude_cycle_cohort_id(conn, ref, account_key=account_key)
|
|
516
629
|
rows = _claude_cycle_rows(
|
|
517
630
|
conn, ref, account_key=account_key, cohort_id=cohort_id)
|
|
518
631
|
milestone_count = len(rows)
|
|
@@ -535,6 +648,10 @@ def _index_entry(conn, ref, current_cycle_key, tz, *, unavailable_rows,
|
|
|
535
648
|
unavailable = _unavailable_join_component(
|
|
536
649
|
conn, start_z, end_z, unavailable_rows)
|
|
537
650
|
scope_part = () if account_key is None else ("a" + account_key,)
|
|
651
|
+
# #1050 spec 4.6: the cycle's stored-content revision, so an in-place
|
|
652
|
+
# correction of any row this cycle reads re-stamps it (and only it).
|
|
653
|
+
revision_part = revision_scopes.component(
|
|
654
|
+
conn, ref, cohort_id, start_z, end_z)
|
|
538
655
|
|
|
539
656
|
# #750 S4 §5.3. A CURRENT single-segment cycle neither fetches its detail
|
|
540
657
|
# nor uses one that arrives, so a gap in the LIVE cycle would stay
|
|
@@ -563,7 +680,7 @@ def _index_entry(conn, ref, current_cycle_key, tz, *, unavailable_rows,
|
|
|
563
680
|
"has_observation_gap": has_gap,
|
|
564
681
|
"detail_stamp": _mh.compute_detail_stamp(
|
|
565
682
|
key, milestone_count, block_count, segment_count, max_captured,
|
|
566
|
-
*block_revision, *unavailable, *scope_part,
|
|
683
|
+
*block_revision, *revision_part, *unavailable, *scope_part,
|
|
567
684
|
),
|
|
568
685
|
}
|
|
569
686
|
|
|
@@ -655,9 +772,11 @@ def build_claude_week_index(conn: sqlite3.Connection, *, account_key=None,
|
|
|
655
772
|
current_cycle_key = (
|
|
656
773
|
_claude_cycle_key(current_ref) if current_ref else None)
|
|
657
774
|
unavailable_rows = _unavailable_join_rows(conn)
|
|
775
|
+
revision_scopes = _ClaudeRevisionScopes(conn)
|
|
658
776
|
entries = [
|
|
659
777
|
_index_entry(conn, ref, current_cycle_key, tz,
|
|
660
|
-
unavailable_rows=unavailable_rows,
|
|
778
|
+
unavailable_rows=unavailable_rows,
|
|
779
|
+
revision_scopes=revision_scopes, decorated=decorated)
|
|
661
780
|
for ref in refs
|
|
662
781
|
]
|
|
663
782
|
else:
|
|
@@ -667,10 +786,12 @@ def build_claude_week_index(conn: sqlite3.Connection, *, account_key=None,
|
|
|
667
786
|
_claude_cycle_key(current_ref) if current_ref else None)
|
|
668
787
|
unavailable_rows = _unavailable_join_rows(
|
|
669
788
|
conn, account_key=account_key)
|
|
789
|
+
revision_scopes = _ClaudeRevisionScopes(conn, account_key=account_key)
|
|
670
790
|
entries = [
|
|
671
791
|
_index_entry(
|
|
672
792
|
conn, ref, current_cycle_key, tz,
|
|
673
|
-
unavailable_rows=unavailable_rows,
|
|
793
|
+
unavailable_rows=unavailable_rows,
|
|
794
|
+
revision_scopes=revision_scopes, decorated=decorated,
|
|
674
795
|
account_key=account_key,
|
|
675
796
|
cohort_id=(scope.reset_event_id
|
|
676
797
|
if ref is current_ref else None),
|
|
@@ -1260,28 +1381,12 @@ def _select_live_physical_cycle(cycles: list, boundary):
|
|
|
1260
1381
|
return min(pool, key=_rank)
|
|
1261
1382
|
|
|
1262
1383
|
|
|
1263
|
-
def _codex_milestone_count(stats_conn, cyc) -> tuple[int, str | None]:
|
|
1264
|
-
"""Unique threshold count + max captured-at for one selected identity."""
|
|
1265
|
-
resets = cyc.member_reset_isos
|
|
1266
|
-
placeholders = ",".join("unixepoch(?)" for _ in resets)
|
|
1267
|
-
row = stats_conn.execute(
|
|
1268
|
-
"SELECT COUNT(DISTINCT percent_threshold), MAX(captured_at_utc) "
|
|
1269
|
-
"FROM quota_percent_milestones "
|
|
1270
|
-
"WHERE source='codex' AND source_root_key=? AND account_key=? "
|
|
1271
|
-
" AND logical_limit_key=? "
|
|
1272
|
-
" AND observed_slot=? AND window_minutes=? "
|
|
1273
|
-
f" AND unixepoch(resets_at_utc) IN ({placeholders}) AND orphaned_at IS NULL",
|
|
1274
|
-
(cyc.root, cyc.account_key, cyc.limit, cyc.slot, cyc.window, *resets),
|
|
1275
|
-
).fetchone()
|
|
1276
|
-
return int(row[0] or 0), row[1]
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
1384
|
def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
|
|
1280
1385
|
"""Every retained 5h block on the selected root intersecting [start, end).
|
|
1281
1386
|
|
|
1282
1387
|
The account filter admits the cycle's own key OR the ``unattributed``
|
|
1283
1388
|
sentinel, and deliberately NOT strict equality. Unlike
|
|
1284
|
-
``
|
|
1389
|
+
``_codex_cycle_raw_rows`` — whose block and milestone parameters both come
|
|
1285
1390
|
from one ``block.identity`` and so cannot disagree — this query spans
|
|
1286
1391
|
identities, selecting by root plus time range. The 300-minute windows are a
|
|
1287
1392
|
separate physical-window group from the weekly one and
|
|
@@ -1333,7 +1438,8 @@ def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
|
|
|
1333
1438
|
return rows
|
|
1334
1439
|
|
|
1335
1440
|
|
|
1336
|
-
def _codex_five_hour_clusters(stats_conn, cyc, *, include_orphaned=False
|
|
1441
|
+
def _codex_five_hour_clusters(stats_conn, cyc, *, include_orphaned=False,
|
|
1442
|
+
rows=None) -> list:
|
|
1337
1443
|
"""Retained 5h blocks intersecting the cycle, jitter-canonicalized.
|
|
1338
1444
|
|
|
1339
1445
|
The 300-minute rows carry the same second-level ``resets_at`` jitter as the
|
|
@@ -1341,9 +1447,16 @@ def _codex_five_hour_clusters(stats_conn, cyc, *, include_orphaned=False) -> lis
|
|
|
1341
1447
|
clustered the same way — one canonical block per physical reset — before
|
|
1342
1448
|
counting/rendering. Returns cluster-representative ``_CodexCycle`` blocks
|
|
1343
1449
|
(``.members`` set), ordered by ascending start.
|
|
1450
|
+
|
|
1451
|
+
``rows`` (#1050) clusters rows the caller already read with
|
|
1452
|
+
``_codex_five_hour_rows`` — `_codex_cycle_inputs`' block rows — in that
|
|
1453
|
+
function's own order, so the stamp and the detail enumerate one population.
|
|
1344
1454
|
"""
|
|
1455
|
+
if rows is None:
|
|
1456
|
+
rows = _codex_five_hour_rows(
|
|
1457
|
+
stats_conn, cyc, include_orphaned=include_orphaned)
|
|
1345
1458
|
blocks: list = []
|
|
1346
|
-
for row in
|
|
1459
|
+
for row in rows:
|
|
1347
1460
|
block = _CodexCycle(row)
|
|
1348
1461
|
if block.start is None or block.reset is None:
|
|
1349
1462
|
continue
|
|
@@ -1359,9 +1472,580 @@ def _codex_five_hour_clusters(stats_conn, cyc, *, include_orphaned=False) -> lis
|
|
|
1359
1472
|
return clusters
|
|
1360
1473
|
|
|
1361
1474
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1475
|
+
# ── #1050 spec 4.7: every input of a Codex cycle detail ─────────────────────
|
|
1476
|
+
#
|
|
1477
|
+
# The Codex cycle detail publishes block `current_percent`, the cycle ladder
|
|
1478
|
+
# and every included block's ladder (crossings, high-water percent, physical
|
|
1479
|
+
# boundaries), and per-milestone dollars and tokens priced at read time from
|
|
1480
|
+
# `codex_session_entries` at the resolved speed. Its stamp named none of those
|
|
1481
|
+
# values, so a cached detail survived every in-place rewrite of them. One
|
|
1482
|
+
# helper reads all of them, and both the index entry (the stamp) and the
|
|
1483
|
+
# detail route consume it.
|
|
1484
|
+
|
|
1485
|
+
#: Every `quota_percent_milestones` column a Codex detail reads, its unique key
|
|
1486
|
+
#: (`source, source_root_key, account_key, logical_limit_key, observed_slot,
|
|
1487
|
+
#: window_minutes, resets_at_utc, percent_threshold`) first, so the rows sort
|
|
1488
|
+
#: by that key as plain tuples (every key column is NOT NULL).
|
|
1489
|
+
_CODEX_MILESTONE_INPUT_COLUMNS = (
|
|
1490
|
+
"source, source_root_key, account_key, logical_limit_key, observed_slot, "
|
|
1491
|
+
"window_minutes, resets_at_utc, percent_threshold, captured_at_utc, "
|
|
1492
|
+
"source_path, line_offset, high_water_percent"
|
|
1493
|
+
)
|
|
1494
|
+
_CODEX_MILESTONE_KEY_WIDTH = 8
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
class CodexCycleInputs(NamedTuple):
|
|
1498
|
+
"""Everything one Codex cycle detail's published values depend on.
|
|
1499
|
+
|
|
1500
|
+
* ``cycle_rows`` — the cycle's raw qualifying ``quota_percent_milestones``
|
|
1501
|
+
rows across its jitter-member resets (the stamp's distinct-threshold count
|
|
1502
|
+
and raw maximum capture come from these), ordered by unique key.
|
|
1503
|
+
* ``block_ladder_rows`` — per selection the detail prices (the cycle's own
|
|
1504
|
+
members, then every included five-hour block cluster's members): the
|
|
1505
|
+
ladder rows ``codex_quota_breakdown`` reads for that member, by unique key.
|
|
1506
|
+
* ``block_rows`` — the five-hour block rows ``_codex_five_hour_rows``
|
|
1507
|
+
returns, by unique key.
|
|
1508
|
+
* ``first_boundaries`` — per priced member, the physical
|
|
1509
|
+
``(captured_at_utc, source_path, line_offset)`` the breakdown opens from,
|
|
1510
|
+
``"absent"`` when it has none, ``"unread"`` when the member has no ladder.
|
|
1511
|
+
* ``selection_aggregates`` — per priced member with a boundary, the
|
|
1512
|
+
aggregate twin of its accounting read.
|
|
1513
|
+
* ``accounting_seq``, ``speed`` (resolved), ``pricing_identity``.
|
|
1514
|
+
* ``block_clusters`` — derived from the block rows in their read order, the
|
|
1515
|
+
clusters the detail renders; excluded from the content hash.
|
|
1516
|
+
"""
|
|
1517
|
+
|
|
1518
|
+
cycle_rows: tuple
|
|
1519
|
+
block_ladder_rows: tuple
|
|
1520
|
+
block_rows: tuple
|
|
1521
|
+
first_boundaries: tuple
|
|
1522
|
+
selection_aggregates: tuple
|
|
1523
|
+
accounting_seq: "int | None"
|
|
1524
|
+
speed: str
|
|
1525
|
+
pricing_identity: str
|
|
1526
|
+
block_clusters: tuple
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _canonical_digest(value) -> str:
|
|
1530
|
+
return hashlib.sha256(json.dumps(
|
|
1531
|
+
value, allow_nan=False, ensure_ascii=False, separators=(",", ":"),
|
|
1532
|
+
).encode("utf-8")).hexdigest()
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _member_ident(member) -> tuple:
|
|
1536
|
+
return (member.root, member.account_key, member.limit, member.slot,
|
|
1537
|
+
member.window, member.reset_iso)
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _codex_cycle_raw_rows(stats_conn, cyc) -> tuple:
|
|
1541
|
+
"""The cycle's raw qualifying milestone rows: the population the stamp's
|
|
1542
|
+
count and maximum capture have always covered, now read whole."""
|
|
1543
|
+
resets = cyc.member_reset_isos
|
|
1544
|
+
placeholders = ",".join("unixepoch(?)" for _ in resets)
|
|
1545
|
+
rows = stats_conn.execute(
|
|
1546
|
+
f"SELECT {_CODEX_MILESTONE_INPUT_COLUMNS} "
|
|
1547
|
+
"FROM quota_percent_milestones "
|
|
1548
|
+
"WHERE source='codex' AND source_root_key=? AND account_key=? "
|
|
1549
|
+
" AND logical_limit_key=? "
|
|
1550
|
+
" AND observed_slot=? AND window_minutes=? "
|
|
1551
|
+
f" AND unixepoch(resets_at_utc) IN ({placeholders}) AND orphaned_at IS NULL",
|
|
1552
|
+
(cyc.root, cyc.account_key, cyc.limit, cyc.slot, cyc.window, *resets),
|
|
1553
|
+
).fetchall()
|
|
1554
|
+
return tuple(sorted(tuple(r) for r in rows))
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def _codex_member_ladder_rows(stats_conn, member) -> tuple:
|
|
1558
|
+
"""One member's ladder exactly as ``_cctally_quota._load_active_milestones``
|
|
1559
|
+
selects it (text-equal reset, the member's own account), every column."""
|
|
1560
|
+
rows = stats_conn.execute(
|
|
1561
|
+
f"SELECT {_CODEX_MILESTONE_INPUT_COLUMNS} "
|
|
1562
|
+
"FROM quota_percent_milestones "
|
|
1563
|
+
"WHERE source='codex' AND source_root_key=? AND account_key=? "
|
|
1564
|
+
" AND logical_limit_key=? AND observed_slot=? AND window_minutes=? "
|
|
1565
|
+
" AND resets_at_utc=? AND orphaned_at IS NULL",
|
|
1566
|
+
(member.root, member.account_key, member.limit, member.slot,
|
|
1567
|
+
member.window, member.reset_iso),
|
|
1568
|
+
).fetchall()
|
|
1569
|
+
return tuple(sorted(tuple(r) for r in rows))
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _codex_block_row_key(row) -> tuple:
|
|
1573
|
+
# (source_root_key, account_key, logical_limit_key, observed_slot,
|
|
1574
|
+
# window_minutes, resets_at_utc): the unique key minus the constant source.
|
|
1575
|
+
return (row[0], row[6], row[1], row[2], row[3], row[7])
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def _codex_cache_file_identity(cache_conn) -> "tuple | None":
|
|
1579
|
+
try:
|
|
1580
|
+
rows = cache_conn.execute("PRAGMA database_list").fetchall()
|
|
1581
|
+
path = next(str(r[2]) for r in rows if str(r[1]) == "main" and r[2])
|
|
1582
|
+
stat = os.stat(path)
|
|
1583
|
+
except (OSError, StopIteration, sqlite3.Error, TypeError, ValueError):
|
|
1584
|
+
return None
|
|
1585
|
+
return (stat.st_dev, stat.st_ino)
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def _codex_accounting_seq(cache_conn) -> "int | None":
|
|
1589
|
+
try:
|
|
1590
|
+
row = cache_conn.execute(
|
|
1591
|
+
"SELECT value FROM cache_meta "
|
|
1592
|
+
"WHERE key='codex_accounting_mutation_seq'").fetchone()
|
|
1593
|
+
except sqlite3.Error:
|
|
1594
|
+
return None
|
|
1595
|
+
try:
|
|
1596
|
+
return None if row is None else int(row[0])
|
|
1597
|
+
except (TypeError, ValueError):
|
|
1598
|
+
return None
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _codex_quota_log_seq(cache_conn) -> "int | None":
|
|
1602
|
+
try:
|
|
1603
|
+
row = cache_conn.execute(
|
|
1604
|
+
"SELECT seq FROM sqlite_sequence "
|
|
1605
|
+
"WHERE name='quota_window_change_log'").fetchone()
|
|
1606
|
+
except sqlite3.Error:
|
|
1607
|
+
return None
|
|
1608
|
+
return 0 if row is None else int(row[0])
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def _codex_snapshot_seq(cache_conn) -> "int | None":
|
|
1612
|
+
try:
|
|
1613
|
+
row = cache_conn.execute(
|
|
1614
|
+
"SELECT seq FROM sqlite_sequence "
|
|
1615
|
+
"WHERE name='quota_window_snapshots'").fetchone()
|
|
1616
|
+
except sqlite3.Error:
|
|
1617
|
+
return None
|
|
1618
|
+
return 0 if row is None else int(row[0])
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _reset_epoch(text) -> "int | None":
|
|
1622
|
+
parsed = _parse_utc(text)
|
|
1623
|
+
return None if parsed is None else math.floor(parsed.timestamp())
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _codex_boundary_order_key(row) -> tuple:
|
|
1627
|
+
"""``_first_block_physical_tuple``'s ``ORDER BY unixepoch(captured_at_utc),
|
|
1628
|
+
unixepoch(resets_at_utc), source_path, line_offset`` over a bulk row
|
|
1629
|
+
``(reset_epoch, captured_epoch, captured_text, path, offset, account)``:
|
|
1630
|
+
SQLite sorts a NULL ``unixepoch`` first."""
|
|
1631
|
+
return (row[1] is not None, row[1] or 0, row[0], row[3], row[4])
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def _codex_fold_boundary_row(out: dict, row: tuple) -> None:
|
|
1635
|
+
"""Fold one snapshot ``(reset_epoch, captured_epoch, captured_text, path,
|
|
1636
|
+
offset, account)`` into its reset's per-flavour minimum (see
|
|
1637
|
+
`_codex_bulk_first_boundaries`). A minimum over a superset is the minimum
|
|
1638
|
+
of the old minimum and the new rows, so folding is also how appended rows
|
|
1639
|
+
join a memoized identity."""
|
|
1640
|
+
slots = out.get(row[0])
|
|
1641
|
+
if slots is None:
|
|
1642
|
+
slots = out[row[0]] = [None, None, {}]
|
|
1643
|
+
key = _codex_boundary_order_key(row)
|
|
1644
|
+
if slots[0] is None or key < slots[0][0]:
|
|
1645
|
+
slots[0] = (key, row)
|
|
1646
|
+
account = row[5]
|
|
1647
|
+
if account is None or account == UNATTRIBUTED:
|
|
1648
|
+
if slots[1] is None or key < slots[1][0]:
|
|
1649
|
+
slots[1] = (key, row)
|
|
1650
|
+
else:
|
|
1651
|
+
prior = slots[2].get(account)
|
|
1652
|
+
if prior is None or key < prior[0]:
|
|
1653
|
+
slots[2][account] = (key, row)
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _codex_bulk_first_boundaries(cache_conn, identity_parts) -> dict:
|
|
1657
|
+
"""Every first boundary of one raw quota identity, in one pass (#1050).
|
|
1658
|
+
|
|
1659
|
+
``_first_block_physical_tuple`` cannot seek its own predicate:
|
|
1660
|
+
``unixepoch(resets_at_utc)=unixepoch(?)`` matches no index expression, so
|
|
1661
|
+
each call scans the identity's whole snapshot history (11-70 ms on the
|
|
1662
|
+
#997 store), and the index needs one per priced member. This is that
|
|
1663
|
+
query's exact twin over every reset at once: per reset second, the
|
|
1664
|
+
minimum row in the same order for each account-predicate flavour the
|
|
1665
|
+
boundary read takes (``_codex_cache_account_predicate(...,
|
|
1666
|
+
admit_unattributed=True)``): every row (no scope), the unattributed rows
|
|
1667
|
+
(``NULL`` or the sentinel), and each real account's own rows — a real
|
|
1668
|
+
scope admits its own rows plus the unattributed ones.
|
|
1669
|
+
"""
|
|
1670
|
+
root, limit, slot, window = identity_parts
|
|
1671
|
+
out: dict = {}
|
|
1672
|
+
for row in cache_conn.execute(
|
|
1673
|
+
"SELECT unixepoch(resets_at_utc), unixepoch(captured_at_utc), "
|
|
1674
|
+
" captured_at_utc, source_path, line_offset, account_key "
|
|
1675
|
+
"FROM quota_window_snapshots "
|
|
1676
|
+
"WHERE source='codex' AND source_root_key=? "
|
|
1677
|
+
" AND logical_limit_key=? AND observed_slot=? "
|
|
1678
|
+
" AND window_minutes=? AND unixepoch(resets_at_utc) IS NOT NULL",
|
|
1679
|
+
(root, limit, slot, window)):
|
|
1680
|
+
_codex_fold_boundary_row(out, tuple(row))
|
|
1681
|
+
return out
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _codex_boundary_from_bulk(slots, account_scope):
|
|
1685
|
+
"""One member's boundary from its reset's bulk slots, parsed exactly as
|
|
1686
|
+
``_first_block_physical_tuple`` parses its row (a row that does not parse
|
|
1687
|
+
is no boundary)."""
|
|
1688
|
+
if slots is None:
|
|
1689
|
+
return None
|
|
1690
|
+
if account_scope is None:
|
|
1691
|
+
best = slots[0]
|
|
1692
|
+
elif account_scope == UNATTRIBUTED:
|
|
1693
|
+
best = slots[1]
|
|
1694
|
+
else:
|
|
1695
|
+
candidates = [c for c in (slots[2].get(account_scope), slots[1])
|
|
1696
|
+
if c is not None]
|
|
1697
|
+
best = min(candidates, key=lambda c: c[0]) if candidates else None
|
|
1698
|
+
if best is None:
|
|
1699
|
+
return None
|
|
1700
|
+
row = best[1]
|
|
1701
|
+
try:
|
|
1702
|
+
return (_quota._parse_utc(str(row[2]), "captured_at_utc"),
|
|
1703
|
+
str(row[3]), int(row[4]))
|
|
1704
|
+
except (TypeError, ValueError):
|
|
1705
|
+
return None
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
class _CodexCycleInputsMemo:
|
|
1709
|
+
"""Process memo of the Codex cycle-index stamps' expensive inputs (#1050).
|
|
1710
|
+
|
|
1711
|
+
The index stamps every retained cycle, and the pricing inputs are
|
|
1712
|
+
per-member reads over `quota_window_snapshots` and `codex_session_entries`.
|
|
1713
|
+
Read one by one they cost seconds per index build, so the source build
|
|
1714
|
+
hands the index this memo. Only the index uses it: the detail route always
|
|
1715
|
+
reads fresh, through the breakdown's own boundary read, so
|
|
1716
|
+
`test_1050_codex_detail_stamp_equals_index_stamp` checks the memo against
|
|
1717
|
+
a fresh read.
|
|
1718
|
+
|
|
1719
|
+
* **First boundaries** come from one bulk pass per raw identity
|
|
1720
|
+
(`_codex_bulk_first_boundaries`), kept while the cache file is the same
|
|
1721
|
+
and every Codex mutation since is an append. The triggers on
|
|
1722
|
+
`quota_window_snapshots` append one row to the AUTOINCREMENT
|
|
1723
|
+
`quota_window_change_log` for every Codex insert, delete and semantic
|
|
1724
|
+
update, and neither sequence is ever reused, so when the ledger's
|
|
1725
|
+
high-water moved by exactly the number of Codex snapshot rows now present
|
|
1726
|
+
above the snapshot high-water last seen, every change was an insert of
|
|
1727
|
+
one of those rows (a delete or update adds a ledger row without adding
|
|
1728
|
+
such a row) and they fold in (`_codex_fold_boundary_row`). Anything else
|
|
1729
|
+
re-reads the boundaries. The projector prunes the ledger, which leaves
|
|
1730
|
+
its high-water where it is, so this never reads ledger rows.
|
|
1731
|
+
* **Entry aggregates** are kept per selection and invalidated by
|
|
1732
|
+
`codex_accounting_change_log`, as
|
|
1733
|
+
`_lib_snapshot_cache.build_cached_codex_accounting` already trusts it: a
|
|
1734
|
+
selection drops when a changed path's old or new timestamp extent
|
|
1735
|
+
intersects it. A replaced file, a counter that went backwards, a ledger
|
|
1736
|
+
gap, a `full` change or too many dirty paths go cold.
|
|
1737
|
+
* **Per-cycle results** are keyed on the caller's `codex_stats_digest`,
|
|
1738
|
+
which (with the two #1050 relations) covers every stats column the helper
|
|
1739
|
+
reads, plus the cycle's own selection; an entry is reused only while
|
|
1740
|
+
every boundary and aggregate it was built from is unchanged. One stats
|
|
1741
|
+
generation is kept at a time.
|
|
1742
|
+
"""
|
|
1743
|
+
|
|
1744
|
+
_MAX_DIRTY_PATHS = 512
|
|
1745
|
+
_MAX_ENTRIES = 8192
|
|
1746
|
+
|
|
1747
|
+
def __init__(self):
|
|
1748
|
+
self.lock = threading.Lock()
|
|
1749
|
+
self.cache_ident = None
|
|
1750
|
+
self.accounting_seq = None
|
|
1751
|
+
self.quota_log_seq = None
|
|
1752
|
+
self.snapshot_seq = None
|
|
1753
|
+
self.path_extents: dict = {}
|
|
1754
|
+
self.bulk_boundaries: dict = {}
|
|
1755
|
+
self.aggregates: dict = {}
|
|
1756
|
+
self.models = None
|
|
1757
|
+
self.stats_key = None
|
|
1758
|
+
self.cycles: dict = {}
|
|
1759
|
+
self.usable = False
|
|
1760
|
+
|
|
1761
|
+
def _cold(self):
|
|
1762
|
+
self.path_extents = {}
|
|
1763
|
+
self.bulk_boundaries = {}
|
|
1764
|
+
self.aggregates = {}
|
|
1765
|
+
self.models = None
|
|
1766
|
+
self.cycles = {}
|
|
1767
|
+
|
|
1768
|
+
def sync(self, cache_conn, stats_key) -> None:
|
|
1769
|
+
"""Bring the memo to ``cache_conn``'s state before an index build."""
|
|
1770
|
+
ident = _codex_cache_file_identity(cache_conn)
|
|
1771
|
+
seq = _codex_accounting_seq(cache_conn)
|
|
1772
|
+
log_seq = _codex_quota_log_seq(cache_conn)
|
|
1773
|
+
snapshot_seq = _codex_snapshot_seq(cache_conn)
|
|
1774
|
+
if stats_key != self.stats_key:
|
|
1775
|
+
self.cycles = {}
|
|
1776
|
+
self.stats_key = stats_key
|
|
1777
|
+
if (ident is None or seq is None or log_seq is None
|
|
1778
|
+
or snapshot_seq is None):
|
|
1779
|
+
self.usable = False
|
|
1780
|
+
self.cache_ident = None
|
|
1781
|
+
self._cold()
|
|
1782
|
+
return
|
|
1783
|
+
cold = (ident != self.cache_ident or self.accounting_seq is None
|
|
1784
|
+
or seq < self.accounting_seq)
|
|
1785
|
+
if not cold and seq > self.accounting_seq:
|
|
1786
|
+
cold = not self._apply_accounting_changes(cache_conn, seq)
|
|
1787
|
+
if cold:
|
|
1788
|
+
self._cold()
|
|
1789
|
+
try:
|
|
1790
|
+
self.path_extents = {
|
|
1791
|
+
(str(root or ""), str(path)): (lo, hi)
|
|
1792
|
+
for root, path, lo, hi in cache_conn.execute(
|
|
1793
|
+
"SELECT source_root_key, source_path, "
|
|
1794
|
+
" MIN(timestamp_utc), MAX(timestamp_utc) "
|
|
1795
|
+
"FROM codex_session_entries "
|
|
1796
|
+
"GROUP BY source_root_key, source_path")
|
|
1797
|
+
}
|
|
1798
|
+
except sqlite3.Error:
|
|
1799
|
+
self.usable = False
|
|
1800
|
+
self.cache_ident = None
|
|
1801
|
+
return
|
|
1802
|
+
elif ((log_seq, snapshot_seq)
|
|
1803
|
+
!= (self.quota_log_seq, self.snapshot_seq)
|
|
1804
|
+
and not self._apply_quota_appends(
|
|
1805
|
+
cache_conn, log_seq, snapshot_seq)):
|
|
1806
|
+
self.bulk_boundaries = {}
|
|
1807
|
+
self.cache_ident = ident
|
|
1808
|
+
self.accounting_seq = seq
|
|
1809
|
+
self.quota_log_seq = log_seq
|
|
1810
|
+
self.snapshot_seq = snapshot_seq
|
|
1811
|
+
self.usable = True
|
|
1812
|
+
for store in (self.aggregates, self.cycles):
|
|
1813
|
+
if len(store) > self._MAX_ENTRIES:
|
|
1814
|
+
store.clear()
|
|
1815
|
+
|
|
1816
|
+
def _apply_quota_appends(self, cache_conn, log_seq, snapshot_seq) -> bool:
|
|
1817
|
+
"""Fold appended snapshots into the memoized boundaries, or ``False``
|
|
1818
|
+
when the change since the last sync is not provably append-only."""
|
|
1819
|
+
if (self.quota_log_seq is None or self.snapshot_seq is None
|
|
1820
|
+
or log_seq < self.quota_log_seq
|
|
1821
|
+
or snapshot_seq < self.snapshot_seq):
|
|
1822
|
+
return False
|
|
1823
|
+
expected = log_seq - self.quota_log_seq
|
|
1824
|
+
if expected > self._MAX_DIRTY_PATHS * 8:
|
|
1825
|
+
return False
|
|
1826
|
+
try:
|
|
1827
|
+
rows = tuple(cache_conn.execute(
|
|
1828
|
+
"SELECT source_root_key, logical_limit_key, observed_slot, "
|
|
1829
|
+
" window_minutes, unixepoch(resets_at_utc), "
|
|
1830
|
+
" unixepoch(captured_at_utc), captured_at_utc, "
|
|
1831
|
+
" source_path, line_offset, account_key "
|
|
1832
|
+
"FROM quota_window_snapshots "
|
|
1833
|
+
"WHERE id > ? AND id <= ? AND source='codex'",
|
|
1834
|
+
(self.snapshot_seq, snapshot_seq)))
|
|
1835
|
+
except sqlite3.Error:
|
|
1836
|
+
return False
|
|
1837
|
+
if len(rows) != expected:
|
|
1838
|
+
return False
|
|
1839
|
+
for row in rows:
|
|
1840
|
+
bulk = self.bulk_boundaries.get(tuple(row[:4]))
|
|
1841
|
+
if bulk is not None and row[4] is not None:
|
|
1842
|
+
_codex_fold_boundary_row(bulk, tuple(row[4:]))
|
|
1843
|
+
return True
|
|
1844
|
+
|
|
1845
|
+
def _apply_accounting_changes(self, cache_conn, seq) -> bool:
|
|
1846
|
+
last = self.accounting_seq
|
|
1847
|
+
try:
|
|
1848
|
+
changes = tuple(cache_conn.execute(
|
|
1849
|
+
"SELECT mutation_seq, change_kind, source_root_key, source_path "
|
|
1850
|
+
"FROM codex_accounting_change_log "
|
|
1851
|
+
"WHERE mutation_seq > ? ORDER BY mutation_seq, seq", (last,)))
|
|
1852
|
+
except sqlite3.Error:
|
|
1853
|
+
return False
|
|
1854
|
+
if (not changes or int(changes[0][0]) > last + 1
|
|
1855
|
+
or int(changes[-1][0]) != seq
|
|
1856
|
+
or any(row[1] == "full" for row in changes)):
|
|
1857
|
+
return False
|
|
1858
|
+
dirty = {(str(row[2] or ""), str(row[3]))
|
|
1859
|
+
for row in changes if row[1] == "path" and row[3]}
|
|
1860
|
+
if len(dirty) > self._MAX_DIRTY_PATHS:
|
|
1861
|
+
return False
|
|
1862
|
+
intervals: dict = {}
|
|
1863
|
+
try:
|
|
1864
|
+
for root, path in dirty:
|
|
1865
|
+
old = self.path_extents.pop((root, path), None)
|
|
1866
|
+
new = cache_conn.execute(
|
|
1867
|
+
"SELECT MIN(timestamp_utc), MAX(timestamp_utc) "
|
|
1868
|
+
"FROM codex_session_entries "
|
|
1869
|
+
"WHERE source_root_key=? AND source_path=?",
|
|
1870
|
+
(root, path)).fetchone()
|
|
1871
|
+
if new is not None and new[0] is not None:
|
|
1872
|
+
self.path_extents[(root, path)] = (new[0], new[1])
|
|
1873
|
+
intervals.setdefault(root, []).append((new[0], new[1]))
|
|
1874
|
+
if old is not None:
|
|
1875
|
+
intervals.setdefault(root, []).append(old)
|
|
1876
|
+
except sqlite3.Error:
|
|
1877
|
+
return False
|
|
1878
|
+
for key in tuple(self.aggregates):
|
|
1879
|
+
root, lo, hi = key[0], key[1], key[2]
|
|
1880
|
+
if any(a <= hi and b >= lo for a, b in intervals.get(root, ())):
|
|
1881
|
+
del self.aggregates[key]
|
|
1882
|
+
self.models = None
|
|
1883
|
+
return True
|
|
1884
|
+
|
|
1885
|
+
def boundary(self, cache_conn, parts, reset_epoch, account_scope):
|
|
1886
|
+
bulk = self.bulk_boundaries.get(parts)
|
|
1887
|
+
if bulk is None:
|
|
1888
|
+
bulk = self.bulk_boundaries[parts] = _codex_bulk_first_boundaries(
|
|
1889
|
+
cache_conn, parts)
|
|
1890
|
+
return _codex_boundary_from_bulk(bulk.get(reset_epoch), account_scope)
|
|
1891
|
+
|
|
1892
|
+
def entry_models(self, cache_conn) -> tuple:
|
|
1893
|
+
if self.models is None:
|
|
1894
|
+
self.models = _quota.codex_entry_models(cache_conn)
|
|
1895
|
+
return self.models
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
_CODEX_CYCLE_INPUTS_MEMO = _CodexCycleInputsMemo()
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def _codex_first_boundary(cache_conn, member, account_scope, memo, used):
|
|
1902
|
+
"""The physical tuple ``codex_quota_breakdown`` opens ``member``'s ladder
|
|
1903
|
+
from: the breakdown's own read, or with the index memo its bulk twin."""
|
|
1904
|
+
if memo is not None:
|
|
1905
|
+
key = ((member.root, member.limit, member.slot, member.window),
|
|
1906
|
+
math.floor(member.reset.timestamp()), account_scope)
|
|
1907
|
+
value = memo.boundary(cache_conn, *key)
|
|
1908
|
+
used.append(("b", key, value))
|
|
1909
|
+
return value
|
|
1910
|
+
ident = QuotaWindowIdentity(
|
|
1911
|
+
source="codex", source_root_key=member.root,
|
|
1912
|
+
logical_limit_key=member.limit, observed_slot=member.slot,
|
|
1913
|
+
window_minutes=member.window, account_key=member.account_key,
|
|
1914
|
+
limit_id=member.limit_id, limit_name=member.limit_name,
|
|
1915
|
+
)
|
|
1916
|
+
return _quota._first_block_physical_tuple(
|
|
1917
|
+
ident, member.reset, cache_conn=cache_conn, account_key=account_scope)
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _codex_selection_aggregate(cache_conn, member, query_start, query_end,
|
|
1921
|
+
account_scope, memo, used, models_box):
|
|
1922
|
+
key = (member.root, query_start, query_end, account_scope)
|
|
1923
|
+
if memo is not None and key in memo.aggregates:
|
|
1924
|
+
value = memo.aggregates[key]
|
|
1925
|
+
used.append(("a", key, value))
|
|
1926
|
+
return value
|
|
1927
|
+
if memo is not None:
|
|
1928
|
+
models = memo.entry_models(cache_conn)
|
|
1929
|
+
else:
|
|
1930
|
+
# The model list is read once per helper call, not per selection.
|
|
1931
|
+
if not models_box:
|
|
1932
|
+
models_box.append(_quota.codex_entry_models(cache_conn))
|
|
1933
|
+
models = models_box[0]
|
|
1934
|
+
value = _quota.codex_entry_selection_aggregate(
|
|
1935
|
+
cache_conn, source_root_key=member.root, query_start=query_start,
|
|
1936
|
+
query_end=query_end, account_key=account_scope, models=models)
|
|
1937
|
+
if memo is not None:
|
|
1938
|
+
memo.aggregates[key] = value
|
|
1939
|
+
used.append(("a", key, value))
|
|
1940
|
+
return value
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def _codex_cycle_inputs(stats_conn, cache_conn, cyc, *, speed: str,
|
|
1944
|
+
account_scope: "str | None",
|
|
1945
|
+
_memo=None, _used=None) -> CodexCycleInputs:
|
|
1946
|
+
"""Read every input of ``cyc``'s Codex detail (#1050 spec 4.7).
|
|
1947
|
+
|
|
1948
|
+
``account_scope`` is the route's or the index's account focus — the scope
|
|
1949
|
+
the two cache reads use (``_union_cluster_milestones``) — never
|
|
1950
|
+
``cyc.account_key``. ``speed`` is resolved here exactly as the breakdown
|
|
1951
|
+
resolves it, so the stamp records the tier the dollars were priced at.
|
|
1952
|
+
|
|
1953
|
+
``cache_conn=None`` opens the breakdown's own read-only cache connection,
|
|
1954
|
+
exactly as ``codex_quota_breakdown`` does for such a caller; a missing
|
|
1955
|
+
cache reads every boundary as absent.
|
|
1956
|
+
|
|
1957
|
+
The reads keep the projection-gate classification of the reads they
|
|
1958
|
+
replace: the block rows still come from the gated
|
|
1959
|
+
``_codex_five_hour_rows``, and ``quota_percent_milestones`` is not a
|
|
1960
|
+
projection family.
|
|
1961
|
+
"""
|
|
1962
|
+
used = _used if _used is not None else []
|
|
1963
|
+
cycle_rows = _codex_cycle_raw_rows(stats_conn, cyc)
|
|
1964
|
+
raw_block_rows = _codex_five_hour_rows(stats_conn, cyc)
|
|
1965
|
+
clusters = tuple(_codex_five_hour_clusters(
|
|
1966
|
+
stats_conn, cyc, rows=raw_block_rows))
|
|
1967
|
+
selections = [(cyc.block_key, cyc)] + [(b.block_key, b) for b in clusters]
|
|
1968
|
+
owns_cache = cache_conn is None
|
|
1969
|
+
cache = cache_conn
|
|
1970
|
+
if owns_cache:
|
|
1971
|
+
try:
|
|
1972
|
+
cache = _quota._cache_connection()
|
|
1973
|
+
except (FileNotFoundError, sqlite3.Error):
|
|
1974
|
+
cache = None
|
|
1975
|
+
ladders: list = []
|
|
1976
|
+
boundaries: list = []
|
|
1977
|
+
aggregates: list = []
|
|
1978
|
+
models_box: list = []
|
|
1979
|
+
try:
|
|
1980
|
+
for selection_key, cluster in selections:
|
|
1981
|
+
for member in cluster.cluster_members:
|
|
1982
|
+
ident = list(_member_ident(member))
|
|
1983
|
+
rows = _codex_member_ladder_rows(stats_conn, member)
|
|
1984
|
+
ladders.append([selection_key, ident, [list(r) for r in rows]])
|
|
1985
|
+
if not rows:
|
|
1986
|
+
boundaries.append([selection_key, ident, "unread"])
|
|
1987
|
+
continue
|
|
1988
|
+
start = (None if cache is None else _codex_first_boundary(
|
|
1989
|
+
cache, member, account_scope, _memo, used))
|
|
1990
|
+
if start is None:
|
|
1991
|
+
boundaries.append([selection_key, ident, "absent"])
|
|
1992
|
+
continue
|
|
1993
|
+
boundaries.append([selection_key, ident, [
|
|
1994
|
+
start[0].astimezone(UTC).isoformat(), start[1], start[2]]])
|
|
1995
|
+
# The breakdown's ladder is ordered by threshold; its last row
|
|
1996
|
+
# bounds the accounting read.
|
|
1997
|
+
last = max(rows, key=lambda r: r[7])
|
|
1998
|
+
query_start, query_end = _quota.codex_entry_selection_bounds(
|
|
1999
|
+
start[0], last[8])
|
|
2000
|
+
count, max_rowid, per_model = _codex_selection_aggregate(
|
|
2001
|
+
cache, member, query_start, query_end, account_scope,
|
|
2002
|
+
_memo, used, models_box)
|
|
2003
|
+
aggregates.append([selection_key, ident, query_start,
|
|
2004
|
+
query_end, count, max_rowid,
|
|
2005
|
+
[list(m) for m in per_model]])
|
|
2006
|
+
accounting_seq = (None if cache is None
|
|
2007
|
+
else _codex_accounting_seq(cache))
|
|
2008
|
+
finally:
|
|
2009
|
+
if owns_cache and cache is not None:
|
|
2010
|
+
cache.close()
|
|
2011
|
+
return CodexCycleInputs(
|
|
2012
|
+
cycle_rows=cycle_rows,
|
|
2013
|
+
block_ladder_rows=tuple(ladders),
|
|
2014
|
+
block_rows=tuple(sorted(
|
|
2015
|
+
(tuple(r) for r in raw_block_rows), key=_codex_block_row_key)),
|
|
2016
|
+
first_boundaries=tuple(boundaries),
|
|
2017
|
+
selection_aggregates=tuple(aggregates),
|
|
2018
|
+
accounting_seq=accounting_seq,
|
|
2019
|
+
speed=_cctally()._resolve_codex_speed(speed),
|
|
2020
|
+
pricing_identity=_lib_pricing.live_codex_pricing_identity(),
|
|
2021
|
+
block_clusters=clusters,
|
|
2022
|
+
)
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
def _codex_inputs_stats_digest(inputs: CodexCycleInputs) -> str:
|
|
2026
|
+
"""The digest of everything but the per-tick scalars, so the index can
|
|
2027
|
+
memoize it per cycle and recombine on an accounting move."""
|
|
2028
|
+
return _canonical_digest([
|
|
2029
|
+
[list(r) for r in inputs.cycle_rows],
|
|
2030
|
+
list(inputs.block_ladder_rows),
|
|
2031
|
+
[list(r) for r in inputs.block_rows],
|
|
2032
|
+
list(inputs.first_boundaries),
|
|
2033
|
+
list(inputs.selection_aggregates),
|
|
2034
|
+
])
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
def _codex_content_component(selection_digest: str, accounting_seq, speed,
|
|
2038
|
+
pricing_identity) -> str:
|
|
2039
|
+
"""#1050 spec 4.7: the stamp's content component — SHA-256 over the
|
|
2040
|
+
canonical JSON of every stats row and cost input, the rows entering
|
|
2041
|
+
through their own SHA-256 so an accounting move recombines without
|
|
2042
|
+
re-hashing every row."""
|
|
2043
|
+
return "i" + _canonical_digest([
|
|
2044
|
+
selection_digest, accounting_seq, speed, pricing_identity])[:16]
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _codex_entry_dict(cyc, tz, milestone_count, block_count, max_captured,
|
|
2048
|
+
component) -> dict:
|
|
1365
2049
|
key = cyc.key
|
|
1366
2050
|
return {
|
|
1367
2051
|
"key": key,
|
|
@@ -1373,11 +2057,70 @@ def _codex_cycle_entry(stats_conn, cyc, tz) -> dict:
|
|
|
1373
2057
|
"milestone_count": milestone_count,
|
|
1374
2058
|
"block_count": block_count,
|
|
1375
2059
|
"detail_stamp": _mh.compute_detail_stamp(
|
|
1376
|
-
key, milestone_count, block_count, max_captured
|
|
2060
|
+
key, milestone_count, block_count, max_captured, component,
|
|
1377
2061
|
),
|
|
1378
2062
|
}
|
|
1379
2063
|
|
|
1380
2064
|
|
|
2065
|
+
def _codex_stamp_parts(inputs: CodexCycleInputs) -> tuple:
|
|
2066
|
+
"""``(milestone_count, block_count, max_captured)``: the distinct
|
|
2067
|
+
thresholds and the raw maximum capture over the cycle's raw rows
|
|
2068
|
+
(independent of the detail's earliest-crossing pick), and the clusters."""
|
|
2069
|
+
milestone_count = len({row[7] for row in inputs.cycle_rows})
|
|
2070
|
+
max_captured = max((row[8] for row in inputs.cycle_rows), default=None)
|
|
2071
|
+
return milestone_count, len(inputs.block_clusters), max_captured
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
def _codex_cycle_entry(stats_conn, cache_conn, cyc, tz, *, speed,
|
|
2075
|
+
account_scope, inputs=None, memo=None) -> dict:
|
|
2076
|
+
"""The index entry for ``cyc``, stamped from `_codex_cycle_inputs`.
|
|
2077
|
+
|
|
2078
|
+
``inputs`` lets the detail stamp from the read it renders from; ``memo``
|
|
2079
|
+
is the index's process memo (`_CodexCycleInputsMemo`)."""
|
|
2080
|
+
if inputs is not None or memo is None or not memo.usable:
|
|
2081
|
+
if inputs is None:
|
|
2082
|
+
inputs = _codex_cycle_inputs(
|
|
2083
|
+
stats_conn, cache_conn, cyc, speed=speed,
|
|
2084
|
+
account_scope=account_scope)
|
|
2085
|
+
milestone_count, block_count, max_captured = _codex_stamp_parts(inputs)
|
|
2086
|
+
component = _codex_content_component(
|
|
2087
|
+
_codex_inputs_stats_digest(inputs), inputs.accounting_seq,
|
|
2088
|
+
inputs.speed, inputs.pricing_identity)
|
|
2089
|
+
return _codex_entry_dict(cyc, tz, milestone_count, block_count,
|
|
2090
|
+
max_captured, component)
|
|
2091
|
+
cycle_key = (
|
|
2092
|
+
account_scope, cyc.key, _iso_z(cyc.start), _iso_z(cyc.end),
|
|
2093
|
+
bool(cyc.is_current),
|
|
2094
|
+
tuple(_member_ident(m) for m in cyc.cluster_members),
|
|
2095
|
+
)
|
|
2096
|
+
cached = memo.cycles.get(cycle_key)
|
|
2097
|
+
# Both kinds revalidate by VALUE. Presence alone is not enough for an
|
|
2098
|
+
# aggregate: two cycles can share one key (a 5-hour block straddling
|
|
2099
|
+
# their boundary), and after a late entry evicts it the first cycle
|
|
2100
|
+
# recomputes and reinserts it, so the second would find the key present
|
|
2101
|
+
# and keep a digest of the old value (#1050 spec 4.7 stamp equality).
|
|
2102
|
+
if cached is not None and all(
|
|
2103
|
+
(memo.boundary(cache_conn, *key) == value)
|
|
2104
|
+
if kind == "b"
|
|
2105
|
+
else (key in memo.aggregates and memo.aggregates[key] == value)
|
|
2106
|
+
for kind, key, value in cached[4]):
|
|
2107
|
+
milestone_count, block_count, max_captured, selection_digest, _ = cached
|
|
2108
|
+
else:
|
|
2109
|
+
used: list = []
|
|
2110
|
+
inputs = _codex_cycle_inputs(
|
|
2111
|
+
stats_conn, cache_conn, cyc, speed=speed,
|
|
2112
|
+
account_scope=account_scope, _memo=memo, _used=used)
|
|
2113
|
+
milestone_count, block_count, max_captured = _codex_stamp_parts(inputs)
|
|
2114
|
+
selection_digest = _codex_inputs_stats_digest(inputs)
|
|
2115
|
+
memo.cycles[cycle_key] = (milestone_count, block_count, max_captured,
|
|
2116
|
+
selection_digest, tuple(used))
|
|
2117
|
+
component = _codex_content_component(
|
|
2118
|
+
selection_digest, memo.accounting_seq,
|
|
2119
|
+
_cctally()._resolve_codex_speed(speed),
|
|
2120
|
+
_lib_pricing.live_codex_pricing_identity())
|
|
2121
|
+
return _codex_entry_dict(cyc, tz, milestone_count, block_count,
|
|
2122
|
+
max_captured, component)
|
|
2123
|
+
|
|
1381
2124
|
def _codex_cycle_label(cyc, tz) -> str:
|
|
1382
2125
|
start_iso = cyc.start.astimezone(UTC).isoformat()
|
|
1383
2126
|
end_iso = cyc.end.astimezone(UTC).isoformat()
|
|
@@ -1389,14 +2132,34 @@ def _codex_cycle_label(cyc, tz) -> str:
|
|
|
1389
2132
|
|
|
1390
2133
|
|
|
1391
2134
|
def build_codex_cycle_index(stats_conn, *, identity, now_utc,
|
|
1392
|
-
account_key=None
|
|
2135
|
+
account_key=None, cache_conn=None, speed="auto",
|
|
2136
|
+
stats_digest=None) -> list:
|
|
1393
2137
|
"""Newest-first Codex cycle index over the durable projection (spec §1c,
|
|
1394
2138
|
§3). Enumerates the hero root's reset-defined ledger with no depth cap;
|
|
1395
2139
|
one selected full native identity per physical reset.
|
|
1396
2140
|
|
|
1397
2141
|
``account_key`` (#416 Slice 3A review B3) builds the index for ONE account,
|
|
1398
2142
|
so a focused hero renders its own milestone history instead of the first
|
|
1399
|
-
account's. ``None`` is the merged parent index
|
|
2143
|
+
account's. ``None`` is the merged parent index.
|
|
2144
|
+
|
|
2145
|
+
``cache_conn`` and ``speed`` (#1050 spec 4.7) are the cost inputs every
|
|
2146
|
+
stamp now names, read exactly as the detail route reads them, with
|
|
2147
|
+
``account_key`` as the account scope; ``cache_conn=None`` opens the
|
|
2148
|
+
breakdown's own read-only connection, as the detail does for such a caller.
|
|
2149
|
+
``stats_digest`` is the caller's `codex_stats_digest` of the generation it
|
|
2150
|
+
builds from: given together with ``cache_conn`` it admits the process memo
|
|
2151
|
+
(`_CodexCycleInputsMemo`), which the source build relies on to keep the
|
|
2152
|
+
index's cost near what it was before the stamps carried every input.
|
|
2153
|
+
Precondition: the memo keys its per-cycle results on ``stats_digest`` and
|
|
2154
|
+
reuses them without re-reading the stats rows they came from, so a caller
|
|
2155
|
+
supplying it must guarantee that a result built from rows newer than the
|
|
2156
|
+
digest is never published as the digest's. Either ``stats_conn`` reads
|
|
2157
|
+
the same pinned snapshot the digest was computed in, or the call sits
|
|
2158
|
+
inside a before/after validation that re-reads the digest and discards
|
|
2159
|
+
the build on movement. The dashboard source build is the second kind: it
|
|
2160
|
+
leaves stats.db in statement-scoped autocommit (#393) and its
|
|
2161
|
+
reconciliation re-reads ``codex_stats_digest`` after construction
|
|
2162
|
+
(`_cctally_tui._tui_build_source_bundle`, `stats_generation_moved`)."""
|
|
1400
2163
|
now = now_utc.astimezone(UTC) if now_utc.tzinfo else now_utc.replace(tzinfo=UTC)
|
|
1401
2164
|
tz = _resolve_display_tz()
|
|
1402
2165
|
cycles = _load_codex_cycles(
|
|
@@ -1404,8 +2167,26 @@ def build_codex_cycle_index(stats_conn, *, identity, now_utc,
|
|
|
1404
2167
|
preferred_identity=getattr(identity, "quota_identity", None),
|
|
1405
2168
|
current_boundary=identity, now_utc=now, account_key=account_key,
|
|
1406
2169
|
)
|
|
1407
|
-
|
|
2170
|
+
if cache_conn is None or stats_digest is None:
|
|
2171
|
+
return [
|
|
2172
|
+
_codex_cycle_entry(stats_conn, cache_conn, c, tz, speed=speed,
|
|
2173
|
+
account_scope=account_key)
|
|
2174
|
+
for c in cycles
|
|
2175
|
+
]
|
|
2176
|
+
memo = _CODEX_CYCLE_INPUTS_MEMO
|
|
2177
|
+
with memo.lock:
|
|
2178
|
+
memo.sync(cache_conn, stats_digest)
|
|
2179
|
+
return [
|
|
2180
|
+
_codex_cycle_entry(stats_conn, cache_conn, c, tz, speed=speed,
|
|
2181
|
+
account_scope=account_key, memo=memo)
|
|
2182
|
+
for c in cycles
|
|
2183
|
+
]
|
|
2184
|
+
|
|
1408
2185
|
|
|
2186
|
+
def reset_codex_cycle_inputs_memo() -> None:
|
|
2187
|
+
"""Drop the index's process memo (tests and measurements)."""
|
|
2188
|
+
global _CODEX_CYCLE_INPUTS_MEMO
|
|
2189
|
+
_CODEX_CYCLE_INPUTS_MEMO = _CodexCycleInputsMemo()
|
|
1409
2190
|
|
|
1410
2191
|
def _shape_codex_milestone(row, *, key_parts, block_key) -> dict:
|
|
1411
2192
|
captured_iso = row.captured_at.astimezone(UTC).isoformat()
|
|
@@ -1506,9 +2287,13 @@ def _union_cluster_milestones(cluster, block_key, speed, cache_conn, stats_conn,
|
|
|
1506
2287
|
|
|
1507
2288
|
|
|
1508
2289
|
def _codex_cycle_blocks(stats_conn, cache_conn, cyc, speed,
|
|
1509
|
-
account_key=None) -> "list | None":
|
|
2290
|
+
account_key=None, clusters=None) -> "list | None":
|
|
2291
|
+
"""``clusters`` (#1050): the block clusters `_codex_cycle_inputs` read, so
|
|
2292
|
+
the detail renders the population its stamp names."""
|
|
1510
2293
|
out: list = []
|
|
1511
|
-
|
|
2294
|
+
if clusters is None:
|
|
2295
|
+
clusters = _codex_five_hour_clusters(stats_conn, cyc)
|
|
2296
|
+
for block in clusters:
|
|
1512
2297
|
block_key = block.block_key
|
|
1513
2298
|
milestones = _union_cluster_milestones(
|
|
1514
2299
|
block, block_key, speed, cache_conn, stats_conn,
|
|
@@ -1585,6 +2370,15 @@ def build_codex_cycle_detail(
|
|
|
1585
2370
|
return (None, "pruned")
|
|
1586
2371
|
return (None, "unknown")
|
|
1587
2372
|
|
|
2373
|
+
# #1050 spec 4.7: ONE read of every input, stamped and rendered from. The
|
|
2374
|
+
# ladders' dollars still come from `codex_quota_breakdown`, over the same
|
|
2375
|
+
# rows: the route holds one read transaction per database around all of it.
|
|
2376
|
+
try:
|
|
2377
|
+
inputs = _codex_cycle_inputs(
|
|
2378
|
+
stats_conn, cache_conn, match, speed=speed,
|
|
2379
|
+
account_scope=account_key)
|
|
2380
|
+
except sqlite3.Error:
|
|
2381
|
+
return (None, "projection_incoherent")
|
|
1588
2382
|
milestones = _union_cluster_milestones(
|
|
1589
2383
|
match, match.block_key, speed, cache_conn, stats_conn,
|
|
1590
2384
|
account_key=account_key,
|
|
@@ -1592,11 +2386,14 @@ def build_codex_cycle_detail(
|
|
|
1592
2386
|
if milestones is None:
|
|
1593
2387
|
return (None, "projection_incoherent")
|
|
1594
2388
|
blocks = _codex_cycle_blocks(
|
|
1595
|
-
stats_conn, cache_conn, match, speed, account_key=account_key
|
|
2389
|
+
stats_conn, cache_conn, match, speed, account_key=account_key,
|
|
2390
|
+
clusters=inputs.block_clusters)
|
|
1596
2391
|
if blocks is None:
|
|
1597
2392
|
return (None, "projection_incoherent")
|
|
1598
2393
|
|
|
1599
|
-
entry = _codex_cycle_entry(
|
|
2394
|
+
entry = _codex_cycle_entry(
|
|
2395
|
+
stats_conn, cache_conn, match, tz, speed=speed,
|
|
2396
|
+
account_scope=account_key, inputs=inputs)
|
|
1600
2397
|
return {
|
|
1601
2398
|
"source": "codex",
|
|
1602
2399
|
"key": entry["key"],
|