cctally 1.92.2 → 1.93.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/_cctally_cache.py +354 -0
  3. package/bin/_cctally_core.py +180 -3
  4. package/bin/_cctally_dashboard.py +71 -1
  5. package/bin/_cctally_dashboard_envelope.py +28 -2
  6. package/bin/_cctally_dashboard_share.py +75 -19
  7. package/bin/_cctally_dashboard_sources.py +12 -0
  8. package/bin/_cctally_db.py +89 -1
  9. package/bin/_cctally_doctor.py +31 -0
  10. package/bin/_cctally_forecast.py +4 -2
  11. package/bin/_cctally_journal.py +3482 -258
  12. package/bin/_cctally_journal_repair.py +123 -32
  13. package/bin/_cctally_milestone_history.py +4 -1
  14. package/bin/_cctally_project.py +8 -6
  15. package/bin/_cctally_quota.py +420 -20
  16. package/bin/_cctally_rederive.py +57 -23
  17. package/bin/_cctally_reporting.py +8 -6
  18. package/bin/_cctally_share.py +74 -37
  19. package/bin/_cctally_source_analytics.py +6 -8
  20. package/bin/_cctally_store.py +13 -2
  21. package/bin/_cctally_tui.py +53 -0
  22. package/bin/_lib_cache_coverage.py +547 -0
  23. package/bin/_lib_doctor.py +54 -2
  24. package/bin/_lib_journal.py +235 -95
  25. package/bin/_lib_journal_router.py +21 -0
  26. package/bin/_lib_segment_summary.py +374 -0
  27. package/bin/_lib_selector_state.py +959 -0
  28. package/bin/_lib_share.py +1073 -165
  29. package/bin/_lib_share_templates.py +35 -11
  30. package/bin/_lib_stats_wal.py +327 -0
  31. package/bin/_lib_view_models.py +2 -1
  32. package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
  33. package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +5 -1
  36. package/dashboard/static/assets/index-DnWdv8um.css +0 -1
