cctally 1.72.0 → 1.74.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 (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/_cctally_cache.py +271 -55
  3. package/bin/_cctally_core.py +20 -40
  4. package/bin/_cctally_dashboard.py +35 -1
  5. package/bin/_cctally_dashboard_conversation.py +731 -94
  6. package/bin/_cctally_dashboard_share.py +38 -3
  7. package/bin/_cctally_dashboard_sources.py +812 -74
  8. package/bin/_cctally_db.py +104 -1
  9. package/bin/_cctally_doctor.py +86 -4
  10. package/bin/_cctally_parser.py +17 -4
  11. package/bin/_cctally_record.py +162 -33
  12. package/bin/_cctally_refresh.py +58 -36
  13. package/bin/_cctally_source_analytics.py +288 -3
  14. package/bin/_cctally_statusline.py +811 -82
  15. package/bin/_cctally_transcript.py +223 -7
  16. package/bin/_cctally_tui.py +17 -14
  17. package/bin/_lib_aggregators.py +48 -34
  18. package/bin/_lib_codex_conversation_export.py +124 -0
  19. package/bin/_lib_codex_conversation_query.py +500 -28
  20. package/bin/_lib_codex_conversation_watch.py +323 -0
  21. package/bin/_lib_conversation_dispatch.py +269 -7
  22. package/bin/_lib_conversation_query.py +88 -0
  23. package/bin/_lib_dashboard_sources.py +20 -7
  24. package/bin/_lib_doctor.py +106 -0
  25. package/bin/_lib_jsonl.py +11 -0
  26. package/bin/_lib_pricing.py +7 -4
  27. package/bin/_lib_pricing_check.py +7 -1
  28. package/bin/_lib_quota.py +12 -11
  29. package/bin/_lib_statusline_candidates.py +734 -0
  30. package/bin/_lib_view_models.py +50 -14
  31. package/bin/cctally +31 -2
  32. package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
  33. package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-BWadAHxC.js +0 -80
  37. package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
@@ -2540,6 +2540,94 @@ def build_anon_plan_for_db(conn, *, home_dir):
2540
2540
  home_dirs=sorted(home_dirs), usernames=sorted(usernames))
2541
2541
 
2542
2542
 
