cctally 1.22.4 → 1.23.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.
@@ -336,6 +336,10 @@ def _build_alert_payload_budget(*args, **kwargs):
336
336
  return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
337
337
 
338
338
 
339
+ def _build_alert_payload_projected(*args, **kwargs):
340
+ return sys.modules["cctally"]._build_alert_payload_projected(*args, **kwargs)
341
+
342
+
339
343
  def _dispatch_alert_notification(*args, **kwargs):
340
344
  return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
341
345
 
@@ -4065,31 +4069,16 @@ def _select_current_block_for_envelope(
4065
4069
  }
4066
4070
 
4067
4071
 
4068
- def _build_alerts_envelope_array(
4069
- conn: sqlite3.Connection,
4070
- limit: int = 100,
4071
- ) -> list[dict]:
4072
- """Return the ``alerts`` array for the SSE snapshot envelope.
4073
-
4074
- Union of ``percent_milestones``, ``five_hour_milestones``, and
4075
- ``budget_milestones`` rows with ``alerted_at IS NOT NULL``, ordered
4076
- newest-first by ``alerted_at``, capped at ``limit`` (default 100).
4077
- Single source of truth for both the dashboard panel (slices to 10
4078
- client-side) and the modal (renders all 100). Forward-only
4079
- semantics: only rows the alert-dispatch path stamped get included;
4080
- pre-deploy crossings stay NULL and are intentionally invisible
4081
- (spec §4.3).
4072
+ # === Alerts-envelope per-axis row-mappers (Task F) =========================
4073
+ # Each mapper turns one axis's ``alerted_at IS NOT NULL`` milestone rows into
4074
+ # the shared envelope-item dicts. The SQL is genuinely heterogeneous per axis
4075
+ # (Codex P0-3: distinct columns, JOINs, id shapes), so the registry unifies the
4076
+ # *set* of axes + their ``milestone_table`` + the shared ``severity_for``
4077
+ # authority, not the query itself. ``descriptor.milestone_table`` drives each
4078
+ # ``FROM`` clause so the table name lives in the registry, not inlined here.
4082
4079
 
4083
- All three axes share the same envelope schema; the ``axis`` field
4084
- (``weekly`` / ``five_hour`` / ``budget``) discriminates.
4085
4080
 
4086
- Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
4087
- up to ``limit``) and the union is re-sorted + sliced — important for
4088
- the boundary case where one axis has ``limit`` rows and the other
4089
- has more recent ones that would otherwise be dropped before the
4090
- final sort.
4091
- """
4092
- out: list[dict] = []
4081
+ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
4093
4082
  # ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
4094
4083
  # across pre-credit (0) and post-credit (event.id) cohorts, both
4095
4084
  # of which can be alerted. The envelope id must include the
