From 1a7e05e2cbb671bed1d112dab9fa4734fe6ab4ca Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 1 Sep 2026 00:34:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(notice):=20what=20a=20point=20buys=20?= =?UTF-8?q?=E2=80=94=20the=20mix=20that=20strands=20a=20scoped=20pool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pools drain toward one wall and nothing said which one binds. The live reading that started this: the account at 81%, the model-scoped weekly at 63%, both resetting at the same instant. Line 1 has printed the two numbers for months and never their relation, and the relation is the decision — 22 of the model's remaining points are already unreachable at the rate this week is mixing them. Both counters are cumulative from one reset instant, so `scope / seven` IS this week's rate of scoped points per account point. `reachable = round((100 - seven) * scope / seven)` is what the account's remaining points can still buy in the scoped pool; `strand = (100 - scope) - reachable` is the rest. At 81/63: mix 0.78, reachable 15, strand 22, against 0.77 mined from the corpus at week level. One division, and the strand is its remainder, because both numbers print beside the total they have to add to. No forecast.cache field and no schema concern. New `+` notice at rank 58, scope fb: `fb ~22% expires at this mix`, in full `7d caps before fb: this mix reaches ~15% of its 37% left · run fb heavier to extract more`. Above the 5h tail, below the fleet hint, and never above the 7d pressure that causes it — the pairing is the dry warning pinned on row 2 with the strand flashing on row 3. It is the mirror of the steering notice and cannot coexist with it: that one needs the model ahead of the account, this one needs the account ahead of the model. Gated, because a ratio is easy to say and easy to get wrong: one wall (SCOPE_SAME_WALL_SEC = 120 — the pools have always reset together to the microsecond, but Anthropic could split them and then the ratio would compare two different weeks), the account at 60% or deeper, the model at 5% or more, both pools under 100, ten stranded points or more, and the 7d window past its young guard. Two statusline-only mutes: a projected scoped cap ("fb caps first" and "fb strands" cannot both be true in one frame) and a live re-base, which moved one of the two counters for a reason the ratio cannot see. `report` gains the same reading in the other currency, one line after the price/spend block: `fb mix: ~0.78 fb-pt per 7d-pt this week · 7d's 19% left carries ~15% of fb's 37%`. No session runs there to name a model, so it takes the binding scoped limit, else the deepest. SCOPE_STRAND_MIN_PCT, SCOPE_MIX_MIN_7D, SCOPE_MIX_MIN_SCOPE and SCOPE_SAME_WALL_SEC are documented in docs/api/state-dir.md as READING RULES computed live rather than cache fields; ccpace v0.5.0 ships the same four with the same closed forms. The coupling in the other direction — what a scoped point costs the account — stays unpublished: n=22 in a 0.2-0.5 band is fluent enough to be believed and too noisy to be true. 464 tests (6 new), shellcheck clean. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 55 ++++++++++++++++++ DESIGN.md | 3 + README.md | 1 + docs/api/state-dir.md | 45 ++++++++++++++- statusline.sh | 97 ++++++++++++++++++++++++++++++++ t/helpers.bash | 4 ++ t/statusline.bats | 128 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 332 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b356e..6585138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## v0.37.0 — 2026-09-01 — what a point buys + +**Two pools drain toward one wall and nothing said which one binds.** The +live reading that started this: the account at 81%, the model-scoped +weekly at 63%, both resetting at the same instant. Line 1 has printed the +two numbers for months and never their relation — and the relation is the +decision, because 22 of the model's remaining points are already +unreachable at the rate this week is mixing them. + +**The ratio is the mix, and it needs no history.** Both counters are +cumulative from one reset instant, so `scope / seven` IS this week's rate +of scoped points per account point. `reachable = round((100 - seven) * +scope / seven)` is what the account's remaining points can still buy in +the scoped pool, and `strand = (100 - scope) - reachable` is the rest of +it. At 81/63: mix 0.78, reachable 15, strand 22 — against 0.77 mined from +the corpus at week level. One division, and the strand is its remainder, +because both numbers print beside the total they have to add to. No new +`forecast.cache` field and no schema concern: the inputs are already on +screen. + +**A new `+` notice at rank 58.** `fb ~22% expires at this mix`, and in +full: `7d caps before fb: this mix reaches ~15% of its 37% left · run fb +heavier to extract more`. Above the 5h tail, below the fleet hint, and it +can never outrank the 7d pressure that causes it — the pairing it was +built for is the dry warning pinned on row 2 with the strand flashing on +row 3. It is the mirror of `fb 91% vs 7d 55%` and the two cannot coexist: +that one needs the model ahead of the account, this one needs the account +ahead of the model. + +**Gates, because a ratio is easy to say and easy to get wrong.** One wall +(`SCOPE_SAME_WALL_SEC = 120`: the pools have always reset together to the +microsecond, but Anthropic could split them, and a ratio across two +different weeks is a fluent lie); the account at 60% or deeper, where +"which cap binds" is a question at all; the model at 5% or more, since a +model untouched this week belongs to the underuse voice; both pools under +100; ten stranded points or more; and the 7d window past the young guard +every projection shares. Two mutes on top, statusline-only: a projected +scoped cap, because "fb caps first" and "fb strands" cannot both be true +in one frame, and a live re-base, which just moved one of the two counters +for a reason the ratio cannot see. + +**`report` prices the same thing in the other currency.** One line after +the price and spend block: `fb mix: ~0.78 fb-pt per 7d-pt this week · +7d's 19% left carries ~15% of fb's 37%`. No session runs there to name a +model, so it takes the binding scoped limit, else the deepest. + +The four constants are shared rules, not local thresholds: +`SCOPE_STRAND_MIN_PCT`, `SCOPE_MIX_MIN_7D`, `SCOPE_MIX_MIN_SCOPE` and +`SCOPE_SAME_WALL_SEC` are documented in `docs/api/state-dir.md` as +READING RULES computed live rather than cache fields, and ccpace v0.5.0 +ships the same four with the same closed forms. The coupling in the other +direction — what a scoped point costs the account — stays unpublished: +n=22 in a 0.2-0.5 band is fluent enough to be believed and too noisy to +be true. + ## v0.36.0 — 2026-09-01 — the night is not runway **The forecast learns when you work, not just how much.** Claude Code can diff --git a/DESIGN.md b/DESIGN.md index 9fff311..b57eeb9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -161,6 +161,9 @@ fb capped ~Thu 07:00 the running model is gone until then last 5h of the week no later window exists to spend the remainder through fb 91% vs 7d 55% the MODEL caps first, not the account: switch, and the week's remaining capacity comes back +fb ~22% expires the ACCOUNT caps first: at this week's mix the 7d pool + runs out before the model's does, and what it cannot + reach expires unspent 5h ~40m left throughput you cannot bank — said only while the week has slack, since an unspent 5h window is otherwise headroom, not waste diff --git a/README.md b/README.md index d398507..fe5da9b 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,7 @@ clauses about one window can never disagree. | `! 5h caps ~05:18` | Linear projection off this window's own pace: you hit the wall before the reset. Long form adds how long you'd sit blocked. | | `! 7d dry ~Thu 09:00 · hard stop` | The learned per-weekday burn profile (EWMA over your own history), not a straight line — your heavy Tuesday counts more than an average, and the learned hour-of-day shape puts the dry point where you are awake to act on it instead of at 03:00. Tail says what happens at 100%: extra billing, or a stop. | | `! fb 91% vs 7d 55% · go op` | **The relation** between two badges: the *model* caps before the account does. Switching models buys the week's remaining capacity back; the roomiest other `weekly_scoped` limit in the payload gets named. | +| `+ fb ~22% expires at this mix` | **The mirror of the row above**: the *account* caps first, so the model's remaining pool cannot all be spent. Both counters run from one reset instant, so their live ratio is this week's mix rate: at 81% / 63% the 19 account points left carry ~15 of the model's 37, and the other 22 expire where nothing can reach them. Running the scoped model heavier is what moves the mix. | | `+ 7d rebased 53%→12%` | Utilization fell *inside* one window instance: burn never runs backwards, so this is a plan change or an out-of-band reset. The projections restart; the ledger still draws the old period. | | `+ last 5h of the week · 47% unused` | The 7d window resets inside this 5h window: no later window exists to spend the remainder through. | | `+ 47% unused · spend it` (or `· ~33% expires even at full burn`) | Expiring surplus, feasibility-checked against your learned `pct_per_window`: "spend it" only when full-tilt burn can actually consume it. | diff --git a/docs/api/state-dir.md b/docs/api/state-dir.md index 3e98a36..2e69ae1 100644 --- a/docs/api/state-dir.md +++ b/docs/api/state-dir.md @@ -7,7 +7,7 @@ read concurrently. - Contract version: **2** (bump on any breaking layout/field change; this file is the changelog) -- Synced with: statusline.sh v0.36.0 +- Synced with: statusline.sh v0.37.0 - Permissions: the script runs under `umask 077` — files are owner-only. Caches hold account PII (email, uuid, org names). @@ -100,6 +100,49 @@ when idle); a `usage.err` JSON file appears during fetch-failure cooldowns Treat `fetched_at` as authoritative staleness; anything older than ~1h means no active session is feeding this account. +#### Two pools, one wall + +The account `seven_day` limit and a `weekly_scoped` limit are two counters +over one week: the same reset instant, both cumulative from it. Their live +ratio is therefore this week's MIX RATE, and no history is needed to read +it. Everything in this section is COMPUTED LIVE from the two numbers above +— nothing here is a stored field, and no cache changes when it changes. + +``` +mix = scope / seven scoped points per 7d point +reachable = round((100 - seven) * scope / seven) of the scoped pool +strand = (100 - scope) - reachable = round(100 * (seven - scope) / seven) +``` + +`reachable` is what the account's remaining points can still buy in the +scoped pool; `strand` is the rest of that pool, which expires because +nothing can reach it. Round ONCE and take the strand as the remainder: the +two numbers are printed beside the total they must add to. + +Live check: `seven = 81`, `scope = 63` gives mix 0.78, reachable 15, strand +22 — against 0.77 mined from the corpus at week level. + +The reading speaks only when ALL of these hold: + +| Rule | Value | Why | +|------|-------|-----| +| `SCOPE_SAME_WALL_SEC` | 120 | Resets no further apart than this are one wall. The two have always reset together to the microsecond, but Anthropic could split them, and a ratio taken across two different weeks is a fluent lie. | +| `SCOPE_MIX_MIN_7D` | 60 | "Which cap binds" is only a question once the account's own end is in sight, and the ratio is well established by then. | +| `SCOPE_MIX_MIN_SCOPE` | 5 | A model untouched this week belongs to the underuse reading, not to a mix. | +| `SCOPE_STRAND_MIN_PCT` | 10 | Under ten points there is nothing to act on. | + +Plus: both pools under 100 (a capped pool is its own message), and the 7d +window past the young guard every projection shares (a day of this +window's own evidence). + +Selecting the scoped limit where no running model names one (`report`, +ccpace): prefer `is_active == true`, else the deepest `weekly_scoped`. A +surface that knows which model the session runs matches on that instead. + +The coupling in the other direction — what a scoped point costs the +account — is deliberately NOT published. Measured at n=22 it sits in a +0.2-0.5 band: fluent enough to be believed, too noisy to be true. + ### profile.cache Raw `/api/oauth/profile` response: `account.{uuid,email,display_name,…}`, diff --git a/statusline.sh b/statusline.sh index f20d368..a361fc8 100755 --- a/statusline.sh +++ b/statusline.sh @@ -2096,6 +2096,20 @@ FORECAST_SCHEMA=2 # that disagree about which hours are spendable disagree about the ration. REST_MULT_MAX=0.25 +# The account 7d pool and the model-scoped weekly pool drain at different +# speeds toward the SAME wall. Both counters are cumulative from one reset +# instant, so their live ratio IS this week's mix — `scope / seven` is +# scoped points per account point — and what the account cannot reach before +# the wall arrives strands in the scoped pool. These are READING RULES, not +# cache fields: nothing is stored, the numbers are computed from usage.cache +# on every frame. They live here beside the cache contract because ccpace +# says the same thing from the same numbers (docs/api/state-dir.md), and two +# tools that disagree about what strands disagree about the week. +SCOPE_STRAND_MIN_PCT=10 # scoped points the mix strands before it is worth a row +SCOPE_MIX_MIN_7D=60 # ...and only once the account's own end is in sight +SCOPE_MIX_MIN_SCOPE=5 # a model untouched this week is the underuse voice's job +SCOPE_SAME_WALL_SEC=120 # resets this close are one wall; further apart, no ratio + # Rotate usage.jsonl when it exceeds the cap. Single .1 backup; the profile # builder reads .1 + current, so rotation never costs learned history. rotate_usage_log() { @@ -2757,6 +2771,51 @@ run_usage_report() { awk -v a="${usd24:-0}" -v b="${usd7d:-0}" \ 'BEGIN{ if (a > 0 || b > 0) printf "spent: $%.2f in 24h · $%.2f in 7d\n", a, b }' + # What a 7d point buys in the pool beside it. The account pool and the + # model-scoped pool are cumulative from one reset instant, so their live + # ratio is this week's mix rate, and the account reaching its wall first + # is what strands the rest of the model's pool. No history is needed and + # nothing is cached — the reading rules are the SCOPE_* constants, shared + # with ccpace (docs/api/state-dir.md). No session runs here, so there is + # no model to match: the binding scoped limit speaks, else the deepest. + local mix_cache="$CLAUDE_ACCOUNT_DIR/usage.cache" mix_tsv="" + if [ -f "$mix_cache" ]; then + mix_tsv=$(jq -r '. as $u + | [$u.limits[]? | select(.kind == "weekly_scoped" + and (.scope.model.display_name // "") != "")] + | ((map(select(.is_active == true)) | first) // (sort_by(.percent // 0) | last) // empty) + | [(.scope.model.display_name | ascii_downcase), (.percent // 0), (.resets_at // ""), + ($u.seven_day.utilization // 0), ($u.seven_day.resets_at // "")] | @tsv' \ + "$mix_cache" 2>/dev/null) || true + fi + if [ -n "$mix_tsv" ]; then + local mx_name mx_pct mx_reset mx_su mx_sreset + IFS=$'\t' read -r mx_name mx_pct mx_reset mx_su mx_sreset <<<"$mix_tsv" + local mx_int mx_seven mx_secs mx_ssecs + mx_int=$(printf '%.0f' "$mx_pct" 2>/dev/null || echo 0) + mx_seven=$(printf '%.0f' "$mx_su" 2>/dev/null || echo 0) + mx_secs=$(get_reset_seconds "$mx_reset") + mx_ssecs=$(get_reset_seconds "$mx_sreset") + if [ -n "$mx_secs" ] && [ -n "$mx_ssecs" ] && [ "$mx_ssecs" -gt 0 ] 2>/dev/null \ + && [ "$mx_int" -ge "$SCOPE_MIX_MIN_SCOPE" ] && [ "$mx_int" -lt 100 ] 2>/dev/null \ + && [ "$mx_seven" -ge "$SCOPE_MIX_MIN_7D" ] && [ "$mx_seven" -lt 100 ] 2>/dev/null \ + && [ $((SEVEN_DAY_WINDOW_SECS - mx_ssecs)) -ge "$SEVEN_DAY_YOUNG_SECS" ] 2>/dev/null; then + local mx_wall=$((mx_secs - mx_ssecs)) + [ "$mx_wall" -lt 0 ] && mx_wall=$((-mx_wall)) + local mx_reach=$(( ((100 - mx_seven) * mx_int + mx_seven / 2) / mx_seven )) + local mx_strand=$((100 - mx_int - mx_reach)) + if [ "$mx_wall" -le "$SCOPE_SAME_WALL_SEC" ] \ + && [ "$mx_strand" -ge "$SCOPE_STRAND_MIN_PCT" ]; then + local mx_ab + mx_ab=$(model_scope_abbrev "$mx_name") + printf "%s mix: ~%s %s-pt per 7d-pt this week · 7d's %s%% left carries ~%s%% of %s's %s%%\n" \ + "$mx_ab" \ + "$(awk -v s="$mx_int" -v v="$mx_seven" 'BEGIN{printf "%.2f", s / v}')" \ + "$mx_ab" "$((100 - mx_seven))" "$mx_reach" "$mx_ab" "$((100 - mx_int))" + fi + fi + fi + # The rhythm the walk below is now shaped by. Stated because it changes # every projection in this report and is the one learned fact the reader # can check against their own week. @@ -4342,6 +4401,44 @@ notice_collect() { pressure=1 fi + # The other way round: the ACCOUNT caps first, and the model's remaining + # points expire in a pool nothing can reach them from. Both counters run + # from the same reset instant, so their live ratio is this week's mix + # rate — no history, no cache field, just the two numbers line 1 already + # shows. Measured at 81/63: mix 0.78 against 0.77 mined from the corpus. + # It is the mirror of the steering notice above and cannot coexist with + # it — that one needs the model ahead of the account, this one needs the + # account ahead of the model. + # + # Rank 58: above the 5h tail, below the fleet hint, and never above the + # 7d pressure that CAUSES it. The pairing this is built for is the dry + # warning pinned on row 2 with the strand flashing on row 3. + if [ -z "$sc_str" ] && [ -z "$rebase_at" ] \ + && [ -n "$scope_int" ] && [ "$scope_int" -ge "$SCOPE_MIX_MIN_SCOPE" ] 2>/dev/null \ + && [ "$scope_int" -lt 100 ] 2>/dev/null \ + && [ "$seven_int" -ge "$SCOPE_MIX_MIN_7D" ] && [ "$seven_int" -lt 100 ] \ + && [ -n "$scope_secs" ] && [ -n "$seven_secs" ] && [ "$seven_secs" -gt 0 ] 2>/dev/null \ + && [ $((SEVEN_DAY_WINDOW_SECS - seven_secs)) -ge "$SEVEN_DAY_YOUNG_SECS" ] 2>/dev/null; then + # One wall, or no ratio: the two pools have always reset together to + # the microsecond, but Anthropic could split them someday, and a mix + # taken across two different weeks is a fluent lie. + local wall_gap=$((scope_secs - seven_secs)) + [ "$wall_gap" -lt 0 ] && wall_gap=$((-wall_gap)) + if [ "$wall_gap" -le "$SCOPE_SAME_WALL_SEC" ]; then + # One division, not two. Both numbers land on one line beside the + # total they must add to, so the strand is the REMAINDER of what + # the mix reaches, never its own rounding. + local mix_left=$((100 - scope_int)) + local mix_reach=$(( ((100 - seven_int) * scope_int + seven_int / 2) / seven_int )) + local mix_strand=$((mix_left - mix_reach)) + if [ "$mix_strand" -ge "$SCOPE_STRAND_MIN_PCT" ] 2>/dev/null; then + notice_add 58 '+' fb "fb.strand.$((mix_strand / 5))" "~${mix_strand}%" \ + "${scope_ab} ~${mix_strand}% expires at this mix" \ + "7d caps before ${scope_ab}: this mix reaches ~${mix_reach}% of its ${mix_left}% left · run ${scope_ab} heavier to extract more" + fi + fi + fi + # Expiring surplus — use it or lose it. Inside the last day of the 7d # window a big green remainder is forfeiture, not headroom. The tail is # feasibility-checked against the learned pct_per_window ratio: burning diff --git a/t/helpers.bash b/t/helpers.bash index 39b2ca2..57902d9 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -64,6 +64,10 @@ MULT_GLYPH="✕" LEDGER_BASE_GLYPH="▁" FORECAST_SCHEMA=2 REST_MULT_MAX=0.25 +SCOPE_STRAND_MIN_PCT=10 +SCOPE_MIX_MIN_7D=60 +SCOPE_MIX_MIN_SCOPE=5 +SCOPE_SAME_WALL_SEC=120 ADVISOR_FLEET_FRESH_SECS=3600 ADVISOR_FLEET_FREE_PCT=40 ADVISOR_EXPIRY_HORIZON_SECS=86400 diff --git a/t/statusline.bats b/t/statusline.bats index 2184e98..95bfa83 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -1306,6 +1306,42 @@ _write_ledger_fixture() { # dir rm -rf "$tmpdir" } +@test "run_usage_report: what a 7d point buys in the pool beside it" { + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + _write_ledger_fixture "$tmpdir" + reset=$(date -u -d '+2 days' '+%Y-%m-%dT%H:%M:%SZ') + _uc() { # seven, then each scoped limit as pct:model:is_active + local seven="$1" lim="" t p n a + shift + for t in "$@"; do + IFS=: read -r p n a <<<"$t" + lim="${lim:+$lim,}$(printf '{"kind":"weekly_scoped","percent":%s,"resets_at":"%s","scope":{"model":{"display_name":"%s"}},"is_active":%s}' \ + "$p" "$reset" "$n" "$a")" + done + printf '{"seven_day":{"utilization":%s,"resets_at":"%s"},"limits":[%s]}' \ + "$seven" "$reset" "$lim" > "$tmpdir/usage.cache" + } + # nothing scoped in the payload: no second pool, no ratio + _uc 81 + run run_usage_report 28 + [[ "$output" != *"mix:"* ]] + # 81 against 63: 19 account points left carry 15 of the 37 the model has + _uc 81 "63:Fable:false" "20:Opus:false" + run run_usage_report 28 + [[ "$output" == *"fb mix: ~0.78 fb-pt per 7d-pt this week · 7d's 19% left carries ~15% of fb's 37%"* ]] + # no session runs here, so no model to match: the binding limit wins over + # the deeper one + _uc 81 "63:Fable:false" "20:Opus:true" + run run_usage_report 28 + [[ "$output" == *"op mix: ~0.25 op-pt per 7d-pt this week · 7d's 19% left carries ~5% of op's 80%"* ]] + # the account has to be deep enough for "which cap binds" to be a question + _uc 50 "30:Fable:false" + run run_usage_report 28 + [[ "$output" != *"mix:"* ]] + rm -rf "$tmpdir" +} + @test "run_usage_report: --days cutoff hides older closes; no history exits 1" { tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" @@ -4199,6 +4235,98 @@ JSON [ -z "$(notice_collect "$usage" auto "claude-fable-5" | awk -F$'\037' '$3 == "fb"')" ] } +# One wall, two pools. Default: five days into the week, both counters +# resetting at the same instant — the shape the mix reading is only ever +# allowed to speak in. +_mix_reset() { date -u -d "${1:-+2 days}" '+%Y-%m-%dT%H:%M:%SZ'; } +_mix_usage() { # seven scope [reset-iso] [scope-wall-skew-secs] + local seven="$1" scope="$2" r7="${3:-}" skew="${4:-0}" rfb + [ -n "$r7" ] || r7=$(_mix_reset) + rfb=$(date -u -d "$r7 + $skew seconds" '+%Y-%m-%dT%H:%M:%SZ') + printf '{"five_hour":{"utilization":40},"seven_day":{"utilization":%s,"resets_at":"%s"},"limits":[{"kind":"weekly_scoped","percent":%s,"resets_at":"%s","scope":{"model":{"display_name":"Fable"}}}]}' \ + "$seven" "$r7" "$scope" "$rfb" +} +_strand_recs() { notice_collect "$(_mix_usage "$1" "$2" "${3:-}" "${4:-0}")" auto claude-fable-5; } +_strand_key() { _strand_recs "$@" | awk -F$'\037' '$4 ~ /strand/ { print $4 }'; } + +@test "notice_collect: the account caps first, so the model's own pool strands" { + # 81 against 63 — the live reading the model was built from. Both + # counters run from one reset instant, so the ratio IS this week's mix + # (0.78, against 0.77 mined from the corpus): the 19 account points left + # carry 15 of the 37 the model still has, and 22 expire unreachable. + recs=$(_strand_recs 81 63) + _fb() { printf '%s\n' "$recs" | awk -F$'\037' -v n="$1" '$3 == "fb" { print $n }'; } + [ "$(_fb 1)" = "58" ] + [ "$(_fb 2)" = "+" ] + [ "$(_fb 4)" = "fb.strand.4" ] + [ "$(_fb 5)" = "~22%" ] + [ "$(_fb 6)" = "fb ~22% expires at this mix" ] + [ "$(_fb 7)" = "7d caps before fb: this mix reaches ~15% of its 37% left · run fb heavier to extract more" ] + # the key re-flashes as the strand grows, like the surplus voice + [ "$(_strand_key 81 59)" = "fb.strand.5" ] +} + +@test "notice_collect: every gate the mix reading stands on" { + # a week that has barely started: the ratio is two days of noise + [ -z "$(_strand_key 81 63 "$(_mix_reset '+6 days 12 hours')")" ] + # two walls 5 minutes apart would be a ratio of two different weeks; + # the observed jitter between them is seconds + [ -z "$(_strand_key 81 63 "" 300)" ] + [ -n "$(_strand_key 81 63 "" 60)" ] + # a model untouched this week belongs to the underuse voice, not here + [ -z "$(_strand_key 81 4)" ] + [ -n "$(_strand_key 81 5)" ] + # ...and the question only exists once the account's own end is in sight + [ -z "$(_strand_key 59 30)" ] + [ -n "$(_strand_key 60 30)" ] + # under 10 points there is nothing to act on + [ -z "$(_strand_key 70 64)" ] + [ -n "$(_strand_key 70 63)" ] + # either pool at 100 is its own notice, and neither is a mix + [ -z "$(_strand_key 81 100)" ] + [ -z "$(_strand_key 100 63)" ] +} + +@test "notice_collect: a model projected to cap first cannot also be stranding" { + # 85 against 95 five days in: the linear scoped pace hits the model's own + # cap a day before its reset, so the frame already says fb runs out + r=$(_mix_reset) + [[ "$(notice_collect "$(_mix_usage 95 85 "$r")" auto claude-fable-5 \ + | awk -F$'\037' '$3 == "fb" { print $4 }')" == fb.caps.* ]] + [ -z "$(_strand_key 95 85 "$r")" ] + # the same two numbers with the wall 20h out: the projection no longer + # reaches the cap, and what the account cannot spend strands again + [ "$(_strand_key 95 85 "$(_mix_reset '+20 hours')")" = "fb.strand.2" ] +} + +@test "notice_collect: the mix stays quiet while the denominator is suspect" { + tmpdir=$(mktemp -d) + export CLAUDE_ACCOUNT_DIR="$tmpdir" + r=$(_mix_reset) + # first sight of the window, then 78 -> 65 inside it. A plan change moved + # one of the two counters for a reason the ratio cannot see. + notice_collect "$(_mix_usage 78 40 "$r")" auto claude-fable-5 >/dev/null + recs=$(notice_collect "$(_mix_usage 65 40 "$r")" auto claude-fable-5) + [ -n "$(printf '%s\n' "$recs" | awk -F$'\037' '$4 ~ /rebase/')" ] + [ -z "$(printf '%s\n' "$recs" | awk -F$'\037' '$4 ~ /strand/')" ] + # once the re-base is no longer newsworthy the same frame speaks + rm -f "$tmpdir/seven_seen" + [ "$(_strand_key 65 40 "$r")" = "fb.strand.7" ] + unset CLAUDE_ACCOUNT_DIR + rm -rf "$tmpdir" +} + +@test "notice_collect: the wall pins the row, the strand it causes flashes" { + state=$(mktemp -d)/notice_seen + recs=$(_strand_recs 81 63) + # the account wall is the pressure and owns row 2; the strand is its + # consequence, and a consequence never outranks its cause + [[ "$(strip_ansi "$(notice_pin_line "$recs")")" == "! 7d caps ~"* ]] + flash=$(strip_ansi "$(notice_flash_line "$recs" "$state" "$(date +%s)")") + [[ "$flash" == "+ 7d caps before fb: this mix reaches ~15%"* ]] + rm -rf "$(dirname "$state")" +} + @test "notice_collect: 7d falling inside one window reads as a re-base, not as burn" { tmpdir=$(mktemp -d) export CLAUDE_ACCOUNT_DIR="$tmpdir"