cctally 1.100.0 → 1.102.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 (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -764,6 +764,18 @@ def _run_conversation_events_stream(
764
764
  pass
765
765
  seen = cached_sigs(files)
766
766
 
767
+ # #630 S2: `ready` above means "this connection is live-tailing"; it fires
768
+ # BEFORE resolve() and ingest(), so it does not mean a baseline exists,
769
+ # which is why six tests used to sleep a second here. This frame means the
770
+ # baseline IS established, so any subsequent growth will be caught. The
771
+ # client listens only for `ready`, `tail` and `error`
772
+ # (dashboard/web/src/hooks/useConversationLiveTail.ts), and EventSource
773
+ # dispatches only event types something listens for, so this is inert for
774
+ # the browser by construction. It exists for tests; do not delete it as
775
+ # unread. Contract: docs/dashboard-gotchas.md.
776
+ handler.wfile.write(b"event: baselined\ndata: {}\n\n")
777
+ handler.wfile.flush()
778
+
767
779
  idle = 0.0
768
780
  cycles = 0
769
781
  while True:
@@ -385,8 +385,9 @@ def _envelope_rows_weekly(
385
385
  # key uniqueness invariant matters.
386
386
  rows = conn.execute(
387
387
  f"""
388
- SELECT week_start_date, percent_threshold, captured_at_utc,
389
- alerted_at, cumulative_cost_usd, reset_event_id, account_key
388
+ SELECT week_start_date, week_start_at, percent_threshold,
389
+ captured_at_utc, alerted_at, cumulative_cost_usd,
390
+ reset_event_id, account_key
390
391
  FROM {descriptor.milestone_table}
391
392
  WHERE alerted_at IS NOT NULL
392
393
  ORDER BY {_CANON_ALERTED_AT} DESC
@@ -409,6 +410,16 @@ def _envelope_rows_weekly(
409
410
  **account_fields("claude", r["account_key"]),
410
411
  "context": {
411
412
  "week_start_date": r["week_start_date"],
413
+ # The subscription week's RESET INSTANT, which the milestone
414
+ # row retains and `AlertEntry.context` already declares. A
415
+ # reader given only `week_start_date` can place the window no
416
+ # better than UTC midnight, which is wrong by the whole
417
+ # reset-hour offset for every account that does not reset at
418
+ # midnight. Empty string on a row that predates the column,
419
+ # mirroring `block_start_at` on the five-hour axis below: the
420
+ # key stays on the wire and the reader degrades to day
421
+ # granularity instead of inventing a clock reading.
422
+ "week_start_at": r["week_start_at"] or "",
412
423
  "cumulative_cost_usd": cumulative,
413
424
  "dollars_per_percent": dpp,
414
425
  # Round-3: parallel to the 5h context block below — both
@@ -984,6 +995,30 @@ def _sync_failure_envelope(
984
995
  "action": None,
985
996
  }
986
997
 
998
+ text = error.casefold()
999
+ # Maintenance ownership supplies a more specific recovery action than a
1000
+ # secondary busy attribution from another leg in the same failed tick.
1001
+ # Classify those markers before the typed busy fallback so a blocked stale
1002
+ # owner is never told merely to checkpoint the WAL.
1003
+ if (
1004
+ "stale maintenance marker" in text
1005
+ or "stale repair marker" in text
1006
+ or "recovery could not claim maintenance" in text
1007
+ ):
1008
+ return {
1009
+ "kind": "maintenance_stale",
1010
+ "label": "⚠ cache repair blocked",
1011
+ "detail": "A stale cache repair owner is blocking server sync.",
1012
+ "action": "cctally cache-sync --rebuild",
1013
+ }
1014
+ if "maintenance is in progress" in text:
1015
+ return {
1016
+ "kind": "maintenance_active",
1017
+ "label": "cache repair in progress",
1018
+ "detail": "Another cctally process is repairing the server cache.",
1019
+ "action": None,
1020
+ }
1021
+
987
1022
  # #583 S2 §7. A locked cache.db is named rather than left in the generic
988
1023
  # bucket. Typed attribution only — a blanket uncorrupted-cache branch
989
1024
  # would be too broad, because ANY exception raised while reading cache.db
@@ -996,9 +1031,7 @@ def _sync_failure_envelope(
996
1031
  # without it a tick carrying a corruption-shaped cache attribution AND a
997
1032
  # separate busy one rendered "cache database busy" and dropped the
998
1033
  # corruption message — the more urgent of the two, whose remedy is not
999
- # interchangeable with a checkpoint. The gate names the TYPED corruption
1000
- # attribution only: a busy attribution still outranks the legacy raw-text
1001
- # corruption legs below, which is what Preserve 10 asks for.
1034
+ # interchangeable with a checkpoint.
1002
1035
  if attributed("cache", sqlite_busy=True) and not attributed(
1003
1036
  "cache", corruption=True
1004
1037
  ):
@@ -1012,26 +1045,6 @@ def _sync_failure_envelope(
1012
1045
  "action": "cctally db checkpoint",
1013
1046
  }
1014
1047
 
1015
- text = error.casefold()
1016
- if (
1017
- "stale maintenance marker" in text
1018
- or "stale repair marker" in text
1019
- or "recovery could not claim maintenance" in text
1020
- ):
1021
- return {
1022
- "kind": "maintenance_stale",
1023
- "label": "⚠ cache repair blocked",
1024
- "detail": "A stale cache repair owner is blocking server sync.",
1025
- "action": "cctally cache-sync --rebuild",
1026
- }
1027
- if "maintenance is in progress" in text:
1028
- return {
1029
- "kind": "maintenance_active",
1030
- "label": "cache repair in progress",
1031
- "detail": "Another cctally process is repairing the server cache.",
1032
- "action": None,
1033
- }
1034
-
1035
1048
  c = sys.modules["cctally"]
1036
1049
  if (
1037
1050
  attributed("cache", corruption=True)
@@ -1941,35 +1954,21 @@ def _claude_source_session_rows(envelope: dict) -> list:
1941
1954
  """The SOURCE-scoped Claude session row lists the overlay must cover.
1942
1955
 
1943
1956
  Since source schema 10 (#583 S3 §4) there is exactly ONE such list, on the
1944
- physical ``sources.claude`` entry. ``sources.all.data.providers.claude`` is
1945
- published null, so the mirror candidate below is a no-op that the
1946
- ``isinstance(data, Mapping)`` guard drops; it is retained because
1947
- ``sourceRows.ts::collectSourceSessionRows`` still reads
1948
- ``providers?.claude ?? env.sources.claude.data``, and a tab still running a
1949
- version 9 bundle after an in-place ``execvp`` update would otherwise get an
1950
- ungated row list. Removing the null stub and this candidate together is
1951
- filed as a residual.
1957
+ physical ``sources.claude`` entry. The null
1958
+ ``sources.all.data.providers.claude`` compatibility stub is not a second
1959
+ row owner and must not receive request-local transcript data.
1952
1960
  """
1953
1961
  sources = envelope.get("sources")
1954
1962
  if not isinstance(sources, Mapping):
1955
1963
  return []
1956
- out = []
1957
- candidates = [(sources.get("claude") or {}).get("data")]
1958
- all_data = (sources.get("all") or {}).get("data")
1959
- if isinstance(all_data, Mapping):
1960
- providers = all_data.get("providers")
1961
- if isinstance(providers, Mapping):
1962
- candidates.append(providers.get("claude"))
1963
- for data in candidates:
1964
- if not isinstance(data, Mapping):
1965
- continue
1966
- sessions = data.get("sessions")
1967
- if not isinstance(sessions, Mapping):
1968
- continue
1969
- rows = sessions.get("rows")
1970
- if isinstance(rows, list):
1971
- out.append(rows)
1972
- return out
1964
+ data = (sources.get("claude") or {}).get("data")
1965
+ if not isinstance(data, Mapping):
1966
+ return []
1967
+ sessions = data.get("sessions")
1968
+ if not isinstance(sessions, Mapping):
1969
+ return []
1970
+ rows = sessions.get("rows")
1971
+ return [rows] if isinstance(rows, list) else []
1973
1972
 
1974
1973
 
1975
1974
  def _overlay_claude_source_session_titles(
@@ -2021,9 +2020,9 @@ def _codex_source_session_rows(envelope: dict) -> list:
2021
2020
  label overlay must cover it under the same per-request transcript gate.
2022
2021
 
2023
2022
  Since source schema 10 (#583 S3 §4) those lists all live on the physical
2024
- ``sources.codex`` entry. ``sources.all.data.providers.codex`` is published
2025
- null, so the mirror candidate below is a no-op the ``isinstance`` guard
2026
- drops; it is retained for the same version 9 reason as the Claude twin.
2023
+ ``sources.codex`` entry. The null ``sources.all.data.providers.codex``
2024
+ compatibility stub is not a second row owner and must not receive
2025
+ request-local transcript data.
2027
2026
  """
2028
2027
  sources = envelope.get("sources")
2029
2028
  if not isinstance(sources, Mapping):
@@ -2051,14 +2050,7 @@ def _codex_source_session_rows(envelope: dict) -> list:
2051
2050
  if isinstance(rows, list):
2052
2051
  out.append(rows)
2053
2052
 
2054
- candidates = [(sources.get("codex") or {}).get("data")]
2055
- all_data = (sources.get("all") or {}).get("data")
2056
- if isinstance(all_data, Mapping):
2057
- providers = all_data.get("providers")
2058
- if isinstance(providers, Mapping):
2059
- candidates.append(providers.get("codex"))
2060
- for data in candidates:
2061
- append_rows(data)
2053
+ append_rows((sources.get("codex") or {}).get("data"))
2062
2054
  return out
2063
2055
 
2064
2056
 
@@ -58,6 +58,25 @@ def _share_load_lib(*args, **kwargs):
58
58
  return sys.modules["cctally"]._share_load_lib(*args, **kwargs)
59
59
 
60
60
 
61
+ def _optional_float(value):
62
+ """``float(value)`` when there is a value, ``None`` when there is not.
63
+
64
+ #620 S1 D5. The idiom this replaces — ``float(getattr(x, "f", 0.0) or
65
+ 0.0)`` — collapses three distinct states (a real zero, an explicit
66
+ ``None``, and a missing attribute) onto one number, so a withheld
67
+ measurement leaves the builder indistinguishable from a measured zero.
68
+ Written as a helper rather than repeated inline because the reason is
69
+ the same at every site and the inline form reads as a default.
70
+ """
71
+ if value is None:
72
+ return None
73
+ # No `except (TypeError, ValueError): return None`. On a surface whose
74
+ # entire purpose is telling "absent" apart from "present", reporting a
75
+ # malformed value as withheld is the same conflation the helper removes.
76
+ # A malformed value is a defect and must surface as one (#620 S1).
77
+ return float(value)
78
+
79
+
61
80
  def _share_now_utc(*args, **kwargs):
62
81
  return sys.modules["cctally"]._share_now_utc(*args, **kwargs)
63
82
 
@@ -631,9 +650,19 @@ def _build_weekly_share_panel_data(options: dict,
631
650
  wsa = getattr(r, "week_start_at", "") or ""
632
651
  start_date = wsa[:10] if isinstance(wsa, str) and len(wsa) >= 10 else wsa
633
652
  cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
634
- used_pct_raw = getattr(r, "used_pct", None)
635
- used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
636
- dpp = float(getattr(r, "dollar_per_pct", 0.0) or 0.0)
653
+ # `WeeklyPeriodRow.used_pct` is `float | None` — `None` when the week
654
+ # has a cost snapshot but no usage snapshot. Coercing it to zero
655
+ # published `0.0%` for a week nothing measured, which reads as a real
656
+ # zero-usage week. Same defect as the rate below, same treatment.
657
+ used_pct_raw = _optional_float(getattr(r, "used_pct", None))
658
+ used_pct = None if used_pct_raw is None else used_pct_raw / 100.0
659
+ # `WeeklyPeriodRow.dollar_per_pct` is `float | None` — it is `None`
660
+ # whenever the week has no usage percentage to divide by. Coercing it
661
+ # to zero here published `$0.000` for a rate nothing measured, which
662
+ # is the same fabrication #620 S1 D5 removed on the current-week and
663
+ # trend panels. Pre-existing rather than introduced by D5, and swept
664
+ # with them because it is the same defect on the same surface.
665
+ dpp = _optional_float(getattr(r, "dollar_per_pct", None))
637
666
  # Per-week top_projects: WeeklyPeriodRow doesn't carry a
638
667
  # per-project rollup, but `week_start_at` / `week_end_at` give us
639
668
  # an exact range — aggregate session_entries once per week so the
@@ -682,7 +711,9 @@ def _build_current_week_share_panel_data(options: dict,
682
711
  return {
683
712
  "kpi_cost_usd": 0.0,
684
713
  "kpi_pct_used": 0.0,
685
- "kpi_dollar_per_pct": 0.0,
714
+ # With no current week at all there is no rate to report, so
715
+ # this is withheld for the same reason as the populated path.
716
+ "kpi_dollar_per_pct": None,
686
717
  "kpi_days_remaining": 0.0,
687
718
  "daily_progression": [],
688
719
  "top_projects": [],
@@ -728,7 +759,13 @@ def _build_current_week_share_panel_data(options: dict,
728
759
  return {
729
760
  "kpi_cost_usd": float(getattr(cw, "spent_usd", 0.0) or 0.0),
730
761
  "kpi_pct_used": used_pct,
731
- "kpi_dollar_per_pct": float(getattr(cw, "dollars_per_percent", 0.0) or 0.0),
762
+ # #620 S1 D5: `TuiCurrentWeek.dollars_per_percent` is `float | None`,
763
+ # and `float(... or 0.0)` turned both a missing attribute and an
764
+ # explicit None into $0.00 — the fabrication D5 removes, restored one
765
+ # layer below the fix. A withheld rate stays withheld; the template
766
+ # renders it as `n/a`.
767
+ "kpi_dollar_per_pct": _optional_float(
768
+ getattr(cw, "dollars_per_percent", None)),
732
769
  "kpi_days_remaining": days_remaining,
733
770
  "daily_progression": progression,
734
771
  "top_projects": top_projects,
@@ -751,25 +788,41 @@ def _build_trend_share_panel_data(options: dict,
751
788
  wsa.strftime("%Y-%m-%d") if isinstance(wsa, dt.datetime)
752
789
  else (str(wsa)[:10] if wsa else "")
753
790
  )
754
- used_pct_raw = getattr(r, "used_pct", None)
755
- used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
756
- dpp = float(getattr(r, "dollars_per_percent", 0.0) or 0.0)
791
+ # `TuiTrendRow.used_pct` is `float | None` (phantom weeks — a cost
792
+ # snapshot with no usage snapshot). See the weekly builder above.
793
+ used_pct_raw = _optional_float(getattr(r, "used_pct", None))
794
+ used_pct = None if used_pct_raw is None else used_pct_raw / 100.0
795
+ # #620 S1 D5, as above. Here the fabricated zero propagated further
796
+ # than the KPI: `cost_usd` is derived from the rate, so a zeroed rate
797
+ # also published a zero weekly cost for a week whose cost is unknown.
798
+ dpp = _optional_float(getattr(r, "dollars_per_percent", None))
757
799
  weeks.append({
758
800
  "start_date": start_date,
759
- "cost_usd": dpp * (used_pct * 100.0), # ≈ row total
801
+ # A rate times a percentage is a cost only when both are known.
802
+ # With `used_pct` coerced to zero, a week with a real rate and no
803
+ # measured percentage published a $0.00 cost.
804
+ "cost_usd": (
805
+ None if (dpp is None or used_pct is None)
806
+ else dpp * (used_pct * 100.0)
807
+ ), # ≈ row total
760
808
  "pct_used": used_pct,
761
809
  "dollar_per_pct": dpp,
762
810
  })
763
811
  # Compute 3-week delta: compare last row vs row-4-from-end.
764
- delta = {"dpp_change_pct": 0.0, "cost_change_usd": 0.0}
812
+ # A delta between two weeks is a fact only when both weeks are known.
813
+ # The previous default published `0.0`, which states "no change" — the
814
+ # same fabrication as a zeroed rate, one aggregate up (#620 S1 D5).
815
+ delta: dict = {"dpp_change_pct": None, "cost_change_usd": None}
765
816
  if len(weeks) >= 4:
766
817
  cur = weeks[-1]
767
818
  ref = weeks[-4]
768
- if ref["dollar_per_pct"]:
819
+ if ref["dollar_per_pct"] and cur["dollar_per_pct"] is not None:
769
820
  delta["dpp_change_pct"] = (
770
821
  (cur["dollar_per_pct"] - ref["dollar_per_pct"]) / ref["dollar_per_pct"]
771
822
  )
772
- delta["cost_change_usd"] = cur["cost_usd"] - ref["cost_usd"]
823
+ # A delta between two costs is only a fact when both are known.
824
+ if cur["cost_usd"] is not None and ref["cost_usd"] is not None:
825
+ delta["cost_change_usd"] = cur["cost_usd"] - ref["cost_usd"]
773
826
  return {"weeks": weeks, "delta_3_weeks": delta}
774
827
 
775
828
 
@@ -923,8 +976,10 @@ def _build_forecast_share_panel_data(options: dict,
923
976
  if fc is None:
924
977
  return {
925
978
  "projected_end_pct": 0.0,
926
- "days_to_100pct": 0.0,
927
- "days_to_90pct": 0.0,
979
+ # With no forecast at all there is no rate, so neither ceiling has
980
+ # a distance — the same withholding the populated path performs.
981
+ "days_to_100pct": None,
982
+ "days_to_90pct": None,
928
983
  "daily_budgets": {
929
984
  "avg": 0.0, "recent_24h": 0.0,
930
985
  "until_90pct": 0.0, "until_100pct": 0.0,
@@ -943,10 +998,18 @@ def _build_forecast_share_panel_data(options: dict,
943
998
  r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
944
999
  # End-of-week projected %
945
1000
  projected_end_pct = (p_now + r_avg * remaining_hours) / 100.0
946
- # Days to ceilings (simple inverse: hours-to-target / 24)
947
- def _days_to_ceiling(target_pct: float) -> float:
948
- if r_avg <= 0 or p_now >= target_pct:
1001
+ # Days to ceilings (simple inverse: hours-to-target / 24).
1002
+ # The two exit conditions are opposite facts and must not share a value.
1003
+ # `p_now >= target_pct` means the target is already reached, and zero days
1004
+ # to it is true. `r_avg <= 0` means no rate was observed, so the target is
1005
+ # not reachable on any timeline this data describes — and in the no-usage
1006
+ # state BOTH hold (`p_now == 0`, `r_avg == 0`), so the shared `0.0`
1007
+ # rendered `Days->90% 0.0`, stating the opposite of the truth.
1008
+ def _days_to_ceiling(target_pct: float) -> "float | None":
1009
+ if p_now >= target_pct:
949
1010
  return 0.0
1011
+ if r_avg <= 0:
1012
+ return None
950
1013
  hours = (target_pct - p_now) / r_avg
951
1014
  return max(0.0, hours / 24.0)
952
1015
  days_to_100 = _days_to_ceiling(100.0)
@@ -954,27 +1017,36 @@ def _build_forecast_share_panel_data(options: dict,
954
1017
  # Daily budgets — prefer ForecastView's pre-routed pair (issue #57)
955
1018
  # when available; otherwise replay the legacy ``fc.budgets`` scan
956
1019
  # inline so positionally-constructed fixture snapshots still work.
957
- budgets: dict = {"avg": 0.0, "recent_24h": 0.0,
958
- "until_90pct": 0.0, "until_100pct": 0.0}
1020
+ # Unknown until a branch below supplies one. A ceiling budget that no
1021
+ # `BudgetRow` describes is not a $0.00/day budget.
1022
+ budgets: dict = {"avg": None, "recent_24h": None,
1023
+ "until_90pct": None, "until_100pct": None}
1024
+ # #620 S1 D5: no `or 0.0` on either branch. `ForecastView.budget_*_per_day_usd`
1025
+ # and `BudgetRow.dollars_per_day` are both `None` whenever the rate is
1026
+ # withheld, and coercing them republished $0.00 on exactly the surface
1027
+ # whose message is that nothing was measured.
959
1028
  if fc_view is not None:
960
- budgets["until_100pct"] = float(
961
- fc_view.budget_100_per_day_usd or 0.0,
962
- )
963
- budgets["until_90pct"] = float(
964
- fc_view.budget_90_per_day_usd or 0.0,
965
- )
1029
+ budgets["until_100pct"] = _optional_float(
1030
+ getattr(fc_view, "budget_100_per_day_usd", None))
1031
+ budgets["until_90pct"] = _optional_float(
1032
+ getattr(fc_view, "budget_90_per_day_usd", None))
966
1033
  else:
967
1034
  for b in getattr(fc, "budgets", None) or []:
968
1035
  tp = getattr(b, "target_percent", None)
969
- dpd = float(getattr(b, "dollars_per_day", 0.0) or 0.0)
1036
+ dpd = _optional_float(getattr(b, "dollars_per_day", None))
970
1037
  if tp == 100:
971
1038
  budgets["until_100pct"] = dpd
972
1039
  elif tp == 90:
973
1040
  budgets["until_90pct"] = dpd
974
1041
  # avg / recent_24h: derive from dollars-per-percent × r_avg/r_recent.
975
- dpp = float(getattr(inputs, "dollars_per_percent", 0.0) or 0.0) if inputs else 0.0
976
- budgets["avg"] = dpp * r_avg * 24.0
977
- budgets["recent_24h"] = dpp * r_recent * 24.0
1042
+ # #620 S1 D5: the `or 0.0` fabrication is REMOVED rather than moved. With
1043
+ # no observed usage there is no rate, so a dollars-per-day figure derived
1044
+ # from it is unavailable; substituting zero here would restore the same
1045
+ # defect one layer below the fix.
1046
+ _raw_dpp = getattr(inputs, "dollars_per_percent", None) if inputs else None
1047
+ dpp = None if _raw_dpp is None else float(_raw_dpp)
1048
+ budgets["avg"] = None if dpp is None else dpp * r_avg * 24.0
1049
+ budgets["recent_24h"] = None if dpp is None else dpp * r_recent * 24.0
978
1050
  # Projection curve — 7-day forward, using r_avg
979
1051
  today = _share_now_utc().date()
980
1052
  projection_curve: list[dict] = []