2543
+ def build_anon_plan_for_sources(conn, *, home_dir, sources):
2544
+ """Provider-aware :class:`AnonPlan` for QUALIFIED conversation surfaces
2545
+ (#294 S7, §3.6). A SEPARATE builder from :func:`build_anon_plan_for_db`, which
2546
+ stays byte-untouched together with every bare-Claude call site — unioning Codex
2547
+ roots into the legacy builder would renumber its ``project-N`` tokens and change
2548
+ bare-Claude anonymized bytes. This builder is used ONLY by ``v1.``-qualified
2549
+ requests (both providers), which are new and carry no byte-compat debt.
2550
+
2551
+ ``sources`` selects the provider legs to include (e.g. ``{"codex"}`` or
2552
+ ``{"claude"}``). The Codex leg draws ONLY from the authoritative tables:
2553
+ provider root dirs from ``codex_source_roots.canonical_root_path``, observed
2554
+ project paths from validated ``codex_conversation_threads.cwd`` (absolute paths
2555
+ only), display labels from ``codex_conversation_rollups.project_label``.
2556
+ ``git_json`` is NOT a path source (its fields are branch/repository metadata,
2557
+ not filesystem roots). The existing home-dir/username machinery and
2558
+ ``SECRET_PATTERNS`` apply unchanged; coverage stays best-effort-over-known-tokens
2559
+ and the per-token replacement stays fail-closed."""
2560
+ from _lib_conversation_anon import build_anon_plan
2561
+ want = set(sources or ())
2562
+ project_roots: dict = {}
2563
+ paths: set = set()
2564
+ if "claude" in want:
2565
+ cwds: set = set()
2566
+ try:
2567
+ for (cwd,) in conn.execute(
2568
+ "SELECT DISTINCT cwd FROM conversation_messages "
2569
+ "WHERE cwd IS NOT NULL AND cwd != ''"):
2570
+ if cwd:
2571
+ cwds.add(cwd)
2572
+ except sqlite3.OperationalError:
2573
+ pass
2574
+ try:
2575
+ for (pp,) in conn.execute(
2576
+ "SELECT DISTINCT project_path FROM session_files "
2577
+ "WHERE project_path IS NOT NULL AND project_path != ''"):
2578
+ if pp:
2579
+ cwds.add(pp)
2580
+ except sqlite3.OperationalError:
2581
+ pass
2582
+ for c in cwds:
2583
+ project_roots[c] = _project_label(c)
2584
+ paths.add(c)
2585
+ if "codex" in want:
2586
+ labels: dict = {}
2587
+ try:
2588
+ for ck, pl in conn.execute(
2589
+ "SELECT conversation_key, project_label FROM codex_conversation_rollups "
2590
+ "WHERE project_label IS NOT NULL AND project_label != ''"):
2591
+ labels[ck] = pl
2592
+ except sqlite3.OperationalError:
2593
+ pass
2594
+ try:
2595
+ for ck, cwd in conn.execute(
2596
+ "SELECT conversation_key, cwd FROM codex_conversation_threads "
2597
+ "WHERE cwd IS NOT NULL AND cwd != ''"):
2598
+ if isinstance(cwd, str) and cwd and os.path.isabs(cwd):
2599
+ project_roots[cwd] = labels.get(ck) or _project_label(cwd)
2600
+ paths.add(cwd)
2601
+ except sqlite3.OperationalError:
2602
+ pass
2603
+ try:
2604
+ for (root_path,) in conn.execute(
2605
+ "SELECT canonical_root_path FROM codex_source_roots "
2606
+ "WHERE canonical_root_path IS NOT NULL AND canonical_root_path != ''"):
2607
+ if isinstance(root_path, str) and root_path and os.path.isabs(root_path):
2608
+ project_roots.setdefault(root_path, _project_label(root_path))
2609
+ paths.add(root_path)
2610
+ except sqlite3.OperationalError:
2611
+ pass
2612
+ home_dirs: set = set()
2613
+ usernames: set = set()
2614
+ if home_dir:
2615
+ home_dirs.add(home_dir)
2616
+ u = os.path.basename(home_dir.rstrip("/"))
2617
+ if u:
2618
+ usernames.add(u)
2619
+ for p in paths:
2620
+ for prefix in ("/Users/", "/home/"):
2621
+ if p.startswith(prefix):
2622
+ user = p[len(prefix):].split("/", 1)[0]
2623
+ if user:
2624
+ home_dirs.add(prefix + user)
2625
+ usernames.add(user)
2626
+ return build_anon_plan(
2627
+ project_roots=project_roots,
2628
+ home_dirs=sorted(home_dirs), usernames=sorted(usernames))
2629
+
2630
+
2543
2631
  _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
2544
2632
 
2545
2633
 
