policy/decide.bend

cswap-bend-cli@0.1.0.0 · 435 lines · 68 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/policy/types.bend as T
3import cswap-bend-cli@0.1.0.0/policy/usage.bend as U
4import cswap-bend-cli@0.1.0.0/policy/rank.bend as R
5import cswap-bend-cli@0.1.0.0/policy/pick.bend as K
6import cswap-bend-cli@0.1.0.0/policy/bar.bend as B
7import cswap-bend-cli@0.1.0.0/policy/exhaust.bend as E
8
9# One autoswitch tick: classify the trigger, honour cooldown, rank the
10# candidates, and say why when nothing qualifies. Mirrors AutoSwitchEngine
11# ._tick_inner of claude_swap 0.27.2 with patch 0002.
12#
13# Not decided here (the CLI owns them): freshening the target, quarantining a
14# target whose token is dead, the consume-first re-fetch (`stale-usage`),
15# `no-viable-target` and `already-active`. To walk the reference's ordered
16# list, re-run `decide` with a failed target marked `disabled`.
17
18# What a tick reads.
19type Env is Data:
20  Env{active: Nat, as: List<&2, T.Account>, us: List<&2, T.Usage>, now: Nat, cfg: T.Config, st: T.State, mem: T.Mem}
21
22def cfg.threshold(+cfg: T.Config) -> Nat:
23  match cfg:
24    case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}:
25      thr
26
27def cfg.models(+cfg: T.Config) -> List<&2, String>:
28  match cfg:
29    case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}:
30      ms
31
32def cfg.include(+cfg: T.Config) -> Bool:
33  match cfg:
34    case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}:
35      ia
36
37def cfg.cf(+cfg: T.Config) -> Bool:
38  match cfg:
39    case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}:
40      K.strat.cf(st)
41
42def in_cooldown(+last: Maybe<&2, Nat>, +now: Nat, +cd: Nat) -> Bool:
43  match last:
44    case None{}:
45      False{}
46    case Some{l}:
47      Nat.is_lt(Nat.sub(now, l), cd)
48
49def cfg.cooldown(+cfg: T.Config) -> Nat:
50  match cfg:
51    case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}:
52      cd
53
54def st.last_at(+st: T.State) -> Maybe<&2, Nat>:
55  match st:
56    case T.State{la, lt, lf, sn}:
57      la
58
59# ------------------------------------------------ the idle hold, in memory
60
61def is_expired(+s: T.Sentinel) -> Bool:
62  match s:
63    case T.TokenExpired{}:
64      True{}
65    case o:
66      False{}
67
68def row.expired(+m: Maybe<&2, T.Usage>) -> Bool:
69  match m:
70    case None{}:
71      False{}
72    case Some{u}:
73      is_expired(U.usage.sentinel(u))
74
75def idle.since(+mem: T.Mem, +now: Nat) -> Nat:
76  match mem:
77    case T.Mem{un, isn}:
78      match isn:
79        case None{}:
80          now
81        case Some{t}:
82          t
83
84def mem.unhealthy(+mem: T.Mem) -> Nat:
85  match mem:
86    case T.Mem{un, isn}:
87      un
88
89# The idle hold lasts 30 minutes from the first expired tick.
90def idle.held(+mem: T.Mem, +now: Nat) -> Bool:
91  Nat.is_le(Nat.sub(now, idle.since(mem, now)), 1800n)
92
93def mem.expired(+mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem:
94  T.Mem{Nat.add(mem.unhealthy(mem), 1n), Some{idle.since(mem, now)}}
95
96def mem.hold(+held: Bool, +mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem:
97  match held:
98    case True{}:
99      T.Mem{0n, Some{idle.since(mem, now)}}
100    case False{}:
101      mem.expired(mem, now, ut)
102
103# Counters after a tick whose active headroom is unknown.
104def mem.unknown(+expired: Bool, +mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem:
105  match expired:
106    case False{}:
107      T.fresh_mem()
108    case True{}:
109      mem.hold(idle.held(mem, now), mem, now, ut)
110
111def mem.known(+ah: Maybe<&2, Nat>, +expired: Bool, +mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem:
112  match ah:
113    case Some{a}:
114      T.fresh_mem()
115    case None{}:
116      mem.unknown(expired, mem, now, ut)
117
118def mem.ut(+cfg: T.Config) -> Nat:
119  match cfg:
120    case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}:
121      ut
122
123def mem.run(+active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem:
124  mem.known(U.head.row(U.usage.of(active, us), now, cfg.models(cfg)), row.expired(U.usage.of(active, us)), mem, now, mem.ut(cfg))
125
126def mem.api(+include: Bool, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem:
127  match include:
128    case False{}:
129      mem
130    case True{}:
131      mem.run(active, us, now, cfg, mem)
132
133def mem.kind(+k: T.AcctKind, +include: Bool, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem:
134  match k:
135    case T.ApiKey{}:
136      mem.api(include, active, us, now, cfg, mem)
137    case o:
138      mem.run(active, us, now, cfg, mem)
139
140def mem.acct(+m: Maybe<&2, T.Account>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem:
141  match m:
142    case None{}:
143      mem
144    case Some{a}:
145      match a:
146        case T.Account{slot, email, kind, disabled}:
147          mem.kind(kind, cfg.include(cfg), active, us, now, cfg, mem)
148
149# The engine's counters after this tick: a known headroom or an unexpired-
150# sentinel unknown resets them; an expired token starts the idle hold and,
151# once it ran out, counts unhealthy ticks. Unmanaged and API-key actives
152# return before touching them.
153def mem_after(+active: Nat, +accounts: List<&2, T.Account>, +usage: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem:
154  mem.acct(U.account.of(active, accounts), active, usage, now, cfg, mem)
155
156# ------------------------------------------------------- why nothing qualified
157
158def none.exh.at(+ex: Bool, +e: E.Er) -> T.Decision:
159  match ex:
160    case True{}:
161      T.NoSwitch{T.AllExhausted{E.er.out(e)}, ""}
162    case False{}:
163      T.NoSwitch{T.NoQualifyingCandidate{}, "no candidate is below the threshold and better than the active account by the hysteresis margin, or usage is unreadable this tick"}
164
165def none.reset(+ars: Maybe<&2, Nat>) -> T.Decision:
166  match ars:
167    case None{}:
168      T.NoSwitch{T.ResetUnknown{}, "active account's weekly reset time is unknown; consume-first is idle until it is reported"}
169    case Some{r}:
170      T.NoSwitch{T.AlreadyConsumingSoonest{}, "no sooner-resetting account with room to spare"}
171
172def none.trig(+t: T.Trigger, +ex: Bool, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> T.Decision:
173  match t:
174    case T.ConsumeFirstT{}:
175      none.reset(ars)
176    case o:
177      none.exh.at(ex, E.earliest(as, us, now, ms, E.ErNone{}))
178
179def none.known(+k: Bool, +t: T.Trigger, +ex: Bool, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> T.Decision:
180  match k:
181    case False{}:
182      T.NoSwitch{T.NoComparison{}, "no candidate has readable usage"}
183    case True{}:
184      none.trig(t, ex, ars, as, us, now, ms)
185
186def none.cf(+t: T.Trigger, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> T.Decision:
187  none.known(E.any_known(as, active, us, now, ms), t, E.all_exhausted(as, active, us, now, ms), ars, as, us, now, ms)
188
189def trig.not_cf(+t: T.Trigger) -> Bool:
190  match t:
191    case T.ConsumeFirstT{}:
192      False{}
193    case o:
194      True{}
195
196def api.pick(+m: Maybe<&2, Nat>, +t: T.Trigger, +next: T.Decision) -> T.Decision:
197  match m:
198    case Some{slot}:
199      T.Switch{slot, t, T.trigger.name(t), 0n, T.MApiLast{}, None{}, None{}}
200    case None{}:
201      next
202
203def api.gate(+ok: Bool, +t: T.Trigger, +as: List<&2, T.Account>, +active: Nat, +next: T.Decision) -> T.Decision:
204  match ok:
205    case True{}:
206      api.pick(E.api.first(as, active), t, next)
207    case False{}:
208      next
209
210# ------------------------------------------------------------ ranking
211
212def dec.sw(+p: K.Pk, +t: T.Trigger, +bar: Maybe<&2, Nat>, +next: T.Decision) -> T.Decision:
213  match p:
214    case K.PkAt{slot, r, h, mode}:
215      T.Switch{slot, t, T.trigger.name(t), h, mode, r, bar}
216    case K.PkNone{}:
217      next
218
219def meas.best(+m: R.Meas) -> Nat:
220  match m:
221    case R.Meas{any, all, best}:
222      best
223
224def rank.ctx(+t: T.Trigger, +ah: Maybe<&2, Nat>, +m: R.Meas, +ar: Maybe<&2, Nat>, +ars: Maybe<&2, Nat>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config) -> K.Rk:
225  match cfg:
226    case T.Config{thr, ms, stg, iv, cd, hy, ttl, ut, ia}:
227      K.rk.mk(t, ah, R.all_above(m, ah, thr), meas.best(m), ar, ars, thr, hy, K.strat.cf(stg), active, None{}, us, now, ms)
228
229# The ranking context of a tick, before the bar.
230def rk.base(+t: T.Trigger, +ah: Maybe<&2, Nat>, +active: Nat, +as: List<&2, T.Account>, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config) -> K.Rk:
231  rank.ctx(t, ah, R.meas(as, active, us, now, cfg.models(cfg), cfg.threshold(cfg), R.Meas{False{}, True{}, 0n}), R.rec.row(U.usage.of(active, us), now, cfg.models(cfg)), R.seven.row(U.usage.of(active, us), now, cfg.models(cfg)), active, us, now, cfg)
232
233# --------------------------------------------------- candidates and cooldown
234
235def below.detail(+a: Nat, +cfg: T.Config) -> String:
236  U.pct.label(Nat.sub(T.full(), a)) ++ "% < " ++ U.pct.label(cfg.threshold(cfg)) ++ "%"
237
238# The pick for one ranking context, as a decision: the target, or `next`.
239def sw.pick(+rk: K.Rk, +as: List<&2, T.Account>, +t: T.Trigger, +next: T.Decision) -> T.Decision:
240  dec.sw(K.pick(as, rk), t, K.rk.bar_of(rk), next)
241
242# Rank with the bar; if that leaves nothing and the barred account has
243# recovered, rank again without it.
244def choose.retry(+go: Bool, +t: T.Trigger, +as: List<&2, T.Account>, +rk0: K.Rk, +next: T.Decision) -> T.Decision:
245  match go:
246    case True{}:
247      sw.pick(K.rk.bar(rk0, None{}), as, t, next)
248    case False{}:
249      next
250
251def env.active(+e: Env) -> Nat:
252  match e:
253    case Env{active, as, us, now, cfg, st, mem}:
254      active
255
256def env.as(+e: Env) -> List<&2, T.Account>:
257  match e:
258    case Env{active, as, us, now, cfg, st, mem}:
259      as
260
261def env.cfg(+e: Env) -> T.Config:
262  match e:
263    case Env{active, as, us, now, cfg, st, mem}:
264      cfg
265
266def trig.is_cf(+t: T.Trigger) -> Bool:
267  Bool.not(trig.not_cf(t))
268
269def env.now(+e: Env) -> Nat:
270  match e:
271    case Env{active, as, us, now, cfg, st, mem}:
272      now
273
274def env.st(+e: Env) -> T.State:
275  match e:
276    case Env{active, as, us, now, cfg, st, mem}:
277      st
278
279# ------------------------------------------------------ the active account
280
281def env.us(+e: Env) -> List<&2, T.Usage>:
282  match e:
283    case Env{active, as, us, now, cfg, st, mem}:
284      us
285
286def env.mem(+e: Env) -> T.Mem:
287  match e:
288    case Env{active, as, us, now, cfg, st, mem}:
289      mem
290
291def trig.of(+a: Nat) -> T.Trigger:
292  match a:
293    case 0n:
294      T.AtLimit{}
295    case 1n+p:
296      T.Proactive{}
297
298# The active account's headroom, as this tick reads it.
299def env.ah(+e: Env) -> Maybe<&2, Nat>:
300  match e:
301    case Env{active, as, us, now, cfg, st, mem}:
302      U.head.row(U.usage.of(active, us), now, cfg.models(cfg))
303
304# No OAuth candidate qualified: last-resort API-key account, else say why.
305def decide.none(+t: T.Trigger, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config) -> T.Decision:
306  match cfg:
307    case T.Config{thr, ms, stg, iv, cd, hy, ttl, ut, ia}:
308      api.gate(ia && trig.not_cf(t), t, as, active, none.cf(t, ars, as, active, us, now, ms))
309
310def rank.bar(+bar: Maybe<&2, Nat>, +recov: Bool, +t: T.Trigger, +e: Env) -> T.Decision:
311  match e:
312    case Env{active, as, us, now, cfg, st, mem}:
313      sw.pick(K.rk.bar(rk.base(t, env.ah(e), active, as, us, now, cfg), bar), as, t, choose.retry(Bool.not(B.is_none(bar)) && recov, t, as, rk.base(t, env.ah(e), active, as, us, now, cfg), decide.none(t, R.seven.row(U.usage.of(active, us), now, cfg.models(cfg)), as, active, us, now, cfg)))
314
315def rank.go(+t: T.Trigger, +e: Env) -> T.Decision:
316  match e:
317    case Env{active, as, us, now, cfg, st, mem}:
318      rank.bar(B.no_return(t, st, active, B.recovered(st, active, us, env.ah(e), cfg.threshold(cfg), now, cfg.models(cfg)), us, env.ah(e), cfg.threshold(cfg), now, cfg.models(cfg)), B.recovered(st, active, us, env.ah(e), cfg.threshold(cfg), now, cfg.models(cfg)), t, e)
319
320def cands.none(+blocked: Bool, +t: T.Trigger, +e: Env) -> T.Decision:
321  match blocked:
322    case True{}:
323      T.NoSwitch{T.NoCandidates{}, ""}
324    case False{}:
325      rank.go(t, e)
326
327# A healthy consume-first account with no OAuth peer to compare against is
328# below the threshold, exactly as under `best`.
329def cands.cf(+hold: Bool, +t: T.Trigger, +e: Env) -> T.Decision:
330  match hold:
331    case True{}:
332      T.NoSwitch{T.BelowThreshold{}, below.detail(K.ah.or0(env.ah(e)), env.cfg(e))}
333    case False{}:
334      cands.none(Bool.not(E.any_cand(env.as(e), env.active(e))) && Bool.not(cfg.include(env.cfg(e)) && E.any_api(env.as(e), env.active(e))), t, e)
335
336def decide.cands(+t: T.Trigger, +e: Env) -> T.Decision:
337  cands.cf(trig.is_cf(t) && Bool.not(E.any_cand(env.as(e), env.active(e))) && Bool.not(B.is_none(env.ah(e))), t, e)
338
339def cool.go(+cool: Bool, +t: T.Trigger, +e: Env) -> T.Decision:
340  match cool:
341    case True{}:
342      T.NoSwitch{T.Cooldown{}, ""}
343    case False{}:
344      decide.cands(t, e)
345
346# Cooldown bounds proactive and consume-first switches; at-limit and
347# failover ignore it.
348def decide.trig(+t: T.Trigger, +e: Env) -> T.Decision:
349  match t:
350    case T.Proactive{}:
351      cool.go(in_cooldown(st.last_at(env.st(e)), env.now(e), cfg.cooldown(env.cfg(e))), t, e)
352    case T.ConsumeFirstT{}:
353      cool.go(in_cooldown(st.last_at(env.st(e)), env.now(e), cfg.cooldown(env.cfg(e))), t, e)
354    case o:
355      decide.cands(t, e)
356
357def fail.count(+wait: Bool, +n: Nat, +ut: Nat, +e: Env) -> T.Decision:
358  match wait:
359    case True{}:
360      T.NoSwitch{T.ActiveUsageUnknown{}, Nat.show(n) ++ "/" ++ Nat.show(ut) ++ " before failover"}
361    case False{}:
362      decide.trig(T.Failover{}, e)
363
364def failover.go(+n: Nat, +e: Env) -> T.Decision:
365  match e:
366    case Env{active, as, us, now, cfg, st, mem}:
367      fail.count(Nat.is_lt(n, mem.ut(cfg)), n, mem.ut(cfg), e)
368
369def idle.go(+held: Bool, +e: Env) -> T.Decision:
370  match held:
371    case True{}:
372      T.NoSwitch{T.ActiveIdle{}, "token expired while Claude Code is idle; resumes on next use"}
373    case False{}:
374      failover.go(Nat.add(mem.unhealthy(env.mem(e)), 1n), e)
375
376def unknown.go(+expired: Bool, +e: Env) -> T.Decision:
377  match expired:
378    case False{}:
379      T.NoSwitch{T.ActiveUsageUnknown{}, "headroom unknown; not counted toward failover"}
380    case True{}:
381      idle.go(idle.held(env.mem(e), env.now(e)), e)
382
383def below.go(+cf: Bool, +a: Nat, +e: Env) -> T.Decision:
384  match cf:
385    case True{}:
386      decide.trig(T.ConsumeFirstT{}, e)
387    case False{}:
388      T.NoSwitch{T.BelowThreshold{}, below.detail(a, env.cfg(e))}
389
390def util.go(+below: Bool, +a: Nat, +e: Env) -> T.Decision:
391  match below:
392    case True{}:
393      below.go(cfg.cf(env.cfg(e)), a, e)
394    case False{}:
395      decide.trig(trig.of(a), e)
396
397def decide.known(+ah: Maybe<&2, Nat>, +e: Env) -> T.Decision:
398  match ah:
399    case Some{a}:
400      util.go(Nat.is_lt(Nat.sub(T.full(), a), cfg.threshold(env.cfg(e))), a, e)
401    case None{}:
402      unknown.go(row.expired(U.usage.of(env.active(e), env.us(e))), e)
403
404def decide.run(+e: Env) -> T.Decision:
405  decide.known(U.head.row(U.usage.of(env.active(e), env.us(e)), env.now(e), cfg.models(env.cfg(e))), e)
406
407def apikey.go(+include: Bool, +e: Env) -> T.Decision:
408  match include:
409    case True{}:
410      decide.run(e)
411    case False{}:
412      T.NoSwitch{T.ActiveApiKey{}, "API-key accounts have no quota to watch"}
413
414def decide.kind(+k: T.AcctKind, +e: Env) -> T.Decision:
415  match k:
416    case T.ApiKey{}:
417      apikey.go(cfg.include(env.cfg(e)), e)
418    case o:
419      decide.run(e)
420
421def decide.acct(+m: Maybe<&2, T.Account>, +e: Env) -> T.Decision:
422  match m:
423    case None{}:
424      T.NoSwitch{T.UnmanagedActive{}, "run 'cswap --add-account' to include it in rotation"}
425    case Some{a}:
426      match a:
427        case T.Account{slot, email, kind, disabled}:
428          decide.kind(kind, e)
429
430# One autoswitch tick. `st` is autoswitch_state.json (last switch, where it
431# came from, what that account looked like), `mem` the engine's counters
432# (see `mem_after` for the next ones). Pure: the caller fetches usage,
433# applies the decision and records the switch.
434def decide(+active: Nat, +accounts: List<&2, T.Account>, +usage: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +st: T.State, +mem: T.Mem) -> T.Decision:
435  decide.acct(U.account.of(active, accounts), Env{active, accounts, usage, now, cfg, st, mem})