@@ -0,0 +1,374 @@
1
+ """Journal segment summaries and the elision predicate — the pure kernel.
2
+
3
+ #496 S5b Stage 4, spec section 5. A rebuild reads every journal byte on every
4
+ pass. Six of the maintainer's seven bootstrap segments hold nothing but Codex
5
+ quota observations — roughly 1.64 GB of raw bytes that contribute no distinct
6
+ effective event, because Stage 3's coverage certificate already proves the cache
7
+ holds them. This module decides when such a segment may be skipped, and records
8
+ exactly what the skipping pass must contribute in its place.
9
+
10
+ **What an elided segment still contributes, and why each piece exists.**
11
+
12
+ Its **exact count of `decoded` entries**. The rebuild appends one element to
13
+ `decoded` for every valid decoded record — the record itself when retained, an
14
+ explicit `None` placeholder otherwise. `resolve_effective_events` numbers
15
+ candidates with `enumerate(records)`, three of the seven structural violation
16
+ kinds put that number inside `ProtocolViolation.evidence`, and the fingerprint
17
+ hashes it. That fingerprint is durable: it lands in
18
+ `journal_protocol_violations` and is referenced BY NAME from a
19
+ `journal_protocol_resolution` op, which `bin/_cctally_journal_repair.py` mints
20
+ from the UNFILTERED record list. Contributing the wrong count renumbers every
21
+ later candidate and makes a previously acknowledged violation unresolvable.
22
+ Malformed lines produce no `decoded` entry and are counted separately, so they
23
+ must never be folded into this number.
24
+
25
+ Its **partial last-seen fold**. `LastSeenAccumulator` is a per-key maximum over
26
+ timestamps, and maximum is associative and commutative, so a segment's partial
27
+ map merges into the running one in any order. The deferred Claude legacy bucket
28
+ still resolves against the cutover account at the end of the pass, and a
29
+ quota-only segment cannot contain the cutover op, which is an `op` and therefore
30
+ a retained record type.
31
+
32
+ Its **line, byte, decode and malformed counters**, so the rebuild record reports
33
+ the same traversal totals a non-eliding pass reports.
34
+
35
+ **What it cannot contribute is the prefix hash.** `PrefixHashAccumulator`
36
+ absorbs completed segments into one sequential `sha256` and `hashlib` can
37
+ neither export nor restore midstate, so a digest over a prefix containing an
38
+ elided segment is not computable from the bytes a skipping pass read. Elision is
39
+ therefore optimistic: a pass that meets a `journal_protocol_resolution` op
40
+ abandons the accumulator and re-reads the prefix from disk for the exact digest.
41
+
42
+ **Why the extent check is a three-way equality.** `_repair_torn_tail` truncates
43
+ a partial trailing line before appending, so a segment's size can decrease or
44
+ land back on exactly its previous value while its bytes and its decoded-entry
45
+ count both change — "an append strictly increases `st_size`" is false. Storing
46
+ only the verified newline boundary collapses the comparison: for a segment of
47
+ raw size 120 whose last newline sits at 100, every operand becomes 100 and a
48
+ permanently torn segment passes. The summary therefore carries BOTH the raw size
49
+ it summarized and the complete-line offset it covered, the pinned vector carries
50
+ the raw `st_size`, and elision requires all three to be equal.
51
+
52
+ This module performs no I/O beyond the sidecar itself and imports nothing from
53
+ `_cctally_journal`, so it is unit-testable without a journal on disk — the same
54
+ rule `bin/_lib_journal_router.py` and `bin/_lib_cache_coverage.py` follow. The
55
+ two modules it does import, `_lib_journal_router` and `_cctally_core`, are
56
+ themselves leaf modules; they are imported for the two constants
57
+ `summary_version` is derived from, not for behaviour.
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import hashlib
62
+ import json
63
+ import os
64
+ import pathlib
65
+ import tempfile
66
+ from dataclasses import dataclass, field, replace
67
+
68
+ import _cctally_core
69
+ import _lib_journal_router
70
+
71
+
72
+ #: The sidecar basename, beside the journal segments it describes. Journal-side
73
+ #: rather than in `stats.db`, because a rebuild frequently runs precisely when
74
+ #: `stats.db` is absent or unreadable. It mirrors `.quota-observation-keys` in
75
+ #: placement but is a separate artifact: that sidecar stores quota natural-key
76
+ #: digests only, with no segment identity, offsets, ordering contribution or
77
+ #: selector semantics, so it cannot serve as coverage.
78
+ SIDECAR_NAME = ".segment-summaries"
79
+
80
+ #: Bumped by hand when a summary's FIELD SET or the meaning of one of its
81
+ #: numbers changes. It is only one of the three inputs to `summary_version`; the
82
+ #: other two are derived, for the reason that function's docstring gives.
83
+ SUMMARY_SHAPE_VERSION = 2
84
+
85
+ #: The refusal reasons `summary_is_elidable` returns. They are stable, because
86
+ #: the rebuild record reports them (spec section 6.3, "recorded, not silent").
87
+ REASON_OK = "ok"
88
+ REASON_RESOLUTION = "resolutionSeen"
89
+ REASON_LAST = "lastSegment"
90
+ REASON_IDENTITY = "identityMismatch"
91
+ REASON_EXTENT = "extentMismatch"
92
+ REASON_RETAINED = "retainedRecords"
93
+ REASON_UNCOVERED = "notCovered"
94
+ REASON_NO_DECODED_COUNT = "decodedCountAbsent"
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class SegmentSummary:
99
+ """Everything a pass needs to skip one segment without reading it.
100
+
101
+ Every field has a default so a test can construct the shape it is asserting
102
+ about without restating the rest, and so a later field addition does not
103
+ break a construction site that predates it. The sidecar reader supplies
104
+ every field explicitly, so a defaulted value never reaches a real decision.
105
+ """
106
+
107
+ segment_name: str = ""
108
+ st_dev: int = 0
109
+ st_ino: int = 0
110
+ #: The raw `st_size` observed when this summary was written.
111
+ summarized_size: int = 0
112
+ #: The offset after the last COMPLETE line within `summarized_size`.
113
+ complete_line_covered_offset: int = 0
114
+ lines: int = 0
115
+ bytes: int = 0
116
+ decodes: int = 0
117
+ malformed: int = 0
118
+ #: True when the segment holds ZERO records of a retained type.
119
+ quota_only: bool = True
120
+ #: Elements the segment contributed to the rebuild's `decoded` list —
121
+ #: retained records plus placeholders, and never a malformed line. `None`
122
+ #: means the summary predates the field and cannot be elided against.
123
+ decoded_entry_count: "int | None" = 0
124
+ #: The segment's partial `LastSeenAccumulator` state.
125
+ last_seen_stamped: dict = field(default_factory=dict)
126
+ last_seen_legacy_claude_at: "str | None" = None
127
+ last_seen_legacy_codex_at: "str | None" = None
128
+
129
+
130
+ def summary_is_elidable(
131
+ summary, *, pinned_raw_extent, is_last, certificate_covers,
132
+ resolution_seen, stat_identity=None,
133
+ ) -> "tuple[bool, str]":
134
+ """``(verdict, reason)`` for one segment against spec section 5.5.
135
+
136
+ ``stat_identity`` is the ``(st_dev, st_ino)`` of the file on disk. It is
137
+ optional because the caller that has already stat'd the segment passes it
138
+ and a unit test asserting one of the other conditions does not; when it is
139
+ absent the identity condition is the caller's to have checked.
140
+
141
+ The order is deliberate, and two parts of it change behaviour rather than
142
+ only wording. `resolution_seen` comes first because it is a property of the
143
+ PASS rather than of the segment, so reporting a segment-shaped reason for it
144
+ would send a reader looking at the wrong file. And the EXTENT check comes
145
+ before the COVERAGE check, because a torn-tail repair moves both: it changes
146
+ the segment's complete-line offset, which is part of the pinned vector, so
147
+ the certificate's identity root moves with it and coverage would refuse the
148
+ segment too. Reporting `notCovered` there would name the certificate for a
149
+ segment the summary's own extent already disqualifies, and a test asserting
150
+ the refusal REASON is what distinguishes the two mechanisms. Reordering
151
+ these two is therefore a behaviour change, not a cleanup.
152
+ """
153
+ if resolution_seen:
154
+ return False, REASON_RESOLUTION
155
+ if is_last:
156
+ return False, REASON_LAST
157
+ if stat_identity is not None:
158
+ if (int(summary.st_dev), int(summary.st_ino)) != (
159
+ int(stat_identity[0]), int(stat_identity[1])):
160
+ return False, REASON_IDENTITY
161
+ # ONE equality over three operands, not two comparisons: see the module
162
+ # docstring for why the raw size and the newline boundary are independent.
163
+ if not (int(summary.complete_line_covered_offset)
164
+ == int(summary.summarized_size)
165
+ == int(pinned_raw_extent)):
166
+ return False, REASON_EXTENT
167
+ if not summary.quota_only:
168
+ return False, REASON_RETAINED
169
+ if not certificate_covers:
170
+ return False, REASON_UNCOVERED
171
+ if summary.decoded_entry_count is None:
172
+ return False, REASON_NO_DECODED_COUNT
173
+ return True, REASON_OK
174
+
175
+
176
+ _FIELDS = (
177
+ "segment_name", "st_dev", "st_ino", "summarized_size",
178
+ "complete_line_covered_offset", "lines", "bytes", "decodes", "malformed",
179
+ "quota_only", "decoded_entry_count", "last_seen_stamped",
180
+ "last_seen_legacy_claude_at", "last_seen_legacy_codex_at",
181
+ )
182
+
183
+
184
+ def summary_version() -> str:
185
+ """The version token the sidecar is written with and validated against.
186
+
187
+ DERIVED rather than hand-written, because the two things a stale summary can
188
+ silently get wrong are both defined elsewhere and neither would remind
189
+ anybody to bump a literal here.
190
+
191
+ `quota_only` is computed from `RETAINED_RECORD_TYPES` at write time. If a
192
+ record type later joins that set, a summary written before the change still
193
+ says `quota_only=True` for a segment that now holds a retained record, the
194
+ segment is elided, and its retained records are replaced by placeholders —
195
+ a wrong selection with no fallback and nothing on stderr. Hashing the sorted
196
+ set makes that change discard every existing sidecar instead.
197
+
198
+ `STATS_INDEX_EPOCH` is the repository's existing marker for "what a rebuild
199
+ derives from the journal changed", and every counter in a summary is part of
200
+ that derivation. The coverage certificate's `interpretationVersion` does not
201
+ cover this: it tracks cache materialization, not the stats selector.
202
+
203
+ `SUMMARY_SHAPE_VERSION` stays hand-written for the one change neither
204
+ derived input can see — a field added to, removed from or redefined within
205
+ the summary itself.
206
+
207
+ Discarding costs exactly one full read, and the sidecar is re-derived by the
208
+ pass that discarded it.
209
+ """
210
+ material = json.dumps(
211
+ {
212
+ "shape": SUMMARY_SHAPE_VERSION,
213
+ "fields": list(_FIELDS),
214
+ "retained": sorted(_lib_journal_router.RETAINED_RECORD_TYPES),
215
+ "epoch": int(_cctally_core.STATS_INDEX_EPOCH),
216
+ },
217
+ separators=(",", ":"), sort_keys=True,
218
+ )
219
+ return "v" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
220
+
221
+
222
+ def to_wire(summary) -> dict:
223
+ """One summary as a JSON-safe dict, in the canonical field order."""
224
+ return {
225
+ "segment_name": str(summary.segment_name),
226
+ "st_dev": int(summary.st_dev),
227
+ "st_ino": int(summary.st_ino),
228
+ "summarized_size": int(summary.summarized_size),
229
+ "complete_line_covered_offset": int(
230
+ summary.complete_line_covered_offset),
231
+ "lines": int(summary.lines),
232
+ "bytes": int(summary.bytes),
233
+ "decodes": int(summary.decodes),
234
+ "malformed": int(summary.malformed),
235
+ "quota_only": bool(summary.quota_only),
236
+ "decoded_entry_count": (
237
+ None if summary.decoded_entry_count is None
238
+ else int(summary.decoded_entry_count)),
239
+ "last_seen_stamped": {
240
+ str(key): str(value)
241
+ for key, value in dict(summary.last_seen_stamped).items()
242
+ },
243
+ "last_seen_legacy_claude_at": (
244
+ None if summary.last_seen_legacy_claude_at is None
245
+ else str(summary.last_seen_legacy_claude_at)),
246
+ "last_seen_legacy_codex_at": (
247
+ None if summary.last_seen_legacy_codex_at is None
248
+ else str(summary.last_seen_legacy_codex_at)),
249
+ }
250
+
251
+
252
+ def from_wire(item) -> SegmentSummary:
253
+ """One summary from its wire form. Raises on any shape it did not write."""
254
+ if not isinstance(item, dict):
255
+ raise ValueError("segment summary is not an object")
256
+ missing = [name for name in _FIELDS if name not in item]
257
+ if missing:
258
+ raise ValueError(f"segment summary is missing {missing}")
259
+ stamped = item["last_seen_stamped"]
260
+ if not isinstance(stamped, dict):
261
+ raise ValueError("last_seen_stamped is not an object")
262
+ count = item["decoded_entry_count"]
263
+ return SegmentSummary(
264
+ segment_name=str(item["segment_name"]),
265
+ st_dev=int(item["st_dev"]),
266
+ st_ino=int(item["st_ino"]),
267
+ summarized_size=int(item["summarized_size"]),
268
+ complete_line_covered_offset=int(item["complete_line_covered_offset"]),
269
+ lines=int(item["lines"]),
270
+ bytes=int(item["bytes"]),
271
+ decodes=int(item["decodes"]),
272
+ malformed=int(item["malformed"]),
273
+ quota_only=bool(item["quota_only"]),
274
+ decoded_entry_count=None if count is None else int(count),
275
+ last_seen_stamped={
276
+ str(key): str(value) for key, value in stamped.items()},
277
+ last_seen_legacy_claude_at=(
278
+ None if item["last_seen_legacy_claude_at"] is None
279
+ else str(item["last_seen_legacy_claude_at"])),
280
+ last_seen_legacy_codex_at=(
281
+ None if item["last_seen_legacy_codex_at"] is None
282
+ else str(item["last_seen_legacy_codex_at"])),
283
+ )
284
+
285
+
286
+ def _checksum(segments) -> str:
287
+ payload = json.dumps(segments, separators=(",", ":"), sort_keys=True,
288
+ ensure_ascii=False)
289
+ return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
290
+
291
+
292
+ def write_sidecar(path, summaries) -> None:
293
+ """Replace the sidecar at ``path`` with ``summaries``, atomically.
294
+
295
+ Atomic because a torn write is indistinguishable from a stale one to the
296
+ reader, and the reader's only safe answer to either is to discard the whole
297
+ file — which would silently retire elision until the next successful write.
298
+ Failures are swallowed: the sidecar is a pure cache and a pass that could
299
+ not write it is still a correct pass.
300
+ """
301
+ path = pathlib.Path(path)
302
+ segments = [to_wire(item) for item in summaries]
303
+ body = json.dumps(
304
+ {
305
+ "version": summary_version(),
306
+ "checksum": _checksum(segments),
307
+ "segments": segments,
308
+ },
309
+ separators=(",", ": "), sort_keys=True, ensure_ascii=False,
310
+ )
311
+ handle = None
312
+ tmp_name = None
313
+ try:
314
+ path.parent.mkdir(parents=True, exist_ok=True)
315
+ handle, tmp_name = tempfile.mkstemp(
316
+ prefix=path.name + ".", dir=str(path.parent))
317
+ with os.fdopen(handle, "w", encoding="utf-8") as out:
318
+ handle = None
319
+ out.write(body)
320
+ out.flush()
321
+ os.fsync(out.fileno())
322
+ os.chmod(tmp_name, 0o600)
323
+ os.replace(tmp_name, str(path))
324
+ tmp_name = None
325
+ except OSError:
326
+ return
327
+ finally:
328
+ if handle is not None:
329
+ os.close(handle)
330
+ if tmp_name is not None:
331
+ try:
332
+ os.unlink(tmp_name)
333
+ except OSError:
334
+ pass
335
+
336
+
337
+ def read_sidecar(path):
338
+ """``{segment_name: SegmentSummary}``, or None for anything unusable.
339
+
340
+ Invalid, truncated, version-mismatched or checksum-mismatched content is
341
+ DISCARDED rather than repaired. The sidecar is re-derivable from the journal
342
+ by the next non-eliding pass, so discarding costs one full read and repairing
343
+ would risk carrying a wrong extent or a wrong decoded-entry count forward —
344
+ the two values every later elision decision rests on.
345
+ """
346
+ try:
347
+ raw = pathlib.Path(path).read_text(encoding="utf-8")
348
+ except (OSError, UnicodeError):
349
+ return None
350
+ try:
351
+ payload = json.loads(raw)
352
+ except ValueError:
353
+ return None
354
+ if not isinstance(payload, dict):
355
+ return None
356
+ if payload.get("version") != summary_version():
357
+ return None
358
+ segments = payload.get("segments")
359
+ if not isinstance(segments, list):
360
+ return None
361
+ try:
362
+ if payload.get("checksum") != _checksum(segments):
363
+ return None
364
+ return {
365
+ item.segment_name: item
366
+ for item in (from_wire(entry) for entry in segments)
367
+ }
368
+ except (AttributeError, TypeError, ValueError):
369
+ return None
370
+
371
+
372
+ def with_identity(summary, *, st_dev, st_ino) -> SegmentSummary:
373
+ """``summary`` restamped with the inode identity of the file just read."""
374
+ return replace(summary, st_dev=int(st_dev), st_ino=int(st_ino))