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
package/bin/_cctally_rederive.py
CHANGED
|
@@ -8,6 +8,8 @@ projection files, provider state, and alert dispatcher are never mutated.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import bisect
|
|
12
|
+
import collections.abc
|
|
11
13
|
import contextlib
|
|
12
14
|
import copy
|
|
13
15
|
import dataclasses
|
|
@@ -22,8 +24,10 @@ import signal
|
|
|
22
24
|
import sqlite3
|
|
23
25
|
import sys
|
|
24
26
|
import tempfile
|
|
27
|
+
from array import array
|
|
25
28
|
from dataclasses import dataclass
|
|
26
29
|
from pathlib import Path
|
|
30
|
+
from typing import ClassVar
|
|
27
31
|
|
|
28
32
|
import _cctally_core
|
|
29
33
|
import _cctally_journal as _journal
|
|
@@ -34,6 +38,7 @@ import _lib_journal
|
|
|
34
38
|
import _lib_json_envelope
|
|
35
39
|
import _lib_rebuild_progress
|
|
36
40
|
import _lib_rederive
|
|
41
|
+
import _lib_retained_records
|
|
37
42
|
import _cctally_store
|
|
38
43
|
from _cctally_store import StatsIndexNotCurrent
|
|
39
44
|
|
|
@@ -64,9 +69,9 @@ class RederiveObligationNotDurable(RederiveApplyError):
|
|
|
64
69
|
@dataclass(frozen=True)
|
|
65
70
|
class RederivePreview:
|
|
66
71
|
plan: _lib_rederive.RederivePlan
|
|
67
|
-
records:
|
|
72
|
+
records: "Sequence[dict]" # #1004: a RetainedRecords, or a tuple
|
|
68
73
|
journal_high_water: "tuple[str, int] | None"
|
|
69
|
-
record_ends:
|
|
74
|
+
record_ends: "Sequence[tuple[str, int]]" # its ends view, or a tuple
|
|
70
75
|
generated_at: str
|
|
71
76
|
batch_id: "str | None"
|
|
72
77
|
incomplete_batch: bool
|
|
@@ -518,29 +523,349 @@ def _joined_entries(cache_conn, range_start, range_end, *,
|
|
|
518
523
|
sql += " AND se.account_key = ?"
|
|
519
524
|
params.append(account_key)
|
|
520
525
|
sql += " ORDER BY se.timestamp_utc ASC, se.id ASC"
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
526
|
+
return [_joined_entry_from_row(cache, row)
|
|
527
|
+
for row in cache_conn.execute(sql, params)]
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
#: The columns `_joined_entry_from_row` reads, in its row order.
|
|
531
|
+
_JOINED_ENTRY_COLUMNS = (
|
|
532
|
+
"se.timestamp_utc, se.model, se.input_tokens, se.output_tokens, "
|
|
533
|
+
"se.cache_create_tokens, se.cache_read_tokens, se.source_path, "
|
|
534
|
+
"sf.session_id, sf.project_path, se.cost_usd_raw, se.speed, "
|
|
535
|
+
"se.cache_create_1h_tokens, se.line_offset, se.account_key"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _joined_entry_from_row(cache, row):
|
|
540
|
+
"""One `_JoinedClaudeEntry` from a `_JOINED_ENTRY_COLUMNS` row: the one
|
|
541
|
+
row-to-entry construction `_joined_entries` and `BlockTotalsEngine`
|
|
542
|
+
share, so an engine entry equals a fresh query's field for field."""
|
|
543
|
+
entry = cache._JoinedClaudeEntry(
|
|
544
|
+
timestamp=dt.datetime.fromisoformat(row[0]),
|
|
545
|
+
model=row[1],
|
|
546
|
+
input_tokens=int(row[2] or 0),
|
|
547
|
+
output_tokens=int(row[3] or 0),
|
|
548
|
+
cache_creation_tokens=int(row[4] or 0),
|
|
549
|
+
cache_read_tokens=int(row[5] or 0),
|
|
550
|
+
source_path=row[6],
|
|
551
|
+
session_id=row[7],
|
|
552
|
+
project_path=row[8],
|
|
553
|
+
cost_usd=row[9],
|
|
554
|
+
usage_extra=({"speed": row[10]} if row[10] else None),
|
|
555
|
+
cache_1h_tokens=(None if row[11] is None else int(row[11])),
|
|
556
|
+
)
|
|
557
|
+
# #869: the retained source identity and account of the entry. The
|
|
558
|
+
# cost path never reads these; the historical-close proof fingerprints
|
|
559
|
+
# the exact population and requires every entry to be the close's.
|
|
560
|
+
entry.source_line_offset = row[12]
|
|
561
|
+
entry.source_account_key = row[13]
|
|
562
|
+
return entry
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
#: The engine's memo marker for an entry it has not priced yet.
|
|
566
|
+
_UNPRICED = object()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class _EntryRun:
|
|
570
|
+
"""The cached entries of one load range start (spec D1 *Entry cache*):
|
|
571
|
+
the entries in SQL order, their raw `timestamp_utc` texts, each entry's
|
|
572
|
+
memoized cost, and ``through``, the largest end text fetched."""
|
|
573
|
+
|
|
574
|
+
__slots__ = ("entries", "texts", "costs", "through", "state_keys")
|
|
575
|
+
|
|
576
|
+
def __init__(self, through: str) -> None:
|
|
577
|
+
self.entries: list = []
|
|
578
|
+
self.texts: list = []
|
|
579
|
+
self.costs: list = []
|
|
580
|
+
self.through = through
|
|
581
|
+
self.state_keys: set = set()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
class _FoldState:
|
|
585
|
+
"""One fold over a run's prefix for one owner and window set (spec D1
|
|
586
|
+
*Fold states*): the prefix length consumed, the totals, the evidence
|
|
587
|
+
sets and the run indexes of the owned entries, in order."""
|
|
588
|
+
|
|
589
|
+
__slots__ = ("consumed", "totals", "missing_without_raw", "observed_raw",
|
|
590
|
+
"owned")
|
|
591
|
+
|
|
592
|
+
def __init__(self, totals) -> None:
|
|
593
|
+
self.consumed = 0
|
|
594
|
+
self.totals = totals
|
|
595
|
+
self.missing_without_raw: set = set()
|
|
596
|
+
self.observed_raw: set = set()
|
|
597
|
+
self.owned: list = []
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _typed(value):
|
|
601
|
+
"""A hashable spelling of one key or window field that keeps values
|
|
602
|
+
equal under ``==`` but different in type or offset apart (``1`` and
|
|
603
|
+
``True``; two instants spelled with different offsets)."""
|
|
604
|
+
if isinstance(value, dt.datetime):
|
|
605
|
+
return ("datetime", value.isoformat())
|
|
606
|
+
return (type(value).__qualname__, value)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class BlockTotalsEngine:
|
|
610
|
+
"""#1003 D1: exact incremental `_compute_block_totals` for one scratch
|
|
611
|
+
replay over a stable cache snapshot.
|
|
612
|
+
|
|
613
|
+
The full re-price re-reads, re-partitions and re-prices every entry an
|
|
614
|
+
open block owns on every tick, so a replay's cost grows with each block's
|
|
615
|
+
age. The engine caches each load range's entries and extends them with a
|
|
616
|
+
delta fetch, keeps one fold per (range start, owner, window set) that it
|
|
617
|
+
extends by the new entries only, and prices each cached entry once. Its
|
|
618
|
+
answer equals the full re-price's: the same entries in the same order
|
|
619
|
+
(text comparisons and the delta predicate reproduce SQLite's byte order),
|
|
620
|
+
the same pure ownership rule, and the same `BlockTotals.add_priced`
|
|
621
|
+
additions in the same order.
|
|
622
|
+
|
|
623
|
+
Only `_scratch_read_adapters` installs one, so the cache connection holds
|
|
624
|
+
one read snapshot for the engine's whole life.
|
|
625
|
+
"""
|
|
626
|
+
|
|
627
|
+
#: Shadow verification: every call also runs the full re-price and
|
|
628
|
+
#: raises on any difference. Set only by tests and the acceptance driver.
|
|
629
|
+
verify: ClassVar[bool] = False
|
|
630
|
+
#: Spec D1 *Bounds*: cached entries across all runs, runs, fold states.
|
|
631
|
+
ENTRY_BUDGET = 65536
|
|
632
|
+
MAX_RUNS = 64
|
|
633
|
+
MAX_STATES = 64
|
|
634
|
+
|
|
635
|
+
def __init__(self, cache_conn: sqlite3.Connection) -> None:
|
|
636
|
+
import collections
|
|
637
|
+
self._conn = cache_conn
|
|
638
|
+
self._cache = sys.modules["_cctally_cache"]
|
|
639
|
+
self._runs: "collections.OrderedDict[str, _EntryRun]" = (
|
|
640
|
+
collections.OrderedDict())
|
|
641
|
+
self._states: "collections.OrderedDict[tuple, _FoldState]" = (
|
|
642
|
+
collections.OrderedDict())
|
|
643
|
+
self._cached_entries = 0
|
|
644
|
+
self._pricing_missing: dict = {}
|
|
645
|
+
self.counts = collections.Counter()
|
|
646
|
+
|
|
647
|
+
# -- entry cache ---------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
def _fetch(self, start_iso: str, end_iso: str, through: "str | None"):
|
|
650
|
+
sql = (
|
|
651
|
+
f"SELECT {_JOINED_ENTRY_COLUMNS} FROM session_entries se "
|
|
652
|
+
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
653
|
+
"WHERE se.timestamp_utc >= ?"
|
|
536
654
|
)
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
655
|
+
params = [start_iso]
|
|
656
|
+
if through is not None:
|
|
657
|
+
sql += " AND se.timestamp_utc > ?"
|
|
658
|
+
params.append(through)
|
|
659
|
+
sql += (" AND se.timestamp_utc <= ?"
|
|
660
|
+
" ORDER BY se.timestamp_utc ASC, se.id ASC")
|
|
661
|
+
params.append(end_iso)
|
|
662
|
+
return self._conn.execute(sql, params).fetchall()
|
|
663
|
+
|
|
664
|
+
def _drop_run(self, start_iso: str) -> None:
|
|
665
|
+
run = self._runs.pop(start_iso)
|
|
666
|
+
self._cached_entries -= len(run.entries)
|
|
667
|
+
for key in run.state_keys:
|
|
668
|
+
self._states.pop(key, None)
|
|
669
|
+
|
|
670
|
+
def _run_for(self, start_iso: str, end_iso: str) -> "_EntryRun | None":
|
|
671
|
+
"""The run holding every entry of ``[start_iso, end_iso]`` (by text),
|
|
672
|
+
or None when that run alone would exceed the entry budget."""
|
|
673
|
+
run = self._runs.get(start_iso)
|
|
674
|
+
if run is not None:
|
|
675
|
+
self._runs.move_to_end(start_iso)
|
|
676
|
+
if end_iso <= run.through:
|
|
677
|
+
return run
|
|
678
|
+
rows = self._fetch(start_iso, end_iso, run.through)
|
|
679
|
+
self.counts["delta_fetches"] += 1
|
|
680
|
+
else:
|
|
681
|
+
rows = self._fetch(start_iso, end_iso, None)
|
|
682
|
+
self.counts["fetches"] += 1
|
|
683
|
+
held = 0 if run is None else len(run.entries)
|
|
684
|
+
if held + len(rows) > self.ENTRY_BUDGET:
|
|
685
|
+
if run is not None:
|
|
686
|
+
self._drop_run(start_iso)
|
|
687
|
+
return None
|
|
688
|
+
if run is None:
|
|
689
|
+
run = _EntryRun(end_iso)
|
|
690
|
+
self._runs[start_iso] = run
|
|
691
|
+
for row in rows:
|
|
692
|
+
run.entries.append(_joined_entry_from_row(self._cache, row))
|
|
693
|
+
run.texts.append(row[0])
|
|
694
|
+
run.costs.append(_UNPRICED)
|
|
695
|
+
run.through = end_iso
|
|
696
|
+
self._cached_entries += len(rows)
|
|
697
|
+
while (self._cached_entries > self.ENTRY_BUDGET
|
|
698
|
+
or len(self._runs) > self.MAX_RUNS):
|
|
699
|
+
oldest = next(iter(self._runs))
|
|
700
|
+
if oldest == start_iso:
|
|
701
|
+
break
|
|
702
|
+
self._drop_run(oldest)
|
|
703
|
+
self.counts["runs_evicted"] += 1
|
|
704
|
+
return run
|
|
705
|
+
|
|
706
|
+
# -- fold states ---------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
def _missing_pricing(self, model) -> bool:
|
|
709
|
+
missing = self._pricing_missing.get(model)
|
|
710
|
+
if missing is None:
|
|
711
|
+
import _lib_pricing
|
|
712
|
+
missing = _lib_pricing._resolve_model_pricing(
|
|
713
|
+
model, warn=False) is None
|
|
714
|
+
self._pricing_missing[model] = missing
|
|
715
|
+
return missing
|
|
716
|
+
|
|
717
|
+
def _cost(self, run: _EntryRun, index: int) -> float:
|
|
718
|
+
cost = run.costs[index]
|
|
719
|
+
if cost is _UNPRICED:
|
|
720
|
+
entry = run.entries[index]
|
|
721
|
+
usage = _record.claude_usage_dict( # #195 chokepoint
|
|
722
|
+
input_tokens=entry.input_tokens,
|
|
723
|
+
output_tokens=entry.output_tokens,
|
|
724
|
+
cache_creation_tokens=entry.cache_creation_tokens,
|
|
725
|
+
cache_read_tokens=entry.cache_read_tokens,
|
|
726
|
+
cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
|
|
727
|
+
speed=getattr(entry, "speed", None),
|
|
728
|
+
)
|
|
729
|
+
cost = _record._calculate_entry_cost(
|
|
730
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
731
|
+
)
|
|
732
|
+
run.costs[index] = cost
|
|
733
|
+
return cost
|
|
734
|
+
|
|
735
|
+
def _extend(self, state: _FoldState, run: _EntryRun, n: int, owner_key,
|
|
736
|
+
windows, owned_entries) -> None:
|
|
737
|
+
"""Fold ``run.entries[state.consumed:n]``: each owned entry, in order,
|
|
738
|
+
is appended to ``owned_entries``, records its evidence, is priced
|
|
739
|
+
(once per run) and is folded, exactly as the full re-price does."""
|
|
740
|
+
import _lib_blocks
|
|
741
|
+
start = state.consumed
|
|
742
|
+
new = run.entries[start:n]
|
|
743
|
+
position = {id(entry): start + i for i, entry in enumerate(new)}
|
|
744
|
+
owned, _unowned = _lib_blocks.partition_entries_by_owner(new, windows)
|
|
745
|
+
for entry in owned[owner_key]:
|
|
746
|
+
index = position[id(entry)]
|
|
747
|
+
if owned_entries is not None:
|
|
748
|
+
owned_entries.append(entry)
|
|
749
|
+
if (entry.input_tokens or entry.output_tokens
|
|
750
|
+
or entry.cache_creation_tokens or entry.cache_read_tokens):
|
|
751
|
+
if entry.cost_usd is not None:
|
|
752
|
+
state.observed_raw.add(entry.model)
|
|
753
|
+
elif self._missing_pricing(entry.model):
|
|
754
|
+
state.missing_without_raw.add(entry.model)
|
|
755
|
+
state.totals.add_priced(
|
|
756
|
+
entry.model, entry.project_path, entry.input_tokens,
|
|
757
|
+
entry.output_tokens, entry.cache_creation_tokens,
|
|
758
|
+
entry.cache_read_tokens, self._cost(run, index),
|
|
759
|
+
)
|
|
760
|
+
state.owned.append(index)
|
|
761
|
+
state.consumed = n
|
|
762
|
+
|
|
763
|
+
def _state_key(self, start_iso: str, owner_key, windows) -> "tuple | None":
|
|
764
|
+
signature = []
|
|
765
|
+
for window in windows:
|
|
766
|
+
if not dataclasses.is_dataclass(window):
|
|
767
|
+
return None
|
|
768
|
+
signature.append(tuple(
|
|
769
|
+
_typed(getattr(window, field.name))
|
|
770
|
+
for field in dataclasses.fields(window)))
|
|
771
|
+
return (start_iso, _typed(owner_key), tuple(signature))
|
|
772
|
+
|
|
773
|
+
# -- the answer ----------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
def totals(self, block_start_at, range_end, *, owner_key, windows: list,
|
|
776
|
+
pricing_evidence: "dict | None",
|
|
777
|
+
owned_entries: "list | None") -> dict:
|
|
778
|
+
"""`_compute_block_totals`' answer for this call, with the same
|
|
779
|
+
side effects on ``pricing_evidence`` and ``owned_entries``."""
|
|
780
|
+
if not self.verify:
|
|
781
|
+
return self._totals(block_start_at, range_end, owner_key,
|
|
782
|
+
windows, pricing_evidence, owned_entries)
|
|
783
|
+
evidence: dict = {}
|
|
784
|
+
owned: list = []
|
|
785
|
+
result = self._totals(block_start_at, range_end, owner_key, windows,
|
|
786
|
+
evidence, owned)
|
|
787
|
+
shadow_evidence: dict = {}
|
|
788
|
+
shadow_owned: list = []
|
|
789
|
+
shadow = _record._block_totals_reprice(
|
|
790
|
+
block_start_at, range_end, owner_key=owner_key,
|
|
791
|
+
ownership_windows=windows, pricing_evidence=shadow_evidence,
|
|
792
|
+
owned_entries=shadow_owned)
|
|
793
|
+
if (_float_exact(result) != _float_exact(shadow)
|
|
794
|
+
or evidence != shadow_evidence
|
|
795
|
+
or [vars(e) for e in owned]
|
|
796
|
+
!= [vars(e) for e in shadow_owned]):
|
|
797
|
+
raise AssertionError(
|
|
798
|
+
"BlockTotalsEngine differs from the full re-price for "
|
|
799
|
+
f"{block_start_at!r}..{range_end!r} owner {owner_key!r}")
|
|
800
|
+
if pricing_evidence is not None:
|
|
801
|
+
pricing_evidence.update(evidence)
|
|
802
|
+
if owned_entries is not None:
|
|
803
|
+
owned_entries.extend(owned)
|
|
804
|
+
return result
|
|
805
|
+
|
|
806
|
+
def _totals(self, block_start_at, range_end, owner_key, windows,
|
|
807
|
+
pricing_evidence, owned_entries) -> dict:
|
|
808
|
+
start_iso = block_start_at.astimezone(dt.timezone.utc).isoformat()
|
|
809
|
+
end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
|
|
810
|
+
key = self._state_key(start_iso, owner_key, windows)
|
|
811
|
+
run = None if key is None else self._run_for(start_iso, end_iso)
|
|
812
|
+
if run is None:
|
|
813
|
+
self.counts["uncached"] += 1
|
|
814
|
+
return _record._block_totals_reprice(
|
|
815
|
+
block_start_at, range_end, owner_key=owner_key,
|
|
816
|
+
ownership_windows=windows, pricing_evidence=pricing_evidence,
|
|
817
|
+
owned_entries=owned_entries)
|
|
818
|
+
n = bisect.bisect_right(run.texts, end_iso)
|
|
819
|
+
state = self._states.get(key)
|
|
820
|
+
if state is not None and n < state.consumed:
|
|
821
|
+
self._states.pop(key)
|
|
822
|
+
state = None
|
|
823
|
+
self.counts["rebuilt"] += 1
|
|
824
|
+
if state is None:
|
|
825
|
+
state = _FoldState(_record.BlockTotals())
|
|
826
|
+
self._states[key] = state
|
|
827
|
+
run.state_keys.add(key)
|
|
828
|
+
while len(self._states) > self.MAX_STATES:
|
|
829
|
+
old_key, _old = self._states.popitem(last=False)
|
|
830
|
+
run_of_old = self._runs.get(old_key[0])
|
|
831
|
+
if run_of_old is not None:
|
|
832
|
+
run_of_old.state_keys.discard(old_key)
|
|
833
|
+
self.counts["states_evicted"] += 1
|
|
834
|
+
else:
|
|
835
|
+
self._states.move_to_end(key)
|
|
836
|
+
if owned_entries is not None:
|
|
837
|
+
owned_entries.extend(run.entries[i] for i in state.owned)
|
|
838
|
+
if n > state.consumed:
|
|
839
|
+
try:
|
|
840
|
+
self._extend(state, run, n, owner_key, windows, owned_entries)
|
|
841
|
+
except BaseException:
|
|
842
|
+
# A half-folded state must never answer a later call.
|
|
843
|
+
self._states.pop(key, None)
|
|
844
|
+
run.state_keys.discard(key)
|
|
845
|
+
raise
|
|
846
|
+
self.counts["extended"] += 1
|
|
847
|
+
else:
|
|
848
|
+
self.counts["cached"] += 1
|
|
849
|
+
result = state.totals.as_legacy_dict()
|
|
850
|
+
if pricing_evidence is not None:
|
|
851
|
+
pricing_evidence["unpricedModels"] = sorted(
|
|
852
|
+
state.missing_without_raw - state.observed_raw)
|
|
853
|
+
ambiguous = sorted(state.missing_without_raw & state.observed_raw)
|
|
854
|
+
if ambiguous:
|
|
855
|
+
pricing_evidence["uncertainModels"] = ambiguous
|
|
856
|
+
return result
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _float_exact(value):
|
|
860
|
+
"""``value`` with every float spelled by its exact bits and every dict
|
|
861
|
+
by its items in order, for the engine's shadow comparison."""
|
|
862
|
+
if isinstance(value, float):
|
|
863
|
+
return ("float", value.hex())
|
|
864
|
+
if isinstance(value, dict):
|
|
865
|
+
return ("dict", tuple((k, _float_exact(v)) for k, v in value.items()))
|
|
866
|
+
if isinstance(value, (list, tuple)):
|
|
867
|
+
return ("seq", tuple(_float_exact(v) for v in value))
|
|
868
|
+
return value
|
|
544
869
|
|
|
545
870
|
|
|
546
871
|
@contextlib.contextmanager
|
|
@@ -562,6 +887,10 @@ def _scratch_read_adapters(cache_conn: sqlite3.Connection):
|
|
|
562
887
|
"load_config": lambda *args, **kwargs: {},
|
|
563
888
|
}
|
|
564
889
|
prior = {name: getattr(cctally, name) for name in replacements}
|
|
890
|
+
# #1003 D1: the replay's block totals come from an incremental engine over
|
|
891
|
+
# this context's stable cache snapshot, one fresh engine per context.
|
|
892
|
+
prior_engine = _record.swap_block_totals_engine(
|
|
893
|
+
BlockTotalsEngine(cache_conn))
|
|
565
894
|
try:
|
|
566
895
|
for name, value in replacements.items():
|
|
567
896
|
setattr(cctally, name, value)
|
|
@@ -569,6 +898,7 @@ def _scratch_read_adapters(cache_conn: sqlite3.Connection):
|
|
|
569
898
|
finally:
|
|
570
899
|
for name, value in prior.items():
|
|
571
900
|
setattr(cctally, name, value)
|
|
901
|
+
_record.swap_block_totals_engine(prior_engine)
|
|
572
902
|
|
|
573
903
|
|
|
574
904
|
#: Op kinds the scratch replay runs through the pipeline hooks.
|
|
@@ -593,8 +923,24 @@ def _is_rederivable_raw(record: dict) -> bool:
|
|
|
593
923
|
return kind in _REDERIVABLE_OP_KINDS or kind in _REVIEWED_WEEKLY_KINDS
|
|
594
924
|
|
|
595
925
|
|
|
596
|
-
def _rederivable_raw_records(records
|
|
597
|
-
|
|
926
|
+
def _rederivable_raw_records(records):
|
|
927
|
+
"""The rederivable raw inputs of ``records``, in journal order: a
|
|
928
|
+
position-subset view of a coordinate-backed sequence (#1004 D4), else a
|
|
929
|
+
list."""
|
|
930
|
+
subset = getattr(records, "subset", None)
|
|
931
|
+
if subset is None:
|
|
932
|
+
return [record for record in records if _is_rederivable_raw(record)]
|
|
933
|
+
return subset(_positions_where(records, _is_rederivable_raw))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _positions_where(records, predicate) -> "array":
|
|
937
|
+
"""The positions of ``records`` whose record satisfies ``predicate``,
|
|
938
|
+
as an unsigned array (8 bytes a position, no record kept)."""
|
|
939
|
+
positions = array("Q")
|
|
940
|
+
for index, record in enumerate(records):
|
|
941
|
+
if predicate(record):
|
|
942
|
+
positions.append(index)
|
|
943
|
+
return positions
|
|
598
944
|
|
|
599
945
|
|
|
600
946
|
#: #825/#832 spec 5.1: the previous release validates a reviewed weekly
|
|
@@ -1058,7 +1404,95 @@ def _normalize_legacy_accounts(records: list[dict]) -> None:
|
|
|
1058
1404
|
_journal._normalize_legacy_account_stamp(record, cutover_claude)
|
|
1059
1405
|
|
|
1060
1406
|
|
|
1061
|
-
|
|
1407
|
+
class _PlanningView(collections.abc.Sequence):
|
|
1408
|
+
"""#1004 spec D4: the planner's normalized view of a coordinate-backed
|
|
1409
|
+
sequence (a `RetainedRecords` or one of its views).
|
|
1410
|
+
|
|
1411
|
+
A streamed position's decode is private, so it is normalized in place and
|
|
1412
|
+
returned; it never enters the `id()` memo, whose key a freed decode's
|
|
1413
|
+
address could later repeat for another position. A retained object (a D4
|
|
1414
|
+
stub, a placeholder, an appended reviewed op) takes `_normalized_planning_
|
|
1415
|
+
view`'s copy-on-write path, memoized by identity: those objects live as
|
|
1416
|
+
long as the source, so their ids cannot be reused while the view is.
|
|
1417
|
+
Views derived from this one (slices, subsets) share the memo, so a shared
|
|
1418
|
+
stub normalizes once for the whole planning call."""
|
|
1419
|
+
|
|
1420
|
+
__slots__ = ("_source", "_cutover", "_changed", "_shared")
|
|
1421
|
+
|
|
1422
|
+
def __init__(self, source, cutover_claude, *, changed=None, shared=None):
|
|
1423
|
+
self._source = source
|
|
1424
|
+
self._cutover = cutover_claude
|
|
1425
|
+
#: id(retained source object) -> its normalized copy.
|
|
1426
|
+
self._changed = {} if changed is None else changed
|
|
1427
|
+
#: ids of every retained object this view returned (shared with the
|
|
1428
|
+
#: caller or memoized), which a replay must copy before mutating.
|
|
1429
|
+
self._shared = set() if shared is None else shared
|
|
1430
|
+
|
|
1431
|
+
def _derived(self, source):
|
|
1432
|
+
return _PlanningView(source, self._cutover, changed=self._changed,
|
|
1433
|
+
shared=self._shared)
|
|
1434
|
+
|
|
1435
|
+
def _fresh(self, record):
|
|
1436
|
+
_journal._normalize_legacy_account_stamp(record, self._cutover)
|
|
1437
|
+
return record
|
|
1438
|
+
|
|
1439
|
+
def _retained(self, record):
|
|
1440
|
+
if not isinstance(record, dict):
|
|
1441
|
+
return record
|
|
1442
|
+
hit = self._changed.get(id(record))
|
|
1443
|
+
if hit is not None:
|
|
1444
|
+
return hit
|
|
1445
|
+
probe = dict(record)
|
|
1446
|
+
if isinstance(record.get("payload"), dict):
|
|
1447
|
+
probe["payload"] = dict(record["payload"])
|
|
1448
|
+
_journal._normalize_legacy_account_stamp(probe, self._cutover)
|
|
1449
|
+
if probe == record:
|
|
1450
|
+
self._shared.add(id(record))
|
|
1451
|
+
return record
|
|
1452
|
+
normalized = copy.deepcopy(record)
|
|
1453
|
+
_journal._normalize_legacy_account_stamp(normalized, self._cutover)
|
|
1454
|
+
self._changed[id(record)] = normalized
|
|
1455
|
+
self._shared.add(id(normalized))
|
|
1456
|
+
return normalized
|
|
1457
|
+
|
|
1458
|
+
def __len__(self) -> int:
|
|
1459
|
+
return len(self._source)
|
|
1460
|
+
|
|
1461
|
+
def __getitem__(self, index):
|
|
1462
|
+
if isinstance(index, slice):
|
|
1463
|
+
part = self._source[index]
|
|
1464
|
+
if isinstance(part, list):
|
|
1465
|
+
return [self[i] for i in range(len(self))[index]]
|
|
1466
|
+
return self._derived(part)
|
|
1467
|
+
record = self._source[index]
|
|
1468
|
+
if self._source.is_streamed(index):
|
|
1469
|
+
return self._fresh(record)
|
|
1470
|
+
return self._retained(record)
|
|
1471
|
+
|
|
1472
|
+
def __iter__(self):
|
|
1473
|
+
for fresh, record in self._source.iter_marked():
|
|
1474
|
+
yield self._fresh(record) if fresh else self._retained(record)
|
|
1475
|
+
|
|
1476
|
+
def iter_marked(self):
|
|
1477
|
+
for fresh, record in self._source.iter_marked():
|
|
1478
|
+
yield ((True, self._fresh(record)) if fresh
|
|
1479
|
+
else (False, self._retained(record)))
|
|
1480
|
+
|
|
1481
|
+
def is_streamed(self, index) -> bool:
|
|
1482
|
+
return self._source.is_streamed(index)
|
|
1483
|
+
|
|
1484
|
+
def subset(self, positions):
|
|
1485
|
+
return self._derived(self._source.subset(positions))
|
|
1486
|
+
|
|
1487
|
+
def prepare_for_replay(self, record):
|
|
1488
|
+
"""The replay's private copy of a record this view returned (#997
|
|
1489
|
+
D5): a fresh decode is already private; a retained one is copied."""
|
|
1490
|
+
if id(record) in self._shared:
|
|
1491
|
+
return copy.deepcopy(record)
|
|
1492
|
+
return record
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _normalized_planning_view(records, cutover_claude=None):
|
|
1062
1496
|
"""#997 spec D5: the planner's normalized records, copy-on-write.
|
|
1063
1497
|
|
|
1064
1498
|
The same global pass as `_normalize_legacy_accounts` (every cutover value
|
|
@@ -1071,6 +1505,8 @@ def _normalized_planning_view(records, cutover_claude=None) -> list:
|
|
|
1071
1505
|
the up-front deep copy of every record normalization leaves alone."""
|
|
1072
1506
|
if cutover_claude is None:
|
|
1073
1507
|
cutover_claude = _planner_cutover_claude(records)
|
|
1508
|
+
if hasattr(records, "iter_marked"):
|
|
1509
|
+
return _PlanningView(records, cutover_claude)
|
|
1074
1510
|
changed: dict = {}
|
|
1075
1511
|
view = []
|
|
1076
1512
|
for record in records:
|
|
@@ -1121,15 +1557,17 @@ def _derive_desired_events_into(
|
|
|
1121
1557
|
held_weekly_observation_ids=frozenset(),
|
|
1122
1558
|
block_sources: "dict | None" = None,
|
|
1123
1559
|
) -> list[dict]:
|
|
1124
|
-
raw_records = _rederivable_raw_records(records)
|
|
1125
1560
|
held_ids = frozenset(held_weekly_observation_ids)
|
|
1126
|
-
|
|
1127
|
-
|
|
1561
|
+
# #1004 D4: only the held ids found, not one id per raw observation,
|
|
1562
|
+
# which the frame would otherwise keep through the whole replay.
|
|
1563
|
+
valid_held_ids = {
|
|
1564
|
+
record.get("id") for record in _rederivable_raw_records(records)
|
|
1128
1565
|
if record.get("t") == "obs"
|
|
1566
|
+
and record.get("id") in held_ids
|
|
1129
1567
|
and "weekly_percent" in (record.get("payload") or {})
|
|
1130
1568
|
and "resets_at" in (record.get("payload") or {})
|
|
1131
1569
|
}
|
|
1132
|
-
unknown_ids = held_ids -
|
|
1570
|
+
unknown_ids = held_ids - valid_held_ids
|
|
1133
1571
|
if unknown_ids:
|
|
1134
1572
|
raise _lib_rederive.RederiveConflict(
|
|
1135
1573
|
"reviewed weekly hold names no Claude raw observation: "
|
|
@@ -1147,9 +1585,12 @@ def _derive_desired_events_into(
|
|
|
1147
1585
|
try:
|
|
1148
1586
|
# #997 spec D5: the planner's view shares unchanged records with the
|
|
1149
1587
|
# caller, so the replay prepares a private copy of each record.
|
|
1588
|
+
# #1004 D4: a streamed record's decode is already private, so the
|
|
1589
|
+
# coordinate-backed view copies only the retained ones.
|
|
1150
1590
|
return replay_scratch_records(
|
|
1151
1591
|
conn, records, cache_conn, fold=fold, held_ids=held_ids,
|
|
1152
|
-
block_sources=block_sources,
|
|
1592
|
+
block_sources=block_sources,
|
|
1593
|
+
prepare=getattr(records, "prepare_for_replay", copy.deepcopy))
|
|
1153
1594
|
finally:
|
|
1154
1595
|
conn.close()
|
|
1155
1596
|
|
|
@@ -1335,8 +1776,10 @@ def plan_claude_usage(
|
|
|
1335
1776
|
replay and comparison contract.
|
|
1336
1777
|
"""
|
|
1337
1778
|
# #997 spec D5: a copy-on-write normalized view, not a whole-list deep
|
|
1338
|
-
# copy. Every planner consumer below reads this view.
|
|
1339
|
-
|
|
1779
|
+
# copy. Every planner consumer below reads this view. #1004 D4: a
|
|
1780
|
+
# coordinate-backed sequence is viewed as it is, never listed.
|
|
1781
|
+
records = _normalized_planning_view(
|
|
1782
|
+
records if hasattr(records, "iter_marked") else list(records))
|
|
1340
1783
|
report = _lib_rederive.validate_family_registry(
|
|
1341
1784
|
evt_kinds=set(_journal._EVT_SPECS),
|
|
1342
1785
|
op_kinds=_planner_op_kinds(),
|
|
@@ -1575,14 +2018,21 @@ def read_rederive_journal_prefix(
|
|
|
1575
2018
|
keeps only the decision records and substitutes `None` placeholders for
|
|
1576
2019
|
everything else; the planner here reads every observation for cache
|
|
1577
2020
|
validation, desired-event derivation and preservation decisions, and walks
|
|
1578
|
-
`records` in parallel with `record_ends`. Both
|
|
1579
|
-
aligned, so the win in this file is the removed double materialization and
|
|
1580
|
-
the removed hash traversals, not reduced retention.
|
|
2021
|
+
`records` in parallel with `record_ends`. Both stay complete and aligned.
|
|
1581
2022
|
|
|
1582
2023
|
#997 spec D4: positions are kept, but an admitted Codex quota observation
|
|
1583
2024
|
is retained as an interned read-only stub (`_inert_codex_stub`) rather
|
|
1584
2025
|
than decoded in full; no consumer on this path reads its quota payload,
|
|
1585
2026
|
`id` or `at`.
|
|
2027
|
+
|
|
2028
|
+
#1004 spec D3/D4: `records` is a `RetainedRecords` and `record_ends` its
|
|
2029
|
+
`LineCoordinates.ends()` view. Every other record (each one retained
|
|
2030
|
+
compacted before) is a streamed slot: its coordinates and the digest of
|
|
2031
|
+
its line, re-read, digest-checked and decoded (then compacted) on each
|
|
2032
|
+
access, so a pass sees a fresh record equal to the one retained before.
|
|
2033
|
+
The D4 stubs and the admission placeholders stay retained objects. With
|
|
2034
|
+
`_lib_retained_records.MATERIALIZE_FOR_TESTS` set, the reader returns
|
|
2035
|
+
today's two lists instead (a test switch).
|
|
1586
2036
|
"""
|
|
1587
2037
|
if high_water is None:
|
|
1588
2038
|
high_water = _journal.journal_high_water()
|
|
@@ -1593,8 +2043,11 @@ def read_rederive_journal_prefix(
|
|
|
1593
2043
|
raise OSError(
|
|
1594
2044
|
f"journal high-water segment is unavailable: {high_water[0]}"
|
|
1595
2045
|
)
|
|
1596
|
-
|
|
1597
|
-
|
|
2046
|
+
materialize = _lib_retained_records.MATERIALIZE_FOR_TESTS
|
|
2047
|
+
coordinates = _lib_retained_records.LineCoordinates()
|
|
2048
|
+
records = _lib_retained_records.RetainedRecords(
|
|
2049
|
+
coordinates, _RederiveLineReader, _decode_retained_line,
|
|
2050
|
+
error=_journal_changed_error)
|
|
1598
2051
|
evidence: list = []
|
|
1599
2052
|
evidence_owners: list = []
|
|
1600
2053
|
malformed = 0
|
|
@@ -1605,7 +2058,6 @@ def read_rederive_journal_prefix(
|
|
|
1605
2058
|
# at its own position, so the digest is taken inline and the validation
|
|
1606
2059
|
# runs after the pass, for admitted decisions only.
|
|
1607
2060
|
pass_boundary = _journal._PassBoundary()
|
|
1608
|
-
coordinates: list = []
|
|
1609
2061
|
pending_reviewed: list = []
|
|
1610
2062
|
inert_stubs: dict = {}
|
|
1611
2063
|
lines = 0
|
|
@@ -1651,17 +2103,20 @@ def read_rederive_journal_prefix(
|
|
|
1651
2103
|
evidence_owners.append(len(records))
|
|
1652
2104
|
elif inert:
|
|
1653
2105
|
record = _inert_codex_stub(record, inert_stubs)
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
2106
|
+
if inert or materialize:
|
|
2107
|
+
records.append_retained(record, segment, offset, len(raw))
|
|
2108
|
+
else:
|
|
2109
|
+
records.append_streamed(raw, segment, offset, len(raw))
|
|
1657
2110
|
prior_high_water = record_end
|
|
2111
|
+
starts = coordinates.starts()
|
|
1658
2112
|
# An inert placeholder keeps every position and sequence number; an
|
|
1659
2113
|
# excluded correction, reviewed decision or Claude record is never planning
|
|
1660
|
-
# input (case F7).
|
|
1661
|
-
|
|
2114
|
+
# input (case F7). The snapshot shares the coordinates and digests, so an
|
|
2115
|
+
# excluded position still decodes its original below.
|
|
2116
|
+
originals = records.slot_snapshot()
|
|
1662
2117
|
placeholder = _lib_account_protocol.excluded_placeholder()
|
|
1663
2118
|
_journal._apply_pass_admission(
|
|
1664
|
-
records,
|
|
2119
|
+
records, starts, pass_boundary.resolve(high_water),
|
|
1665
2120
|
placeholder=placeholder)
|
|
1666
2121
|
# #825/#832 fix round C (CD2-005): an excluded acknowledgement takes its
|
|
1667
2122
|
# raw-prefix evidence with it, so selection reads it as absent.
|
|
@@ -1671,18 +2126,24 @@ def read_rederive_journal_prefix(
|
|
|
1671
2126
|
# coordinate for the outcome-quarantine fold of the scratch replay (a
|
|
1672
2127
|
# trigger whose readings widen the every-account envelope). Nothing else
|
|
1673
2128
|
# reads a placeholder's fields.
|
|
1674
|
-
for index
|
|
1675
|
-
if slot is placeholder:
|
|
2129
|
+
for index in range(len(records)):
|
|
2130
|
+
if records.slot(index) is placeholder:
|
|
2131
|
+
segment, offset = starts[index]
|
|
1676
2132
|
records[index] = {
|
|
1677
2133
|
**placeholder,
|
|
1678
2134
|
"quarantine": {
|
|
1679
2135
|
"record": originals[index],
|
|
1680
|
-
"segment":
|
|
1681
|
-
"offset":
|
|
2136
|
+
"segment": segment,
|
|
2137
|
+
"offset": offset,
|
|
1682
2138
|
},
|
|
1683
2139
|
}
|
|
2140
|
+
originals.close()
|
|
2141
|
+
originals = None
|
|
1684
2142
|
for index, record, prior, digest in pending_reviewed:
|
|
1685
|
-
|
|
2143
|
+
# #1004 D4: a position check. The slot still holds the op (a
|
|
2144
|
+
# retained form) or is still streamed, so admission kept it.
|
|
2145
|
+
slot = records.slot(index)
|
|
2146
|
+
if slot is record or slot is _lib_retained_records.STREAMED:
|
|
1686
2147
|
_validate_retained_reviewed_op(
|
|
1687
2148
|
record, prior, _PinnedPrefixDigest(prior, digest),
|
|
1688
2149
|
)
|
|
@@ -1697,7 +2158,62 @@ def read_rederive_journal_prefix(
|
|
|
1697
2158
|
raise _lib_rederive.RederiveConflict(
|
|
1698
2159
|
f"journal prefix contains {malformed} malformed line(s)"
|
|
1699
2160
|
)
|
|
1700
|
-
|
|
2161
|
+
if materialize:
|
|
2162
|
+
# Every slot is a retained object here, so this decodes nothing.
|
|
2163
|
+
listed = list(records)
|
|
2164
|
+
records.close()
|
|
2165
|
+
return listed, high_water, list(coordinates.ends()), tuple(evidence)
|
|
2166
|
+
return records, high_water, coordinates.ends(), tuple(evidence)
|
|
2167
|
+
|
|
2168
|
+
|
|
2169
|
+
def _decode_retained_line(line: bytes):
|
|
2170
|
+
"""#1004 D4: a streamed slot's record, exactly as the reader retained
|
|
2171
|
+
it before: the strict decode, then #997 D15's compaction."""
|
|
2172
|
+
record = _lib_journal.decode_line(line)
|
|
2173
|
+
if record is None:
|
|
2174
|
+
return None
|
|
2175
|
+
return _lib_journal.compact_retained(record)
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
def _journal_changed_error(message: str) -> Exception:
|
|
2179
|
+
"""The reader's integrity error for a line that changed under it: the
|
|
2180
|
+
`RederiveConflict` a malformed prefix raises."""
|
|
2181
|
+
return _lib_rederive.RederiveConflict(
|
|
2182
|
+
f"journal prefix changed since it was read: {message}")
|
|
2183
|
+
|
|
2184
|
+
|
|
2185
|
+
class _RederiveLineReader:
|
|
2186
|
+
"""`LineReader` for the rederive reader's streamed records: the framed
|
|
2187
|
+
read `QuotaLineSource` uses (#997 D7), through the one physical segment
|
|
2188
|
+
read boundary, with one open handle (the current segment's), so an
|
|
2189
|
+
in-order pass opens each segment once."""
|
|
2190
|
+
|
|
2191
|
+
__slots__ = ("_segment", "_fh", "__weakref__")
|
|
2192
|
+
|
|
2193
|
+
def __init__(self) -> None:
|
|
2194
|
+
self._segment = None
|
|
2195
|
+
self._fh = None
|
|
2196
|
+
|
|
2197
|
+
def read(self, segment: str, start: int, length: int) -> bytes:
|
|
2198
|
+
if segment != self._segment:
|
|
2199
|
+
self.close()
|
|
2200
|
+
try:
|
|
2201
|
+
self._fh = _journal._open_segment_for_read(
|
|
2202
|
+
_cctally_core.JOURNAL_DIR / segment)
|
|
2203
|
+
except OSError as exc:
|
|
2204
|
+
raise _journal_changed_error(
|
|
2205
|
+
f"{segment}: {type(exc).__name__}: {exc}") from exc
|
|
2206
|
+
self._segment = segment
|
|
2207
|
+
try:
|
|
2208
|
+
return _journal._read_framed_line(self._fh, segment, start, length)
|
|
2209
|
+
except (_journal.QuotaLineSourceError, OSError) as exc:
|
|
2210
|
+
raise _journal_changed_error(str(exc)) from exc
|
|
2211
|
+
|
|
2212
|
+
def close(self) -> None:
|
|
2213
|
+
fh, self._fh = self._fh, None
|
|
2214
|
+
self._segment = None
|
|
2215
|
+
if fh is not None:
|
|
2216
|
+
fh.close()
|
|
1701
2217
|
|
|
1702
2218
|
|
|
1703
2219
|
def _read_only_journal_high_water() -> "tuple[str, int] | None":
|
|
@@ -1937,16 +2453,34 @@ def _fresh_manifest_baseline(records, completed_batches, family, *,
|
|
|
1937
2453
|
idempotence stated on `_decision_batch_completed`: the op's actions
|
|
1938
2454
|
re-enter the delta and apply idempotently over anything the torn batch
|
|
1939
2455
|
already wrote."""
|
|
1940
|
-
|
|
1941
|
-
|
|
2456
|
+
kept = [] if getattr(records, "subset", None) is None else None
|
|
2457
|
+
positions = array("Q")
|
|
2458
|
+
for index, record in enumerate(records):
|
|
1942
2459
|
if index != exclude_index and not (
|
|
1943
2460
|
record.get("t") == "op"
|
|
1944
2461
|
and (record.get("payload") or {}).get("kind")
|
|
1945
2462
|
in _REVIEWED_WEEKLY_KINDS
|
|
1946
2463
|
and not _decision_batch_completed(
|
|
1947
2464
|
records, index, completed_batches, family)
|
|
1948
|
-
)
|
|
1949
|
-
|
|
2465
|
+
):
|
|
2466
|
+
if kept is None:
|
|
2467
|
+
positions.append(index)
|
|
2468
|
+
else:
|
|
2469
|
+
kept.append(record)
|
|
2470
|
+
# #1004 D4: a position-subset view of a coordinate-backed sequence.
|
|
2471
|
+
return kept if kept is not None else records.subset(positions)
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def correction_only_suffix(records, start) -> bool:
|
|
2475
|
+
"""Whether every non-review-neutral record from ``start`` on is a
|
|
2476
|
+
correction or a correction-batch marker (#1004 D4: a streaming predicate
|
|
2477
|
+
that keeps no record)."""
|
|
2478
|
+
for record in records[start:]:
|
|
2479
|
+
if _is_review_neutral(record):
|
|
2480
|
+
continue
|
|
2481
|
+
if record.get("t") not in {"correction", "correction_batch"}:
|
|
2482
|
+
return False
|
|
2483
|
+
return True
|
|
1950
2484
|
|
|
1951
2485
|
|
|
1952
2486
|
def _preview_from_snapshot(
|
|
@@ -1987,7 +2521,10 @@ def _preview_from_snapshot(
|
|
|
1987
2521
|
raise _lib_rederive.RederiveConflict(
|
|
1988
2522
|
"reviewed weekly decision journalPrefixHash drifted"
|
|
1989
2523
|
)
|
|
1990
|
-
|
|
2524
|
+
with_extra = getattr(records, "with_extra", None)
|
|
2525
|
+
planning_records = (
|
|
2526
|
+
[*records, reviewed_op] if with_extra is None
|
|
2527
|
+
else with_extra(reviewed_op))
|
|
1991
2528
|
baseline_records = None # #1015 R8: resolved after the selection
|
|
1992
2529
|
else:
|
|
1993
2530
|
decision_indexes = [
|
|
@@ -2008,18 +2545,10 @@ def _preview_from_snapshot(
|
|
|
2008
2545
|
baseline_records = _fresh_manifest_baseline(
|
|
2009
2546
|
records, selection.completed_batches, family)
|
|
2010
2547
|
if retained_decision is not None:
|
|
2011
|
-
suffix = [
|
|
2012
|
-
record for record in records[retained_decision_index + 1:]
|
|
2013
|
-
if not _is_review_neutral(record)
|
|
2014
|
-
]
|
|
2015
2548
|
completed_decision_batch = _decision_batch_completed(
|
|
2016
2549
|
records, retained_decision_index, selection.completed_batches,
|
|
2017
2550
|
family)
|
|
2018
|
-
correction_only_suffix
|
|
2019
|
-
record.get("t") in {"correction", "correction_batch"}
|
|
2020
|
-
for record in suffix
|
|
2021
|
-
)
|
|
2022
|
-
if correction_only_suffix:
|
|
2551
|
+
if correction_only_suffix(records, retained_decision_index + 1):
|
|
2023
2552
|
decision_op = retained_decision
|
|
2024
2553
|
decision_from_journal = not completed_decision_batch
|
|
2025
2554
|
decision_batch_completed = completed_decision_batch
|
|
@@ -2134,9 +2663,11 @@ def _preview_from_snapshot(
|
|
|
2134
2663
|
preview = RederivePreview(
|
|
2135
2664
|
journal_conflicts=journal_conflicts,
|
|
2136
2665
|
plan=plan,
|
|
2137
|
-
|
|
2666
|
+
# #1004 D3/D4: a coordinate-backed sequence and its ends view are
|
|
2667
|
+
# stored as they are; `tuple(...)` would decode every record back.
|
|
2668
|
+
records=_preview_sequence(records),
|
|
2138
2669
|
journal_high_water=high_water,
|
|
2139
|
-
record_ends=
|
|
2670
|
+
record_ends=_preview_sequence(record_ends),
|
|
2140
2671
|
generated_at=generated_at,
|
|
2141
2672
|
batch_id=batch_id,
|
|
2142
2673
|
incomplete_batch=incomplete,
|
|
@@ -2533,9 +3064,18 @@ def _compact_preview(preview: RederivePreview) -> RederivePreview:
|
|
|
2533
3064
|
renderers and every error read what they read before. The one release
|
|
2534
3065
|
seam: T18 disables the release by patching it to the identity.
|
|
2535
3066
|
"""
|
|
3067
|
+
close = getattr(preview.records, "close", None)
|
|
3068
|
+
if close is not None:
|
|
3069
|
+
close()
|
|
2536
3070
|
return dataclasses.replace(preview, records=(), record_ends=())
|
|
2537
3071
|
|
|
2538
3072
|
|
|
3073
|
+
def _preview_sequence(sequence):
|
|
3074
|
+
"""A preview's `records`/`record_ends`: today's tuple for a list, the
|
|
3075
|
+
coordinate-backed sequence or view itself otherwise (#1004 D4)."""
|
|
3076
|
+
return tuple(sequence) if isinstance(sequence, list) else sequence
|
|
3077
|
+
|
|
3078
|
+
|
|
2539
3079
|
def _release_preview_records(*previews):
|
|
2540
3080
|
"""#997 D18: compact copies of `previews`, in order, for the apply to
|
|
2541
3081
|
rebind immediately before a rebuild, so no full preview stays reachable
|