@@ -4097,25 +4086,27 @@ def _build_alerts_envelope_array(
4097
4086
  # collide on the duplicate (week, threshold) pair. Older clients
4098
4087
  # tolerate longer ids — the id is opaque to them; only the React
4099
4088
  # key uniqueness invariant matters.
4100
- weekly_rows = conn.execute(
4101
- """
4089
+ rows = conn.execute(
4090
+ f"""
4102
4091
  SELECT week_start_date, percent_threshold, captured_at_utc,
4103
4092
  alerted_at, cumulative_cost_usd, reset_event_id
4104
- FROM percent_milestones
4093
+ FROM {descriptor.milestone_table}
4105
4094
  WHERE alerted_at IS NOT NULL
4106
4095
  ORDER BY alerted_at DESC
4107
4096
  LIMIT ?
4108
4097
  """,
4109
4098
  (limit,),
4110
4099
  ).fetchall()
4111
- for r in weekly_rows:
4100
+ out: list[dict] = []
4101
+ for r in rows:
4112
4102
  threshold = int(r["percent_threshold"])
4113
4103
  cumulative = float(r["cumulative_cost_usd"])
4114
4104
  dpp = (cumulative / threshold) if threshold else None
4115
4105
  out.append({
4116
4106
  "id": f"weekly:{r['week_start_date']}:{threshold}:{r['reset_event_id']}",
4117
- "axis": "weekly",
4107
+ "axis": descriptor.id,
4118
4108
  "threshold": threshold,
4109
+ "severity": severity_for(threshold),
4119
4110
  "crossed_at": r["captured_at_utc"],
4120
4111
  "alerted_at": r["alerted_at"],
4121
4112
  "context": {
@@ -4132,20 +4123,22 @@ def _build_alerts_envelope_array(
4132
4123
  "reset_event_id": int(r["reset_event_id"]),
4133
4124
  },
4134
4125
  })
4126
+ return out
4127
+
4135
4128
 
4129
+ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict]:
4136
4130
  # Site F (spec §3.2 bucket C / §3.3): widen the row identity to
4137
4131
  # include ``reset_event_id`` so post-credit (seg=event.id) crossings
4138
4132
  # of the same (window_key, threshold) don't collide with pre-credit
4139
4133
  # (seg=0) crossings on the React row key. Older clients tolerate
4140
4134
  # longer ids — the id is opaque to them; only the React key
4141
- # uniqueness invariant matters. Mirrors the weekly precedent at
4142
- # line ~2597.
4143
- fh_rows = conn.execute(
4144
- """
4135
+ # uniqueness invariant matters. Mirrors the weekly precedent.
4136
+ rows = conn.execute(
4137
+ f"""
4145
4138
  SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
4146
4139
  m.alerted_at, m.block_cost_usd, m.reset_event_id,
4147
4140
  b.block_start_at
4148
- FROM five_hour_milestones m
4141
+ FROM {descriptor.milestone_table} m
4149
4142
  LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
4150
4143
  WHERE m.alerted_at IS NOT NULL
4151
4144
  ORDER BY m.alerted_at DESC
@@ -4153,15 +4146,17 @@ def _build_alerts_envelope_array(
4153
4146
  """,
4154
4147
  (limit,),
4155
4148
  ).fetchall()
4156
- for r in fh_rows:
4149
+ out: list[dict] = []
4150
+ for r in rows:
4157
4151
  threshold = int(r["percent_threshold"])
4158
4152
  out.append({
4159
4153
  "id": (
4160
4154
  f"five_hour:{int(r['five_hour_window_key'])}:"
4161
4155
  f"{threshold}:{int(r['reset_event_id'])}"
4162
4156
  ),
4163
- "axis": "five_hour",
4157
+ "axis": descriptor.id,
4164
4158
  "threshold": threshold,
4159
+ "severity": severity_for(threshold),
4165
4160
  "crossed_at": r["captured_at_utc"],
4166
4161
  "alerted_at": r["alerted_at"],
4167
4162
  "context": {
@@ -4171,7 +4166,10 @@ def _build_alerts_envelope_array(
4171
4166
  "reset_event_id": int(r["reset_event_id"]),
4172
4167
  },
4173
4168
  })
4169
+ return out
4170
+
4174
4171
 
4172
+ def _envelope_rows_budget(conn, descriptor, limit, severity_for) -> list[dict]:
4175
4173
  # Third axis (issue #19): equiv-$ budget threshold crossings. Budget
4176
4174
  # alerts are keyed by the effective (post-reset) week_start_at + the
4177
4175
  # integer threshold; the envelope id mirrors the dispatch payload's
@@ -4179,23 +4177,25 @@ def _build_alerts_envelope_array(
4179
4177
  # (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
4180
4178
  # a mid-week reset re-anchors ``week_start_at`` so the new window
4181
4179
  # naturally gets fresh rows under ``UNIQUE(week_start_at, threshold)``.
4182
- budget_rows = conn.execute(
4183
- """
4180
+ rows = conn.execute(
4181
+ f"""
4184
4182
  SELECT week_start_at, threshold, crossed_at_utc, alerted_at,
4185
4183
  budget_usd, spent_usd, consumption_pct
4186
- FROM budget_milestones
4184
+ FROM {descriptor.milestone_table}
4187
4185
  WHERE alerted_at IS NOT NULL
4188
4186
  ORDER BY alerted_at DESC
4189
4187
  LIMIT ?
4190
4188
  """,
4191
4189
  (limit,),
4192
4190
  ).fetchall()
4193
- for r in budget_rows:
4191
+ out: list[dict] = []
4192
+ for r in rows:
4194
4193
  threshold = int(r["threshold"])
4195
4194
  out.append({
4196
4195
  "id": f"budget:{r['week_start_at']}:{threshold}",
4197
- "axis": "budget",
4196
+ "axis": descriptor.id,
4198
4197
  "threshold": threshold,
4198
+ "severity": severity_for(threshold),
4199
4199
  "crossed_at": r["crossed_at_utc"],
4200
4200
  "alerted_at": r["alerted_at"],
4201
4201
  "context": {
@@ -4205,12 +4205,115 @@ def _build_alerts_envelope_array(
4205
4205
  "consumption_pct": float(r["consumption_pct"]),
4206
4206
  },
4207
4207
  })
4208
+ return out
4209
+
4210
+
4211
+ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict]:
4212
+ # Fourth axis (issue #121): projected-pace threshold crossings. Like
4213
+ # budget, projected alerts re-anchor ``week_start_at`` on a mid-week
4214
+ # reset, so there is NO ``reset_event_id`` segment — the new window gets
4215
+ # fresh rows under ``UNIQUE(week_start_at, metric, threshold)``. The
4216
+ # ``metric`` discriminator (``weekly_pct`` | ``budget_usd``) drives the
4217
+ # frontend's metric-aware context renderer; ``denominator`` +
4218
+ # ``projected_value`` are rendered FROM THE ROW (the values snapshotted at
4219
+ # crossing), never live config that may have changed since (Codex P0-4).
4220
+ # The envelope id mirrors the dispatch payload's
4221
+ # ``projected:<week_start_at>:<metric>:<threshold>`` shape.
4222
+ rows = conn.execute(
4223
+ f"""
4224
+ SELECT week_start_at, metric, threshold, projected_value,
4225
+ denominator, crossed_at_utc, alerted_at
4226
+ FROM {descriptor.milestone_table}
4227
+ WHERE alerted_at IS NOT NULL
4228
+ ORDER BY alerted_at DESC
4229
+ LIMIT ?
4230
+ """,
4231
+ (limit,),
4232
+ ).fetchall()
4233
+ out: list[dict] = []
4234
+ for r in rows:
4235
+ threshold = int(r["threshold"])
4236
+ metric = str(r["metric"])
4237
+ out.append({
4238
+ "id": f"projected:{r['week_start_at']}:{metric}:{threshold}",
4239
+ "axis": descriptor.id,
4240
+ "metric": metric,
4241
+ "threshold": threshold,
4242
+ "severity": severity_for(threshold),
4243
+ "crossed_at": r["crossed_at_utc"],
4244
+ "alerted_at": r["alerted_at"],
4245
+ "context": {
4246
+ "week_start_at": r["week_start_at"],
4247
+ "metric": metric,
4248
+ "projected_value": float(r["projected_value"]),
4249
+ "denominator": float(r["denominator"]),
4250
+ },
4251
+ })
4252
+ return out
4253
+
4254
+
4255
+ # Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
4256
+ # in what order; this table supplies the bespoke heterogeneous row-mapper.
4257
+ _ENVELOPE_AXIS_MAPPERS = {
4258
+ "weekly": _envelope_rows_weekly,
4259
+ "five_hour": _envelope_rows_five_hour,
4260
+ "budget": _envelope_rows_budget,
4261
+ "projected": _envelope_rows_projected,
4262
+ }
4263
+
4264
+
4265
+ def _build_alerts_envelope_array(
4266
+ conn: sqlite3.Connection,
4267
+ limit: int = 100,
4268
+ ) -> list[dict]:
4269
+ """Return the ``alerts`` array for the SSE snapshot envelope.
4270
+
4271
+ Union of ``percent_milestones``, ``five_hour_milestones``,
4272
+ ``budget_milestones``, and ``projected_milestones`` rows with
4273
+ ``alerted_at IS NOT NULL``, ordered newest-first by ``alerted_at``,
4274
+ capped at ``limit`` (default 100). Single source of truth for both the
4275
+ dashboard panel (slices to 10 client-side) and the modal (renders all
4276
+ 100). Forward-only semantics: only rows the alert-dispatch path stamped
4277
+ get included; pre-deploy crossings stay NULL and are intentionally
4278
+ invisible (spec §4.3).
4279
+
4280
+ All four axes share the same envelope schema; the ``axis`` field
4281
+ (``weekly`` / ``five_hour`` / ``budget`` / ``projected``) discriminates.
4282
+ The ``projected`` axis additionally carries a top-level ``metric``
4283
+ (``weekly_pct`` | ``budget_usd``) so the frontend can pick its
4284
+ metric-aware context renderer.
4285
+
4286
+ Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
4287
+ up to ``limit``) and the union is re-sorted + sliced — important for
4288
+ the boundary case where one axis has ``limit`` rows and the other
4289
+ has more recent ones that would otherwise be dropped before the
4290
+ final sort.
4291
+
4292
+ **Registry-driven (Task F).** The *set* of axes, their union *order*,
4293
+ and each axis's ``milestone_table`` come from
4294
+ ``_lib_alert_axes.AXIS_REGISTRY`` — adding a future axis is "register a
4295
+ descriptor + add a row-mapper", not "hand-roll a parallel branch". The
4296
+ SQL stays genuinely heterogeneous per axis (Codex P0-3: different
4297
+ columns, JOINs, id shapes), so each descriptor pairs with a bespoke
4298
+ row-mapper keyed by ``descriptor.id`` in ``_ENVELOPE_AXIS_MAPPERS``. The
4299
+ shared ``severity_for`` kernel stamps the additive ``severity`` field on
4300
+ every item (single severity authority, consumed by the frontend too).
4301
+ """
4302
+ c = _cctally()
4303
+ registry = c.AXIS_REGISTRY
4304
+ severity_for = c.severity_for
4305
+ out: list[dict] = []
4306
+ for descriptor in registry:
4307
+ mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
4308
+ if mapper is None: # pragma: no cover - registry/mapper drift guard
4309
+ continue
4310
+ out.extend(mapper(conn, descriptor, limit, severity_for))
4208
4311
 
4209
4312
  # Python's list.sort is stable. When two alerts share the same
4210
4313
  # `alerted_at` ISO string (rare; multiple axes firing within the same
4211
- # millisecond), the union order (weekly, then 5h, then budget)
4212
- # determines the tiebreaker — no extra deterministic key is added
4213
- # because the spec doesn't require one.
4314
+ # millisecond), the union order (weekly, then 5h, then budget, then
4315
+ # projected) determines the tiebreaker — no extra deterministic key is
4316
+ # added because the spec doesn't require one.
4214
4317
  out.sort(key=lambda a: a["alerted_at"], reverse=True)
4215
4318
  return out[:limit]
4216
4319
 
@@ -4583,6 +4686,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4583
4686
  "enabled": False,
4584
4687
  "weekly_thresholds": [],
4585
4688
  "five_hour_thresholds": [],
4689
+ "projected_enabled": False,
4586
4690
  }
4587
4691
  # Budget is its OWN config block (issue #19) — source budget fields
4588
4692
  # from ``_get_budget_config`` (the validated ``budget`` block), NOT
@@ -4592,13 +4696,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4592
4696
  _budget_cfg = _get_budget_config(_cfg_for_alerts)
4593
4697
  except _BudgetConfigError:
4594
4698
  _budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
4595
- "alert_thresholds": []}
4699
+ "alert_thresholds": [], "projected_enabled": False}
4596
4700
  alerts_settings = {
4597
4701
  "enabled": _alerts_cfg["enabled"],
4598
4702
  "weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
4599
4703
  "five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
4600
4704
  "budget_thresholds": list(_budget_cfg["alert_thresholds"]),
4601
4705
  "budget_enabled": _budget_alerts_active(_budget_cfg),
4706
+ # Projected-pace opt-in mirrors (#121). Two flags, one per parent
4707
+ # axis — the frontend SettingsOverlay seeds two toggles. Sourced
4708
+ # from the validated getters' ``projected_enabled`` (default False).
4709
+ "projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
4710
+ "projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
4602
4711
  }
4603
4712
 
4604
4713
  # Mirror update-state.json + update-suppress.json into the envelope
@@ -5182,16 +5291,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5182
5291
  "update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
5183
5292
  "cache_report"?: {"anomaly_threshold_pp"?: int},
5184
5293
  "budget"?: {"weekly_usd"?: number|null, "alerts_enabled"?: bool,
5185
- "alert_thresholds"?: int[]}}`` — every top-level key is optional;
5186
- any subset may be sent together (combined save). Unknown
5187
- top-level keys are rejected with 400.
5294
+ "alert_thresholds"?: int[], "projected_enabled"?: bool}}`` — every
5295
+ top-level key is optional; any subset may be sent together
5296
+ (combined save). Unknown top-level keys are rejected with 400.
5188
5297
 
5189
5298
  Per-block validation:
5190
5299
  * ``display.tz`` — "local", "utc", or a valid IANA zone (via
5191
5300
  ``normalize_display_tz_value``); 400 on invalid.
5192
- * ``alerts`` — must be a dict; ``alerts.enabled`` must be a
5193
- JSON boolean (string "yes"/"true" rejected, per spec). Merged
5194
- block is validated via ``_get_alerts_config(merged)``;
5301
+ * ``alerts`` — must be a dict; ``alerts.enabled`` and
5302
+ ``alerts.projected_enabled`` must each be a JSON boolean
5303
+ (string "yes"/"true" rejected, per spec). Merged block is
5304
+ validated via ``_get_alerts_config(merged)``;
5195
5305
  ``_AlertsConfigError`` → 400.
5196
5306
  * ``update.check.enabled`` — JSON bool; 400 on type mismatch.
5197
5307
  * ``update.check.ttl_hours`` — JSON int (NOT string), in
@@ -5203,10 +5313,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5203
5313
  ``{error, field: "anomaly_threshold_pp"}`` on out-of-range
5204
5314
  or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
5205
5315
  F10 tracks lifting that.
5206
- * ``budget`` — must be a dict; merged onto the persisted
5207
- ``budget`` block and validated via ``_get_budget_config(merged)``
5208
- (issue #19); ``_BudgetConfigError`` 400. Budget is its OWN
5209
- config block, distinct from ``alerts``.
5316
+ * ``budget`` — must be a dict; the inbound leaves
5317
+ (``weekly_usd`` / ``alerts_enabled`` / ``alert_thresholds`` /
5318
+ ``projected_enabled``) are merged onto the persisted ``budget``
5319
+ block and validated via ``_get_budget_config(merged)`` (issue
5320
+ #19, projected toggle #121); ``_BudgetConfigError`` → 400.
5321
+ Budget is its OWN config block, distinct from ``alerts``.
5210
5322
 
5211
5323
  Atomic merged write: if all touched blocks validate, the merged
5212
5324
  config is persisted in a single ``save_config`` call inside the
@@ -5367,6 +5479,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5367
5479
  {"error": "alerts.enabled must be a JSON boolean"},
5368
5480
  )
5369
5481
  return
5482
+ if "projected_enabled" in alerts_block and not isinstance(
5483
+ alerts_block["projected_enabled"], bool
5484
+ ):
5485
+ self._respond_json(
5486
+ 400,
5487
+ {"error": "alerts.projected_enabled must be a JSON boolean"},
5488
+ )
5489
+ return
5370
5490
 
5371
5491
  # Pre-validate budget shape (the structural type check; full
5372
5492
  # cross-key validation runs inside the lock via
@@ -5478,6 +5598,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5478
5598
  alerts_in = payload["alerts"]
5479
5599
  if "enabled" in alerts_in:
5480
5600
  merged_alerts["enabled"] = alerts_in["enabled"]
5601
+ if "projected_enabled" in alerts_in:
5602
+ merged_alerts["projected_enabled"] = (
5603
+ alerts_in["projected_enabled"]
5604
+ )
5481
5605
  merged["alerts"] = merged_alerts
5482
5606
  # Final cross-field validation against the merged block.
5483
5607
  # _AlertsConfigError → 400 (no partial write since
@@ -5504,7 +5628,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5504
5628
  return
5505
5629
  merged_budget = dict(existing_budget or {})
5506
5630
  budget_in = payload["budget"]
5507
- for leaf in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
5631
+ for leaf in (
5632
+ "weekly_usd", "alerts_enabled", "alert_thresholds",
5633
+ "projected_enabled",
5634
+ ):
5508
5635
  if leaf in budget_in:
5509
5636
  merged_budget[leaf] = budget_in[leaf]
5510
5637
  merged["budget"] = merged_budget
@@ -5641,8 +5768,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5641
5768
  dispatch status string in the JSON response.
5642
5769
 
5643
5770
  Body (all fields optional): ``{"axis":
5644
- "weekly"|"five_hour"|"budget", "threshold": 1..100}``. Defaults:
5645
- axis="weekly", threshold=90.
5771
+ "weekly"|"five_hour"|"budget"|"projected", "threshold": 1..100,
5772
+ "metric": "weekly_pct"|"budget_usd"}``. Defaults: axis="weekly",
5773
+ threshold=90, metric="weekly_pct". ``metric`` is only consulted for
5774
+ the ``projected`` axis (mirrors the CLI ``alerts test --axis
5775
+ projected --metric`` surface); it is ignored for the other axes.
5646
5776
 
5647
5777
  IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
5648
5778
  in the JSON API to match the dispatch payload's internal axis
@@ -5681,12 +5811,25 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5681
5811
  return
5682
5812
 
5683
5813
  axis = body.get("axis", "weekly")
5684
- if axis not in ("weekly", "five_hour", "budget"):
5814
+ if axis not in ("weekly", "five_hour", "budget", "projected"):
5815
+ self._respond_json(
5816
+ 400,
5817
+ {"error": (
5818
+ "axis must be 'weekly', 'five_hour', 'budget' or "
5819
+ f"'projected', got {axis!r}"
5820
+ )},
5821
+ )
5822
+ return
5823
+ # ``metric`` discriminates the projected axis (weekly_pct vs
5824
+ # budget_usd); the other axes ignore it. Validate only when it
5825
+ # matters so a stray metric on a weekly/budget test isn't a 400.
5826
+ metric = body.get("metric", "weekly_pct")
5827
+ if axis == "projected" and metric not in ("weekly_pct", "budget_usd"):
5685
5828
  self._respond_json(
5686
5829
  400,
5687
5830
  {"error": (
5688
- "axis must be 'weekly', 'five_hour' or 'budget', "
5689
- f"got {axis!r}"
5831
+ "metric must be 'weekly_pct' or 'budget_usd', "
5832
+ f"got {metric!r}"
5690
5833
  )},
5691
5834
  )
5692
5835
  return
@@ -5724,6 +5867,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5724
5867
  spent_usd=300.0 * threshold / 100.0,
5725
5868
  consumption_pct=float(threshold),
5726
5869
  )
5870
+ elif axis == "projected":
5871
+ # Synthetic projected-pace payload — mirrors the CLI
5872
+ # cmd_alerts_test projected branch (NO DB writes, test/real
5873
+ # divergence contract). The metric discriminator picks the
5874
+ # wiring; projected_value is the threshold's denominator-relative
5875
+ # value (so the body reads plausibly, e.g. weekly 100% → "~100% of
5876
+ # cap", budget 100% → "$300 of $300"). denominator is the
5877
+ # at-crossing target the row would carry (Codex P0-4): 100.0 for
5878
+ # weekly_pct, $300 for budget_usd.
5879
+ if metric == "budget_usd":
5880
+ denominator = 300.0
5881
+ projected_value = 300.0 * threshold / 100.0
5882
+ else: # weekly_pct
5883
+ denominator = 100.0
5884
+ projected_value = float(threshold)
5885
+ payload = _build_alert_payload_projected(
5886
+ metric=metric,
5887
+ threshold=threshold,
5888
+ projected_value=projected_value,
5889
+ denominator=denominator,
5890
+ week_start_at=dt.date.today().isoformat(),
5891
+ )
5727
5892
  else:
5728
5893
  payload = _build_alert_payload_five_hour(
5729
5894
  threshold=threshold,
@@ -266,7 +266,9 @@ def _resolve_current_budget_window(conn, now_utc):
266
266
  return (week_start_at, week_end_at)
267
267
 
268
268
 
269
- def _build_budget_status_inputs(conn, *, target_usd, now_utc, alert_thresholds):
269
+ def _build_budget_status_inputs(
270
+ conn, *, target_usd, now_utc, alert_thresholds, skip_sync=False
271
+ ):
270
272
  """Gather live spend over the current subscription week and build a
271
273
  :class:`BudgetInputs`. Returns ``None`` when no week window resolves.
272
274
 
@@ -280,19 +282,29 @@ def _build_budget_status_inputs(conn, *, target_usd, now_utc, alert_thresholds):
280
282
  (``max(week_start_at, now - 24h)``) so a heavy spend just before reset
281
283
  can't leak last week's dollars into a fresh week's verdict (false
282
284
  WARN/OVER).
285
+
286
+ ``skip_sync`` skips the JSONL ingest pass inside ``get_entries`` for BOTH
287
+ cost SUMs — used by the projected-pace record path, where the actual-budget
288
+ axis already ran ``_sum_cost_for_range`` (warming the cache) earlier in the
289
+ same ``cmd_record_usage`` tick. The default ``False`` preserves the
290
+ standalone ``budget`` command's sync-on-read behavior unchanged.
283
291
  """
284
292
  c = _cctally()
285
293
  window = _resolve_current_budget_window(conn, now_utc)
286
294
  if window is None:
287
295
  return None
288
296
  week_start_at, week_end_at = window
289
- spent = c._sum_cost_for_range(week_start_at, now_utc, mode="auto")
297
+ spent = c._sum_cost_for_range(
298
+ week_start_at, now_utc, mode="auto", skip_sync=skip_sync
299
+ )
290
300
  # Clamp the recent-rate window at the week start: both bounds are tz-aware
291
301
  # so max() is well-defined. Without this, a brand-new week (now < week
292
302
  # start + 24h) would pull pre-reset spend into rate_recent and flip a
293
303
  # fresh verdict to warn/over.
294
304
  recent_start = max(week_start_at, now_utc - dt.timedelta(hours=24))
295
- recent_24h = c._sum_cost_for_range(recent_start, now_utc, mode="auto")
305
+ recent_24h = c._sum_cost_for_range(
306
+ recent_start, now_utc, mode="auto", skip_sync=skip_sync
307
+ )
296
308
  return c.BudgetInputs(
297
309
  target_usd=float(target_usd),
298
310
  spent_usd=float(spent),
@@ -497,6 +509,7 @@ class ForecastOutput:
497
509
  r_recent: float | None # pct per hour, 24h recent; None if no prior sample
498
510
  final_percent_low: float
499
511
  final_percent_high: float
512
+ week_avg_projection_pct: float # p_now + r_avg*remaining (smooth estimator)
500
513
  projected_cap: bool
501
514
  already_capped: bool
502
515
  cap_at: dt.datetime | None
@@ -527,6 +540,12 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
527
540
  )
528
541
  final_low, final_high = min(a, b), max(a, b)
529
542
 
543
+ # Smooth week-average projection (additive surface field). Distinct from
544
+ # the displayed band (which keys off final_high): this is the conservative
545
+ # week-average value the projected-pace alert axis fires on.
546
+ # p_now + r_avg*remaining (== project_linear collapsed to the single rate).
547
+ week_avg_projection_pct = inputs.p_now + r_avg * inputs.remaining_hours
548
+
530
549
  already_capped = inputs.p_now >= 100.0
531
550
  projected_cap = already_capped or final_high >= 100.0
532
551
 
@@ -562,6 +581,7 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
562
581
  r_recent=r_recent,
563
582
  final_percent_low=final_low,
564
583
  final_percent_high=final_high,
584
+ week_avg_projection_pct=week_avg_projection_pct,
565
585
  projected_cap=projected_cap,
566
586
  already_capped=already_capped,
567
587
  cap_at=cap_at,
@@ -628,6 +648,7 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
628
648
  "forecast": {
629
649
  "final_percent_low": round(out.final_percent_low, 3),
630
650
  "final_percent_high": round(out.final_percent_high, 3),
651
+ "week_avg_projection_pct": round(out.week_avg_projection_pct, 3),
631
652
  "projected_cap": out.projected_cap,
632
653
  "cap_at": (None if out.cap_at is None else _iso_z(out.cap_at)),
633
654
  "already_capped": out.already_capped,
@@ -1818,6 +1839,7 @@ def _budget_emit_json(budget_cfg, inputs, status) -> int:
1818
1839
  "elapsed_fraction": status.elapsed_fraction,
1819
1840
  "projected_eow_low_usd": status.projected_eow_low_usd,
1820
1841
  "projected_eow_high_usd": status.projected_eow_high_usd,
1842
+ "week_avg_projection_usd": status.week_avg_projection_usd,
1821
1843
  "daily_pace_usd": status.daily_pace_usd,
1822
1844
  "daily_budget_remaining_usd": status.daily_budget_remaining_usd,
1823
1845
  "verdict": status.verdict,
@@ -369,6 +369,74 @@ def insert_budget_milestone(
369
369
  return int(cur.rowcount)
370
370
 
371
371
 
372
+ def insert_projected_milestone(
373
+ conn: sqlite3.Connection,
374
+ *,
375
+ week_start_at: str,
376
+ metric: str,
377
+ threshold: int,
378
+ projected_value: float,
379
+ denominator: float,
380
+ commit: bool = True,
381
+ ) -> int:
382
+ """INSERT OR IGNORE a projected-pace crossing. Returns ``cur.rowcount``
383
+ (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
384
+ ``(week_start_at, metric, threshold)`` row).
385
+
386
+ Mirrors :func:`insert_budget_milestone`'s rowcount contract so the
387
+ alert-fire predicate (`if inserted == 1`) is race-safe without a follow-up
388
+ SELECT. ``alerted_at`` is left NULL — the caller stamps it in the SAME
389
+ transaction BEFORE dispatching (set-then-dispatch invariant, CLAUDE.md
390
+ Alerts gotcha). ``commit=False`` lets the caller bundle the INSERT with the
391
+ follow-up ``alerted_at`` UPDATE in one transaction so a crash between them
392
+ can't strand ``alerted_at`` NULL forever.
393
+ """
394
+ cur = conn.execute(
395
+ "INSERT OR IGNORE INTO projected_milestones "
396
+ "(week_start_at, metric, threshold, projected_value, denominator, "
397
+ " crossed_at_utc) "
398
+ "VALUES (?, ?, ?, ?, ?, ?)",
399
+ (
400
+ week_start_at,
401
+ str(metric),
402
+ int(threshold),
403
+ float(projected_value),
404
+ float(denominator),
405
+ now_utc_iso(),
406
+ ),
407
+ )
408
+ if commit:
409
+ conn.commit()
410
+ return int(cur.rowcount)
411
+
412
+
413
+ def _projected_levels_already_latched(
414
+ conn: sqlite3.Connection,
415
+ *,
416
+ week_start_at: str,
417
+ metric: str,
418
+ levels: "tuple[int, ...]",
419
+ ) -> bool:
420
+ """True iff EVERY level in ``levels`` already has a row for
421
+ ``(week_start_at, metric)``.
422
+
423
+ Cheap indexed SELECT used as the pre-probe gate BEFORE any projection math
424
+ / cost work ([Pre-probe before sync_cache]). Empty ``levels`` → True
425
+ (nothing owed). When False, at least one level is still un-recorded and the
426
+ caller must do the projection. Mirrors the per-week pre-probe SELECT in
427
+ :func:`maybe_record_budget_milestone`.
428
+ """
429
+ if not levels:
430
+ return True
431
+ rows = conn.execute(
432
+ "SELECT threshold FROM projected_milestones "
433
+ "WHERE week_start_at = ? AND metric = ?",
434
+ (week_start_at, str(metric)),
435
+ ).fetchall()
436
+ have = {int(r[0]) for r in rows}
437
+ return all(int(level) in have for level in levels)
438
+
439
+
372
440
  def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
373
441
  """Forward-only-from-set reconcile (spec §5): on `budget set`, every
374
442
  threshold ALREADY crossed for the current week is recorded with
@@ -2186,14 +2186,15 @@ def build_parser() -> argparse.ArgumentParser:
2186
2186
  cctally alerts test
2187
2187
  cctally alerts test --axis five-hour --threshold 95
2188
2188
  cctally alerts test --axis budget --threshold 100
2189
+ cctally alerts test --axis projected --metric budget_usd
2189
2190
  """),
2190
2191
  )
2191
2192
  p_alerts_test.add_argument(
2192
2193
  "--axis",
2193
- choices=["weekly", "five-hour", "budget"],
2194
+ choices=["weekly", "five-hour", "budget", "projected"],
2194
2195
  default="weekly",
2195
2196
  help="Alert axis to simulate: weekly subscription window, 5h block, "
2196
- "or equiv-$ budget (default: weekly).",
2197
+ "equiv-$ budget, or projected-pace (default: weekly).",
2197
2198
  )
2198
2199
  p_alerts_test.add_argument(
2199
2200
  "--threshold",
@@ -2201,6 +2202,13 @@ def build_parser() -> argparse.ArgumentParser:
2201
2202
  default=90,
2202
2203
  help="Threshold percent (1-100, default: 90).",
2203
2204
  )
2205
+ p_alerts_test.add_argument(
2206
+ "--metric",
2207
+ choices=["weekly_pct", "budget_usd"],
2208
+ default="weekly_pct",
2209
+ help="For --axis projected: which projected metric to preview "
2210
+ "(default: weekly_pct).",
2211
+ )
2204
2212
  p_alerts_test.set_defaults(func=c.cmd_alerts_test)
2205
2213
 
2206
2214
  # ---- setup (onboarding spec §2) ----