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
package/bin/_cctally_forecast.py
CHANGED
|
@@ -28,6 +28,7 @@ Spec: docs/superpowers/specs/2026-05-31-extract-forecast-budget-cmd-design.md
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
30
|
import argparse
|
|
31
|
+
import dataclasses
|
|
31
32
|
import datetime as dt
|
|
32
33
|
import json
|
|
33
34
|
import math
|
|
@@ -85,7 +86,9 @@ _ensure_sibling_loaded("_lib_forecast")
|
|
|
85
86
|
from _lib_forecast import (
|
|
86
87
|
ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast,
|
|
87
88
|
ForecastConfidenceAssessment, ForecastConfidenceCause,
|
|
88
|
-
assess_forecast_confidence,
|
|
89
|
+
assess_forecast_confidence, corrected_percent_interval,
|
|
90
|
+
corrected_percent_point, select_projection_basis, ProjectionBasis,
|
|
91
|
+
SelectedProjection, dollars_per_percent_source_label,
|
|
89
92
|
)
|
|
90
93
|
|
|
91
94
|
# #279 S6 W4: the canonical None-safe UTC-Z serializer. forecast's former local
|
|
@@ -366,6 +369,397 @@ def _build_budget_status_inputs(
|
|
|
366
369
|
)
|
|
367
370
|
|
|
368
371
|
|
|
372
|
+
#: Why a week's realized meter movement could not be measured. A closed set,
|
|
373
|
+
#: local to this consumer: these causes never reach the wire, because a week
|
|
374
|
+
#: whose movement is withheld is simply absent from the trailing median.
|
|
375
|
+
#:
|
|
376
|
+
#: The two `*-baseline-absent` members name the RECORD that created the
|
|
377
|
+
#: segment. Reporting a reset-created segment as `credit-baseline-absent`
|
|
378
|
+
#: named the wrong boundary kind — the withholding itself was right either
|
|
379
|
+
#: way, but the cause pointed a reader at a table with nothing in it.
|
|
380
|
+
#: `boundary-records-unreadable` is the fail-closed member: a present
|
|
381
|
+
#: boundary table that could not be read withholds the week rather than
|
|
382
|
+
#: summing it as if the credit had zeroed the meter.
|
|
383
|
+
MOVEMENT_WITHHELD_CAUSES = ("credit-baseline-absent", "reset-baseline-absent",
|
|
384
|
+
"boundary-records-unreadable", "right-censored",
|
|
385
|
+
"no-readings")
|
|
386
|
+
|
|
387
|
+
#: The `source` `_insert_credit_snapshot` stamps on the synthetic post-credit
|
|
388
|
+
#: baseline it writes. That writer is the sole producer of the string, and it
|
|
389
|
+
#: is what separates a new baseline from an ordinary reading.
|
|
390
|
+
_CREDIT_SNAPSHOT_SOURCE = "record-credit"
|
|
391
|
+
|
|
392
|
+
#: A displayed reading at or above this denotes `[99, inf)` and has no point
|
|
393
|
+
#: estimate, so the week is right-censored (spec §3.2, §4.1).
|
|
394
|
+
_CENSORED_READING = 100.0
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass(frozen=True)
|
|
398
|
+
class WeekMovement:
|
|
399
|
+
"""The meter movement one prior week realized, or why it is unknowable.
|
|
400
|
+
|
|
401
|
+
`points` and `withheld_cause` are mutually exclusive: a withheld week
|
|
402
|
+
carries no number, and is absent from the trailing median rather than
|
|
403
|
+
contributing a zero.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
points: "float | None"
|
|
407
|
+
withheld_cause: "str | None"
|
|
408
|
+
segments: int = 0
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _credit_snapshot_effective(payload_json):
|
|
412
|
+
"""The credit instant a synthetic post-credit snapshot names, or None.
|
|
413
|
+
|
|
414
|
+
`_insert_credit_snapshot` writes `{"kind", "from", "to", "effective"}`
|
|
415
|
+
with the effective moment normalized to a `+00:00` spelling. A payload
|
|
416
|
+
this cannot read yields None, which the caller treats as unbound rather
|
|
417
|
+
than as a mismatch.
|
|
418
|
+
"""
|
|
419
|
+
if not payload_json:
|
|
420
|
+
return None
|
|
421
|
+
try:
|
|
422
|
+
payload = json.loads(payload_json)
|
|
423
|
+
except (TypeError, ValueError):
|
|
424
|
+
return None
|
|
425
|
+
if not isinstance(payload, dict):
|
|
426
|
+
return None
|
|
427
|
+
try:
|
|
428
|
+
return parse_iso_datetime(
|
|
429
|
+
str(payload.get("effective")), "credit.effective")
|
|
430
|
+
except (TypeError, ValueError):
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class BoundaryRecordsUnreadable(Exception):
|
|
435
|
+
"""A recorded boundary table exists but could not be read.
|
|
436
|
+
|
|
437
|
+
The fail-closed signal for `_realized_week_movement`. Losing either
|
|
438
|
+
boundary leg does not degrade the answer, it changes it: a credited week
|
|
439
|
+
whose credit rows are missing collapses to one segment and is summed as
|
|
440
|
+
if the credit had zeroed the meter, which spec §4.1 calls unknowable.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _boundary_table_present(conn, table: str) -> bool:
|
|
445
|
+
"""Whether `table` exists, RAISING when the catalogue itself is unreadable.
|
|
446
|
+
|
|
447
|
+
The one permitted relaxation: a store with no `weekly_credit_floors`
|
|
448
|
+
table has recorded no credits, and one with no `week_reset_events` table
|
|
449
|
+
has recorded no resets, so an absent table really does contribute no
|
|
450
|
+
boundaries. Everything else — a locked store, a corrupt file, a renamed
|
|
451
|
+
column — is a read that failed and must withhold the week.
|
|
452
|
+
"""
|
|
453
|
+
try:
|
|
454
|
+
return bool(conn.execute(f"PRAGMA table_info({table})").fetchall())
|
|
455
|
+
except sqlite3.Error as exc:
|
|
456
|
+
raise BoundaryRecordsUnreadable(table) from exc
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _week_segment_boundaries(conn, week_start_at, week_end_at,
|
|
460
|
+
week_start_date, *, account_key=None):
|
|
461
|
+
"""The recorded reset and credit instants strictly inside the week.
|
|
462
|
+
|
|
463
|
+
Returns `[(instant, kind)]` sorted by instant, where `kind` is `"reset"`
|
|
464
|
+
or `"credit"` — the caller names the boundary kind in its withholding
|
|
465
|
+
cause, so a reset-created segment is not reported as a missing credit
|
|
466
|
+
baseline.
|
|
467
|
+
|
|
468
|
+
Spec §4.1: `week_reset_events` and `weekly_credit_floors` are the
|
|
469
|
+
authoritative boundary records, and a decrease is never used to infer
|
|
470
|
+
one. Both legs are scoped the way `_reset_aware_floor` scopes them, to
|
|
471
|
+
the same account and the same week, so this reducer and #290's floor
|
|
472
|
+
agree on which record belongs to which week. The reset leg's upper
|
|
473
|
+
comparison is strict here where the floor's is inclusive, because a
|
|
474
|
+
record landing exactly on `week_end_at` opens the NEXT week rather than
|
|
475
|
+
segmenting this one. `unixepoch()` rather than a textual comparison,
|
|
476
|
+
because the two legs carry mixed offset spellings.
|
|
477
|
+
|
|
478
|
+
Both legs fail CLOSED, by raising `BoundaryRecordsUnreadable`, on any
|
|
479
|
+
store error other than the table not existing — and the credit leg also
|
|
480
|
+
fails closed on a week key it cannot query with. Ten lines away
|
|
481
|
+
`_snapshot_columns` already fails closed for the same class of defect,
|
|
482
|
+
and the whole fail-closed argument for this reducer depends on seeing
|
|
483
|
+
the credit rows. An ABSENT table is the one permitted relaxation: a
|
|
484
|
+
store with no `weekly_credit_floors` records no credits at all, so there
|
|
485
|
+
is no row to miss.
|
|
486
|
+
"""
|
|
487
|
+
acct = "" if account_key is None else " AND account_key = ?"
|
|
488
|
+
acct_p: tuple = () if account_key is None else (account_key,)
|
|
489
|
+
stamps: list = []
|
|
490
|
+
if _boundary_table_present(conn, "week_reset_events"):
|
|
491
|
+
try:
|
|
492
|
+
stamps += [
|
|
493
|
+
(row[0], "reset") for row in conn.execute(
|
|
494
|
+
"SELECT effective_reset_at_utc FROM week_reset_events"
|
|
495
|
+
" WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)"
|
|
496
|
+
" AND unixepoch(effective_reset_at_utc) < unixepoch(?)"
|
|
497
|
+
+ acct,
|
|
498
|
+
(week_start_at.isoformat(), week_end_at.isoformat())
|
|
499
|
+
+ acct_p,
|
|
500
|
+
).fetchall()]
|
|
501
|
+
except sqlite3.Error as exc:
|
|
502
|
+
raise BoundaryRecordsUnreadable("week_reset_events") from exc
|
|
503
|
+
if _boundary_table_present(conn, "weekly_credit_floors"):
|
|
504
|
+
if not week_start_date:
|
|
505
|
+
# The THIRD route to the same wrong arithmetic (#661 S2 Stage C
|
|
506
|
+
# review). This table is keyed by `week_start_date`, so a falsy
|
|
507
|
+
# key cannot select the week's credit rows: the leg used to be
|
|
508
|
+
# SKIPPED here, silently, and a credited week then summed as one
|
|
509
|
+
# unsegmented run. That is the arithmetic the two `raise`
|
|
510
|
+
# statements around it exist to prevent, so this leg fails closed
|
|
511
|
+
# too. `weekly_usage_snapshots.week_start_date` is `TEXT NOT
|
|
512
|
+
# NULL`, so only an empty string reaches this in production and
|
|
513
|
+
# the branch costs nothing; it makes the fail-closed claim true
|
|
514
|
+
# rather than nearly true.
|
|
515
|
+
raise BoundaryRecordsUnreadable("weekly_credit_floors")
|
|
516
|
+
try:
|
|
517
|
+
stamps += [
|
|
518
|
+
(row[0], "credit") for row in conn.execute(
|
|
519
|
+
"SELECT effective_at_utc FROM weekly_credit_floors"
|
|
520
|
+
" WHERE week_start_date = ?" + acct,
|
|
521
|
+
(week_start_date,) + acct_p,
|
|
522
|
+
).fetchall()]
|
|
523
|
+
except sqlite3.Error as exc:
|
|
524
|
+
raise BoundaryRecordsUnreadable("weekly_credit_floors") from exc
|
|
525
|
+
found: dict = {}
|
|
526
|
+
for stamp, kind in stamps:
|
|
527
|
+
try:
|
|
528
|
+
at = parse_iso_datetime(str(stamp), "movement.boundary")
|
|
529
|
+
except (TypeError, ValueError):
|
|
530
|
+
continue
|
|
531
|
+
if week_start_at < at < week_end_at:
|
|
532
|
+
# A credit record wins a shared instant: it is the one that
|
|
533
|
+
# writes the synthetic baseline the segment is measured from.
|
|
534
|
+
if kind == "credit" or at not in found:
|
|
535
|
+
found[at] = kind
|
|
536
|
+
return [(at, found[at]) for at in sorted(found)]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _realized_week_movement(conn, week_start_at, week_end_at, week_start_date,
|
|
540
|
+
readings, *,
|
|
541
|
+
account_key: "str | None" = None) -> WeekMovement:
|
|
542
|
+
"""How many meter points one completed week actually consumed.
|
|
543
|
+
|
|
544
|
+
`readings` is the week's snapshots as `(captured_at, weekly_percent,
|
|
545
|
+
source, payload_json)`; the caller supplies them because it has already
|
|
546
|
+
selected them, and `conn` is used only for the two boundary tables.
|
|
547
|
+
|
|
548
|
+
The reducer SEGMENTS at each recorded reset and credit boundary and sums
|
|
549
|
+
positive deltas within each segment. The first segment measures from
|
|
550
|
+
zero, because a week starts at an empty meter and its first reading is
|
|
551
|
+
itself consumption. Every later segment measures from the synthetic
|
|
552
|
+
post-credit snapshot `_apply_credit` writes, which is a new baseline and
|
|
553
|
+
not fresh consumption.
|
|
554
|
+
|
|
555
|
+
When a segment holding readings has no such baseline the WHOLE WEEK is
|
|
556
|
+
withheld. It is not rescued by counting the first post-credit observation
|
|
557
|
+
as consumption: on the #290 fixture's readings of 46 then 31, a naive
|
|
558
|
+
positive-adjacent sum yields 46 because the drop contributes zero, and
|
|
559
|
+
counting 31 yields 77 by assuming the credit zeroed the meter. Neither is
|
|
560
|
+
knowable. A segment holding no readings needs no baseline, because a
|
|
561
|
+
reset ends a week and the readings after it carry the next window's
|
|
562
|
+
bounds.
|
|
563
|
+
|
|
564
|
+
A week whose meter reached a displayed 100% is right-censored and is
|
|
565
|
+
withheld for the same reason §3.2 refuses a point estimate there.
|
|
566
|
+
"""
|
|
567
|
+
parsed: list = []
|
|
568
|
+
for row in readings:
|
|
569
|
+
captured, percent = row[0], row[1]
|
|
570
|
+
source = row[2] if len(row) > 2 else None
|
|
571
|
+
payload = row[3] if len(row) > 3 else None
|
|
572
|
+
if percent is None:
|
|
573
|
+
continue
|
|
574
|
+
try:
|
|
575
|
+
at = parse_iso_datetime(str(captured), "movement.captured_at")
|
|
576
|
+
except (TypeError, ValueError):
|
|
577
|
+
continue
|
|
578
|
+
parsed.append((at, float(percent), str(source or ""), payload))
|
|
579
|
+
if not parsed:
|
|
580
|
+
return WeekMovement(None, "no-readings")
|
|
581
|
+
parsed.sort(key=lambda row: row[0])
|
|
582
|
+
if any(row[1] >= _CENSORED_READING for row in parsed):
|
|
583
|
+
return WeekMovement(None, "right-censored")
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
boundaries = _week_segment_boundaries(
|
|
587
|
+
conn, week_start_at, week_end_at, week_start_date,
|
|
588
|
+
account_key=account_key)
|
|
589
|
+
except BoundaryRecordsUnreadable:
|
|
590
|
+
# Fail CLOSED. Without the recorded boundaries `edges` collapses to
|
|
591
|
+
# the week start and a credited week is summed as one segment, which
|
|
592
|
+
# is the "assume the credit zeroed the meter" arithmetic §4.1 calls
|
|
593
|
+
# unknowable — and on the #290 shape it yields 46 as the denominator
|
|
594
|
+
# instead of a withholding.
|
|
595
|
+
return WeekMovement(None, "boundary-records-unreadable")
|
|
596
|
+
edges = [(week_start_at, "week-start")] + boundaries
|
|
597
|
+
total = 0.0
|
|
598
|
+
segments = 0
|
|
599
|
+
for position, (low, kind) in enumerate(edges):
|
|
600
|
+
high = edges[position + 1][0] if position + 1 < len(edges) else None
|
|
601
|
+
chunk = [row for row in parsed
|
|
602
|
+
if (position == 0 or low <= row[0])
|
|
603
|
+
and (high is None or row[0] < high)]
|
|
604
|
+
if not chunk:
|
|
605
|
+
continue
|
|
606
|
+
segments += 1
|
|
607
|
+
if position == 0:
|
|
608
|
+
previous = 0.0
|
|
609
|
+
rest = chunk
|
|
610
|
+
else:
|
|
611
|
+
first = chunk[0]
|
|
612
|
+
stated = _credit_snapshot_effective(first[3])
|
|
613
|
+
if first[2] != _CREDIT_SNAPSHOT_SOURCE or (
|
|
614
|
+
stated is not None and stated != low):
|
|
615
|
+
# Name the record that created the segment. A reset-created
|
|
616
|
+
# segment reported as `credit-baseline-absent` names the
|
|
617
|
+
# wrong boundary kind; the withholding itself is right
|
|
618
|
+
# either way.
|
|
619
|
+
return WeekMovement(None, f"{kind}-baseline-absent")
|
|
620
|
+
previous = first[1]
|
|
621
|
+
rest = chunk[1:]
|
|
622
|
+
for row in rest:
|
|
623
|
+
if row[1] > previous:
|
|
624
|
+
total += row[1] - previous
|
|
625
|
+
previous = row[1]
|
|
626
|
+
return WeekMovement(total, None, segments)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _snapshot_columns(conn) -> set:
|
|
630
|
+
"""The column names `weekly_usage_snapshots` actually carries.
|
|
631
|
+
|
|
632
|
+
Every production store has `source` and `payload_json`, and several
|
|
633
|
+
hand-built fixtures do not. Selecting them unconditionally would turn a
|
|
634
|
+
thin fixture into an `OperationalError`; selecting NULL instead makes the
|
|
635
|
+
reducer unable to identify a synthetic post-credit baseline, which
|
|
636
|
+
withholds a credited week rather than mispricing it.
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
rows = conn.execute(
|
|
640
|
+
"PRAGMA table_info(weekly_usage_snapshots)").fetchall()
|
|
641
|
+
except sqlite3.Error:
|
|
642
|
+
return set()
|
|
643
|
+
return {str(row[1]) for row in rows}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _dpp_candidate_regime(current_week_start, *, account_key):
|
|
647
|
+
"""The S1 regime the TARGET week sits in, or None when there is none.
|
|
648
|
+
|
|
649
|
+
None means "no regime restriction is knowable", which is the inert state
|
|
650
|
+
and the common one. It arises when the validated reader refuses — no
|
|
651
|
+
calibration, a fingerprint or revision mismatch, or S1's `detection-only`
|
|
652
|
+
prediction gate — and equally when the target week falls outside the OPEN
|
|
653
|
+
regime the reader publishes. The reader returns only that one regime, so
|
|
654
|
+
a target week inside a closed predecessor cannot be matched against
|
|
655
|
+
anything, and restricting it would exclude every candidate rather than
|
|
656
|
+
the incomparable ones.
|
|
657
|
+
|
|
658
|
+
`account_key=None` reads the MERGED `*` bucket, which spec §5.3 declares
|
|
659
|
+
invalid as a source of published quota on a decorated install, and this
|
|
660
|
+
path deliberately carries no decoration gate. Three reasons, recorded
|
|
661
|
+
here so the question is not re-opened from the §5.3 text alone.
|
|
662
|
+
|
|
663
|
+
First, §5.3 forbids PUBLISHING a modelled figure attributed to an
|
|
664
|
+
account from a merged calibration. Nothing of the kind happens here: the
|
|
665
|
+
regime is used only to decide which prior weeks are comparable, and its
|
|
666
|
+
`units_per_point` never enters the returned rate, which is
|
|
667
|
+
`week_cost / realized_points` end to end. A test pins that behaviourally
|
|
668
|
+
— two regimes differing only in `unitsPerPoint` publish the same rate.
|
|
669
|
+
|
|
670
|
+
Second, the regime's scope and the population's scope always agree,
|
|
671
|
+
because both come from this same `account_key`: a merged call reads the
|
|
672
|
+
merged bucket against merged candidate weeks and a merged entry
|
|
673
|
+
population, and `forecast --account X` reads X's bucket against X's.
|
|
674
|
+
|
|
675
|
+
Third, the residual case is bounded. On a decorated install `cctally
|
|
676
|
+
quota` resolves real account keys and never writes the `*` bucket, so
|
|
677
|
+
the reader refuses and the restriction is inert. The one reachable state
|
|
678
|
+
is a `*` bucket fitted while the install had a single account and read
|
|
679
|
+
after it became decorated, and there the regime can only DROP candidates
|
|
680
|
+
— pushing the branch toward the fallback it already has — never publish
|
|
681
|
+
a wrong number.
|
|
682
|
+
"""
|
|
683
|
+
c = _cctally()
|
|
684
|
+
try:
|
|
685
|
+
qcg = c._load_sibling("_cctally_quota_calibration")
|
|
686
|
+
read = qcg.read_calibration_file(account_key=account_key)
|
|
687
|
+
except Exception: # noqa: BLE001
|
|
688
|
+
return None
|
|
689
|
+
regime = getattr(read, "regime", None)
|
|
690
|
+
if regime is None:
|
|
691
|
+
return None
|
|
692
|
+
if current_week_start < regime.effective_from:
|
|
693
|
+
return None
|
|
694
|
+
if regime.effective_until is not None \
|
|
695
|
+
and current_week_start >= regime.effective_until:
|
|
696
|
+
return None
|
|
697
|
+
return regime
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _dpp_candidate_in_regime(regime, week_start_at, week_end_at) -> bool:
|
|
701
|
+
"""Whether one candidate week sits inside `regime`'s own interval.
|
|
702
|
+
|
|
703
|
+
Split out of `_dpp_candidate_comparability` (#661 S2 Stage C review)
|
|
704
|
+
because the two halves of that verdict have different costs and
|
|
705
|
+
different failure modes. This half is a pure date comparison that
|
|
706
|
+
reaches no store, so the caller keeps applying it after a population
|
|
707
|
+
read has failed; the other half opens `cache.db`, so the caller stops
|
|
708
|
+
applying it once one read has failed. Folding the two together let one
|
|
709
|
+
unreadable probe admit every later week, including weeks on the far side
|
|
710
|
+
of a metering-rate boundary, which is precisely what spec section 4.2's
|
|
711
|
+
sparse fallback exists to avoid.
|
|
712
|
+
"""
|
|
713
|
+
if week_start_at < regime.effective_from:
|
|
714
|
+
return False
|
|
715
|
+
if regime.effective_until is not None \
|
|
716
|
+
and week_end_at > regime.effective_until:
|
|
717
|
+
return False
|
|
718
|
+
return True
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _dpp_candidate_comparability(regime, week_start_at, week_end_at, *,
|
|
722
|
+
account_key, cache_conn_factory=None):
|
|
723
|
+
"""One candidate's verdict, over four distinct outcomes.
|
|
724
|
+
|
|
725
|
+
- `"comparable"` — in regime, and its own population passes the support
|
|
726
|
+
test.
|
|
727
|
+
- `"incomparable"` — in regime, population read, support test failed.
|
|
728
|
+
This is drift, and only this.
|
|
729
|
+
- `"excluded"` — on the far side of a rate boundary.
|
|
730
|
+
- `"unreadable"` — the population could not be read at all. Folding this
|
|
731
|
+
into `"excluded"` made a locked `cache.db` look like twelve weeks that
|
|
732
|
+
had each crossed a rate boundary, which silently collapsed the branch
|
|
733
|
+
to `this_week_sparse`; the caller now acts on the distinction.
|
|
734
|
+
|
|
735
|
+
Spec §4.2. Same-regime is necessary but not sufficient, because an S1
|
|
736
|
+
regime describes weighted units per meter point while dollars per point
|
|
737
|
+
also varies with model mix, pricing, cache mix and speed tier. The
|
|
738
|
+
additional condition is §1.1's apply adapter run over the candidate
|
|
739
|
+
week's own population: a week whose composition sits outside the regime's
|
|
740
|
+
recorded radii is exactly the week `cctally quota` would refuse to model,
|
|
741
|
+
so no second notion of comparability is invented here.
|
|
742
|
+
|
|
743
|
+
`cache_conn_factory`, when given, returns a `cache.db` connection the
|
|
744
|
+
caller reuses across candidates, so one selector call opens that database
|
|
745
|
+
once rather than once per probe.
|
|
746
|
+
"""
|
|
747
|
+
if not _dpp_candidate_in_regime(regime, week_start_at, week_end_at):
|
|
748
|
+
return "excluded"
|
|
749
|
+
c = _cctally()
|
|
750
|
+
try:
|
|
751
|
+
qcg = c._load_sibling("_cctally_quota_calibration")
|
|
752
|
+
entries = _week_entry_records(
|
|
753
|
+
week_start_at, week_end_at, account_key=account_key,
|
|
754
|
+
cache_conn_factory=cache_conn_factory)
|
|
755
|
+
except Exception: # noqa: BLE001
|
|
756
|
+
return "unreadable"
|
|
757
|
+
applied = qcg.apply_regime(regime, entries)
|
|
758
|
+
if isinstance(applied, qcg.ApplyRejection):
|
|
759
|
+
return "incomparable"
|
|
760
|
+
return "comparable"
|
|
761
|
+
|
|
762
|
+
|
|
369
763
|
def _select_dollars_per_percent(
|
|
370
764
|
conn: sqlite3.Connection,
|
|
371
765
|
now_utc: dt.datetime,
|
|
@@ -376,9 +770,21 @@ def _select_dollars_per_percent(
|
|
|
376
770
|
skip_sync: bool = False,
|
|
377
771
|
use_weekref_cost_cache: bool = False,
|
|
378
772
|
account_key: "str | None" = None,
|
|
773
|
+
p_now_corrected: "float | None" = None,
|
|
379
774
|
) -> "tuple[float | None, str]":
|
|
380
775
|
"""Return (dollars_per_percent, source_label). See spec §1 selection rule.
|
|
381
776
|
|
|
777
|
+
#661 S2 spec section 3.1: ``p_now_corrected`` is the ceiling-corrected
|
|
778
|
+
estimate of what was actually consumed and is the DIVISOR for the two
|
|
779
|
+
current-week paths. ``p_now`` stays the displayed reading and remains the
|
|
780
|
+
GATE, deliberately: the ``>= 10`` threshold is about having a stable
|
|
781
|
+
sample, and dividing 10 into 9.5 would flip a displayed 10 into the
|
|
782
|
+
trailing-median branch; the ``> 0`` threshold is what withholds the rate
|
|
783
|
+
on a week with no observed usage, and the corrected point for a displayed
|
|
784
|
+
0 is 0.25, so correcting the gate would publish a rate there. Omitting
|
|
785
|
+
the argument keeps the displayed reading as the divisor, which is the
|
|
786
|
+
right-censored case: there is no corrected point to divide by.
|
|
787
|
+
|
|
382
788
|
#620 S1 D5: the rate is ``None`` when no usage has been observed. It used
|
|
383
789
|
to be ``0.0`` paired with the ``this_week_sparse`` label, which published
|
|
384
790
|
a rate of exactly $0.00 per percent and blamed a sparse week — a
|
|
@@ -386,7 +792,11 @@ def _select_dollars_per_percent(
|
|
|
386
792
|
spend. Absence is now typed, and the cause travels on the existing
|
|
387
793
|
companion source field rather than a new key.
|
|
388
794
|
|
|
389
|
-
Eligible prior week
|
|
795
|
+
Eligible prior week (#661 S2 spec §4): it closed before `now_utc`, its
|
|
796
|
+
REALIZED meter movement is known and at least 1 point, and — when a
|
|
797
|
+
trustworthy S1 regime covers the target week — it sits in that same
|
|
798
|
+
regime and its own population still passes the regime's composition
|
|
799
|
+
support test. The denominator is that movement, not `_floored_week_max`.
|
|
390
800
|
Uses the existing `_sum_cost_for_range` helper (which opens the cache DB
|
|
391
801
|
via `get_entries`); `conn` is only used for snapshot queries.
|
|
392
802
|
|
|
@@ -396,14 +806,21 @@ def _select_dollars_per_percent(
|
|
|
396
806
|
the trailing-4wk-median prior-week snapshot + cost reads to that account.
|
|
397
807
|
"""
|
|
398
808
|
c = _cctally()
|
|
809
|
+
divisor = p_now if p_now_corrected is None else p_now_corrected
|
|
399
810
|
# Path 1: current week, stable sample.
|
|
400
811
|
if p_now >= 10.0 and p_now > 0:
|
|
401
|
-
return spent_usd /
|
|
402
|
-
|
|
403
|
-
# Path 2: trailing 4-week median
|
|
812
|
+
return spent_usd / divisor, "this_week"
|
|
813
|
+
|
|
814
|
+
# Path 2: trailing 4-week median over REALIZED meter movement (#661 S2
|
|
815
|
+
# spec §4). The denominator used to be `_floored_week_max(week)` — #290's
|
|
816
|
+
# reset-aware, display-oriented high-water mark, which on a credited week
|
|
817
|
+
# is the POST-credit reading and therefore a fragment of the week's
|
|
818
|
+
# cost-bearing consumption. `_floored_week_max` is unchanged and still
|
|
819
|
+
# serves its current-high-water and MAX-clamp consumers; the defect was
|
|
820
|
+
# in this consumer.
|
|
404
821
|
import statistics
|
|
405
822
|
# Bounded candidate selection: the <=12 most-recent prior weeks. The median
|
|
406
|
-
# needs 4 eligible; 12 leaves margin so
|
|
823
|
+
# needs 4 eligible; 12 leaves margin so a withheld or incomparable week
|
|
407
824
|
# can't starve it, while keeping this hot path (live forecast view +
|
|
408
825
|
# dashboard refresh) from materializing all history.
|
|
409
826
|
_acct_pred = "" if account_key is None else " AND account_key = ?"
|
|
@@ -417,18 +834,29 @@ def _select_dollars_per_percent(
|
|
|
417
834
|
(current_week_start.isoformat(),) + _acct_p,
|
|
418
835
|
).fetchall()
|
|
419
836
|
we_by_ws: dict[dt.datetime, dt.datetime] = {}
|
|
420
|
-
|
|
837
|
+
wsd_by_ws: dict[dt.datetime, str] = {}
|
|
838
|
+
readings_by_ws: dict = {}
|
|
421
839
|
ws_iso_list = [row[0] for row in cand]
|
|
422
840
|
if ws_iso_list:
|
|
423
841
|
placeholders = ",".join("?" * len(ws_iso_list))
|
|
842
|
+
# `source` and `payload_json` are what separate a synthetic
|
|
843
|
+
# post-credit baseline from an ordinary reading. A store without them
|
|
844
|
+
# cannot supply that distinction, so they are selected as NULL there
|
|
845
|
+
# and the reducer withholds any credited week — the fail-closed
|
|
846
|
+
# direction.
|
|
847
|
+
present = _snapshot_columns(conn)
|
|
848
|
+
source_col = ("source" if "source" in present else "NULL")
|
|
849
|
+
payload_col = ("payload_json" if "payload_json" in present
|
|
850
|
+
else "NULL")
|
|
424
851
|
snap = conn.execute(
|
|
425
852
|
"SELECT week_start_date, week_start_at, week_end_at, "
|
|
426
|
-
" captured_at_utc, weekly_percent "
|
|
853
|
+
" captured_at_utc, weekly_percent, "
|
|
854
|
+
f" {source_col}, {payload_col} "
|
|
427
855
|
"FROM weekly_usage_snapshots "
|
|
428
856
|
"WHERE week_start_at IN (" + placeholders + ")" + _acct_pred,
|
|
429
857
|
ws_iso_list + list(_acct_p),
|
|
430
858
|
).fetchall()
|
|
431
|
-
for wsd, ws_iso, we_iso, cap_iso, pct in snap:
|
|
859
|
+
for wsd, ws_iso, we_iso, cap_iso, pct, source, payload in snap:
|
|
432
860
|
if pct is None:
|
|
433
861
|
continue
|
|
434
862
|
try:
|
|
@@ -437,22 +865,131 @@ def _select_dollars_per_percent(
|
|
|
437
865
|
except ValueError:
|
|
438
866
|
continue
|
|
439
867
|
we_by_ws.setdefault(ws, we) # first-wins; all rows share one instant
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
]
|
|
451
|
-
|
|
868
|
+
if wsd is not None:
|
|
869
|
+
wsd_by_ws.setdefault(ws, wsd)
|
|
870
|
+
readings_by_ws.setdefault(ws, []).append(
|
|
871
|
+
(cap_iso, pct, source, payload))
|
|
872
|
+
# Spec §4.2. Both clauses are gated on a trustworthy regime existing: the
|
|
873
|
+
# validated reader publishes only the OPEN regime and refuses one S1
|
|
874
|
+
# marked `detection-only`, so on an install with no calibration there is
|
|
875
|
+
# no regime information and the restriction is inert. Applying it anyway
|
|
876
|
+
# would exclude every candidate rather than the incomparable ones.
|
|
877
|
+
regime = _dpp_candidate_regime(current_week_start, account_key=account_key)
|
|
878
|
+
eligible: list[tuple[dt.datetime, dt.datetime, float]] = []
|
|
879
|
+
drifted = False
|
|
880
|
+
unverified = False
|
|
881
|
+
# Cost bound (#661 S2 Stage C review). Each comparability probe opens
|
|
882
|
+
# `cache.db`, scans one subscription week of `session_entries` and turns
|
|
883
|
+
# the rows into kernel records, so an unbounded per-candidate probe
|
|
884
|
+
# added up to twelve opens and twelve scans to the live forecast view
|
|
885
|
+
# and to every dashboard refresh, arriving the day a user's calibration
|
|
886
|
+
# first became prediction-ready. Two bounds, in the spirit of the
|
|
887
|
+
# `use_weekref_cost_cache` read beside it:
|
|
888
|
+
# * the loop stops at the four candidates the median consumes, so the
|
|
889
|
+
# usual cost is four scans rather than twelve; and
|
|
890
|
+
# * one `cache.db` connection serves every probe in the call, so the
|
|
891
|
+
# open cost is paid once rather than per candidate.
|
|
892
|
+
# MEASURED, over the twelve most recent completed subscription weeks of
|
|
893
|
+
# the maintainer's August 2026 store snapshot (476,216 `session_entries`
|
|
894
|
+
# rows), on an Apple M4 Max Mac Studio under CPython 3.14 with the file
|
|
895
|
+
# cache warm: a median 16,923 rows and 28 ms per probe, worst 27,664
|
|
896
|
+
# rows and 46 ms; the SQL scan alone is a median 13 ms and the rest is
|
|
897
|
+
# building the records. So the usual bounded cost is about 110 ms, and
|
|
898
|
+
# the WORST CASE — a trustworthy prediction-ready regime with eight or
|
|
899
|
+
# more candidates rejected before four survive — is one open plus twelve
|
|
900
|
+
# probes: about 340 ms at the MEDIAN per-probe cost, and up to about
|
|
901
|
+
# 550 ms if every one of the twelve is as heavy as the heaviest week
|
|
902
|
+
# measured. Both figures are stated because an earlier revision gave only
|
|
903
|
+
# the first while calling it the worst case, which mixes the statistics:
|
|
904
|
+
# a reader taking "worst case" literally computes 12 x 46 ms from the
|
|
905
|
+
# figures two lines above and gets a different number. Those durations
|
|
906
|
+
# are this machine's; another host will differ, and the two LAN runners
|
|
907
|
+
# alone differ by about 1.4x. The
|
|
908
|
+
# STRUCTURAL bound, at most twelve week-scans per call, is hardware
|
|
909
|
+
# independent and is what the tests pin. With no trustworthy regime —
|
|
910
|
+
# every install that has never fit one — no probe runs at all and the
|
|
911
|
+
# cost is zero.
|
|
912
|
+
#
|
|
913
|
+
# NO COMMITTED PROBE-COST HARNESS, deliberately, unlike the realized-error
|
|
914
|
+
# measurement at `tests/quota_budget_error_harness.py`. That one exists
|
|
915
|
+
# because spec §4's acceptance criterion is a RE-RUN of it against a
|
|
916
|
+
# committed artifact, so the number has to be reproducible to be checked.
|
|
917
|
+
# This one is a duration on one machine over one private store, and a
|
|
918
|
+
# committed harness would either need that store — which validates a
|
|
919
|
+
# figure but must never supply one — or a synthetic corpus whose timings
|
|
920
|
+
# say nothing about the real one. The bound a reader can act on is the
|
|
921
|
+
# structural one, and it is pinned by test rather than by this comment.
|
|
922
|
+
shared_cache: dict = {"conn": None, "opened": False}
|
|
923
|
+
|
|
924
|
+
def _shared_cache_conn():
|
|
925
|
+
# Opened on FIRST probe and never before: a selector call that never
|
|
926
|
+
# reaches a probe — no regime, or every candidate rejected on
|
|
927
|
+
# movement — must not touch `cache.db` at all.
|
|
928
|
+
if not shared_cache["opened"]:
|
|
929
|
+
shared_cache["opened"] = True
|
|
930
|
+
try:
|
|
931
|
+
shared_cache["conn"] = c._load_sibling(
|
|
932
|
+
"_cctally_cache").open_cache_db()
|
|
933
|
+
except Exception: # noqa: BLE001
|
|
934
|
+
shared_cache["conn"] = None
|
|
935
|
+
return shared_cache["conn"]
|
|
936
|
+
|
|
937
|
+
try:
|
|
938
|
+
for ws in sorted(readings_by_ws, reverse=True):
|
|
939
|
+
if len(eligible) >= 4:
|
|
940
|
+
# Only the first four are ever used (`eligible[:4]` below),
|
|
941
|
+
# so a fifth probe cannot change the published rate. It also
|
|
942
|
+
# cannot change `drifted`, which qualifies the SELECTED
|
|
943
|
+
# population: a candidate examined after the fourth survivor
|
|
944
|
+
# was never a candidate for selection.
|
|
945
|
+
break
|
|
946
|
+
we = we_by_ws.get(ws)
|
|
947
|
+
if we is None or ws >= current_week_start or we >= now_utc:
|
|
948
|
+
continue
|
|
949
|
+
movement = _realized_week_movement(
|
|
950
|
+
conn, ws, we, wsd_by_ws.get(ws), readings_by_ws[ws],
|
|
951
|
+
account_key=account_key)
|
|
952
|
+
if movement.points is None or movement.points < 1.0:
|
|
953
|
+
continue
|
|
954
|
+
if regime is not None:
|
|
955
|
+
# The BOUNDARY half is applied to EVERY candidate, including
|
|
956
|
+
# the ones examined after a read has failed (#661 S2 Stage C
|
|
957
|
+
# review). It is a date comparison that reaches no store, so
|
|
958
|
+
# keeping it costs nothing, and dropping it admitted weeks
|
|
959
|
+
# from the far side of a metering-rate boundary into the
|
|
960
|
+
# median — the failure section 4.2 names outright. Only the
|
|
961
|
+
# POPULATION half below goes inert on an unreadable store.
|
|
962
|
+
if not _dpp_candidate_in_regime(regime, ws, we):
|
|
963
|
+
continue
|
|
964
|
+
if regime is not None and not unverified:
|
|
965
|
+
verdict = _dpp_candidate_comparability(
|
|
966
|
+
regime, ws, we, account_key=account_key,
|
|
967
|
+
cache_conn_factory=_shared_cache_conn)
|
|
968
|
+
if verdict == "incomparable":
|
|
969
|
+
drifted = True
|
|
970
|
+
continue
|
|
971
|
+
if verdict == "unreadable":
|
|
972
|
+
# The same store serves every candidate, so a read that
|
|
973
|
+
# failed here fails for all of them. Dropping them would
|
|
974
|
+
# exclude every candidate rather than the incomparable
|
|
975
|
+
# ones — the exact failure `_dpp_candidate_regime`'s
|
|
976
|
+
# docstring refuses — so the POPULATION test becomes
|
|
977
|
+
# inert for this call and the rate says it was not
|
|
978
|
+
# verified. The boundary test above is unaffected.
|
|
979
|
+
unverified = True
|
|
980
|
+
elif verdict != "comparable":
|
|
981
|
+
continue
|
|
982
|
+
eligible.append((ws, we, movement.points))
|
|
983
|
+
finally:
|
|
984
|
+
if shared_cache["conn"] is not None:
|
|
985
|
+
try:
|
|
986
|
+
shared_cache["conn"].close()
|
|
987
|
+
except Exception: # noqa: BLE001
|
|
988
|
+
pass
|
|
452
989
|
prior = eligible[:4]
|
|
453
990
|
if len(prior) >= 4:
|
|
454
991
|
values: list[float] = []
|
|
455
|
-
for ws, we,
|
|
992
|
+
for ws, we, realized_points in prior:
|
|
456
993
|
if use_weekref_cost_cache:
|
|
457
994
|
# #269 §4: every `prior` week satisfies `we < now_utc` (an
|
|
458
995
|
# eligibility filter above), so all four are CLOSED and
|
|
@@ -475,12 +1012,30 @@ def _select_dollars_per_percent(
|
|
|
475
1012
|
ws, we, mode="auto", skip_sync=skip_sync,
|
|
476
1013
|
account_key=account_key,
|
|
477
1014
|
)
|
|
478
|
-
|
|
479
|
-
|
|
1015
|
+
# `realized_points` is the week's REALIZED meter movement, not a
|
|
1016
|
+
# final percentage: since §4 the denominator is what the meter
|
|
1017
|
+
# actually moved through, so a credited week no longer divides by
|
|
1018
|
+
# its post-credit fragment.
|
|
1019
|
+
values.append(week_cost / realized_points)
|
|
1020
|
+
# Spec §4.2's third clause: the selected rate carries reduced
|
|
1021
|
+
# confidence when the historical population has drifted. The
|
|
1022
|
+
# qualification travels on the existing companion source field rather
|
|
1023
|
+
# than a new key, which is the same decision #620 S1 D5 made for the
|
|
1024
|
+
# withheld-rate cause. `unverified` is the third register: the
|
|
1025
|
+
# comparability test could not run at all, which is neither drift nor
|
|
1026
|
+
# a clean verification, and it outranks `drifted` because a drift
|
|
1027
|
+
# verdict from a partly-unreadable population is not one.
|
|
1028
|
+
if unverified:
|
|
1029
|
+
label = "trailing_4wk_median_unverified"
|
|
1030
|
+
elif drifted:
|
|
1031
|
+
label = "trailing_4wk_median_drifted"
|
|
1032
|
+
else:
|
|
1033
|
+
label = "trailing_4wk_median"
|
|
1034
|
+
return statistics.median(values), label
|
|
480
1035
|
|
|
481
1036
|
# Path 3: fall back to current week even if sparse.
|
|
482
1037
|
if p_now > 0:
|
|
483
|
-
return spent_usd /
|
|
1038
|
+
return spent_usd / divisor, "this_week_sparse"
|
|
484
1039
|
# p_now == 0: there is no signal to divide by. Withhold the rate and say
|
|
485
1040
|
# why (#620 S1 D5). Every dollar figure derived from it becomes
|
|
486
1041
|
# unavailable; percent projections are unaffected because they never
|
|
@@ -525,6 +1080,191 @@ def _pick_p_24h_ago(
|
|
|
525
1080
|
return pick[1], t_actual
|
|
526
1081
|
|
|
527
1082
|
|
|
1083
|
+
#: Which member of the quota kernel's closed `EVIDENCE_CODES` union states
|
|
1084
|
+
#: each calibration-read rejection. `stale` is the union's word for "fitted
|
|
1085
|
+
#: under other constants", which is what a fingerprint or revision mismatch
|
|
1086
|
+
#: means; every other rejection is reported as `unavailable`, because the
|
|
1087
|
+
#: reader deliberately cannot distinguish a quarantined file from an absent
|
|
1088
|
+
#: one without scanning sidecars, and it does not scan them.
|
|
1089
|
+
_CALIBRATION_REJECTION_CODES: dict = {
|
|
1090
|
+
"calibration-fingerprint-mismatch": "stale",
|
|
1091
|
+
"calibration-revision-mismatch": "stale",
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
#: The cause a `detection-only` refusal states when the stored regime's own
|
|
1095
|
+
#: status is not itself a member of the closed union. S1 only marks a
|
|
1096
|
+
#: successor `detection-only` while its status is NOT `ok`, so the stored
|
|
1097
|
+
#: status is normally the precise cause and this is the floor under it.
|
|
1098
|
+
_DETECTION_ONLY_FALLBACK_CODE = "insufficient-history"
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
@dataclasses.dataclass(frozen=True)
|
|
1102
|
+
class CalibratedWeek:
|
|
1103
|
+
"""Everything one calibrated week-scan produces, or the cause it did not.
|
|
1104
|
+
|
|
1105
|
+
A single dataclass rather than a widening tuple because the dashboard
|
|
1106
|
+
needs the CONSUMPTION and the headroom (spec §10) as well as the
|
|
1107
|
+
projection, and reading them from a second call would run a second
|
|
1108
|
+
unbounded week scan per refresh for numbers the first scan already
|
|
1109
|
+
computed.
|
|
1110
|
+
"""
|
|
1111
|
+
projection_pct: "float | None" = None
|
|
1112
|
+
consumption_pct: "float | None" = None
|
|
1113
|
+
consumption_lo: "float | None" = None
|
|
1114
|
+
consumption_hi: "float | None" = None
|
|
1115
|
+
headroom_pct: "float | None" = None
|
|
1116
|
+
code: "str | None" = None
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _calibrated_projection(
|
|
1120
|
+
now_utc: dt.datetime,
|
|
1121
|
+
week_start_at: dt.datetime,
|
|
1122
|
+
week_end_at: dt.datetime,
|
|
1123
|
+
*,
|
|
1124
|
+
account_key: "str | None",
|
|
1125
|
+
) -> "tuple[float | None, str | None]":
|
|
1126
|
+
"""`(projected_end_of_week_percent, withheld_code)` from the S1 model.
|
|
1127
|
+
|
|
1128
|
+
The two-value form every caller outside the dashboard envelope wants.
|
|
1129
|
+
`_calibrated_week_detail` is the one implementation.
|
|
1130
|
+
"""
|
|
1131
|
+
detail = _calibrated_week_detail(
|
|
1132
|
+
now_utc, week_start_at, week_end_at, account_key=account_key)
|
|
1133
|
+
return detail.projection_pct, detail.code
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _calibrated_week_detail(
|
|
1137
|
+
now_utc: dt.datetime,
|
|
1138
|
+
week_start_at: dt.datetime,
|
|
1139
|
+
week_end_at: dt.datetime,
|
|
1140
|
+
*,
|
|
1141
|
+
account_key: "str | None",
|
|
1142
|
+
) -> CalibratedWeek:
|
|
1143
|
+
"""The S1 model's view of THIS week, or the typed cause it has none.
|
|
1144
|
+
|
|
1145
|
+
Reads the persisted calibration through the section 1.1 NON-MUTATING
|
|
1146
|
+
reader — never `load_calibrations`, which quarantines by renaming — and
|
|
1147
|
+
only opens the entry cache when a regime actually validates, so the
|
|
1148
|
+
common no-calibration install pays one small file read and nothing else.
|
|
1149
|
+
|
|
1150
|
+
The support test runs against THIS week's own population rather than
|
|
1151
|
+
trusting the regime's stored status, and the pace projection mirrors the
|
|
1152
|
+
kernel's `_project`: consumption scaled by span over elapsed.
|
|
1153
|
+
"""
|
|
1154
|
+
c = _cctally()
|
|
1155
|
+
try:
|
|
1156
|
+
qcg = c._load_sibling("_cctally_quota_calibration")
|
|
1157
|
+
except Exception: # noqa: BLE001
|
|
1158
|
+
# A `CalibratedWeek`, never the two-value tuple this function returned
|
|
1159
|
+
# before it grew a dataclass. `_calibrated_projection` reads
|
|
1160
|
+
# `detail.projection_pct` off the result, so a tuple here raises
|
|
1161
|
+
# `AttributeError` instead of withholding — the same defect class as
|
|
1162
|
+
# `persist_and_detect`'s `None, None`.
|
|
1163
|
+
return CalibratedWeek(code="unavailable")
|
|
1164
|
+
read = qcg.read_calibration_file(account_key=account_key)
|
|
1165
|
+
if read.regime is None:
|
|
1166
|
+
rejection = getattr(read.rejection, "value", None)
|
|
1167
|
+
if rejection == "calibration-detection-only":
|
|
1168
|
+
# S1 held this successor fit below its prediction gate, and the
|
|
1169
|
+
# regime records which blocking status it carried. State that
|
|
1170
|
+
# rather than a generic word: the user reconciles it against
|
|
1171
|
+
# `cctally quota`, which prints the same status.
|
|
1172
|
+
qm = c._load_sibling("_lib_quota_model")
|
|
1173
|
+
stored = read.regime_status
|
|
1174
|
+
return CalibratedWeek(code=(
|
|
1175
|
+
stored if stored in qm.EVIDENCE_CODES
|
|
1176
|
+
else _DETECTION_ONLY_FALLBACK_CODE))
|
|
1177
|
+
return CalibratedWeek(code=_CALIBRATION_REJECTION_CODES.get(
|
|
1178
|
+
rejection, "unavailable"))
|
|
1179
|
+
|
|
1180
|
+
horizon = min(now_utc, week_end_at)
|
|
1181
|
+
try:
|
|
1182
|
+
entries = _week_entry_records(
|
|
1183
|
+
week_start_at, horizon, account_key=account_key)
|
|
1184
|
+
except sqlite3.Error:
|
|
1185
|
+
return CalibratedWeek(code="unavailable")
|
|
1186
|
+
applied = qcg.apply_regime(read.regime, entries)
|
|
1187
|
+
if isinstance(applied, qcg.ApplyRejection):
|
|
1188
|
+
return CalibratedWeek(code=applied.value)
|
|
1189
|
+
|
|
1190
|
+
elapsed = (horizon - week_start_at).total_seconds()
|
|
1191
|
+
span = (week_end_at - week_start_at).total_seconds()
|
|
1192
|
+
if elapsed <= 0 or span <= 0:
|
|
1193
|
+
return CalibratedWeek(code="unavailable")
|
|
1194
|
+
projected = applied.consumed_points * (span / elapsed)
|
|
1195
|
+
if not math.isfinite(projected):
|
|
1196
|
+
# A non-finite product is not a projection. Returning it would let
|
|
1197
|
+
# the selector fall back to the meter with no cause on the wire —
|
|
1198
|
+
# `calibration_code` would read null beside a `corrected-meter`
|
|
1199
|
+
# basis, which is the silent fallback this function's own contract
|
|
1200
|
+
# forbids. The refusal is stated here, at the producer.
|
|
1201
|
+
return CalibratedWeek(code="unavailable")
|
|
1202
|
+
consumed = applied.consumed_points
|
|
1203
|
+
return CalibratedWeek(
|
|
1204
|
+
projection_pct=projected,
|
|
1205
|
+
consumption_pct=consumed,
|
|
1206
|
+
consumption_lo=getattr(applied, "consumed_lo", None),
|
|
1207
|
+
consumption_hi=getattr(applied, "consumed_hi", None),
|
|
1208
|
+
# Headroom is measured against the meter's 100-point ceiling and is
|
|
1209
|
+
# clamped at zero: a week already past its quota has no headroom
|
|
1210
|
+
# left, and a negative one would read as an overdraft the meter does
|
|
1211
|
+
# not express.
|
|
1212
|
+
headroom_pct=max(0.0, 100.0 - consumed),
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _week_entry_records(start, end, *, account_key,
|
|
1217
|
+
cache_conn_factory=None):
|
|
1218
|
+
"""One subscription week's priced requests as quota-kernel `EntryRecord`s.
|
|
1219
|
+
|
|
1220
|
+
Bounded by the subscription week, and read straight out of `cache.db`
|
|
1221
|
+
rather than through `analyse_account`, which runs two unbounded reads
|
|
1222
|
+
plus a detector.
|
|
1223
|
+
|
|
1224
|
+
Named for a week rather than for THE current week because §4.2's
|
|
1225
|
+
comparability probe calls it for prior weeks too.
|
|
1226
|
+
|
|
1227
|
+
`cache_conn_factory`, when given, returns a shared `cache.db` connection
|
|
1228
|
+
this call reads from and does NOT close; the caller owns its lifetime.
|
|
1229
|
+
The comparability probe passes one so a selector call opens that database
|
|
1230
|
+
once instead of once per candidate. It is a FACTORY rather than a live
|
|
1231
|
+
connection so a selector call that never reaches a probe never opens the
|
|
1232
|
+
database at all.
|
|
1233
|
+
"""
|
|
1234
|
+
c = _cctally()
|
|
1235
|
+
qm = c._load_sibling("_lib_quota_model")
|
|
1236
|
+
glue = c._load_sibling("_cctally_quota_model")
|
|
1237
|
+
shared = None if cache_conn_factory is None else cache_conn_factory()
|
|
1238
|
+
owned = shared is None
|
|
1239
|
+
cache = (c._load_sibling("_cctally_cache").open_cache_db()
|
|
1240
|
+
if owned else shared)
|
|
1241
|
+
try:
|
|
1242
|
+
sql = (
|
|
1243
|
+
"SELECT timestamp_utc, model, input_tokens, output_tokens,"
|
|
1244
|
+
" cache_create_tokens, cache_create_1h_tokens, cache_read_tokens"
|
|
1245
|
+
" FROM session_entries"
|
|
1246
|
+
" WHERE timestamp_utc >= ? AND timestamp_utc < ?"
|
|
1247
|
+
)
|
|
1248
|
+
params: list = [start.isoformat(), end.isoformat()]
|
|
1249
|
+
clause, extra = glue._account_clause("account_key", account_key)
|
|
1250
|
+
sql += clause
|
|
1251
|
+
params.extend(extra)
|
|
1252
|
+
rows = cache.execute(sql, params).fetchall()
|
|
1253
|
+
finally:
|
|
1254
|
+
if owned:
|
|
1255
|
+
cache.close()
|
|
1256
|
+
records = []
|
|
1257
|
+
for row in rows:
|
|
1258
|
+
at = glue.parse_instant(row[0], "timestamp_utc")
|
|
1259
|
+
if at is None or not (start <= at < end):
|
|
1260
|
+
continue
|
|
1261
|
+
records.append(qm.EntryRecord(
|
|
1262
|
+
at=at, model=str(row[1] or ""), fresh=row[2] or 0,
|
|
1263
|
+
output=row[3] or 0, cache_create_total=row[4] or 0,
|
|
1264
|
+
cache_1h=row[5], cache_read=row[6] or 0))
|
|
1265
|
+
return records
|
|
1266
|
+
|
|
1267
|
+
|
|
528
1268
|
def _load_forecast_inputs(
|
|
529
1269
|
conn: sqlite3.Connection,
|
|
530
1270
|
now_utc: dt.datetime,
|
|
@@ -578,6 +1318,20 @@ def _load_forecast_inputs(
|
|
|
578
1318
|
)
|
|
579
1319
|
p_24h_ago, t_24h = _pick_p_24h_ago(samples, now_utc)
|
|
580
1320
|
|
|
1321
|
+
# #661 S2 spec section 3.1. The ceiling correction is applied HERE,
|
|
1322
|
+
# before `ForecastInputs` is constructed, for two independent reasons:
|
|
1323
|
+
# `_compute_forecast` does not select the dollars-per-percent
|
|
1324
|
+
# denominator, so a kernel-only correction would leave the call below
|
|
1325
|
+
# raw; and the kernel computes the recent rate as a DIFFERENCE of the two
|
|
1326
|
+
# endpoints, so correcting only the current one would corrupt that
|
|
1327
|
+
# difference rather than fix it. Both endpoints are corrected together.
|
|
1328
|
+
p_now_corrected = corrected_percent_point(p_now)
|
|
1329
|
+
p_now_interval = corrected_percent_interval(p_now)
|
|
1330
|
+
p_24h_ago_corrected = corrected_percent_point(p_24h_ago)
|
|
1331
|
+
right_censored = p_now_corrected is None
|
|
1332
|
+
calibrated = _calibrated_week_detail(
|
|
1333
|
+
now_utc, week_start_at, week_end_at, account_key=account_key)
|
|
1334
|
+
|
|
581
1335
|
# Cache is warm for this invocation after the spent_usd lookup. Suppress
|
|
582
1336
|
# re-syncs in downstream cost lookups (trailing-4wk-median loop hits
|
|
583
1337
|
# _sum_cost_for_range once per historical week).
|
|
@@ -585,6 +1339,7 @@ def _load_forecast_inputs(
|
|
|
585
1339
|
conn, now_utc, week_start_at, p_now, spent_usd, skip_sync=True,
|
|
586
1340
|
use_weekref_cost_cache=use_weekref_cost_cache,
|
|
587
1341
|
account_key=account_key,
|
|
1342
|
+
p_now_corrected=p_now_corrected,
|
|
588
1343
|
)
|
|
589
1344
|
target_24h = now_utc - dt.timedelta(hours=24)
|
|
590
1345
|
has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
|
|
@@ -612,6 +1367,17 @@ def _load_forecast_inputs(
|
|
|
612
1367
|
dollars_per_percent_source=dpp_source,
|
|
613
1368
|
confidence=confidence,
|
|
614
1369
|
low_confidence_reasons=reasons,
|
|
1370
|
+
p_now_corrected=p_now_corrected,
|
|
1371
|
+
p_now_interval=p_now_interval,
|
|
1372
|
+
p_24h_ago_corrected=p_24h_ago_corrected,
|
|
1373
|
+
right_censored=right_censored,
|
|
1374
|
+
calibrated_projection_pct=calibrated.projection_pct,
|
|
1375
|
+
calibrated_withheld_code=calibrated.code,
|
|
1376
|
+
calibrated_consumption_pct=calibrated.consumption_pct,
|
|
1377
|
+
calibrated_consumption_interval=(
|
|
1378
|
+
None if calibrated.consumption_pct is None
|
|
1379
|
+
else (calibrated.consumption_lo, calibrated.consumption_hi)),
|
|
1380
|
+
calibrated_headroom_pct=calibrated.headroom_pct,
|
|
615
1381
|
)
|
|
616
1382
|
|
|
617
1383
|
|
|
@@ -639,6 +1405,17 @@ def _parse_forecast_targets(raw: str) -> list[int]:
|
|
|
639
1405
|
|
|
640
1406
|
TOOL_VERSION = "forecast-v1" # Bumped on material JSON-schema changes.
|
|
641
1407
|
|
|
1408
|
+
#: `forecast --json`'s envelope version (#661 S2 spec section 3.5).
|
|
1409
|
+
#:
|
|
1410
|
+
#: Bumped from 1 because `final_percent_low`, `final_percent_high` and
|
|
1411
|
+
#: `week_avg_projection_pct` changed from always-number to NULLABLE, and
|
|
1412
|
+
#: because their meaning changed from raw-derived to corrected-derived.
|
|
1413
|
+
#: `docs/cli-contract.md` classifies a changed value type and a changed value
|
|
1414
|
+
#: meaning as breaking, and this change is both. The new corrected, interval,
|
|
1415
|
+
#: censoring and basis keys are additive and would not on their own have
|
|
1416
|
+
#: required a bump.
|
|
1417
|
+
FORECAST_JSON_SCHEMA_VERSION = 2
|
|
1418
|
+
|
|
642
1419
|
|
|
643
1420
|
def _build_forecast_json_payload(out: ForecastOutput) -> dict:
|
|
644
1421
|
"""Dict shape for the forecast JSON endpoint and for the dashboard
|
|
@@ -657,6 +1434,18 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
|
|
|
657
1434
|
},
|
|
658
1435
|
"current": {
|
|
659
1436
|
"weekly_percent": round(i.p_now, 3),
|
|
1437
|
+
# #661 S2 spec section 3.1. `weekly_percent` stays the RAW
|
|
1438
|
+
# displayed reading; the two keys below state the ceiling-
|
|
1439
|
+
# corrected operand every rate and projection above is computed
|
|
1440
|
+
# from, so a consumer can re-derive them rather than re-deriving
|
|
1441
|
+
# from a number the kernel did not use. Both null when the
|
|
1442
|
+
# reading is right-censored.
|
|
1443
|
+
"weekly_percent_corrected": (
|
|
1444
|
+
None if i.p_now_corrected is None
|
|
1445
|
+
else round(i.p_now_corrected, 3)),
|
|
1446
|
+
"weekly_percent_interval": (
|
|
1447
|
+
None if i.p_now_interval is None
|
|
1448
|
+
else [i.p_now_interval[0], i.p_now_interval[1]]),
|
|
660
1449
|
"five_hour_percent": (None if i.five_hour_percent is None
|
|
661
1450
|
else round(i.five_hour_percent, 3)),
|
|
662
1451
|
"spent_usd": round(i.spent_usd, 6),
|
|
@@ -664,7 +1453,11 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
|
|
|
664
1453
|
"latest_snapshot_at": _iso_z(i.latest_snapshot_at),
|
|
665
1454
|
},
|
|
666
1455
|
"rates": {
|
|
667
|
-
|
|
1456
|
+
# #661 S2 spec section 3.2: a right-censored reading supplies no
|
|
1457
|
+
# rate either, so this nulls rather than dividing the displayed
|
|
1458
|
+
# value the model declares censored.
|
|
1459
|
+
"week_average_pct_per_hour": (None if out.r_avg is None
|
|
1460
|
+
else round(out.r_avg, 6)),
|
|
668
1461
|
"recent_24h_pct_per_hour": (None if out.r_recent is None
|
|
669
1462
|
else round(out.r_recent, 6)),
|
|
670
1463
|
"dollars_per_percent": (
|
|
@@ -674,9 +1467,28 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
|
|
|
674
1467
|
"dollars_per_percent_source": i.dollars_per_percent_source,
|
|
675
1468
|
},
|
|
676
1469
|
"forecast": {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1470
|
+
# #661 S2 spec section 3.2: a right-censored reading has no point
|
|
1471
|
+
# estimate, so these three null rather than carrying a value the
|
|
1472
|
+
# observation cannot supply. `right_censored` is the additive
|
|
1473
|
+
# companion field that says which state the nulls mean.
|
|
1474
|
+
"final_percent_low": (None if out.final_percent_low is None
|
|
1475
|
+
else round(out.final_percent_low, 3)),
|
|
1476
|
+
"final_percent_high": (None if out.final_percent_high is None
|
|
1477
|
+
else round(out.final_percent_high, 3)),
|
|
1478
|
+
"week_avg_projection_pct": (
|
|
1479
|
+
None if out.week_avg_projection_pct is None
|
|
1480
|
+
else round(out.week_avg_projection_pct, 3)),
|
|
1481
|
+
"right_censored": bool(out.right_censored),
|
|
1482
|
+
# #661 S2 spec section 3.3: one named typed state per surface.
|
|
1483
|
+
"projection_basis": out.projection_basis,
|
|
1484
|
+
"projection_code": out.projection_code,
|
|
1485
|
+
# Why the CALIBRATED basis was not reached, as a member of the
|
|
1486
|
+
# quota kernel's closed `EVIDENCE_CODES` union; null when it was.
|
|
1487
|
+
# `projection_code` is the cause of a WITHHELD projection and
|
|
1488
|
+
# cannot carry this: falling back to the corrected meter is not a
|
|
1489
|
+
# withholding, and a surface that says nothing here is silently
|
|
1490
|
+
# falling back (spec section 1.1).
|
|
1491
|
+
"calibration_code": i.calibrated_withheld_code,
|
|
680
1492
|
"projected_cap": out.projected_cap,
|
|
681
1493
|
"cap_at": (None if out.cap_at is None else _iso_z(out.cap_at)),
|
|
682
1494
|
"already_capped": out.already_capped,
|
|
@@ -710,10 +1522,35 @@ def _emit_forecast_json(out: ForecastOutput, *, extra: "dict | None" = None) ->
|
|
|
710
1522
|
if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
|
|
711
1523
|
payload.update(extra)
|
|
712
1524
|
return json.dumps(
|
|
713
|
-
_cctally().stamp_schema_version(
|
|
1525
|
+
_cctally().stamp_schema_version(
|
|
1526
|
+
payload, version=FORECAST_JSON_SCHEMA_VERSION),
|
|
714
1527
|
indent=2)
|
|
715
1528
|
|
|
716
1529
|
|
|
1530
|
+
#: The sentence a withheld projection states in the terminal's wide register
|
|
1531
|
+
#: (spec section 8). Keyed on `ForecastOutput.projection_code`, a member of
|
|
1532
|
+
#: the quota kernel's closed `EVIDENCE_CODES` union.
|
|
1533
|
+
_WITHHELD_PROJECTION_WORDING: dict = {
|
|
1534
|
+
"right-censored": "the meter reads at its cap, so consumption has no "
|
|
1535
|
+
"upper bound",
|
|
1536
|
+
# The uncensored withholding: the corrected point, the elapsed span or
|
|
1537
|
+
# the remaining span is missing, so there is no pace to project along.
|
|
1538
|
+
"unavailable": "the week's window supplies no pace to project along",
|
|
1539
|
+
# RETAINED although `select_projection_basis` no longer emits it. Spec
|
|
1540
|
+
# section 3.6 reversed the zero-end withholding, but `projection_code`
|
|
1541
|
+
# is a wire field a dashboard tab can still be holding from an older
|
|
1542
|
+
# server across an `execvp`, and a code with no wording renders the
|
|
1543
|
+
# generic fallback where a specific sentence exists.
|
|
1544
|
+
"no-local-history": "no usage has been observed this week",
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _withheld_projection_wording(out: ForecastOutput) -> str:
|
|
1549
|
+
"""Why the projection is absent, never a blank."""
|
|
1550
|
+
return _WITHHELD_PROJECTION_WORDING.get(
|
|
1551
|
+
out.projection_code, "the projection is unavailable")
|
|
1552
|
+
|
|
1553
|
+
|
|
717
1554
|
def _render_forecast_status_line(out: ForecastOutput, color: bool) -> str:
|
|
718
1555
|
"""Compact one-line status-line segment (spec §5)."""
|
|
719
1556
|
c = _cctally()
|
|
@@ -721,12 +1558,25 @@ def _render_forecast_status_line(out: ForecastOutput, color: bool) -> str:
|
|
|
721
1558
|
return c._style_ansi(s, code, color)
|
|
722
1559
|
|
|
723
1560
|
i = out.inputs
|
|
1561
|
+
# `already_capped` is the whole predicate here: `_compute_forecast` sets
|
|
1562
|
+
# it only in the right-censored branch. That is NOT the only branch that
|
|
1563
|
+
# nulls `final_percent_high` — the WITHHELD branch does too — so the
|
|
1564
|
+
# `low is None or high is None` arm below is what handles that shape, and
|
|
1565
|
+
# the two arms are disjoint rather than one subsuming the other.
|
|
724
1566
|
if out.already_capped:
|
|
725
1567
|
return _c("\u26a0 CAPPED", "31") # red
|
|
726
1568
|
if i.confidence == "low":
|
|
727
1569
|
return _c("tracking\u2026", "2") # dim
|
|
728
1570
|
low = out.final_percent_low
|
|
729
1571
|
high = out.final_percent_high
|
|
1572
|
+
if low is None or high is None:
|
|
1573
|
+
# A projection the kernel withheld without the meter being capped:
|
|
1574
|
+
# the corrected point, the elapsed span or the remaining span was
|
|
1575
|
+
# missing, which `_load_forecast_inputs` does not produce. This arm
|
|
1576
|
+
# therefore guards the KERNEL's contract rather than the loader's
|
|
1577
|
+
# current output \u2014 `_compute_forecast` may return this shape for any
|
|
1578
|
+
# caller-built inputs, and the arms below would `round(None)`.
|
|
1579
|
+
return _c("tracking\u2026", "2") # dim
|
|
730
1580
|
low_disp = round(low)
|
|
731
1581
|
high_disp = round(high)
|
|
732
1582
|
pct_range = f"{low_disp}\u2013{high_disp}%"
|
|
@@ -966,6 +1816,16 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
966
1816
|
forecast_extra_lines = [
|
|
967
1817
|
c._style_ansi(row, "33", color) for row in conf_rows[1:]
|
|
968
1818
|
]
|
|
1819
|
+
elif out.final_percent_high is None or out.final_percent_low is None:
|
|
1820
|
+
# A projection the kernel withheld without the meter being capped —
|
|
1821
|
+
# today, a week with no observed usage. Through `_load_forecast_inputs`
|
|
1822
|
+
# that reading also trips `percent<2` and the LOW CONF arm above
|
|
1823
|
+
# catches it, so this arm guards the KERNEL's contract rather than the
|
|
1824
|
+
# loader's current output: the rounding below would `round(None)` for
|
|
1825
|
+
# any caller-built inputs in that state.
|
|
1826
|
+
forecast_line = c._style_ansi(
|
|
1827
|
+
f"Forecast withheld — {_withheld_projection_wording(out)}",
|
|
1828
|
+
"33", color)
|
|
969
1829
|
else:
|
|
970
1830
|
low, high = out.final_percent_low, out.final_percent_high
|
|
971
1831
|
low_rnd = round(low)
|
|
@@ -980,10 +1840,15 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
980
1840
|
forecast_line = c._style_ansi(
|
|
981
1841
|
f"Forecast {low_rnd}%\u2013{high_disp}", glyph_color, color) + warn
|
|
982
1842
|
|
|
1843
|
+
# A withheld projection collapses the bar's projection band onto the
|
|
1844
|
+
# observed reading, so the bar shows what was measured and draws no
|
|
1845
|
+
# forecast region at all (#661 S2 spec section 3.2).
|
|
983
1846
|
bar_lines = _render_forecast_progress_bar(
|
|
984
1847
|
used=i.p_now,
|
|
985
|
-
low=out.final_percent_low
|
|
986
|
-
|
|
1848
|
+
low=(i.p_now if out.final_percent_low is None
|
|
1849
|
+
else out.final_percent_low),
|
|
1850
|
+
high=(i.p_now if out.final_percent_high is None
|
|
1851
|
+
else out.final_percent_high),
|
|
987
1852
|
width=inner_w - 2,
|
|
988
1853
|
unicode_ok=unicode_ok,
|
|
989
1854
|
color=color,
|
|
@@ -1007,7 +1872,9 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
1007
1872
|
# ── Footer
|
|
1008
1873
|
footer_bits = []
|
|
1009
1874
|
footer_bits.append(c._style_ansi(
|
|
1010
|
-
|
|
1875
|
+
"rate source: "
|
|
1876
|
+
+ dollars_per_percent_source_label(i.dollars_per_percent_source),
|
|
1877
|
+
"2", color))
|
|
1011
1878
|
if out.cap_at is not None:
|
|
1012
1879
|
# format_display_dt: zone-label suffix disambiguates --tz vs host-local
|
|
1013
1880
|
# in the rendered footer (matches the reset-chip subtitle).
|
|
@@ -1041,8 +1908,9 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
|
|
|
1041
1908
|
# --explain footer
|
|
1042
1909
|
if getattr(args, "explain", False):
|
|
1043
1910
|
r_rec = "\u2014" if out.r_recent is None else f"{out.r_recent:.3f}%/h"
|
|
1911
|
+
r_av = "\u2014" if out.r_avg is None else f"{out.r_avg:.3f}%/h"
|
|
1044
1912
|
lines.append(c._style_ansi(
|
|
1045
|
-
f" r_avg={
|
|
1913
|
+
f" r_avg={r_av} \u00b7 r_recent={r_rec} \u00b7 "
|
|
1046
1914
|
f"{i.snapshot_count} snapshots \u00b7 $/1% source={i.dollars_per_percent_source}",
|
|
1047
1915
|
"2", color))
|
|
1048
1916
|
|
|
@@ -1704,14 +2572,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1704
2572
|
)
|
|
1705
2573
|
now_x = (i.now_utc - i.week_start_at).total_seconds() / 3600.0
|
|
1706
2574
|
end_x = (i.week_end_at - i.week_start_at).total_seconds() / 3600.0
|
|
1707
|
-
if output.
|
|
1708
|
-
#
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
(end_label, end_x, float(i.p_now))
|
|
1714
|
-
)
|
|
2575
|
+
if output.final_percent_high is None:
|
|
2576
|
+
# #661 S2 spec section 3.2. The ray used to run flat at
|
|
2577
|
+
# `p_now` across the remaining window, which draws a
|
|
2578
|
+
# projection under a "projected" label out of a reading that
|
|
2579
|
+
# supplies no point estimate. There is no ray to draw.
|
|
2580
|
+
pass
|
|
1715
2581
|
else:
|
|
1716
2582
|
projected_series.append(
|
|
1717
2583
|
(now_label, now_x, float(i.p_now))
|
|
@@ -1730,8 +2596,15 @@ def cmd_forecast(args: argparse.Namespace) -> int:
|
|
|
1730
2596
|
actual_series=actual_series,
|
|
1731
2597
|
projected_series=projected_series,
|
|
1732
2598
|
current_pct=float(i.p_now),
|
|
1733
|
-
|
|
1734
|
-
|
|
2599
|
+
# #661 S2 spec section 3.2. Substituting `p_now` printed
|
|
2600
|
+
# "Projected end-of-week % 103.0% — 103.0%" — the CURRENT reading
|
|
2601
|
+
# under a "Projected" label, with nothing saying it was withheld.
|
|
2602
|
+
# `None` reaches the table as the same withheld token the ceiling
|
|
2603
|
+
# distances already use.
|
|
2604
|
+
projected_low_pct=(None if output.final_percent_low is None
|
|
2605
|
+
else float(output.final_percent_low)),
|
|
2606
|
+
projected_high_pct=(None if output.final_percent_high is None
|
|
2607
|
+
else float(output.final_percent_high)),
|
|
1735
2608
|
days_remaining=float(i.remaining_days),
|
|
1736
2609
|
# #620 S1 D5: no `float(...)` coercion — that would turn a
|
|
1737
2610
|
# withheld rate back into $0.00 one layer below the fix.
|