cctally 1.108.0 → 1.110.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 (85) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_account.py +4 -0
  4. package/bin/_cctally_alerts.py +6 -2
  5. package/bin/_cctally_cache.py +1652 -166
  6. package/bin/_cctally_config.py +3 -2
  7. package/bin/_cctally_core.py +594 -33
  8. package/bin/_cctally_dashboard.py +1585 -201
  9. package/bin/_cctally_dashboard_conversation.py +494 -48
  10. package/bin/_cctally_dashboard_envelope.py +167 -11
  11. package/bin/_cctally_dashboard_share.py +154 -36
  12. package/bin/_cctally_dashboard_sources.py +2615 -422
  13. package/bin/_cctally_db.py +678 -5
  14. package/bin/_cctally_diagnosis_sources.py +238 -26
  15. package/bin/_cctally_doctor.py +99 -18
  16. package/bin/_cctally_five_hour.py +1295 -40
  17. package/bin/_cctally_forecast.py +124 -28
  18. package/bin/_cctally_journal.py +408 -38
  19. package/bin/_cctally_milestone_history.py +150 -14
  20. package/bin/_cctally_parser.py +5 -0
  21. package/bin/_cctally_percent_breakdown.py +6 -1
  22. package/bin/_cctally_project.py +48 -76
  23. package/bin/_cctally_quota.py +39 -7
  24. package/bin/_cctally_quota_model.py +17 -2
  25. package/bin/_cctally_record.py +1647 -443
  26. package/bin/_cctally_rederive.py +899 -30
  27. package/bin/_cctally_refresh.py +2 -1
  28. package/bin/_cctally_share.py +24 -5
  29. package/bin/_cctally_source_analytics.py +1035 -78
  30. package/bin/_cctally_statusline.py +83 -19
  31. package/bin/_cctally_store.py +61 -4
  32. package/bin/_cctally_transcript.py +27 -2
  33. package/bin/_cctally_tui.py +264 -76
  34. package/bin/_cctally_weekrefs.py +28 -5
  35. package/bin/_lib_blocks.py +127 -20
  36. package/bin/_lib_cache_report.py +20 -5
  37. package/bin/_lib_codex_conversation_query.py +297 -64
  38. package/bin/_lib_codex_hooks.py +102 -0
  39. package/bin/_lib_codex_metadata.py +207 -0
  40. package/bin/_lib_conversation.py +119 -0
  41. package/bin/_lib_conversation_dispatch.py +31 -1
  42. package/bin/_lib_conversation_query.py +315 -51
  43. package/bin/_lib_conversation_retention.py +413 -25
  44. package/bin/_lib_dashboard_sources.py +267 -7
  45. package/bin/_lib_diagnosis.py +10 -0
  46. package/bin/_lib_diff_kernel.py +12 -1
  47. package/bin/_lib_doctor.py +230 -8
  48. package/bin/_lib_ingest_frontier.py +1366 -49
  49. package/bin/_lib_journal.py +19 -2
  50. package/bin/_lib_merge_gate.py +307 -0
  51. package/bin/_lib_pricing.py +588 -29
  52. package/bin/_lib_record.py +130 -12
  53. package/bin/_lib_rederive.py +903 -2
  54. package/bin/_lib_render.py +9 -3
  55. package/bin/_lib_retained_size.py +27 -2
  56. package/bin/_lib_segment_summary.py +66 -8
  57. package/bin/_lib_share_templates.py +28 -18
  58. package/bin/_lib_snapshot_cache.py +115 -1
  59. package/bin/_lib_source_retry.py +320 -0
  60. package/bin/_lib_statusline_candidates.py +400 -36
  61. package/bin/_lib_tick_stats.py +119 -0
  62. package/bin/_lib_view_models.py +256 -76
  63. package/bin/cctally +69 -0
  64. package/dashboard/static/assets/ConversationsView-BB9vUiL6.js +72 -0
  65. package/dashboard/static/assets/{DoctorModal-CWn3U4Wl.js → DoctorModal-DFY2iPDf.js} +1 -1
  66. package/dashboard/static/assets/ModalRoot-DvRKHbQE.js +1 -0
  67. package/dashboard/static/assets/ProjectsDrillPanel-DAv39uSo.js +1 -0
  68. package/dashboard/static/assets/SourceDetailModal-DSAWo2Fp.js +1 -0
  69. package/dashboard/static/assets/{UpdateModal-D3m8GG6V.js → UpdateModal-R_9dOVnh.js} +3 -3
  70. package/dashboard/static/assets/dashboardStream.shared-worker-DbG6Ef0T.js +1 -0
  71. package/dashboard/static/assets/index-COhxe3vU.css +1 -0
  72. package/dashboard/static/assets/index-DdU62sov.js +13 -0
  73. package/dashboard/static/assets/outlineNavigation-BOCLcRa6.js +9 -0
  74. package/dashboard/static/assets/useKeymap-CW7Up0Ay.js +1 -0
  75. package/dashboard/static/dashboard.html +3 -3
  76. package/package.json +4 -1
  77. package/dashboard/static/assets/ConversationsView-BOSaBRtu.js +0 -72
  78. package/dashboard/static/assets/ModalRoot-SR070V6c.js +0 -1
  79. package/dashboard/static/assets/ProjectsDrillPanel-QLI9i5mZ.js +0 -1
  80. package/dashboard/static/assets/SourceDetailModal-pv0wlFxl.js +0 -1
  81. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +0 -1
  82. package/dashboard/static/assets/index-BHu4mxd8.css +0 -1
  83. package/dashboard/static/assets/index-CIWsbux3.js +0 -13
  84. package/dashboard/static/assets/outlineNavigation-CJvKqmLV.js +0 -9
  85. package/dashboard/static/assets/useKeymap-ffqsS5G0.js +0 -1