@@ -272,8 +272,9 @@ def unavailable_source_state(
272
272
 
273
273
  def _coherent_provider(state: SourceDashboardState) -> bool:
274
274
  return (
275
- state.availability in ("ok", "empty")
275
+ state.availability in ("ok", "empty", "partial")
276
276
  and state.freshness == "fresh"
277
+ and bool(state.data_version)
277
278
  and state.data is not None
278
279
  )
279
280
 
@@ -302,6 +303,10 @@ def _combined_metrics(
302
303
  ) -> Mapping[str, object] | None:
303
304
  if not (_coherent_provider(claude) and _coherent_provider(codex)):
304
305
  return None
306
+ for state in (claude, codex):
307
+ hero_capability = state.capabilities.get("hero")
308
+ if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
309
+ return None
305
310
  try:
306
311
  claude_hero = claude.data["hero"]
307
312
  codex_hero = codex.data["hero"]
@@ -360,19 +365,27 @@ def compose_all_state(
360
365
  if claude.source != "claude" or codex.source != "codex":
361
366
  raise ValueError("all composition requires Claude and Codex provider states")
362
367
  combined = _combined_metrics(claude, codex)
363
- coherent = combined is not None
364
- if coherent:
368
+ providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
369
+ if providers_coherent:
365
370
  availability: Availability = (
366
- "empty"
367
- if claude.availability == "empty" and codex.availability == "empty"
368
- else "ok"
371
+ "partial"
372
+ if combined is None or "partial" in (claude.availability, codex.availability)
373
+ else (
374
+ "empty"
375
+ if claude.availability == "empty" and codex.availability == "empty"
376
+ else "ok"
377
+ )
369
378
  )
370
379
  freshness: Freshness = "fresh"
371
380
  else:
372
381
  availability = "partial"
373
382
  freshness = "stale"
374
383
  version_material = json.dumps(
375
- [claude.data_version, codex.data_version, coherent],
384
+ [
385
+ claude.data_version, claude.availability, claude.freshness,
386
+ codex.data_version, codex.availability, codex.freshness,
387
+ combined is not None,
388
+ ],
376
389
  separators=(",", ":"),
377
390
  ).encode("utf-8")
378
391
  data_version = "all:" + hashlib.sha256(version_material).hexdigest()[:24]
@@ -15,6 +15,7 @@ import dataclasses
15
15
  import datetime as dt
16
16
  import hashlib
17
17
  import json
18
+ import math
18
19
  import pathlib
19
20
  import sys
20
21
  from typing import Optional
@@ -213,6 +214,16 @@ class DoctorState:
213
214
  # never-WARN posture (the hooks.installed / settings warnings already
214
215
  # surface a genuinely-unreadable settings.json — no double-WARN here).
215
216
  statusline_refresh_state: str = "unavailable"
217
+ # #318: read-only evidence for the per-session statusline candidate
218
+ # arbitration pipeline. None means the gather could not inspect it;
219
+ # absent transport/tombstones are normal when Claude is closed.
220
+ statusline_pipeline: Optional[dict] = None
221
+ # #312: all-history, root-qualified Codex accounting metadata partition.
222
+ # The I/O layer reports an explicit error instead of silently converting a
223
+ # failed health query into zero rows, so doctor can distinguish an empty
224
+ # retained corpus from unreadable metadata.
225
+ codex_project_metadata_health: Optional[dict] = None
226
+ codex_project_metadata_error: Optional[str] = None
216
227
 
217
228
 
218
229
  @dataclasses.dataclass(frozen=True)
@@ -737,6 +748,62 @@ def _check_data_latest_snapshot_age(s: DoctorState) -> CheckResult:
737
748
  )
738
749
 
739
750
 
751
+ def _check_statusline_pipeline(s: DoctorState) -> CheckResult:
752
+ """Report transport, selected, control, and authority health separately."""
753
+ pipeline = s.statusline_pipeline if isinstance(s.statusline_pipeline, dict) else {}
754
+ tombstones = pipeline.get("tombstones")
755
+ tombstones = tombstones if isinstance(tombstones, dict) else {}
756
+ details = {
757
+ "transport_age_seconds": pipeline.get("transport_age_seconds"),
758
+ "selected_age_seconds": pipeline.get("selected_age_seconds"),
759
+ "active_candidate_count": pipeline.get("active_candidate_count", 0),
760
+ "control_db_agrees": pipeline.get("control_db_agrees"),
761
+ "tombstones": tombstones,
762
+ }
763
+ if any(value in {"invalid", "inflight"} for value in tombstones.values()):
764
+ return CheckResult(
765
+ id="data.statusline_pipeline", title="Statusline pipeline",
766
+ severity="warn", summary="authoritative state needs repair",
767
+ remediation="Run `cctally refresh-usage`", details=details,
768
+ )
769
+ if pipeline.get("control_db_agrees") is False:
770
+ return CheckResult(
771
+ id="data.statusline_pipeline", title="Statusline pipeline",
772
+ severity="warn", summary="selected control disagrees with database",
773
+ remediation="Run `cctally refresh-usage`, then inspect active sessions",
774
+ details=details,
775
+ )
776
+ transport_age = pipeline.get("transport_age_seconds")
777
+ selected_age = pipeline.get("selected_age_seconds")
778
+ transport_age = (
779
+ float(transport_age)
780
+ if isinstance(transport_age, (int, float)) and not isinstance(transport_age, bool)
781
+ else math.inf
782
+ )
783
+ selected_age = (
784
+ float(selected_age)
785
+ if isinstance(selected_age, (int, float)) and not isinstance(selected_age, bool)
786
+ else math.inf
787
+ )
788
+ if transport_age < 90 and selected_age >= 300:
789
+ return CheckResult(
790
+ id="data.statusline_pipeline", title="Statusline pipeline",
791
+ severity="warn", summary="timer active; selected usage stale",
792
+ remediation="Run `cctally refresh-usage`, then inspect active sessions",
793
+ details=details,
794
+ )
795
+ if transport_age >= 90:
796
+ summary = "no recent regular-pool timer observed"
797
+ elif selected_age < 300:
798
+ summary = "timer and selected usage fresh"
799
+ else:
800
+ summary = "selected usage awaiting next active timer"
801
+ return CheckResult(
802
+ id="data.statusline_pipeline", title="Statusline pipeline",
803
+ severity="ok", summary=summary, remediation=None, details=details,
804
+ )
805
+
806
+
740
807
  def _check_data_cache_sync_state(s: DoctorState) -> CheckResult:
741
808
  count = s.cache_entries_count or 0
742
809
  if count == 0:
@@ -815,6 +882,43 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
815
882
  )
