cctally 1.103.0 → 1.104.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.
- package/CHANGELOG.md +56 -0
- package/bin/_cctally_alerts.py +65 -4
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +11 -0
- package/bin/_cctally_dashboard_envelope.py +319 -14
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_doctor.py +63 -0
- package/bin/_cctally_forecast.py +917 -44
- package/bin/_cctally_journal.py +153 -5
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +13 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +1616 -0
- package/bin/_cctally_record.py +114 -6
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +214 -45
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_doctor.py +159 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_meter_rate_change.py +294 -0
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +131 -0
- package/bin/_lib_quota_model.py +2333 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_statusline.py +226 -2
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
|
@@ -824,6 +824,68 @@ def _source_bundle_to_envelope(bundle: object | None) -> dict:
|
|
|
824
824
|
}
|
|
825
825
|
|
|
826
826
|
|
|
827
|
+
#: How many rate-change events the envelope publishes. A metering-rate
|
|
828
|
+
#: transition is rare — the maintainer's store holds one — so this is a
|
|
829
|
+
#: bound rather than a page size, and a store holding more than a handful
|
|
830
|
+
#: describes a detector that is misfiring rather than a busy account.
|
|
831
|
+
METER_RATE_CHANGE_LIMIT = 20
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _build_meter_rate_change_array(
|
|
835
|
+
conn: sqlite3.Connection, limit: int = METER_RATE_CHANGE_LIMIT,
|
|
836
|
+
) -> list:
|
|
837
|
+
"""The #661 S2 §6.1 non-threshold event family's wire rows.
|
|
838
|
+
|
|
839
|
+
A SEPARATE array from `alerts`, and deliberately so. Every member of
|
|
840
|
+
`AXIS_REGISTRY` is a numeric threshold axis: `AlertEntry` requires a
|
|
841
|
+
numeric `threshold`, its `id` is threshold-shaped, and `alert_row_owner`
|
|
842
|
+
raises on a seventh axis so that adding one without deciding its
|
|
843
|
+
ownership fails a test rather than shipping an invisible row. A rate
|
|
844
|
+
transition has neither a threshold nor a threshold-derived severity, so
|
|
845
|
+
it gets its own discriminated variant instead of being forced into that
|
|
846
|
+
union.
|
|
847
|
+
|
|
848
|
+
Each row carries the explicit severity, the old and new rates, the
|
|
849
|
+
effective instant, its OWNERSHIP (which provider tab renders it) and its
|
|
850
|
+
SCOPE (the account, decorated under R8 exactly as the alert rows are).
|
|
851
|
+
"""
|
|
852
|
+
account_fields = _alert_account_resolver(conn)
|
|
853
|
+
try:
|
|
854
|
+
rows = conn.execute(
|
|
855
|
+
"SELECT provider, account_key, effective_from,"
|
|
856
|
+
" previous_units_per_point, new_units_per_point, severity,"
|
|
857
|
+
" detected_at_utc, created_at_utc"
|
|
858
|
+
" FROM meter_rate_change_events"
|
|
859
|
+
" ORDER BY unixepoch(effective_from) DESC, id DESC"
|
|
860
|
+
" LIMIT ?", (int(limit),)).fetchall()
|
|
861
|
+
except sqlite3.Error:
|
|
862
|
+
# A store predating epoch 1011 has no such table, and the rebuild
|
|
863
|
+
# that creates it is deferred to a background worker. An empty array
|
|
864
|
+
# renders as "no change recorded", which is the truth on that store.
|
|
865
|
+
return []
|
|
866
|
+
out: list[dict] = []
|
|
867
|
+
for (provider, account_key, effective_from, previous, new, severity,
|
|
868
|
+
detected_at, created_at) in rows:
|
|
869
|
+
entry = {
|
|
870
|
+
# Opaque React key. It is never parsed — the same contract the
|
|
871
|
+
# alert `id` carries.
|
|
872
|
+
"id": f"meter_rate_change:{provider}:{account_key}:{effective_from}",
|
|
873
|
+
"family": "meter_rate_change",
|
|
874
|
+
"provider": str(provider),
|
|
875
|
+
"owner": str(provider),
|
|
876
|
+
"severity": str(severity or "info"),
|
|
877
|
+
"effective_from": str(effective_from),
|
|
878
|
+
"detected_at": str(detected_at or created_at or ""),
|
|
879
|
+
"recorded_at": str(created_at or ""),
|
|
880
|
+
"previous_units_per_point": (
|
|
881
|
+
None if previous is None else float(previous)),
|
|
882
|
+
"new_units_per_point": None if new is None else float(new),
|
|
883
|
+
}
|
|
884
|
+
entry.update(account_fields(str(provider), account_key))
|
|
885
|
+
out.append(entry)
|
|
886
|
+
return out
|
|
887
|
+
|
|
888
|
+
|
|
827
889
|
def _build_alerts_envelope_array(
|
|
828
890
|
conn: sqlite3.Connection,
|
|
829
891
|
limit: int = 100,
|
|
@@ -1103,6 +1165,222 @@ def _sync_activity_envelope(activity: "dict | None") -> dict:
|
|
|
1103
1165
|
}
|
|
1104
1166
|
|
|
1105
1167
|
|
|
1168
|
+
#: How far two subscription-week anchors may drift and still count as the
|
|
1169
|
+
#: same reset domain. The week anchor is normalized to the hour to absorb
|
|
1170
|
+
#: Anthropic's reset jitter, so two hours is comfortably wider than the
|
|
1171
|
+
#: jitter and far narrower than the shortest interval a synthesized
|
|
1172
|
+
#: mid-week row could produce.
|
|
1173
|
+
_RESET_DOMAIN_TOLERANCE_SECONDS = 2 * 3600
|
|
1174
|
+
_SUBSCRIPTION_WEEK_SECONDS = 7 * 24 * 3600
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _same_reset_domain(current_start, prior_start) -> bool:
|
|
1178
|
+
"""Whether two week anchors belong to the same reset domain.
|
|
1179
|
+
|
|
1180
|
+
Spec §10.1 requires the prior operand to share the current row's reset
|
|
1181
|
+
domain. The rows carry no domain field, so it is derived from the one
|
|
1182
|
+
thing they always carry: two anchors are in the same domain when they
|
|
1183
|
+
are a whole number of subscription weeks apart. A row synthesized by
|
|
1184
|
+
reset or credit handling sits at a fractional offset and is skipped
|
|
1185
|
+
rather than silently used, which is exactly what that clause is for.
|
|
1186
|
+
"""
|
|
1187
|
+
if current_start is None or prior_start is None:
|
|
1188
|
+
return False
|
|
1189
|
+
delta = (current_start - prior_start).total_seconds()
|
|
1190
|
+
if delta <= 0:
|
|
1191
|
+
return False
|
|
1192
|
+
offset = delta % _SUBSCRIPTION_WEEK_SECONDS
|
|
1193
|
+
return min(offset, _SUBSCRIPTION_WEEK_SECONDS - offset) <= \
|
|
1194
|
+
_RESET_DOMAIN_TOLERANCE_SECONDS
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _cause_presentation(code):
|
|
1198
|
+
"""The §8 presentation fields for one cause, or None for no cause.
|
|
1199
|
+
|
|
1200
|
+
ADDITIVE beside the machine `code`, which is unchanged and stays the
|
|
1201
|
+
stable value a client keys on. A client that receives no presentation
|
|
1202
|
+
fields — a new tab against an older server, which `execvp` makes
|
|
1203
|
+
ordinary — derives the short token from the code itself through the same
|
|
1204
|
+
named function this uses.
|
|
1205
|
+
"""
|
|
1206
|
+
if not code:
|
|
1207
|
+
return None
|
|
1208
|
+
try:
|
|
1209
|
+
copy = sys.modules["cctally"]._load_sibling("_lib_quota_copy")
|
|
1210
|
+
except Exception: # noqa: BLE001
|
|
1211
|
+
return None
|
|
1212
|
+
out = copy.presentation(code)
|
|
1213
|
+
return out or None
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _forecast_quota_envelope(fc, cw, rate_change):
|
|
1217
|
+
"""Spec §10's optional typed `forecast.quota` object.
|
|
1218
|
+
|
|
1219
|
+
Additive, and deliberately does NOT bump `source_schema_version`: that
|
|
1220
|
+
field describes the provider-source bundles, not this.
|
|
1221
|
+
|
|
1222
|
+
Every quantity here was already computed. The basis, the codes, the
|
|
1223
|
+
corrected interval and the calibrated triple come off the forecast the
|
|
1224
|
+
envelope is already serializing, and `rate_change` is the caller's cheap
|
|
1225
|
+
calibration-file read. Nothing here opens a store.
|
|
1226
|
+
|
|
1227
|
+
NO PRESENTATION LATCH: this is rebuilt from the current forecast on
|
|
1228
|
+
every refresh, so the surface switches with the shared selector rather
|
|
1229
|
+
than presenting a model whose evidence has since been withheld.
|
|
1230
|
+
"""
|
|
1231
|
+
if fc is None:
|
|
1232
|
+
return None
|
|
1233
|
+
inputs = getattr(fc, "inputs", None)
|
|
1234
|
+
interval = getattr(inputs, "p_now_interval", None)
|
|
1235
|
+
code = getattr(fc, "projection_code", None)
|
|
1236
|
+
calibration_code = getattr(inputs, "calibrated_withheld_code", None)
|
|
1237
|
+
consumption = getattr(inputs, "calibrated_consumption_pct", None)
|
|
1238
|
+
consumption_interval = getattr(
|
|
1239
|
+
inputs, "calibrated_consumption_interval", None)
|
|
1240
|
+
observed = getattr(cw, "used_pct", None) if cw is not None else None
|
|
1241
|
+
basis = getattr(fc, "projection_basis", None)
|
|
1242
|
+
projection = getattr(fc, "week_avg_projection_pct", None)
|
|
1243
|
+
headroom = getattr(inputs, "calibrated_headroom_pct", None)
|
|
1244
|
+
# The calibrated view is published as a UNIT (#661 S2 review, D2).
|
|
1245
|
+
#
|
|
1246
|
+
# `select_projection_basis` reads ONLY `calibrated_projection_pct`, so a
|
|
1247
|
+
# non-null projection beside a null modelled consumption would publish
|
|
1248
|
+
# `basis: "calibrated"` over a number the model does not state — a modal
|
|
1249
|
+
# reading "basis: model" above "modelled consumption: —". The invariant
|
|
1250
|
+
# held until now only because `_calibrated_week_detail` sets the whole
|
|
1251
|
+
# triple or none of it, which is the producer's discipline rather than a
|
|
1252
|
+
# property of this serializer.
|
|
1253
|
+
#
|
|
1254
|
+
# WITHHELD rather than demoted. Demoting to `corrected-meter` would
|
|
1255
|
+
# relabel a number the calibrated model produced, and the meter's own
|
|
1256
|
+
# projection is not on this forecast to substitute, so there is nothing
|
|
1257
|
+
# honest to publish. `withheld` is an already-rendered basis on the panel
|
|
1258
|
+
# and in the modal, and it carries a cause.
|
|
1259
|
+
# `projection is None` is included for completeness rather than because
|
|
1260
|
+
# it is reachable: `select_projection_basis` reads `calibrated_projection_pct`,
|
|
1261
|
+
# so a null projection does not yield this basis in the first place, and
|
|
1262
|
+
# `bin/_lib_forecast.py` nulls the projection only on its WITHHELD branch.
|
|
1263
|
+
# The argument that motivates the other two operands -- that the whole
|
|
1264
|
+
# triple is the producer's discipline and not a property of this
|
|
1265
|
+
# serializer -- applies to the projection identically, so leaving it out
|
|
1266
|
+
# would make the guard state a narrower invariant than the one it means.
|
|
1267
|
+
if basis == "calibrated" and (projection is None
|
|
1268
|
+
or consumption is None
|
|
1269
|
+
or headroom is None):
|
|
1270
|
+
basis = "withheld"
|
|
1271
|
+
projection = None
|
|
1272
|
+
consumption = None
|
|
1273
|
+
consumption_interval = None
|
|
1274
|
+
headroom = None
|
|
1275
|
+
code = code or "unavailable"
|
|
1276
|
+
calibration_code = calibration_code or "unavailable"
|
|
1277
|
+
return {
|
|
1278
|
+
"basis": basis,
|
|
1279
|
+
"projection_pct": projection,
|
|
1280
|
+
"right_censored": bool(getattr(fc, "right_censored", False)),
|
|
1281
|
+
# The cause of a WITHHELD projection, and separately why the
|
|
1282
|
+
# CALIBRATED basis was not reached. They are different questions and
|
|
1283
|
+
# a surface that collapses them says nothing when it falls back.
|
|
1284
|
+
"code": code,
|
|
1285
|
+
"code_presentation": _cause_presentation(code),
|
|
1286
|
+
"calibration_code": calibration_code,
|
|
1287
|
+
"calibration_code_presentation":
|
|
1288
|
+
_cause_presentation(calibration_code),
|
|
1289
|
+
# The corrected-meter interval a displayed reading denotes. `hi` is
|
|
1290
|
+
# null at a right-censored 100, which is unbounded above.
|
|
1291
|
+
"corrected_interval":
|
|
1292
|
+
None if interval is None
|
|
1293
|
+
else {"lo": interval[0], "hi": interval[1]},
|
|
1294
|
+
"calibrated_consumption_pct": consumption,
|
|
1295
|
+
"calibrated_consumption_interval":
|
|
1296
|
+
None if not consumption_interval
|
|
1297
|
+
else {"lo": consumption_interval[0],
|
|
1298
|
+
"hi": consumption_interval[1]},
|
|
1299
|
+
"calibrated_headroom_pct": headroom,
|
|
1300
|
+
"rate_change": rate_change,
|
|
1301
|
+
# Spec §10: the observed meter minus the modelled local quota,
|
|
1302
|
+
# labelled as exactly that. It is a DIFFERENCE between two
|
|
1303
|
+
# quantities and does not identify or estimate usage from another
|
|
1304
|
+
# machine; the modal states that sentence, and it is null unless
|
|
1305
|
+
# both sides exist.
|
|
1306
|
+
"observed_minus_modelled_pct":
|
|
1307
|
+
None if (observed is None or consumption is None)
|
|
1308
|
+
else observed - consumption,
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _header_rate_and_delta(current_rate, current_week_start, trend):
|
|
1313
|
+
"""`(dollar_per_pct, vs_last_week_delta)` from ONE current operand.
|
|
1314
|
+
|
|
1315
|
+
Spec §10.1, and the defect it closes: `header.dollar_per_pct` came from
|
|
1316
|
+
`_tui_build_current_week` while `header.vs_last_week_delta` came from the
|
|
1317
|
+
operand `build_trend_view` returned. Two computations, no guard, so the
|
|
1318
|
+
hero could publish a week-over-week comparison on a screen stating no
|
|
1319
|
+
`$ / 1%` at all.
|
|
1320
|
+
|
|
1321
|
+
Both values are computed here, from the header's own current operand,
|
|
1322
|
+
before serialization — so the client never reconciles two differently
|
|
1323
|
+
sourced "current" values. When the current rate is absent BOTH are null,
|
|
1324
|
+
because a delta measured from an absent operand is not a delta.
|
|
1325
|
+
|
|
1326
|
+
The prior operand is the NEAREST COMPARABLE completed row, and
|
|
1327
|
+
comparable is the exact list §10.1 gives. Provider and account scope are
|
|
1328
|
+
properties of the whole snapshot, so every row in `trend` shares them by
|
|
1329
|
+
construction; what has to be tested per row is the remaining three:
|
|
1330
|
+
|
|
1331
|
+
* a completed rather than current interval (`is_current` is False);
|
|
1332
|
+
* a non-null rate on that row; and
|
|
1333
|
+
* the same reset domain as the current week.
|
|
1334
|
+
|
|
1335
|
+
A row failing any of them is SKIPPED and the walk continues to the next
|
|
1336
|
+
older row, rather than the delta being abandoned or the row being used.
|
|
1337
|
+
|
|
1338
|
+
NEAREST IS BY ANCHOR, not by list position. The walk used to be
|
|
1339
|
+
`reversed(list(trend))`, which makes "nearest" mean "last in the list" and
|
|
1340
|
+
rests the whole clause on `snap.trend` arriving ascending. That holds for
|
|
1341
|
+
`build_trend_view` and for every committed fixture and was stated nowhere,
|
|
1342
|
+
and `_same_reset_domain` accepts any positive whole-week multiple, so a
|
|
1343
|
+
row twenty weeks back was as comparable as one week back. The candidates
|
|
1344
|
+
are therefore ordered here, newest anchor first. A row carrying no anchor
|
|
1345
|
+
sorts last and is skipped by the reset-domain test anyway.
|
|
1346
|
+
"""
|
|
1347
|
+
if current_rate is None:
|
|
1348
|
+
return None, None
|
|
1349
|
+
# Only rows whose anchor is a `datetime` are sorted; anything else keeps
|
|
1350
|
+
# list order after them. `sorted` compares the keys, so one naive anchor
|
|
1351
|
+
# beside an aware one -- or a `str` anchor, which the sibling row type at
|
|
1352
|
+
# `bin/_lib_view_models.py:174` carries -- would raise `TypeError` inside
|
|
1353
|
+
# the dashboard's serialization path and surface as a 500 rather than a
|
|
1354
|
+
# withheld figure. `build_trend_view` emits aware datetimes today, so the
|
|
1355
|
+
# partition is a guard against a future producer, not a live defect.
|
|
1356
|
+
# Ties keep list order, because `sorted` is stable: two rows sharing an
|
|
1357
|
+
# anchor resolve to the earlier one in `trend`.
|
|
1358
|
+
rows = list(trend or ())
|
|
1359
|
+
anchored = [r for r in rows
|
|
1360
|
+
if isinstance(getattr(r, "week_start_at", None), dt.datetime)]
|
|
1361
|
+
unanchored = [r for r in rows
|
|
1362
|
+
if not isinstance(getattr(r, "week_start_at", None),
|
|
1363
|
+
dt.datetime)]
|
|
1364
|
+
aware = [r for r in anchored if r.week_start_at.tzinfo is not None]
|
|
1365
|
+
naive = [r for r in anchored if r.week_start_at.tzinfo is None]
|
|
1366
|
+
candidates = (
|
|
1367
|
+
sorted(aware, key=lambda row: row.week_start_at, reverse=True)
|
|
1368
|
+
+ sorted(naive, key=lambda row: row.week_start_at, reverse=True)
|
|
1369
|
+
+ unanchored
|
|
1370
|
+
)
|
|
1371
|
+
for row in candidates:
|
|
1372
|
+
if getattr(row, "is_current", False):
|
|
1373
|
+
continue
|
|
1374
|
+
prior_rate = getattr(row, "dollars_per_percent", None)
|
|
1375
|
+
if prior_rate is None:
|
|
1376
|
+
continue
|
|
1377
|
+
if not _same_reset_domain(current_week_start,
|
|
1378
|
+
getattr(row, "week_start_at", None)):
|
|
1379
|
+
continue
|
|
1380
|
+
return current_rate, current_rate - prior_rate
|
|
1381
|
+
return current_rate, None
|
|
1382
|
+
|
|
1383
|
+
|
|
1106
1384
|
def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
1107
1385
|
now_utc: "dt.datetime",
|
|
1108
1386
|
monotonic_now: "float | None" = None,
|
|
@@ -1256,17 +1534,13 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1256
1534
|
inputs = getattr(fc, "inputs", None)
|
|
1257
1535
|
if inputs is not None:
|
|
1258
1536
|
confidence = getattr(inputs, "confidence", None)
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
and r_recent is not None):
|
|
1267
|
-
p_final_recent = p_now + r_recent * rem_hrs
|
|
1268
|
-
if fcast_pct is None or p_final_recent != fcast_pct:
|
|
1269
|
-
recent_24h_pct = p_final_recent
|
|
1537
|
+
# The ONE decomposition (#661 S2 spec section 3.3), so this legacy
|
|
1538
|
+
# branch cannot answer the projection question differently from the
|
|
1539
|
+
# View path above it — which is exactly what the `over` golden
|
|
1540
|
+
# recorded before this call replaced the inline arithmetic.
|
|
1541
|
+
from _lib_view_models import _forecast_projection_pcts
|
|
1542
|
+
|
|
1543
|
+
fcast_pct, recent_24h_pct = _forecast_projection_pcts(fc)
|
|
1270
1544
|
if getattr(fc, "already_capped", False):
|
|
1271
1545
|
verdict = "capped"
|
|
1272
1546
|
elif getattr(fc, "projected_cap", False):
|
|
@@ -1474,6 +1748,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1474
1748
|
# the entire snapshot — fall back to safe defaults and rely on
|
|
1475
1749
|
# `_warn_alerts_bad_config_once` for the user-visible signal.
|
|
1476
1750
|
alerts_array = list(getattr(snap, "alerts", []) or [])
|
|
1751
|
+
# #661 S2 §6.1. Precomputed at sync time beside `alerts`, so
|
|
1752
|
+
# `snapshot_to_envelope` stays a pure renderer with no DB I/O on the
|
|
1753
|
+
# dashboard hot path.
|
|
1754
|
+
meter_rate_changes_array = list(
|
|
1755
|
+
getattr(snap, "meter_rate_changes", []) or [])
|
|
1477
1756
|
# #268 M4: reuse the precompute's config (or the fallback load_config()
|
|
1478
1757
|
# resolved above) — the envelope used to call load_config() a SECOND time
|
|
1479
1758
|
# here. Within one tick both reads returned the same file, so this is
|
|
@@ -1551,6 +1830,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1551
1830
|
# template itself).
|
|
1552
1831
|
"notifier": _alerts_cfg.get("notifier", "auto"),
|
|
1553
1832
|
"command_configured": _alerts_cfg.get("command_template") is not None,
|
|
1833
|
+
# #661 S2 §6.2: the metering-rate-change family's PUSH toggle. The
|
|
1834
|
+
# events themselves are recorded whatever this says, so the client
|
|
1835
|
+
# renders the history with no configuration; this mirror only tells
|
|
1836
|
+
# Settings whether the OS popup is armed.
|
|
1837
|
+
"rate_change_enabled": bool(_alerts_cfg.get("rate_change_enabled")),
|
|
1554
1838
|
}
|
|
1555
1839
|
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
|
|
1556
1840
|
# Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
|
|
@@ -1666,6 +1950,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1666
1950
|
(r for r in reversed(snap.trend) if r.is_current), None
|
|
1667
1951
|
) if snap.trend else None
|
|
1668
1952
|
|
|
1953
|
+
# #661 S2 §10.1. BOTH header operands are decided here, together, from
|
|
1954
|
+
# the header's own current rate. `_current_trend` is retained above
|
|
1955
|
+
# because other blocks read it, but its `delta_dpp` is no longer what
|
|
1956
|
+
# the header publishes: that field is computed against whichever row
|
|
1957
|
+
# happened to precede the current one in the trend series, which is a
|
|
1958
|
+
# different question from "the nearest comparable prior operand".
|
|
1959
|
+
dollar_pp, header_delta = _header_rate_and_delta(
|
|
1960
|
+
dollar_pp, week_start_at_utc, snap.trend)
|
|
1961
|
+
|
|
1669
1962
|
envelope = {
|
|
1670
1963
|
"envelope_version": 2,
|
|
1671
1964
|
# #278 Theme A: single additive first-paint hydration latch. True only
|
|
@@ -1718,9 +2011,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1718
2011
|
"dollar_per_pct": dollar_pp,
|
|
1719
2012
|
"forecast_pct": header_fcast_pct,
|
|
1720
2013
|
"forecast_verdict": verdict,
|
|
1721
|
-
"vs_last_week_delta":
|
|
1722
|
-
_current_trend.delta_dpp if _current_trend is not None else None
|
|
1723
|
-
),
|
|
2014
|
+
"vs_last_week_delta": header_delta,
|
|
1724
2015
|
},
|
|
1725
2016
|
|
|
1726
2017
|
"current_week":
|
|
@@ -1804,6 +2095,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1804
2095
|
else 2 if confidence == "low"
|
|
1805
2096
|
else 0),
|
|
1806
2097
|
"explain": sys.modules["cctally"]._build_forecast_json_payload(fc),
|
|
2098
|
+
# #661 S2 §10. Optional, typed, and ADDITIVE: it sits under
|
|
2099
|
+
# `forecast` because `ForecastEnvelope` is its semantic
|
|
2100
|
+
# boundary, and it does not bump `source_schema_version`,
|
|
2101
|
+
# which describes the provider-source bundles.
|
|
2102
|
+
"quota": _forecast_quota_envelope(
|
|
2103
|
+
fc, cw, getattr(snap, "quota_rate_change", None)),
|
|
1807
2104
|
},
|
|
1808
2105
|
|
|
1809
2106
|
"trend":
|
|
@@ -1921,6 +2218,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1921
2218
|
"alerts": alerts_array,
|
|
1922
2219
|
"alerts_settings": alerts_settings,
|
|
1923
2220
|
|
|
2221
|
+
# #661 S2 §6.1: the non-threshold event family, in its OWN array
|
|
2222
|
+
# rather than inside `alerts`. `AlertEntry` requires a numeric
|
|
2223
|
+
# threshold and a rate transition has none, so mixing the two would
|
|
2224
|
+
# either weaken that contract or ship a row every consumer skips.
|
|
2225
|
+
# Additive optional — `envelope_version` stays at 2, and an older
|
|
2226
|
+
# client simply does not read it.
|
|
2227
|
+
"meter_rate_changes": meter_rate_changes_array,
|
|
2228
|
+
|
|
1924
2229
|
# Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
|
|
1925
2230
|
# §5). Additive optional, like alerts_settings — envelope_version
|
|
1926
2231
|
# stays at 2. The client derives `markersEnabled` from this, defaulting
|
|
@@ -966,16 +966,24 @@ def _build_forecast_share_panel_data(options: dict,
|
|
|
966
966
|
Reuses ``DataSnapshot.forecast`` (ForecastOutput) and, when populated
|
|
967
967
|
by the sync thread, ``DataSnapshot.forecast_view`` (the kernel
|
|
968
968
|
wrapper from issue #57) for the (100, 90) budget pair.
|
|
969
|
-
|
|
970
|
-
``
|
|
971
|
-
|
|
972
|
-
|
|
969
|
+
|
|
970
|
+
``projected_end_pct`` is the kernel's SELECTED projection (#661 S2 spec
|
|
971
|
+
section 3.3) rather than a sixth re-derivation of it, and the curve and
|
|
972
|
+
the ceiling distances are measured from the same ceiling-corrected base
|
|
973
|
+
the kernel used. This function previously paired a RAW ``inputs.p_now``
|
|
974
|
+
with the corrected ``r_avg`` the kernel publishes and described that as
|
|
975
|
+
"the same arithmetic ``snapshot_to_envelope`` does", which stopped being
|
|
976
|
+
true the moment the correction landed.
|
|
977
|
+
|
|
978
|
+
Every one of those quantities is withheld at a right-censored reading and
|
|
979
|
+
on a week with no observed usage, because each is derived from a meter
|
|
980
|
+
that supplies no point estimate.
|
|
973
981
|
"""
|
|
974
982
|
fc = getattr(snap, "forecast", None) if snap else None
|
|
975
983
|
fc_view = getattr(snap, "forecast_view", None) if snap else None
|
|
976
984
|
if fc is None:
|
|
977
985
|
return {
|
|
978
|
-
"projected_end_pct":
|
|
986
|
+
"projected_end_pct": None,
|
|
979
987
|
# With no forecast at all there is no rate, so neither ceiling has
|
|
980
988
|
# a distance — the same withholding the populated path performs.
|
|
981
989
|
"days_to_100pct": None,
|
|
@@ -987,25 +995,43 @@ def _build_forecast_share_panel_data(options: dict,
|
|
|
987
995
|
"projection_curve": [],
|
|
988
996
|
"confidence": "LOW CONF",
|
|
989
997
|
}
|
|
998
|
+
from _lib_forecast import projection_base
|
|
999
|
+
|
|
990
1000
|
inputs = getattr(fc, "inputs", None)
|
|
991
|
-
|
|
1001
|
+
# The CEILING-CORRECTED base, through the one named resolver. `None` at a
|
|
1002
|
+
# right-censored reading, where there is no point estimate at all.
|
|
1003
|
+
base = projection_base(inputs) if inputs is not None else None
|
|
1004
|
+
p_now = None if base is None else float(base)
|
|
992
1005
|
remaining_hours = float(
|
|
993
1006
|
getattr(inputs, "remaining_hours", 0.0) or 0.0
|
|
994
1007
|
) if inputs else 0.0
|
|
995
1008
|
confidence = getattr(inputs, "confidence", "ok") if inputs else "ok"
|
|
996
|
-
|
|
1009
|
+
r_avg_raw = getattr(fc, "r_avg", None)
|
|
1010
|
+
r_avg = None if r_avg_raw is None else float(r_avg_raw)
|
|
997
1011
|
r_recent_raw = getattr(fc, "r_recent", None)
|
|
998
1012
|
r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
|
|
999
|
-
# End-of-week projected %
|
|
1000
|
-
|
|
1013
|
+
# End-of-week projected % — the kernel's selected value, expressed as the
|
|
1014
|
+
# fraction this panel's contract carries. `None` when the kernel withheld
|
|
1015
|
+
# it, which the templates render as `n/a` rather than as `0.0%`.
|
|
1016
|
+
selected = getattr(fc, "week_avg_projection_pct", None)
|
|
1017
|
+
projected_end_pct = None if selected is None else float(selected) / 100.0
|
|
1001
1018
|
# Days to ceilings (simple inverse: hours-to-target / 24).
|
|
1002
|
-
# The
|
|
1003
|
-
# `p_now >= target_pct` means the target is already reached, and
|
|
1004
|
-
# to it is true. `r_avg <= 0` means no rate was observed, so the
|
|
1005
|
-
# not reachable on any timeline this data describes
|
|
1006
|
-
#
|
|
1007
|
-
#
|
|
1019
|
+
# The three exit conditions are different facts and must not share a
|
|
1020
|
+
# value. `p_now >= target_pct` means the target is already reached, and
|
|
1021
|
+
# zero days to it is true. `r_avg <= 0` means no rate was observed, so the
|
|
1022
|
+
# target is not reachable on any timeline this data describes. A withheld
|
|
1023
|
+
# base or rate means the meter supplies no distance at all.
|
|
1024
|
+
#
|
|
1025
|
+
# #661 S2 §3.1 changed which of these fires in the no-usage state. Before
|
|
1026
|
+
# the ceiling correction both `p_now == 0` and `r_avg == 0` held there,
|
|
1027
|
+
# and the shared `0.0` rendered `Days->90% 0.0`, stating the opposite of
|
|
1028
|
+
# the truth. The corrected point of a displayed 0 is 0.25, so NEITHER
|
|
1029
|
+
# holds now: the distance is a real, finite, very large number. Bounding
|
|
1030
|
+
# it is a PRESENTATION decision and is made in `_optional_days`, so this
|
|
1031
|
+
# function keeps publishing the exact figure.
|
|
1008
1032
|
def _days_to_ceiling(target_pct: float) -> "float | None":
|
|
1033
|
+
if p_now is None or r_avg is None:
|
|
1034
|
+
return None
|
|
1009
1035
|
if p_now >= target_pct:
|
|
1010
1036
|
return 0.0
|
|
1011
1037
|
if r_avg <= 0:
|
|
@@ -1045,18 +1071,23 @@ def _build_forecast_share_panel_data(options: dict,
|
|
|
1045
1071
|
# defect one layer below the fix.
|
|
1046
1072
|
_raw_dpp = getattr(inputs, "dollars_per_percent", None) if inputs else None
|
|
1047
1073
|
dpp = None if _raw_dpp is None else float(_raw_dpp)
|
|
1048
|
-
budgets["avg"] = None if dpp is None
|
|
1049
|
-
|
|
1050
|
-
|
|
1074
|
+
budgets["avg"] = (None if dpp is None or r_avg is None
|
|
1075
|
+
else dpp * r_avg * 24.0)
|
|
1076
|
+
budgets["recent_24h"] = (None if dpp is None or r_recent is None
|
|
1077
|
+
else dpp * r_recent * 24.0)
|
|
1078
|
+
# Projection curve — 7-day forward, using r_avg from the corrected base.
|
|
1079
|
+
# Empty when either is withheld: a curve drawn from a censored reading is
|
|
1080
|
+
# the same fabrication the scalar above refuses.
|
|
1051
1081
|
today = _share_now_utc().date()
|
|
1052
1082
|
projection_curve: list[dict] = []
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1083
|
+
if p_now is not None and r_avg is not None:
|
|
1084
|
+
for i in range(7):
|
|
1085
|
+
d = today + dt.timedelta(days=i)
|
|
1086
|
+
pct = (p_now + r_avg * (i * 24.0)) / 100.0
|
|
1087
|
+
projection_curve.append({
|
|
1088
|
+
"date": d.isoformat(),
|
|
1089
|
+
"projected_pct_used": pct,
|
|
1090
|
+
})
|
|
1060
1091
|
return {
|
|
1061
1092
|
"projected_end_pct": projected_end_pct,
|
|
1062
1093
|
"days_to_100pct": days_to_100,
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -1021,6 +1021,41 @@ def _load_codex_quota_observations_for_doctor(*, force_cold: bool = False):
|
|
|
1021
1021
|
return loaded
|
|
1022
1022
|
|
|
1023
1023
|
|
|
1024
|
+
_QUOTA_NO_CHANGE: dict = {
|
|
1025
|
+
"active": False, "effective_from": None,
|
|
1026
|
+
"previous_units_per_point": None, "new_units_per_point": None,
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _gather_quota_rate_change(c, rejection=None) -> "dict | None":
|
|
1031
|
+
"""§6.6's derived marker state, read WITHOUT the prediction gate.
|
|
1032
|
+
|
|
1033
|
+
The rate-change surfaces read the regime pair deliberately: S1 marks a
|
|
1034
|
+
successor `detection-only` while its fit stays below the PREDICTION gate,
|
|
1035
|
+
and detection is exactly what a detection-grade fit is for. So this
|
|
1036
|
+
gather goes to the stored regimes rather than through the validated
|
|
1037
|
+
reader's default, which refuses that mark.
|
|
1038
|
+
|
|
1039
|
+
An ABSENT calibration is "no change detected", not "not assessed" — a
|
|
1040
|
+
store with no fitted regime has no transition to report, and that is the
|
|
1041
|
+
state of every install that has never run `cctally quota`. `None` is
|
|
1042
|
+
reserved for a calibration that exists and could not be read, where the
|
|
1043
|
+
sibling `quota.calibration` check is already WARNing about it.
|
|
1044
|
+
"""
|
|
1045
|
+
if rejection == "calibration-absent":
|
|
1046
|
+
return dict(_QUOTA_NO_CHANGE)
|
|
1047
|
+
mrc = c._load_sibling("_lib_meter_rate_change")
|
|
1048
|
+
glue = c._load_sibling("_cctally_quota_model")
|
|
1049
|
+
loaded = glue.read_stored_state_readonly()
|
|
1050
|
+
if loaded is None:
|
|
1051
|
+
return None
|
|
1052
|
+
regimes = glue.stored_regimes(loaded, None)
|
|
1053
|
+
active = mrc.active_rate_change(regimes)
|
|
1054
|
+
if active is None:
|
|
1055
|
+
return dict(_QUOTA_NO_CHANGE)
|
|
1056
|
+
return active
|
|
1057
|
+
|
|
1058
|
+
|
|
1024
1059
|
def doctor_gather_state(
|
|
1025
1060
|
*,
|
|
1026
1061
|
now_utc: "dt.datetime | None" = None,
|
|
@@ -2028,6 +2063,31 @@ def _doctor_gather_state_impl(
|
|
|
2028
2063
|
except Exception:
|
|
2029
2064
|
pricing_coverage = None
|
|
2030
2065
|
|
|
2066
|
+
with _lib_perf.phase("doctor.quota_calibration"):
|
|
2067
|
+
# ── Quota (#661 S2 §7) ───────────────────────────────────────────
|
|
2068
|
+
# Through the §1.1 NON-MUTATING reader. `load_calibrations` renames a
|
|
2069
|
+
# malformed or version-ahead file aside through `_quarantine`, so
|
|
2070
|
+
# calling it here would make a documented read-only command a writer.
|
|
2071
|
+
# An already-quarantined file is reported simply as a missing primary:
|
|
2072
|
+
# the reader cannot tell it from an absent one without scanning
|
|
2073
|
+
# sidecars, and it deliberately does not scan them.
|
|
2074
|
+
quota_calibration = None
|
|
2075
|
+
quota_rate_change = None
|
|
2076
|
+
try:
|
|
2077
|
+
qcg = c._load_sibling("_cctally_quota_calibration")
|
|
2078
|
+
read = qcg.read_calibration_file(account_key=None)
|
|
2079
|
+
quota_calibration = {
|
|
2080
|
+
"rejection": (None if read.rejection is None
|
|
2081
|
+
else str(read.rejection.value)),
|
|
2082
|
+
"status": read.regime_status,
|
|
2083
|
+
"present": read.regime is not None,
|
|
2084
|
+
}
|
|
2085
|
+
quota_rate_change = _gather_quota_rate_change(
|
|
2086
|
+
c, quota_calibration["rejection"])
|
|
2087
|
+
except Exception:
|
|
2088
|
+
quota_calibration = None
|
|
2089
|
+
quota_rate_change = None
|
|
2090
|
+
|
|
2031
2091
|
# ── Meta ─────────────────────────────────────────────────────────
|
|
2032
2092
|
with _lib_perf.phase("doctor.journal"):
|
|
2033
2093
|
# ── Journal (DB journal redesign §9) ─────────────────────────────
|
|
@@ -2413,6 +2473,9 @@ def _doctor_gather_state_impl(
|
|
|
2413
2473
|
telemetry_reason=telemetry_reason,
|
|
2414
2474
|
# Pricing-freshness check (spec §5.1): trailing-30d coverage gaps.
|
|
2415
2475
|
pricing_coverage=pricing_coverage,
|
|
2476
|
+
# #661 S2 §7: the `quota` category's two inputs.
|
|
2477
|
+
quota_calibration=quota_calibration,
|
|
2478
|
+
quota_rate_change=quota_rate_change,
|
|
2416
2479
|
# Conversation-sessions rollup consistency (#217 S1 / U9).
|
|
2417
2480
|
conv_sessions_rollup_count=conv_sessions_rollup_count,
|
|
2418
2481
|
conv_messages_distinct_sessions=conv_messages_distinct_sessions,
|