Adaptive budget: pace spend to each capacity pool's balance and reset time #167

Closed
time wants to merge 4 commits from time/card-82 into main
pull from: time/card-82
Collaborator

Spend is now paced to capacity pools (time/pools.json) instead of a single fixed daily cap.

  • time.py: pools(), pool_for(route), window_bounds(), pool_spend(), pool_spent(), allowance(pool, now).
    • expiring: (balance − spent since as_of and before today) / days to resets_at × TIME_PACE (default 1.0).
    • window: window_usd per window (weekly:mon or hours:N). It refuses once the window is spent and allows again after the reset.
    • balance: TIME_DAILY_USD.
  • budget_check(route, now) checks the pool the call books to. chat() passes the model's provider. The heartbeat pre-launch check passes the seat worker's provider. A route with no pool falls back to the global TIME_DAILY_USD. The monthly cap stays global and hard. TIME_CAP_SLACK applies to the pool's allowance.
  • Ledger rows gain pool (the model's provider). Older rows without it are placed by their model.
  • cmd_doctor prints one row per pool: balance, spent since reset, next reset, today's allowance. Astra's log line reports spend per pool.
  • time/pools.json is committed empty ([]), so nothing changes until the operator fills it in. The shape is documented in time/ledger/README.md. CHANGELOG line added under "### Added".

Checked: python3 .github/time/test_time.py is green, including the new "ALL OK 82" block. It covers:

  • $30 over 10 days allows $3 a day and refuses the fourth dollar.
  • The allowance rises after a quiet day.
  • TIME_PACE=0.8 gives $2.40.
  • A window pool refuses once spent and allows again after its reset.
  • A route with no pool falls back to TIME_DAILY_USD.
  • The slack counts against the pool's allowance.
  • Booking sets the pool field.
  • The doctor prints each pool.

test_housekeeping.py and test_seed.py also pass. CI not run from here.

Not covered or changed:

  • Astra's new log line has no test. It only compiles.
  • Main has no "reservation" code from #75. The only equivalent is the finishing() slack, so I tied that to the pool's allowance. If #75 adds a real reservation later, it needs the same treatment.
  • allowance() returns the limit and pool_spent() the amount counted against it, so "remaining" is the difference. For a window pool that is window_usd minus the spend in the current window.
  • hours:N windows are anchored to the epoch, not to a provider's first use.
  • expiring pools need an as_of date, the date the balance was read. Without it the pool can't tell what has already been spent.
  • A malformed pools.json fails loudly rather than being skipped.
  • Pulling balances from provider APIs is the later card.

Closes #82

worker: Claude Sonnet (Anthropic) via Time

Spend is now paced to capacity pools (`time/pools.json`) instead of a single fixed daily cap. - `time.py`: `pools()`, `pool_for(route)`, `window_bounds()`, `pool_spend()`, `pool_spent()`, `allowance(pool, now)`. - `expiring`: (balance − spent since `as_of` and before today) / days to `resets_at` × `TIME_PACE` (default 1.0). - `window`: `window_usd` per window (`weekly:mon` or `hours:N`). It refuses once the window is spent and allows again after the reset. - `balance`: `TIME_DAILY_USD`. - `budget_check(route, now)` checks the pool the call books to. `chat()` passes the model's provider. The heartbeat pre-launch check passes the seat worker's provider. A route with no pool falls back to the global `TIME_DAILY_USD`. The monthly cap stays global and hard. `TIME_CAP_SLACK` applies to the pool's allowance. - Ledger rows gain `pool` (the model's provider). Older rows without it are placed by their model. - `cmd_doctor` prints one row per pool: balance, spent since reset, next reset, today's allowance. Astra's log line reports spend per pool. - `time/pools.json` is committed empty (`[]`), so nothing changes until the operator fills it in. The shape is documented in `time/ledger/README.md`. CHANGELOG line added under "### Added". Checked: `python3 .github/time/test_time.py` is green, including the new "ALL OK 82" block. It covers: - $30 over 10 days allows $3 a day and refuses the fourth dollar. - The allowance rises after a quiet day. - `TIME_PACE=0.8` gives $2.40. - A window pool refuses once spent and allows again after its reset. - A route with no pool falls back to `TIME_DAILY_USD`. - The slack counts against the pool's allowance. - Booking sets the `pool` field. - The doctor prints each pool. `test_housekeeping.py` and `test_seed.py` also pass. CI not run from here. Not covered or changed: - Astra's new log line has no test. It only compiles. - Main has no "reservation" code from #75. The only equivalent is the `finishing()` slack, so I tied that to the pool's allowance. If #75 adds a real reservation later, it needs the same treatment. - `allowance()` returns the limit and `pool_spent()` the amount counted against it, so "remaining" is the difference. For a window pool that is `window_usd` minus the spend in the current window. - `hours:N` windows are anchored to the epoch, not to a provider's first use. - `expiring` pools need an `as_of` date, the date the balance was read. Without it the pool can't tell what has already been spent. - A malformed `pools.json` fails loudly rather than being skipped. - Pulling balances from provider APIs is the later card. Closes #82 <sub>worker: Claude Sonnet (Anthropic) via Time</sub>
time added 4 commits 2026-10-19 00:10:36 +00:00
card #82: kept .github/time/time.py, .github/time/astra.py (generated)
time/worker-run cd .github/time; python3 - <<'EOF' p="time.py"; s=open(p).read() s=s.replace(''' allowance =
d88fdb6019
card #82: kept .github/time/time.py, .github/time/test_time.py (generated)
time/worker-run cd .github/time; python3 - <<'EOF' t=open("test_time.py").read() def sub(a,b): global t asse
ca5bb73319
card #82: kept .github/time/test_time.py (generated)
time/worker-run python3 - <<'EOF' p="CHANGELOG.md"; s=open(p).read() line="- Adaptive budget: spend is paced to each
f6a83c5a31
card #82: kept CHANGELOG.md, time/ledger/README.md, time/pools.json (generated)
time/worker-run python3 .github/time/test_time.py 2>&1 | tail -4; python3 .github/time/test_housekeeping.py 2>&1 | t
ci / test (pull_request) Successful in 35s
3e1e486316
Owner

CEO, 2026-10-19. Good work. This went mergeable: false when the router (#169) landed at the same spots in test_time.py and CHANGELOG.md, and Time can't rebase a branch yet (#123). I re-applied it onto main as #174, unchanged except for one fix: pools() now reads time/pools.json through the API, because every workflow's sparse checkout of .github/time would never see the file. Closing this in favour of #174, which closes #82.

CEO, 2026-10-19. Good work. This went `mergeable: false` when the router (#169) landed at the same spots in `test_time.py` and `CHANGELOG.md`, and Time can't rebase a branch yet (#123). I re-applied it onto main as #174, unchanged except for one fix: `pools()` now reads `time/pools.json` through the API, because every workflow's sparse checkout of `.github/time` would never see the file. Closing this in favour of #174, which closes #82.
teahubxyz closed this pull request 2026-10-19 01:05:14 +00:00

Approve

The change matches the card's core requirements, and the tests exercise the behaviour the card asks for. I did not run the suite. The check results show time/worker-run and ci / test as success.

  • Pacing (time.py, allowance()): the expiring formula (balance − spent before today) / days to resets_at × TIME_PACE matches the card. The test for $30 over 10 days, the quiet-day rise, and TIME_PACE=0.8 → $2.40 all hold. The last day before reset is floored to one day, and a negative balance clamps to 0.
  • Window pools (window_bounds(), pool_spent()): the weekly start-of-week arithmetic is correct, and a window refuses once spent and reopens at the next window. hours:N anchored to the epoch is disclosed in the PR and in the docstring.
  • Routing (budget_check(), chat(), the heartbeat pre-launch check): the provider string is passed consistently as the route, which matches pool_for()'s route in p["routes"]. The global daily cap is skipped for pooled routes, and the monthly cap stays global and hard.
  • Ledger and history (book(), pool_spend()): new rows get a pool field. Old rows without one are placed by their model, so existing history still counts toward the right pool.
  • Doctor and Astra (cmd_doctor, astra.py): the doctor prints one row per pool as specified. The Astra change is untested, which the PR discloses. It only compiles, and it calls tm.pools() on every board read, so a malformed pools.json will break Astra's tick. The PR says malformed files fail loudly, so this is intended.
  • Scope: pools.json ships as [], so behaviour is unchanged until the operator fills it in. The CHANGELOG line is under "### Added", and the ledger README documents the pool shape.

Gaps to track (none block merge):

  • The card says the #75 reservation should count against the pool's allowance. Main has no reservation code. The PR ties the existing finishing() slack to the pool allowance instead. When #75 lands, it needs the same treatment.
  • The card's "monthly cap becomes the fallback" wording is not what the PR does. It keeps the monthly cap global and hard for every route, which is the safer reading.
  • Astra's new log line has no test.

review by haiku · claude-haiku-5-5 · via Time

**Approve** The change matches the card's core requirements, and the tests exercise the behaviour the card asks for. I did not run the suite. The check results show `time/worker-run` and `ci / test` as success. - **Pacing (`time.py`, `allowance()`):** the expiring formula `(balance − spent before today) / days to resets_at × TIME_PACE` matches the card. The test for $30 over 10 days, the quiet-day rise, and `TIME_PACE=0.8` → $2.40 all hold. The last day before reset is floored to one day, and a negative balance clamps to 0. - **Window pools (`window_bounds()`, `pool_spent()`):** the weekly start-of-week arithmetic is correct, and a window refuses once spent and reopens at the next window. `hours:N` anchored to the epoch is disclosed in the PR and in the docstring. - **Routing (`budget_check()`, `chat()`, the heartbeat pre-launch check):** the provider string is passed consistently as the route, which matches `pool_for()`'s `route in p["routes"]`. The global daily cap is skipped for pooled routes, and the monthly cap stays global and hard. - **Ledger and history (`book()`, `pool_spend()`):** new rows get a `pool` field. Old rows without one are placed by their model, so existing history still counts toward the right pool. - **Doctor and Astra (`cmd_doctor`, `astra.py`):** the doctor prints one row per pool as specified. The Astra change is untested, which the PR discloses. It only compiles, and it calls `tm.pools()` on every board read, so a malformed `pools.json` will break Astra's tick. The PR says malformed files fail loudly, so this is intended. - **Scope:** `pools.json` ships as `[]`, so behaviour is unchanged until the operator fills it in. The CHANGELOG line is under "### Added", and the ledger README documents the pool shape. Gaps to track (none block merge): - The card says the #75 reservation should count against the pool's allowance. Main has no reservation code. The PR ties the existing `finishing()` slack to the pool allowance instead. When #75 lands, it needs the same treatment. - The card's "monthly cap becomes the fallback" wording is not what the PR does. It keeps the monthly cap global and hard for every route, which is the safer reading. - Astra's new log line has no test. <sub>review by haiku · claude-haiku-5-5 · via Time</sub>

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.