@@ -2890,9 +2890,15 @@ def _render_project_table(
2890
2890
  return ("\u2014", _gray) # em-dash for unknown
2891
2891
  base = f"{ap:.1f}%"
2892
2892
  if weeks_in_range > 1:
2893
- # Count weeks the user asked about; surface via `(Nwk)` suffix
2894
- # (spec §3). Keep the suffix short so column width stays sane.
2895
- base = f"{base} ({weeks_in_range}wk)"
2893
+ # The count of BILLING CYCLES the window covers, surfaced as an
2894
+ # `(Ncy)` suffix. It was `(Nwk)`, and a credited week contributes
2895
+ # two cycles, so the abbreviation named the wrong unit — the same
2896
+ # inconsistency #750 S4 D2 removes from every other surface. `cy`
2897
+ # rather than the spelled-out word because the suffix is exactly as
2898
+ # wide as before: the cell already truncates to `80.0% (…` at
2899
+ # ordinary terminal widths, and a longer token would truncate more
2900
+ # often. The summary line below the table spells `cycles` out.
2901
+ base = f"{base} ({weeks_in_range}cy)"
2896
2902
  return (base, _used_pct_color(ap))
2897
2903
 
2898
2904
  def _cost_per_pct_cell(cpp: float | None) -> tuple[str, Any]:
@@ -110,6 +110,8 @@ def retained_size_bytes(
110
110
  *,
111
111
  stop_after: int | None = None,
112
112
  cancelled: Callable[[], bool] | None = None,
113
+ borrowed: Callable[[object], bool] | None = None,
114
+ opaque: Callable[[object], bool] | None = None,
113
115
  _object_id: Callable[[object], int] = id,
114
116
  ) -> int:
115
117
  """Return owned reachable bytes, or ``stop_after + 1`` once over budget.
@@ -120,6 +122,25 @@ def retained_size_bytes(
120
122
  charged for their object shell and, when present, ``__dict__``/``__slots__``.
121
123
  Modules, functions and classes therefore do not cause an unbounded walk of
122
124
  interpreter-global state.
125
+
126
+ ``borrowed`` is the ownership-graph seam (#769 S6 T2a, #716 Task B). It
127
+ answers, for one referent, "does some OTHER declared owner carry this
128
+ payload's charge?". A borrowed referent is charged nothing AND is not
129
+ descended into, so a cache that merely repeats a payload another cache
130
+ retains pays for its own containers and references and for nothing else.
131
+ That is what makes a sum of per-owner charges comparable with one walk of
132
+ the whole retained graph instead of double counting every repeat. The
133
+ predicate is never applied to ``value`` itself: a caller asking for the
134
+ size of an object it declares borrowed is asking a contradictory question,
135
+ and answering zero would silently drop the owner's own charge.
136
+
137
+ ``opaque`` is the same seam for a CONTAINER of borrowed payloads. The
138
+ container's own shell is the owner's charge — a tuple of 200,000 borrowed
139
+ rows costs eight bytes a slot and somebody has to pay for them — but
140
+ descending into it to ask ``borrowed`` about each slot would make the
141
+ charge proportional to a population the owner does not own. An opaque
142
+ referent is therefore charged its ``sys.getsizeof`` and not descended into.
143
+ Like ``borrowed`` it is never applied to ``value`` itself.
123
144
  """
124
145
  if stop_after is not None and stop_after < 0:
125
146
  raise ValueError("stop_after must be non-negative or None")
@@ -146,12 +167,16 @@ def retained_size_bytes(
146
167
  )
147
168
  )
