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
10
11
12
13
14
15
16
17
18
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
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
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
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
150
151
152
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
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
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
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
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
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
243
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
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
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
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
328
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
347
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
431
432
433
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})