cctally 1.97.0 → 1.99.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.
@@ -55,6 +55,8 @@ from _cctally_core import (
55
55
  )
56
56
  from _lib_dashboard_sources import (
57
57
  SOURCE_SCHEMA_VERSION,
58
+ canonical_alerted_at as _canonical_alerted_at,
59
+ canonical_alerted_at_sql as _canonical_alerted_at_sql,
58
60
  dashboard_resource_key as _dashboard_resource_key,
59
61
  )
60
62
  from _lib_display_tz import _compute_display_block, format_display_dt
@@ -361,6 +363,16 @@ def _alert_account_resolver(conn: sqlite3.Connection):
361
363
  return fields
362
364
 
363
365
 
366
+ # #556 S3 §2.3. Every per-axis mapper orders by the CANONICAL firing instant
367
+ # before its ``LIMIT``, because that limit decides which rows exist downstream
368
+ # and a row excluded there is unrecoverable. Two spellings of one instant
369
+ # compare equal here; a nonzero offset — which the ``all-combined`` fixture
370
+ # stores deliberately — orders by its true instant rather than by its
371
+ # local-time text.
372
+ _CANON_ALERTED_AT = _canonical_alerted_at_sql()
373
+ _CANON_ALERTED_AT_M = _canonical_alerted_at_sql("m.alerted_at")
374
+
375
+
364
376
  def _envelope_rows_weekly(
365
377
  conn, descriptor, limit, severity_for, account_fields,
366
378
  ) -> list[dict]:
@@ -377,7 +389,7 @@ def _envelope_rows_weekly(
377
389
  alerted_at, cumulative_cost_usd, reset_event_id, account_key
378
390
  FROM {descriptor.milestone_table}
379
391
  WHERE alerted_at IS NOT NULL
380
- ORDER BY alerted_at DESC
392
+ ORDER BY {_CANON_ALERTED_AT} DESC
381
393
  LIMIT ?
382
394
  """,
383
395
  (limit,),
@@ -431,7 +443,7 @@ def _envelope_rows_five_hour(
431
443
  ON b.five_hour_window_key = m.five_hour_window_key
432
444
  AND b.account_key = m.account_key
433
445
  WHERE m.alerted_at IS NOT NULL
434
- ORDER BY m.alerted_at DESC
446
+ ORDER BY {_CANON_ALERTED_AT_M} DESC
435
447
  LIMIT ?
436
448
  """,
437
449
  (limit,),
@@ -506,7 +518,7 @@ def _envelope_rows_budget_family(
506
518
  budget_usd, spent_usd, consumption_pct, account_key
507
519
  FROM {descriptor.milestone_table}
508
520
  WHERE vendor = ? AND alerted_at IS NOT NULL
509
- ORDER BY alerted_at DESC
521
+ ORDER BY {_CANON_ALERTED_AT} DESC
510
522
  LIMIT ?
511
523
  """,
512
524
  (default_noun, vendor, limit),
@@ -572,7 +584,7 @@ def _envelope_rows_projected(
572
584
  denominator, crossed_at_utc, alerted_at, account_key
573
585
  FROM {descriptor.milestone_table}
574
586
  WHERE alerted_at IS NOT NULL
575
- ORDER BY alerted_at DESC
587
+ ORDER BY {_CANON_ALERTED_AT} DESC
576
588
  LIMIT ?
577
589
  """,
578
590
  (limit,),
@@ -629,7 +641,7 @@ def _envelope_rows_project_budget(
629
641
  consumption_pct, crossed_at_utc, alerted_at, account_key
630
642
  FROM {descriptor.milestone_table}
631
643
  WHERE alerted_at IS NOT NULL
632
- ORDER BY alerted_at DESC
644
+ ORDER BY {_CANON_ALERTED_AT} DESC
633
645
  LIMIT ?
634
646
  """,
635
647
  (limit,),
@@ -858,11 +870,29 @@ def _build_alerts_envelope_array(
858
870
  ))
859
871
 
860
872
  # Python's list.sort is stable. When two alerts share the same
861
- # `alerted_at` ISO string (rare; multiple axes firing within the same
862
- # millisecond), the union order (weekly, then 5h, then budget, then
873
+ # `alerted_at` instant (rare; multiple axes firing within the same
874
+ # second), the union order (weekly, then 5h, then budget, then
863
875
  # projected) determines the tiebreaker — no extra deterministic key is
864
876
  # added because the spec doesn't require one.
865
- out.sort(key=lambda a: a["alerted_at"], reverse=True)
877
+ #
878
+ # #556 S3 §2.3: the slice below is a truncation, so this re-sort compares
879
+ # instants rather than spellings. Each axis reaches here already ordered by
880
+ # its own canonical SQL expression, but the axes are merged raw, so two
881
+ # differently-spelled instants meet for the first time right here.
882
+ def _order_key(alert):
883
+ # A raise here empties the whole legacy array AND the Claude projection
884
+ # derived from it, so the diagnostic must name the offending row the way
885
+ # `_combined_alert_rows` does — otherwise the operator sees an empty
886
+ # panel and a message that identifies nothing.
887
+ try:
888
+ return _canonical_alerted_at(alert["alerted_at"])
889
+ except (KeyError, ValueError) as exc:
890
+ identity = alert.get("key") or alert.get("id")
891
+ raise ValueError(
892
+ f"alert row {identity!r} (axis {alert.get('axis')!r}): {exc}"
893
+ ) from exc
894
+
895
+ out.sort(key=_order_key, reverse=True)
866
896
  return out[:limit]
867
897
 
868
898
 
@@ -1270,21 +1300,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1270
1300
  }
1271
1301
 
1272
1302
  def _daily_row_to_dict(r: "DailyPanelRow") -> dict:
1273
- return {
1274
- "date": r.date,
1275
- "label": r.label,
1276
- "cost_usd": r.cost_usd,
1277
- "is_today": r.is_today,
1278
- "intensity_bucket": r.intensity_bucket,
1279
- "models": list(r.models),
1280
- # ---- v2.3 additions ----
1281
- "input_tokens": r.input_tokens,
1282
- "output_tokens": r.output_tokens,
1283
- "cache_creation_tokens": r.cache_creation_tokens,
1284
- "cache_read_tokens": r.cache_read_tokens,
1285
- "total_tokens": r.total_tokens,
1286
- "cache_hit_pct": r.cache_hit_pct,
1287
- }
1303
+ # #556 S2 §6.3a: one owner for the daily row wire shape. The All-only
1304
+ # `periods.daily_aggregate.rows` sibling publishes the same shape, and
1305
+ # the client reads one shape whichever sibling produced it, so the
1306
+ # dict is built in `_cctally_dashboard` and this renderer delegates.
1307
+ return sys.modules["_cctally_dashboard"].daily_panel_row_to_wire(r)
1288
1308
 
1289
1309
  # Spec §2.7: empty state is `weekly.rows === []`, not `weekly === null`.
1290
1310
  # Always emit a `{rows: [...]}` envelope (possibly empty) so the panel
@@ -1662,7 +1662,8 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1662
1662
  ls.ColumnSpec(key="current", label="Current", align="right"),
1663
1663
  ls.ColumnSpec(key="projected", label="Projected", align="right"),
1664
1664
  ),
1665
- rows=tuple(rows), chart=None, totals=(), notes=(), generated_at=end,
1665
+ rows=tuple(rows), chart=None, totals=(),
1666
+ notes=_share_budget_notes(data), generated_at=end,
1666
1667
  version=sys.modules["cctally"]._share_resolve_version(),
1667
1668
  template_id=template_id, source="codex", source_label="Codex",
1668
1669
  availability=availability, availability_reason=reason,
@@ -1979,6 +1980,38 @@ def _share_scope_codex_state(state, account: "str | None"):
1979
1980
  return replace(state, data=MappingProxyType({**dict(data), **scoped}))
1980
1981
 
1981
1982
 
1983
+ def _share_budget_notes(data) -> tuple[str, ...]:
1984
+ """The configured-budget status, as an artifact note (#556 S5 §5.12).
1985
+
1986
+ The Forecast artifact carried quota projections and nothing about the
1987
+ CONFIGURED budget, so a shared Forecast said less than the panel it was
1988
+ taken from — and after S5 the panel renders both side by side. The note is
1989
+ ADDITIVE and omitted when no status is published, so an install with no
1990
+ budget produces a byte-identical artifact.
1991
+
1992
+ ``data`` is already the ACCOUNT-SCOPED provider body when the request named
1993
+ an account (`_share_scope_codex_state` rewrites the scoped
1994
+ domains before this runs), so a focused share carries that account's own
1995
+ budget and never the vendor-wide one.
1996
+ """
1997
+ budget = data.get("budget") if isinstance(data, Mapping) else None
1998
+ status = budget.get("status") if isinstance(budget, Mapping) else None
1999
+ if not isinstance(status, Mapping):
2000
+ return ()
2001
+ try:
2002
+ spent = float(status["spent_usd"])
2003
+ target = float(status["budget_usd"])
2004
+ consumed = float(status["consumption_pct"])
2005
+ period = str(status["period"])
2006
+ verdict = str(status["verdict"])
2007
+ except (KeyError, TypeError, ValueError):
2008
+ return ()
2009
+ return (
2010
+ f"Budget ({period}): ${spent:,.2f} of ${target:,.2f} "
2011
+ f"({consumed:.1f}%) — {verdict}",
2012
+ )
2013
+
2014
+
1982
2015
  def _share_build_source_snapshots(*, ls, template, template_id: str,
1983
2016
  panel: str, options: dict, source: str,
1984
2017
  source_explicit: bool, data_snap,
@@ -2010,6 +2043,22 @@ def _share_build_source_snapshots(*, ls, template, template_id: str,
2010
2043
  claude_snapshot = _share_apply_current_week_freshness(
2011
2044
  claude_snapshot, claude_state, panel,
2012
2045
  )
2046
+ # #556 S5 §5.12 (Unit 2 review F7). The budget note was wired into
2047
+ # the Codex builder only, while §5.12 says "the configured-budget
2048
+ # sections" and the Claude Forecast panel renders one after S5 — so
2049
+ # a shared Claude Forecast said less than the panel it came from.
2050
+ # Gated exactly like the freshness stamp above: a source-less
2051
+ # request is the shipped legacy Claude contract and stays
2052
+ # byte-identical, because it has no source state to read at all.
2053
+ if panel == "forecast":
2054
+ notes = _share_budget_notes(
2055
+ getattr(claude_state, "data", None) or {},
2056
+ )
2057
+ if notes:
2058
+ claude_snapshot = replace(
2059
+ claude_snapshot,
2060
+ notes=tuple(claude_snapshot.notes) + notes,
2061
+ )
2013
2062
 
2014
2063
  codex_snapshot = None
2015
2064
  codex_state = None