148
169
 
149
- def walk(obj) -> bool:
170
+ def walk(obj, root: bool = False) -> bool:
150
171
  object_id = _object_id(obj)
151
172
  if object_id in seen:
152
173
  return False
174
+ if not root and borrowed is not None and borrowed(obj):
175
+ return False
153
176
  if add(obj, object_id):
154
177
  return True
178
+ if not root and opaque is not None and opaque(obj):
179
+ return False
155
180
  if isinstance(obj, _ATOMIC):
156
181
  return False
157
182
  if isinstance(obj, (types.ModuleType, type)) or callable(obj):
@@ -183,5 +208,5 @@ def retained_size_bytes(
183
208
  return True
184
209
  return False
185
210
 
186
- exceeded = walk(value)
211
+ exceeded = walk(value, root=True)
187
212
  return sentinel if exceeded and sentinel is not None else total
@@ -52,9 +52,12 @@ the raw `st_size`, and elision requires all three to be equal.
52
52
  This module performs no I/O beyond the sidecar itself and imports nothing from
53
53
  `_cctally_journal`, so it is unit-testable without a journal on disk — the same
54
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.
55
+ three modules it does import — `_lib_journal_router`, `_cctally_core` and
56
+ `_lib_ingest_frontier` — are themselves leaf modules; the first two supply the
57
+ two constants `summary_version` is derived from, and the third supplies
58
+ `source_identity_replaced`, the repository's single point of truth for "does a
59
+ different file now occupy this pathname". The prefix proof the identity check
60
+ may require is supplied BY THE CALLER as a callable, so the no-I/O rule holds.
58
61
  """
59
62
  from __future__ import annotations
60
63
 
@@ -66,6 +69,7 @@ import tempfile
66
69
  from dataclasses import dataclass, field, replace
67
70
 
68
71
  import _cctally_core
72
+ import _lib_ingest_frontier
69
73
  import _lib_journal_router
70
74
 
71
75
 
@@ -80,7 +84,7 @@ SIDECAR_NAME = ".segment-summaries"
80
84
  #: Bumped by hand when a summary's FIELD SET or the meaning of one of its
81
85
  #: numbers changes. It is only one of the three inputs to `summary_version`; the
82
86
  #: other two are derived, for the reason that function's docstring gives.
83
- SUMMARY_SHAPE_VERSION = 2
87
+ SUMMARY_SHAPE_VERSION = 3
84
88
 
85
89
  #: The refusal reasons `summary_is_elidable` returns. They are stable, because
86
90
  #: the rebuild record reports them (spec section 6.3, "recorded, not silent").
@@ -121,6 +125,14 @@ class SegmentSummary:
121
125
  #: retained records plus placeholders, and never a malformed line. `None`
122
126
  #: means the summary predates the field and cannot be elided against.
123
127
  decoded_entry_count: "int | None" = 0
128
+ #: Hex `sha256` of the segment's bytes in `[0, complete_line_covered_offset)`
129
+ #: — the COMPLETE prefix this summary describes. #834 S2 (#827): it exists
130
+ #: so a device-only identity change can be settled by content rather than
131
+ #: refused. Inode numbers are unique only within a device, and no other
132
+ #: field separates two files sharing device-independent identity, size and
133
+ #: pinned extent. `None` means the summary predates the field, and no proof
134
+ #: is possible against it, so such a summary refuses a device-only change.
135
+ complete_prefix_sha256: "str | None" = None
124
136
  #: The segment's partial `LastSeenAccumulator` state.
125
137
  last_seen_stamped: dict = field(default_factory=dict)
126
138
  last_seen_legacy_claude_at: "str | None" = None
@@ -129,7 +141,7 @@ class SegmentSummary:
129
141
 
130
142
  def summary_is_elidable(
131
143
  summary, *, pinned_raw_extent, is_last, certificate_covers,
132
- resolution_seen, stat_identity=None,
144
+ resolution_seen, stat_identity=None, prefix_proof=None,
133
145
  ) -> "tuple[bool, str]":
134
146
  """``(verdict, reason)`` for one segment against spec section 5.5.
135
147
 
@@ -138,6 +150,13 @@ def summary_is_elidable(
138
150
  and a unit test asserting one of the other conditions does not; when it is
139
151
  absent the identity condition is the caller's to have checked.
140
152
 
153
+ ``prefix_proof`` is a callable returning the hex ``sha256`` of the segment's
154
+ CURRENT bytes in ``[0, complete_line_covered_offset)``. It is evaluated ONLY
155
+ on a device-only identity change, because evaluating it on the common path
156
+ would read every segment elision exists to skip. ``None`` means the caller
157
+ can supply no proof, which refuses that change rather than reusing
158
+ optimistically.
159
+
141
160
  The order is deliberate, and two parts of it change behaviour rather than
142
161
  only wording. `resolution_seen` comes first because it is a property of the
143
162
  PASS rather than of the segment, so reporting a segment-shaped reason for it
@@ -149,15 +168,47 @@ def summary_is_elidable(
149
168
  segment the summary's own extent already disqualifies, and a test asserting
150
169
  the refusal REASON is what distinguishes the two mechanisms. Reordering
151
170
  these two is therefore a behaviour change, not a cleanup.
171
+
172
+ #834 S2 (#827): THE INODE DECIDES; THE DEVICE CORROBORATES. This used to
173
+ compare ``(st_dev, st_ino)`` as a pair, so an ordinary remount of a journal
174
+ on an external or network volume refused every summary for files that had
175
+ not changed at all — `st_dev` is assigned when a volume is mounted rather
176
+ than when a file is created. The verdict now goes through
177
+ `_lib_ingest_frontier.source_identity_replaced`, which both Codex walks and
178
+ `classify_recent_active_path` already use, because three copies of one
179
+ comparison is the shape in which one of them silently stops matching the
180
+ others.
181
+
182
+ A device-only change additionally requires the byte-identity proof, because
183
+ inode numbers are unique only WITHIN a device and no other field separates
184
+ two files sharing device-independent identity, size and pinned extent. The
185
+ collision is narrow — size and pinned extent must also match — but the
186
+ failure it produces is a silent omission of retained data rather than a
187
+ recomputation, so it is refused rather than argued about. A summary written
188
+ before the digest field carries ``None`` and refuses the same way.
152
189
  """
153
190
  if resolution_seen:
154
191
  return False, REASON_RESOLUTION
155
192
  if is_last:
156
193
  return False, REASON_LAST
157
194
  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])):
195
+ observed_dev, observed_ino = int(stat_identity[0]), int(stat_identity[1])
196
+ if _lib_ingest_frontier.source_identity_replaced(
197
+ int(summary.st_dev), int(summary.st_ino),
198
+ observed_dev, observed_ino):
160
199
  return False, REASON_IDENTITY
200
+ if int(summary.st_dev) != observed_dev:
201
+ digest = summary.complete_prefix_sha256
202
+ if not digest or prefix_proof is None:
203
+ return False, REASON_IDENTITY
204
+ try:
205
+ observed_digest = prefix_proof()
206
+ except OSError:
207
+ # An unreadable prefix is not a proof. Refusing costs a re-read
208
+ # of one segment; accepting would skip retained data.
209
+ return False, REASON_IDENTITY
210
+ if observed_digest != digest:
211
+ return False, REASON_IDENTITY
161
212
  # ONE equality over three operands, not two comparisons: see the module
162
213
  # docstring for why the raw size and the newline boundary are independent.
163
214
  if not (int(summary.complete_line_covered_offset)
@@ -176,7 +227,8 @@ def summary_is_elidable(
176
227
  _FIELDS = (
177
228
  "segment_name", "st_dev", "st_ino", "summarized_size",
178
229
  "complete_line_covered_offset", "lines", "bytes", "decodes", "malformed",
179
- "quota_only", "decoded_entry_count", "last_seen_stamped",
230
+ "quota_only", "decoded_entry_count", "complete_prefix_sha256",
231
+ "last_seen_stamped",
180
232
  "last_seen_legacy_claude_at", "last_seen_legacy_codex_at",
181
233
  )
182
234
 
@@ -236,6 +288,9 @@ def to_wire(summary) -> dict:
236
288
  "decoded_entry_count": (
237
289
  None if summary.decoded_entry_count is None
238
290
  else int(summary.decoded_entry_count)),
291
+ "complete_prefix_sha256": (
292
+ None if summary.complete_prefix_sha256 is None
293
+ else str(summary.complete_prefix_sha256)),
239
294
  "last_seen_stamped": {
240
295
  str(key): str(value)
241
296
  for key, value in dict(summary.last_seen_stamped).items()
@@ -272,6 +327,9 @@ def from_wire(item) -> SegmentSummary:
272
327
  malformed=int(item["malformed"]),
273
328
  quota_only=bool(item["quota_only"]),
274
329
  decoded_entry_count=None if count is None else int(count),
330
+ complete_prefix_sha256=(
331
+ None if item["complete_prefix_sha256"] is None
332
+ else str(item["complete_prefix_sha256"])),
275
333
  last_seen_stamped={
276
334
  str(key): str(value) for key, value in stamped.items()},
277
335
  last_seen_legacy_claude_at=(
@@ -750,12 +750,16 @@ def _build_trend_recap(*, panel_data, options):
750
750
  delta = panel_data.get("delta_3_weeks") or {}
751
751
  return _LS.ShareSnapshot(
752
752
  cmd="report",
753
- title="$/% trend — last 8 weeks",
753
+ title="$/% trend — last 8 cycles",
754
754
  subtitle=None,
755
- period=_civil_period(start, end, label="Last 8 weeks",
755
+ period=_civil_period(start, end, label="Last 8 cycles",
756
756
  display_tz=_display_tz(options)),
757
757
  columns=(
758
- _LS.ColumnSpec(key="week", label="Week", align="left"),
758
+ # The KEY stays `week` — it is a frozen wire name. Only the
759
+ # human label changes: a Claude trend row is a billing CYCLE,
760
+ # and `report` split a credited week into two long before this
761
+ # epic (#750 S4 D2 / §4.5).
762
+ _LS.ColumnSpec(key="week", label="Cycle", align="left"),
759
763
  _LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
760
764
  _LS.ColumnSpec(key="pct", label="% used", align="right"),
761
765
  _LS.ColumnSpec(key="dpp", label="$/%", align="right"),
@@ -776,8 +780,8 @@ def _build_trend_recap(*, panel_data, options):
776
780
  reference_lines=(),
777
781
  ) if weeks else None,
778
782
  totals=_kpi_strip(
779
- ("Δ $/% (3wk)", _optional_signed_pct(delta.get('dpp_change_pct'))),
780
- ("Δ $ (3wk)", _optional_signed_money(delta.get('cost_change_usd'))),
783
+ ("Δ $/% (3cy)", _optional_signed_pct(delta.get('dpp_change_pct'))),
784
+ ("Δ $ (3cy)", _optional_signed_money(delta.get('cost_change_usd'))),
781
785
  ),
782
786
  notes=(),
783
787
  generated_at=_utc_now(),
@@ -1261,9 +1265,9 @@ def _build_trend_visual(*, panel_data, options):
1261
1265
  delta = panel_data.get("delta_3_weeks") or {}
1262
1266
  return _LS.ShareSnapshot(
1263
1267
  cmd="report",
1264
- title="$/% trend visual — last 8 weeks",
1268
+ title="$/% trend visual — last 8 cycles",
1265
1269
  subtitle=None,
1266
- period=_civil_period(start, end, label="Last 8 weeks",
1270
+ period=_civil_period(start, end, label="Last 8 cycles",
1267
1271
  display_tz=_display_tz(options)),
1268
1272
  columns=(),
1269
1273
  rows=(),
@@ -1274,8 +1278,8 @@ def _build_trend_visual(*, panel_data, options):
1274
1278
  reference_lines=(),
1275
1279
  ) if weeks else None,
1276
1280
  totals=_kpi_strip(
1277
- ("Δ $/% (3wk)", _optional_signed_pct(delta.get('dpp_change_pct'))),
1278
- ("Δ $ (3wk)", _optional_signed_money(delta.get('cost_change_usd'))),
1281
+ ("Δ $/% (3cy)", _optional_signed_pct(delta.get('dpp_change_pct'))),
1282
+ ("Δ $ (3cy)", _optional_signed_money(delta.get('cost_change_usd'))),
1279
1283
  ),
1280
1284
  notes=(),
1281
1285
  generated_at=_utc_now(),
@@ -1292,12 +1296,16 @@ def _build_trend_detail(*, panel_data, options):
1292
1296
  delta = panel_data.get("delta_3_weeks") or {}
1293
1297
  return _LS.ShareSnapshot(
1294
1298
  cmd="report",
1295
- title="$/% trend detail — last 8 weeks",
1299
+ title="$/% trend detail — last 8 cycles",
1296
1300
  subtitle=None,
1297
- period=_civil_period(start, end, label="Last 8 weeks",
1301
+ period=_civil_period(start, end, label="Last 8 cycles",
1298
1302
  display_tz=_display_tz(options)),
1299
1303
  columns=(
1300
- _LS.ColumnSpec(key="week", label="Week", align="left"),
1304
+ # The KEY stays `week` — it is a frozen wire name. Only the
1305
+ # human label changes: a Claude trend row is a billing CYCLE,
1306
+ # and `report` split a credited week into two long before this
1307
+ # epic (#750 S4 D2 / §4.5).
1308
+ _LS.ColumnSpec(key="week", label="Cycle", align="left"),
1301
1309
  _LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
1302
1310
  _LS.ColumnSpec(key="pct", label="% used", align="right"),
1303
1311
  _LS.ColumnSpec(key="dpp", label="$/%", align="right"),
@@ -1318,8 +1326,8 @@ def _build_trend_detail(*, panel_data, options):
1318
1326
  reference_lines=(),
1319
1327
  ) if weeks else None,
1320
1328
  totals=_kpi_strip(
1321
- ("Δ $/% (3wk)", _optional_signed_pct(delta.get('dpp_change_pct'))),
1322
- ("Δ $ (3wk)", _optional_signed_money(delta.get('cost_change_usd'))),
1329
+ ("Δ $/% (3cy)", _optional_signed_pct(delta.get('dpp_change_pct'))),
1330
+ ("Δ $ (3cy)", _optional_signed_money(delta.get('cost_change_usd'))),
1323
1331
  ),
1324
1332
  notes=(),
1325
1333
  generated_at=_utc_now(),
@@ -1929,8 +1937,10 @@ def _projects_chart_for_template(rows: list[dict], cap: int):
1929
1937
  def _projects_period(panel_data: dict, options: dict):
1930
1938
  start = panel_data["period_start"]
1931
1939
  end = panel_data["period_end"]
1940
+ # `window_weeks` stays the frozen wire name; the rendered noun does not.
1941
+ # The Projects window is a set of billing-cycle buckets (#750 S4 D2/D6).
1932
1942
  weeks = int(panel_data.get("window_weeks", 1) or 1)
1933
- label = "This week" if weeks == 1 else f"Last {weeks} weeks"
1943
+ label = "This cycle" if weeks == 1 else f"Last {weeks} cycles"
1934
1944
  return _period(start, end, label=label,
1935
1945
  display_tz=_display_tz(options))
1936
1946
 
@@ -2048,11 +2058,11 @@ _RECAP = (
2048
2058
  default_options={"top_n": 5, "show_chart": True, "show_table": True},
2049
2059
  builder=_build_weekly_recap),
2050
2060
  ShareTemplate(id="current-week-recap", panel="current-week", label="Recap",
2051
- description="Week-to-date KPIs + line + top-3 projects",
2061
+ description="Cycle-to-date KPIs + line + top-3 projects",
2052
2062
  default_options={"top_n": 3, "show_chart": True, "show_table": True},
2053
2063
  builder=_build_current_week_recap),
2054
2064
  ShareTemplate(id="trend-recap", panel="trend", label="Recap",
2055
- description="$/% trend over 8 weeks + 3-week delta",
2065
+ description="$/% trend over 8 cycles + 3-cycle delta",
2056
2066
  default_options={"top_n": 3, "show_chart": True, "show_table": True},
2057
2067
  builder=_build_trend_recap),
2058
2068
  ShareTemplate(id="daily-recap", panel="daily", label="Recap",
@@ -2093,7 +2103,7 @@ _VISUAL = (
2093
2103
  default_options={"top_n": 8, "show_chart": True, "show_table": False},
2094
2104
  builder=_build_weekly_visual),
2095
2105
  ShareTemplate(id="current-week-visual", panel="current-week", label="Visual",
2096
- description="Week-to-date line with KPI overlay",
2106
+ description="Cycle-to-date line with KPI overlay",
2097
2107
  default_options={"top_n": 8, "show_chart": True, "show_table": False},
2098
2108
  builder=_build_current_week_visual),
2099
2109
  ShareTemplate(id="trend-visual", panel="trend", label="Visual",
@@ -1079,6 +1079,14 @@ def reset_session_cache_state() -> None:
1079
1079
  # === #582 — persistent Codex accounting rows by dirty physical path =========
1080
1080
 
1081
1081
 
1082
+ #: Bumped when anything about how this population or the labels derived from
1083
+ #: it is COMPUTED changes, rather than when the data changes. A consumer
1084
+ #: comparing the signature is therefore invalidated by an algorithm change as
1085
+ #: well as by a mutation, which is what lets the project label cache retain
1086
+ #: derived labels across ticks at all.
1087
+ CODEX_POPULATION_SIGNATURE_VERSION = "codex-accounting-population-v1"
1088
+
1089
+
1082
1090
  @dataclass(frozen=True)
1083
1091
  class CodexAccountingCacheResult:
1084
1092
  """One cold or incrementally refreshed accounting population."""
@@ -1089,6 +1097,12 @@ class CodexAccountingCacheResult:
1089
1097
  cold: bool
1090
1098
  changed_old: tuple[object, ...] = ()
1091
1099
  changed_new: tuple[object, ...] = ()
1100
+ #: The exact identity of the population in `entries` (#769 S6 T2a, #716
1101
+ #: Task B). A consumer that compares this instead of scanning the rows
1102
+ #: cannot serve a stale population, and a clean comparison visits no row at
1103
+ #: all. `None` means "cannot establish an identity", which every consumer
1104
+ #: must treat as a cold read rather than as a hit.
1105
+ population_signature: tuple | None = None
1092
1106
 
1093
1107
 
1094
1108
  _CODEX_ACCOUNTING_CACHE_STATE: dict[str, object] = _ObservedSnapshotDict()
@@ -1135,6 +1149,64 @@ def _codex_accounting_mutation_seq(conn: sqlite3.Connection) -> int | None:
1135
1149
  return None
1136
1150
 
1137
1151
 
1152
+ def _codex_window_attribution_revision(conn: sqlite3.Connection) -> str | None:
1153
+ """`codex_window_attributions`' revision — a LEADING attribution signal.
1154
+
1155
+ An earlier version of this docstring said that window-scoped spend adoption
1156
+ rewrites `account_key` on existing rows and bumps this revision in the same
1157
+ transaction. Both halves were wrong, and the leg is worth keeping for a
1158
+ different reason.
1159
+
1160
+ The restamp does not need this leg. `account_key` is a member of
1161
+ `_CODEX_ACCOUNTING_SEMANTIC_COLUMNS` (`bin/_cctally_db.py`), so an UPDATE to
1162
+ it fires `trg_codex_accounting_upd`, advances `codex_accounting_mutation_seq`
1163
+ and marks the path dirty — the ledger DDL's own docstring calls this "the
1164
+ ordinary account-adoption case", and the sequence leg above already carries
1165
+ it. And `apply_codex_window_spend_adoption` (`bin/_cctally_cache.py`) does
1166
+ not call `bump_codex_window_attribution_revision` at all; the three callers
1167
+ are in `bin/_cctally_journal.py` and belong to the `codex_window_attributions`
1168
+ overlay, a different table outside the accounting population.
1169
+
1170
+ What this leg actually covers is the GAP BETWEEN the two. The revision
1171
+ advances in the same transaction as the overlay's own rows, during journal
1172
+ replay. The restamp those rows imply is performed later, by
1173
+ `reconcile_codex_window_attribution_spend` at the end of the next Codex
1174
+ sync. Between the two the accounting ledger head has not moved, so a memo
1175
+ keyed on the ledger alone would keep serving a population built before the
1176
+ operator's attribution landed. Carrying the revision turns that window into
1177
+ an unnecessary miss, which is the direction this signature is written in.
1178
+ """
1179
+ try:
1180
+ row = conn.execute(
1181
+ "SELECT value FROM cache_meta "
1182
+ "WHERE key='codex_window_attribution_revision'"
1183
+ ).fetchone()
1184
+ except sqlite3.Error:
1185
+ return None
1186
+ return None if row is None or row[0] is None else str(row[0])
1187
+
1188
+
1189
+ def _codex_main_database_path(conn: sqlite3.Connection) -> str | None:
1190
+ try:
1191
+ return next(
1192
+ str(row[2]) for row in conn.execute("PRAGMA database_list")
1193
+ if str(row[1]) == "main"
1194
+ )
1195
+ except (sqlite3.Error, StopIteration):
1196
+ return None
1197
+
1198
+
1199
+ def current_codex_population_signature() -> tuple | None:
1200
+ """The signature of the population the process cache currently holds.
1201
+
1202
+ Read by the project and label caches, which are built from that population
1203
+ but are not handed the `CodexAccountingCacheResult` that produced it.
1204
+ """
1205
+ _assert_owner()
1206
+ signature = _CODEX_ACCOUNTING_CACHE_STATE.get("population_signature")
1207
+ return signature if isinstance(signature, tuple) else None
1208
+
1209
+
1138
1210
  def codex_accounting_cache_pending(conn: sqlite3.Connection) -> bool:
1139
1211
  """Whether the process cache has not consumed the durable ledger head."""
1140
1212
  _assert_owner()
@@ -1174,8 +1246,35 @@ def build_cached_codex_accounting(
1174
1246
  ):
1175
1247
  raise ValueError("Codex accounting cache range must be aware and ordered")
1176
1248
  current_seq = _codex_accounting_mutation_seq(cache_conn)
1249
+ database_path = _codex_main_database_path(cache_conn)
1250
+ attribution_revision = _codex_window_attribution_revision(cache_conn)
1177
1251
  state = _CODEX_ACCOUNTING_CACHE_STATE
1178
1252
  prior_cached_entries = tuple(state.get("entries", ())) if state else ()
1253
+
1254
+ def signature(generation: int) -> tuple | None:
1255
+ """The exact identity of the population this call is returning.
1256
+
1257
+ `range_end` is deliberately NOT a leg. It advances on every tick, so a
1258
+ signature carrying it would move every tick and no consumer could ever
1259
+ reuse anything — which is the same trap `_cached_projects_wire` already
1260
+ records for its own key. An upper bound that actually reveals a row
1261
+ dirties that row's path, and the generation below moves with it; an
1262
+ upper bound that reveals nothing leaves the population identical, and
1263
+ saying so is correct rather than optimistic.
1264
+ """
1265
+ if current_seq is None or database_path is None:
1266
+ # No ledger is not an idle ledger. Refusing to name an identity is
1267
+ # what makes every consumer take a cold read.
1268
+ return None
1269
+ return (
1270
+ CODEX_POPULATION_SIGNATURE_VERSION,
1271
+ database_path,
1272
+ int(current_seq),
1273
+ int(generation),
1274
+ range_start,
1275
+ extra_signature,
1276
+ attribution_revision,
1277
+ )
1179
1278
  cold = (
1180
1279
  current_seq is None
1181
1280
  or not state
@@ -1240,6 +1339,8 @@ def build_cached_codex_accounting(
1240
1339
  str(account_of(entry))
1241
1340
  for entry in (*prior_cached_entries, *entries)
1242
1341
  }))
1342
+ generation = int(state.get("generation", 0)) + 1
1343
+ population_signature = signature(generation)
1243
1344
  _CODEX_ACCOUNTING_CACHE_STATE.clear()
1244
1345
  _CODEX_ACCOUNTING_CACHE_STATE.update({
1245
1346
  "entries": entries,
@@ -1247,16 +1348,24 @@ def build_cached_codex_accounting(
1247
1348
  "start": range_start,
1248
1349
  "end": range_end,
1249
1350
  "extra": extra_signature,
1351
+ "generation": generation,
1352
+ "population_signature": population_signature,
1250
1353
  })
1251
1354
  return CodexAccountingCacheResult(
1252
1355
  entries, (), accounts, True, prior_cached_entries, entries,
1356
+ population_signature=population_signature,
1253
1357
  )
1254
1358
 
1255
1359
  prior_entries = tuple(state["entries"])
1256
1360
  if not dirty:
1361
+ population_signature = signature(int(state.get("generation", 0)))
1257
1362
  state["seq"] = current_seq
1258
1363
  state["end"] = range_end
1259
- return CodexAccountingCacheResult(prior_entries, (), (), False)
1364
+ state["population_signature"] = population_signature
1365
+ return CodexAccountingCacheResult(
1366
+ prior_entries, (), (), False,
1367
+ population_signature=population_signature,
1368
+ )
1260
1369
 
1261
1370
  dirty_paths = tuple(sorted(dirty))
1262
1371
  prior_dirty = tuple(
@@ -1294,11 +1403,16 @@ def build_cached_codex_accounting(
1294
1403
  # while keeping the tuple's shallow shape. Mark this semantic replacement
1295
1404
  # explicitly; the routine ``seq``/``end`` watermark updates below do not.
1296
1405
  _mark_snapshot_accelerators_dirty()
1406
+ generation = int(state.get("generation", 0)) + 1
1407
+ population_signature = signature(generation)
1297
1408
  state["entries"] = entries
1298
1409
  state["seq"] = current_seq
1299
1410
  state["end"] = range_end
1411
+ state["generation"] = generation
1412
+ state["population_signature"] = population_signature
1300
1413
  return CodexAccountingCacheResult(
1301
1414
  entries, dirty_paths, dirty_accounts, False, changed_old, changed_new,
1415
+ population_signature=population_signature,
1302
1416
  )
1303
1417
 
1304
1418