816
883
 
817
884
 
885
+ def _check_data_codex_project_metadata(s: DoctorState) -> CheckResult:
886
+ """Report the identity-safe all-history Codex metadata partition."""
887
+ if s.codex_project_metadata_error is not None:
888
+ return CheckResult(
889
+ id="data.codex_project_metadata", title="Codex project metadata",
890
+ severity="fail", summary="metadata health could not be read",
891
+ remediation="Run `cctally doctor --verbose` after checking cache.db.",
892
+ details={"error": s.codex_project_metadata_error},
893
+ )
894
+
895
+ health = s.codex_project_metadata_health or {}
896
+ total_rows = int(health.get("total_rows", 0))
897
+ qualified_rows = int(health.get("qualified_rows", 0))
898
+ missing_key_rows = int(health.get("missing_conversation_key_rows", 0))
899
+ missing_join_rows = int(health.get("missing_thread_join_rows", 0))
900
+ incomplete_rows = missing_key_rows + missing_join_rows
901
+ details = {
902
+ "total_rows": total_rows,
903
+ "qualified_rows": qualified_rows,
904
+ "missing_conversation_key_rows": missing_key_rows,
905
+ "missing_thread_join_rows": missing_join_rows,
906
+ "incomplete_rows": incomplete_rows,
907
+ }
908
+ if incomplete_rows:
909
+ return CheckResult(
910
+ id="data.codex_project_metadata", title="Codex project metadata",
911
+ severity="warn",
912
+ summary=f"{incomplete_rows} accounting row(s) need metadata repair",
913
+ remediation="Run `cctally cache-sync --source codex --rebuild`.",
914
+ details=details,
915
+ )
916
+ return CheckResult(
917
+ id="data.codex_project_metadata", title="Codex project metadata",
918
+ severity="ok", summary="qualified", remediation=None, details=details,
919
+ )
920
+
921
+
818
922
  def _check_data_codex_quota(s: DoctorState) -> CheckResult:
819
923
  """Report local physical Codex quota evidence without fabricating a window."""
820
924
  raw_windows = s.codex_quota_windows or []
@@ -1658,8 +1762,10 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
1658
1762
  )),
