cctally 1.101.0 → 1.103.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.
@@ -82,7 +82,11 @@ def _ensure_sibling_loaded(name: str) -> None:
82
82
 
83
83
 
84
84
  _ensure_sibling_loaded("_lib_forecast")
85
- from _lib_forecast import ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast
85
+ from _lib_forecast import (
86
+ ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast,
87
+ ForecastConfidenceAssessment, ForecastConfidenceCause,
88
+ assess_forecast_confidence,
89
+ )
86
90
 
87
91
  # #279 S6 W4: the canonical None-safe UTC-Z serializer. forecast's former local
88
92
  # _iso_z (dt-only, no None guard) collapses to this single definition; the union
@@ -485,17 +489,23 @@ def _select_dollars_per_percent(
485
489
 
486
490
 
487
491
  def _assess_forecast_confidence(
488
- elapsed_hours: float, p_now: float, snapshot_count: int
492
+ elapsed_hours: float, p_now: float, snapshot_count: int,
493
+ *, has_sample_ge_24h: bool = True,
489
494
  ) -> tuple[str, list[str]]:
490
- """Binary confidence (spec §2)."""
491
- reasons: list[str] = []
492
- if elapsed_hours < 24:
493
- reasons.append("elapsed_hours<24")
494
- if p_now < 2:
495
- reasons.append("percent<2")
496
- if snapshot_count < 3:
497
- reasons.append("snapshots<3")
498
- return ("low", reasons) if reasons else ("high", [])
495
+ """Alias onto the pure predicate in `_lib_forecast` (#620 S2 E3).
496
+
497
+ Four import paths reach this name — `bin/cctally:1600`,
498
+ `bin/_cctally_record.py:427-428`, `bin/_cctally_record.py:1539` and
499
+ `tests/test_620_confidence_wording.py:141` — so it keeps its three
500
+ positional parameters and its `(confidence, list_of_reasons)` return
501
+ shape. `has_sample_ge_24h` defaults to True, which is what makes a
502
+ three-argument call emit exactly the three reasons it always did.
503
+ """
504
+ assessment = assess_forecast_confidence(
505
+ elapsed_hours, p_now, snapshot_count,
506
+ has_sample_ge_24h=has_sample_ge_24h,
507
+ )
508
+ return assessment.confidence, list(assessment.reasons)
499
509
 
500
510
 
501
511
  def _pick_p_24h_ago(
@@ -576,12 +586,12 @@ def _load_forecast_inputs(
576
586
  use_weekref_cost_cache=use_weekref_cost_cache,
577
587
  account_key=account_key,
578
588
  )
579
- confidence, reasons = _assess_forecast_confidence(elapsed_hours, p_now, len(samples))
580
589
  target_24h = now_utc - dt.timedelta(hours=24)
581
590
  has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
582
- if not has_sample_ge_24h:
583
- reasons = list(reasons) + ["no_sample_ge_24h"]
584
- confidence = "low"
591
+ confidence, reasons = _assess_forecast_confidence(
592
+ elapsed_hours, p_now, len(samples),
593
+ has_sample_ge_24h=has_sample_ge_24h,
594
+ )
585
595
 
586
596
  return ForecastInputs(
587
597
  now_utc=now_utc,
@@ -2385,6 +2385,89 @@ def _build_diff_parser(subparsers, name, *, help_text, xref=None, fixed_source=N
2385
2385
  _add_account_arg(diff_p)
2386
2386
  diff_p.set_defaults(func=c.cmd_diff)
2387
2387
 
2388
+ def _build_explain_parser(subparsers, name, *, help_text, xref=None):
2389
+ """Build the `explain` parser (registered via _REGISTRATION; #620 S2).
2390
+
2391
+ `explain` deliberately does NOT adopt the shareable-output surface
2392
+ (`--format`/`--theme`/`--output`/`--copy`/`--open`). That surface is a
2393
+ separate concern with its own render kernel and byte-stability gates;
2394
+ only `--reveal-projects` is shared, because privacy is part of what the
2395
+ diagnosis publishes rather than part of how it is shared.
2396
+ """
2397
+ c = _cctally()
2398
+ explain_p = subparsers.add_parser(
2399
+ name,
2400
+ help=help_text,
2401
+ formatter_class=CLIHelpFormatter,
2402
+ description=textwrap.dedent(
2403
+ """\
2404
+ Explain where locally retained usage went, over seven
2405
+ contributor classes. Four are accounting-native: expensive model
2406
+ mix, one project dominating, one session dominating, and
2407
+ concentrated 5-hour bursts. Three are conversation-derived:
2408
+ prompt-cache churn, short conversations carrying large context,
2409
+ and subagent fan-out.
2410
+
2411
+ Each class reports a contributor when its top subject reaches the
2412
+ classification floor of its named denominator, reports no
2413
+ contributor when it is measured and nothing reaches the floor,
2414
+ and withholds with a typed cause when it cannot be measured. The
2415
+ classes overlap, so shares do not sum to 100 percent. Each class
2416
+ states the rule in force, so a verdict can be reproduced rather
2417
+ than inferred.
2418
+
2419
+ The three conversation-derived classes read the local transcript
2420
+ store, so they are withheld where it is absent or unreadable.
2421
+ Codex subagent attribution is derived from accounting metadata
2422
+ and needs no transcript access.
2423
+
2424
+ Exit codes: 0 for a valid report, including a healthy one and one
2425
+ whose fields are withheld; 2 for selector and usage errors; 3 for
2426
+ a report-establishment failure.
2427
+ """
2428
+ ),
2429
+ )
2430
+ explain_p.add_argument(
2431
+ # Defaulted to None rather than to the token itself, so the mutual
2432
+ # exclusion below can tell "the user asked for a window" from "nobody
2433
+ # said". `cmd_explain` resolves the absent case to `this-week`, which
2434
+ # is what keeps the default behaviour and the --help text unchanged.
2435
+ "--window", default=None,
2436
+ help="Window token (this-week | last-week | Nw-ago | this-month | "
2437
+ "last-month | Nm-ago | last-Nd | prev-Nd | "
2438
+ "YYYY-MM-DD..YYYY-MM-DD). Default: this-week.",
2439
+ )
2440
+ # Exact instants, because date grammar cannot express the window a warning
2441
+ # fired against: a five-hour block starts at an instant, not on a calendar
2442
+ # day. This is the pair the dashboard route already accepts, and the pair
2443
+ # every conversation-derived next step carries, so following one reproduces
2444
+ # the population the row measured rather than a day that contains it.
2445
+ explain_p.add_argument(
2446
+ "--start-at", default=None, metavar="ISO",
2447
+ help="Exact window start instant, inclusive (ISO 8601, e.g. "
2448
+ "2026-08-10T00:00:00Z). Requires --end-at and cannot be "
2449
+ "combined with --window.",
2450
+ )
2451
+ explain_p.add_argument(
2452
+ "--end-at", default=None, metavar="ISO",
2453
+ help="Exact window end instant, EXCLUSIVE (ISO 8601). Requires "
2454
+ "--start-at and cannot be combined with --window.",
2455
+ )
2456
+ explain_p.add_argument("--json", dest="emit_json", action="store_true",
2457
+ help="Emit the stamped JSON envelope instead of the terminal report.")
2458
+ explain_p.add_argument(
2459
+ "--reveal-projects", action="store_true", dest="reveal_projects",
2460
+ help="Show derived project display labels instead of the default "
2461
+ "project-1, project-2, ... anonymization. Never shows "
2462
+ "filesystem paths, and never widens session identities.",
2463
+ )
2464
+ explain_p.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
2465
+ help="Display timezone: local, utc, or IANA name. "
2466
+ "Overrides config display.tz for this call.")
2467
+ _add_source_args(explain_p, speed=True)
2468
+ _add_account_arg(explain_p)
2469
+ explain_p.set_defaults(func=c.cmd_explain)
2470
+
2388
2471
  def _build_claude_parser(subparsers, name, *, help_text, xref=None):
2389
2472
  """Build the `claude` parser (registered via _REGISTRATION; #279 S6 W3).
2390
2473
 
@@ -3743,6 +3826,7 @@ _REGISTRATION = (
3743
3826
  _Reg('alerts', _build_alerts_parser, "Manage threshold alerts", None, None),
3744
3827
  _Reg('setup', _build_setup_parser, "Install provider hooks/handlers + symlinks", None, None),
3745
3828
  _Reg('db', _build_db_parser, "Migration / DB management (status, skip, unskip)", None, None),
3829
+ _Reg('explain', _build_explain_parser, "Explain where locally retained usage went, ranked with evidence", None, None),
3746
3830
  _Reg('doctor', _build_doctor_parser, "Diagnose data freshness and install state", None, None),
3747
3831
  _Reg('dashboard-perf', _build_dashboard_perf_parser, "Read a running dashboard's tick cost; arm its phase trace", None, None),
3748
3832
  _Reg('pricing-check', _build_pricing_check_parser, "Detect stale or missing embedded model pricing", None, None),
@@ -194,6 +194,7 @@ PROJECTION_READ_CHOKEPOINTS: "frozenset[str]" = frozenset({
194
194
  "_cctally_quota.py::_orphan_unseen::quota_window_blocks",
195
195
  "_cctally_quota.py::_orphan_unseen_scoped::quota_window_blocks",
196
196
  "_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state",
197
+ "_cctally_diagnosis_sources.py::<module>::quota_window_blocks",
197
198
  })
198
199
 
199
200
  #: What each enumerated site does about the gate.
@@ -246,6 +247,10 @@ PROJECTION_READ_SITE_ACTIONS: "dict[str, str]" = {
246
247
  "projector",
247
248
  "_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state":
248
249
  "projector",
250
+ # `cctally explain` holds its Codex block SQL as a module-level constant,
251
+ # so the function that executes it gates instead.
252
+ "_cctally_diagnosis_sources.py::<module>::quota_window_blocks":
253
+ "gate_at_caller",
249
254
  }
250
255
 
251
256
  #: For each `gate_at_caller` site, the `<file>::<function>` callers that run the
@@ -274,6 +279,15 @@ PROJECTION_GATE_CALLERS: "dict[str, tuple[str, ...]]" = {
274
279
  "_cctally_tui.py::_tui_compute_dispatch_signature",
275
280
  "_cctally_dashboard_sources.py::_codex_quota_reuse_identity",
276
281
  ),
282
+ # Two functions in that module execute module-level SQL against
283
+ # `quota_window_blocks` — the block read and the observation-horizon read —
284
+ # and the scanner sees both as the one `<module>` site. Both are named,
285
+ # because "the horizon read is safe today because of call order" is not a
286
+ # property this guard can check, and a reordering would not fail it.
287
+ "_cctally_diagnosis_sources.py::<module>::quota_window_blocks": (
288
+ "_cctally_diagnosis_sources.py::_read_codex_blocks",
289
+ "_cctally_diagnosis_sources.py::_read_store_horizon",
290
+ ),
277
291
  }
278
292
 
279
293
  #: Dynamic-target read sites per file — a `FROM`/`JOIN` whose target is
@@ -173,8 +173,8 @@ import re
173
173
  import sqlite3
174
174
  import sys
175
175
  import time
176
- from dataclasses import dataclass
177
- from typing import Any
176
+ from dataclasses import dataclass, field
177
+ from typing import Any, Iterable
178
178
 
179
179
 
180
180
  def _cctally():
@@ -1509,11 +1509,12 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
1509
1509
  binds the fired value to forecast's ``week_avg_projection_pct`` within
1510
1510
  1e-9, so the two MUST share the formula by construction.
1511
1511
 
1512
- LOW CONF mirrors the displayed forecast confidence: the binary
1513
- ``_assess_forecast_confidence(elapsed_hours, p_now, len(samples))`` plus the
1514
- ``no_sample_ge_24h`` clause ``_load_forecast_inputs`` appends — so a thin
1515
- early-week window that forecast renders ``LOW CONF`` never fires a
1516
- projected alert.
1512
+ LOW CONF mirrors the displayed forecast confidence: ONE call to
1513
+ ``_assess_forecast_confidence(elapsed_hours, p_now, len(samples),
1514
+ has_sample_ge_24h=…)``, whose fourth trigger ``no_sample_ge_24h`` moved
1515
+ inside the predicate with #620 S2 E3 — so a thin early-week window that
1516
+ forecast renders ``LOW CONF`` never fires a projected alert, and glue no
1517
+ longer appends a reason of its own.
1517
1518
 
1518
1519
  Deliberately does NOT call ``_sum_cost_for_range`` (the weekly-% projection
1519
1520
  needs no spend; the forecast kernel's ``week_avg_projection_pct`` is also
@@ -1534,15 +1535,14 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
1534
1535
  r_avg = p_now / elapsed_hours if elapsed_hours > 0 else 0.0
1535
1536
  projected_pct = p_now + r_avg * remaining_hours
1536
1537
 
1537
- # Confidence: same predicate + the same no_sample_ge_24h augmentation that
1538
- # _load_forecast_inputs applies, so this LOW CONF gate == forecast's.
1538
+ # Confidence comes from the predicate in one call, fourth trigger
1539
+ # included, so this LOW CONF gate == forecast's and glue no longer
1540
+ # downgrades a confidence the predicate returned (#620 S2 E3).
1541
+ target_24h = now_utc - dt.timedelta(hours=24)
1539
1542
  confidence, _reasons = _assess_forecast_confidence(
1540
- elapsed_hours, p_now, len(samples)
1543
+ elapsed_hours, p_now, len(samples),
1544
+ has_sample_ge_24h=any(s[0] <= target_24h for s in samples),
1541
1545
  )
1542
- target_24h = now_utc - dt.timedelta(hours=24)
1543
- has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
1544
- if not has_sample_ge_24h:
1545
- confidence = "low"
1546
1546
  return (projected_pct, confidence == "low")
1547
1547
 
1548
1548
 
@@ -1882,6 +1882,112 @@ def maybe_record_projected_alert(
1882
1882
  eprint(f"[projected-alert] payload build failed: {build_exc}")
1883
1883
 
1884
1884
 
1885
+ @dataclass
1886
+ class PricedEntry:
1887
+ """One accounting entry after pricing, as the block fold consumes it.
1888
+
1889
+ The canonical priced record: the fields `fold_block_totals` reads and
1890
+ nothing else. `_compute_block_totals` builds these from
1891
+ `_JoinedClaudeEntry` rows it has already priced; the diagnosis
1892
+ (#620 S2) builds them from its own half-open account-scoped window.
1893
+ """
1894
+ model: str
1895
+ project_path: str | None
1896
+ input_tokens: int
1897
+ output_tokens: int
1898
+ cache_creation_tokens: int
1899
+ cache_read_tokens: int
1900
+ cost_usd: float
1901
+
1902
+
1903
+ @dataclass
1904
+ class BlockBucket:
1905
+ """One model or project bucket inside a block's totals."""
1906
+ input_tokens: int = 0
1907
+ output_tokens: int = 0
1908
+ cache_create_tokens: int = 0
1909
+ cache_read_tokens: int = 0
1910
+ cost_usd: float = 0.0
1911
+ entry_count: int = 0
1912
+
1913
+ def as_legacy_dict(self) -> dict[str, Any]:
1914
+ return {
1915
+ "input_tokens": self.input_tokens,
1916
+ "output_tokens": self.output_tokens,
1917
+ "cache_create_tokens": self.cache_create_tokens,
1918
+ "cache_read_tokens": self.cache_read_tokens,
1919
+ "cost_usd": self.cost_usd,
1920
+ "entry_count": self.entry_count,
1921
+ }
1922
+
1923
+
1924
+ @dataclass
1925
+ class BlockTotals:
1926
+ """The summed result of `fold_block_totals`."""
1927
+ input_tokens: int = 0
1928
+ output_tokens: int = 0
1929
+ cache_create_tokens: int = 0
1930
+ cache_read_tokens: int = 0
1931
+ cost_usd: float = 0.0
1932
+ entry_count: int = 0
1933
+ by_model: dict[str, BlockBucket] = field(default_factory=dict)
1934
+ by_project: dict[str, BlockBucket] = field(default_factory=dict)
1935
+
1936
+ def as_legacy_dict(self) -> dict[str, Any]:
1937
+ """The exact dict `_compute_block_totals` has always returned.
1938
+
1939
+ Key order is preserved because callers and fixture builders read
1940
+ this dict directly, and `entry_count` is deliberately absent at the
1941
+ top level: it exists on the dataclass for the diagnosis, and adding
1942
+ it here would change a shape every existing caller sees.
1943
+ """
1944
+ return {
1945
+ "input_tokens": self.input_tokens,
1946
+ "output_tokens": self.output_tokens,
1947
+ "cache_create_tokens": self.cache_create_tokens,
1948
+ "cache_read_tokens": self.cache_read_tokens,
1949
+ "cost_usd": self.cost_usd,
1950
+ "by_model": {k: v.as_legacy_dict() for k, v in self.by_model.items()},
1951
+ "by_project": {k: v.as_legacy_dict() for k, v in self.by_project.items()},
1952
+ }
1953
+
1954
+
1955
+ def fold_block_totals(entries: "Iterable[PricedEntry]") -> BlockTotals:
1956
+ """Sum priced entries into block totals plus model and project buckets.
1957
+
1958
+ Pure: it opens nothing, prices nothing, and reads no clock. Summation
1959
+ follows the iteration order of `entries`, and bucket insertion order
1960
+ follows first appearance, both of which the callers observe.
1961
+
1962
+ A NULL `project_path` buckets under `(unknown)` so the reconcile
1963
+ invariant `SUM(child.cost) == parent.total` continues to hold. The
1964
+ JSONL-fallback loader always populates `project_path`, so `(unknown)`
1965
+ only appears on the cache-backed path during the brief `session_files`
1966
+ lazy-backfill window.
1967
+ """
1968
+ totals = BlockTotals()
1969
+ for entry in entries:
1970
+ totals.input_tokens += entry.input_tokens
1971
+ totals.output_tokens += entry.output_tokens
1972
+ totals.cache_create_tokens += entry.cache_creation_tokens
1973
+ totals.cache_read_tokens += entry.cache_read_tokens
1974
+ totals.cost_usd += entry.cost_usd
1975
+ totals.entry_count += 1
1976
+
1977
+ for key, bucket_dict in (
1978
+ (entry.model, totals.by_model),
1979
+ (entry.project_path or "(unknown)", totals.by_project),
1980
+ ):
1981
+ b = bucket_dict.setdefault(key, BlockBucket())
1982
+ b.input_tokens += entry.input_tokens
1983
+ b.output_tokens += entry.output_tokens
1984
+ b.cache_create_tokens += entry.cache_creation_tokens
1985
+ b.cache_read_tokens += entry.cache_read_tokens
1986
+ b.cost_usd += entry.cost_usd
1987
+ b.entry_count += 1
1988
+ return totals
1989
+
1990
+
1885
1991
  def _compute_block_totals(
1886
1992
  block_start_at: dt.datetime,
1887
1993
  range_end: dt.datetime,
@@ -1907,64 +2013,32 @@ def _compute_block_totals(
1907
2013
  cost_usd, entry_count}]
1908
2014
  by_project: dict[project_path_or_'(unknown)' -> same shape]
1909
2015
  """
1910
- totals: dict[str, Any] = {
1911
- "input_tokens": 0,
1912
- "output_tokens": 0,
1913
- "cache_create_tokens": 0,
1914
- "cache_read_tokens": 0,
1915
- "cost_usd": 0.0,
1916
- "by_model": {},
1917
- "by_project": {},
1918
- }
1919
- for entry in get_claude_session_entries(
1920
- block_start_at, range_end, skip_sync=skip_sync,
1921
- ):
1922
- usage = claude_usage_dict( # #195 chokepoint
1923
- input_tokens=entry.input_tokens,
1924
- output_tokens=entry.output_tokens,
1925
- cache_creation_tokens=entry.cache_creation_tokens,
1926
- cache_read_tokens=entry.cache_read_tokens,
1927
- cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
1928
- speed=getattr(entry, "speed", None),
1929
- )
1930
- cost = _calculate_entry_cost(
1931
- entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
1932
- )
1933
-
1934
- totals["input_tokens"] += entry.input_tokens
1935
- totals["output_tokens"] += entry.output_tokens
1936
- totals["cache_create_tokens"] += entry.cache_creation_tokens
1937
- totals["cache_read_tokens"] += entry.cache_read_tokens
1938
- totals["cost_usd"] += cost
1939
-
1940
- # Bucket by model and by project_path. NULL project_path → sentinel
1941
- # so reconcile invariant SUM(child.cost) == parent.total holds.
1942
- # Note: the JSONL-fallback path (_direct_parse_claude_session_entries)
1943
- # always populates project_path = cwd (never NULL); '(unknown)' only
1944
- # appears on the cache-backed path during the brief session_files
1945
- # lazy-backfill window.
1946
- for key, bucket_dict in (
1947
- (entry.model, totals["by_model"]),
1948
- (entry.project_path or "(unknown)", totals["by_project"]),
2016
+ def _priced():
2017
+ for entry in get_claude_session_entries(
2018
+ block_start_at, range_end, skip_sync=skip_sync,
1949
2019
  ):
1950
- b = bucket_dict.setdefault(
1951
- key,
1952
- {
1953
- "input_tokens": 0,
1954
- "output_tokens": 0,
1955
- "cache_create_tokens": 0,
1956
- "cache_read_tokens": 0,
1957
- "cost_usd": 0.0,
1958
- "entry_count": 0,
1959
- },
2020
+ usage = claude_usage_dict( # #195 chokepoint
2021
+ input_tokens=entry.input_tokens,
2022
+ output_tokens=entry.output_tokens,
2023
+ cache_creation_tokens=entry.cache_creation_tokens,
2024
+ cache_read_tokens=entry.cache_read_tokens,
2025
+ cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
2026
+ speed=getattr(entry, "speed", None),
1960
2027
  )
1961
- b["input_tokens"] += entry.input_tokens
1962
- b["output_tokens"] += entry.output_tokens
1963
- b["cache_create_tokens"] += entry.cache_creation_tokens
1964
- b["cache_read_tokens"] += entry.cache_read_tokens
1965
- b["cost_usd"] += cost
1966
- b["entry_count"] += 1
1967
- return totals
2028
+ cost = _calculate_entry_cost(
2029
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
2030
+ )
2031
+ yield PricedEntry(
2032
+ model=entry.model,
2033
+ project_path=entry.project_path,
2034
+ input_tokens=entry.input_tokens,
2035
+ output_tokens=entry.output_tokens,
2036
+ cache_creation_tokens=entry.cache_creation_tokens,
2037
+ cache_read_tokens=entry.cache_read_tokens,
2038
+ cost_usd=cost,
2039
+ )
2040
+
2041
+ return fold_block_totals(_priced()).as_legacy_dict()
1968
2042
 
1969
2043
 
1970
2044
  def maybe_update_five_hour_block(