1659
1763
  ("data", "Data", (
1660
1764
  ("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
1765
+ ("data.statusline_pipeline", "_check_statusline_pipeline"),
1661
1766
  ("data.cache_sync_state", "_check_data_cache_sync_state"),
1662
1767
  ("data.codex_cache", "_check_data_codex_cache"),
1768
+ ("data.codex_project_metadata", "_check_data_codex_project_metadata"),
1663
1769
  ("data.codex_quota", "_check_data_codex_quota"),
1664
1770
  ("data.parse_health", "_check_data_parse_health"),
1665
1771
  ("data.forked_buckets", "_check_data_forked_buckets"),
package/bin/_lib_jsonl.py CHANGED
@@ -720,6 +720,14 @@ def _accounting_from_record(
720
720
  _codex_skip(state, "no-last-token-usage")
721
721
  return None, last_total_tokens, filename_session_id_warned
722
722
 
723
+ # Forked/subagent rollouts can begin with a copied slice of their parent's
724
+ # physical history before the child's first model-bearing turn_context.
725
+ # Those records remain in the physical event stream, but projecting them
726
+ # into accounting would duplicate the parent's usage under model="unknown".
727
+ if (state.model is None and state.thread is not None
728
+ and state.thread.parent_thread_id is not None):
729
+ return None, last_total_tokens, filename_session_id_warned
730
+
723
731
  total_token_usage = info.get("total_token_usage")
724
732
  if isinstance(total_token_usage, dict):
725
733
  try:
@@ -839,6 +847,9 @@ def _iter_codex_fused_records_with_offsets(
839
847
  session_id = _codex_string(payload.get("id"))
840
848
  if session_id is not None:
841
849
  state.session_id = session_id
850
+ session_model = _codex_string(payload.get("model"))
851
+ if session_model is not None:
852
+ state.model = session_model.strip()
842
853
  thread_update = _thread_metadata_from_session_meta(
843
854
  payload, path_str, source_root_key
844
855
  )
@@ -53,7 +53,7 @@ def _chip_for_model(name: str) -> str:
53
53
  # Date the embedded pricing snapshots below were last verified against
54
54
  # vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
55
55
  # is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
56
- PRICING_SNAPSHOT_DATE = "2026-07-10"
56
+ PRICING_SNAPSHOT_DATE = "2026-07-19"
57
57
  PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
58
58
 
59
59
  # Canonical machine-readable pricing source (Claude values + Codex values).
@@ -99,7 +99,7 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
99
99
 
100
100
  # Anthropic API pricing snapshot:
101
101
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
102
- # - Captured: 2026-07-01 (see PRICING_SNAPSHOT_DATE)
102
+ # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
103
103
  # - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
104
104
  # update in PRs touching this table.
105
105
  # 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
@@ -321,8 +321,8 @@ _unknown_model_warnings: set[str] = set()
321
321
  #
322
322
  # Codex (OpenAI) API pricing snapshot:
323
323
  # - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
324
- # - Captured: 2026-05-30 (see PRICING_SNAPSHOT_DATE)
325
- # - As of the 2026-05-30 sync (issue #123) this carries every openai-provider
324
+ # - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
325
+ # - As of the 2026-07-19 sync this carries every openai-provider
326
326
  # gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
327
327
  # nothing missing. Models absent from this table still fall back to `gpt-5`
328
328
  # pricing with isFallback=true (matches upstream's LEGACY_FALLBACK_MODEL
@@ -332,6 +332,9 @@ _unknown_model_warnings: set[str] = set()
332
332
  # `pricing-check` as missing_from_us (gpt-5.6-sol had real usage falling back
333
333
  # to gpt-5). Exact model_prices_and_context_window.json values, each with the
334
334
  # above-272k tier (max_input_tokens 1050000).
335
+ # 2026-07-19: verified all tracked values and the complete openai-provider
336
+ # gpt-5* model set against the live LiteLLM snapshot; no named table entries
337
+ # were missing. Official OpenAI model pages were used as the vendor cross-check.
335
338
  #
336
339
  # Billing rules:
337
340
  # - reasoning_output_tokens is billed at the *output* rate (matches
@@ -34,11 +34,17 @@ class DriftResult:
34
34
  ahead_of_litellm: list[str] # informational; never actionable
35
35
 
36
36
 
37
+ _CODEX_UNATTRIBUTED_MODEL = "unknown"
38
+
39
+
37
40
  def classify_coverage(observed, resolve_claude, is_codex_fallback) -> list[CoverageGap]:
38
41
  """observed: iterable of (provider, model, entry_count, token_total).
39
42
 
40
43
  Claude model with resolve_claude(model) is None -> kind="unpriced".
41
44
  Codex model with is_codex_fallback(model) True -> kind="fallback".
45
+ The ingest-only Codex ``unknown`` sentinel is skipped because it records
46
+ missing model metadata, not a model identifier that can be added to the
47
+ embedded pricing table.
42
48
  Priced models produce no gap. Order preserved.
43
49
  """
44
50
  gaps: list[CoverageGap] = []
@@ -46,7 +52,7 @@ def classify_coverage(observed, resolve_claude, is_codex_fallback) -> list[Cover
46
52
  if provider == "claude":
47
53
  if resolve_claude(model) is None:
48
54
  gaps.append(CoverageGap("claude", model, "unpriced", entry_count, token_total))
49
- elif provider == "codex":
55
+ elif provider == "codex" and model != _CODEX_UNATTRIBUTED_MODEL:
50
56
  if is_codex_fallback(model):
51
57
  gaps.append(CoverageGap("codex", model, "fallback", entry_count, token_total))
52
58
  return gaps
package/bin/_lib_quota.py CHANGED
@@ -407,7 +407,7 @@ def _single_identity_history(observations: Iterable[QuotaObservation]) -> QuotaH
407
407
  def forecast_quota(
408
408
  observations: Iterable[QuotaObservation], as_of: dt.datetime,
409
409
  ) -> QuotaForecast:
410
- """Fit a reset-scoped quota forecast from positive adjacent intervals only."""
410
+ """Forecast reset-scoped quota use from elapsed native-cycle pace."""
411
411
  _require_aware(as_of, "as_of")
412
412
  history = _single_identity_history(observations)
413
413
  if history is None:
@@ -423,28 +423,29 @@ def forecast_quota(
423
423
  if observation.resets_at == baseline.resets_at and observation.captured_at <= as_of
424
424
  )
425
425
  points = tuple(sorted(points, key=physical_order_key))
426
- usable_elapsed_seconds = 0.0
427
- positive_delta_percent = 0.0
428
426
  sample_count = 0
429
427
  for prior, current in zip(points, points[1:]):
430
428
  elapsed_seconds = (current.captured_at - prior.captured_at).total_seconds()
431
429
  delta_percent = current.used_percent - prior.used_percent
432
430
  if elapsed_seconds > 0 and delta_percent > 0:
433
- usable_elapsed_seconds += elapsed_seconds
434
- positive_delta_percent += delta_percent
435
431
  sample_count += 1
436
432
 
437
433
  remaining_seconds = max(0, int((baseline.resets_at - as_of).total_seconds()))
438
- if usable_elapsed_seconds > 0:
439
- rate = positive_delta_percent / (usable_elapsed_seconds / 3600)
434
+ native_window_seconds = baseline.identity.window_minutes * 60
435
+ cycle_start = baseline.resets_at - dt.timedelta(minutes=baseline.identity.window_minutes)
436
+ cycle_elapsed_seconds = min(
437
+ float(native_window_seconds),
438
+ max(0.0, (as_of - cycle_start).total_seconds()),
439
+ )
440
+ if sample_count > 0 and cycle_elapsed_seconds > 0:
441
+ rate = baseline.used_percent / (cycle_elapsed_seconds / 3600)
440
442
  projected = min(100.0, max(
441
443
  baseline.used_percent,
442
444
  baseline.used_percent + rate * (remaining_seconds / 3600),
443
445
  ))
444
- native_window_seconds = baseline.identity.window_minutes * 60
445
- if sample_count >= 4 and usable_elapsed_seconds >= native_window_seconds * 0.25:
446
+ if sample_count >= 4 and cycle_elapsed_seconds >= native_window_seconds * 0.25:
446
447
  confidence: Literal["high", "medium", "low"] = "high"
447
- elif sample_count >= 2 and usable_elapsed_seconds >= native_window_seconds * 0.10:
448
+ elif sample_count >= 2 and cycle_elapsed_seconds >= native_window_seconds * 0.10:
448
449
  confidence = "medium"
449
450
  else:
450
451
  confidence = "low"
@@ -469,7 +470,7 @@ def forecast_quota(
469
470
  resets_at=baseline.resets_at,
470
471
  remaining_seconds=remaining_seconds,
471
472
  sample_count=sample_count,
472
- sample_span_seconds=int(usable_elapsed_seconds),
473
+ sample_span_seconds=int(cycle_elapsed_seconds) if sample_count > 0 else 0,
473
474
  confidence=confidence,
474
475
  )
475
476