1 import Base 2 import ./types.bend as T 3 import ./usage.bend as U 4 import ./rank.bend as R 5 import ./exhaust.bend as E 6 import ./decide.bend as D 7 import ./safe.bend as SF 8 9 # Behaviour profiles, part 2: one tick (or one manual rotation) under a 10 # strategy. `best` is `D.decide`, unchanged. The others share the gates below 11 # and differ only in the ranking key (`SF.key`): 12 # 13 # gates, in order: unmanaged active account, active API key, unknown active 14 # headroom (never switches, there is no failover), and for an automatic tick 15 # the threshold and the cooldown; then `SF.pick` over the safe candidates. 16 # 17 # A rotation (`VRotate`, the manual `switch` with no target) skips the 18 # threshold and the cooldown: the user asked to move now. 19 20 type Via is Data: 21 VAuto{} 22 VRotate{} 23 24 def via.auto(+v: Via) -> Bool: 25 match v: 26 case VAuto{}: 27 True{} 28 case VRotate{}: 29 False{} 30 31 def drain(+algo: SF.Algo) -> Bool: 32 match algo: 33 case SF.ADrainFirst{}: 34 True{} 35 case o: 36 False{} 37 38 def need.under(+under: Bool, +thr: Nat) -> Nat: 39 match under: 40 case True{}: 41 Nat.add(Nat.sub(T.full(), thr), 1n) 42 case False{}: 43 1n 44 45 # The least headroom a target needs: drain-first and a proactive automatic 46 # switch must land below the threshold; an at-limit switch or a rotation takes 47 # any headroom. 48 def need.of(+algo: SF.Algo, +via: Via, +a: Nat, +thr: Nat) -> Nat: 49 need.under(drain(algo) || (via.auto(via) && Nat.is_gt(a, 0n)), thr) 50 51 def mode.of(+algo: SF.Algo) -> T.Mode: 52 match algo: 53 case SF.ASoonestReset{}: 54 T.MReset{} 55 case o: 56 T.MHead{} 57 58 def trig.via(+via: Via, +a: Nat) -> T.Trigger: 59 match via: 60 case VAuto{}: 61 D.trig.of(a) 62 case VRotate{}: 63 T.Proactive{} 64 65 def sw(+m: Maybe<&2, SF.Cd>, +algo: SF.Algo, +t: T.Trigger, +next: T.Decision) -> T.Decision: 66 match m: 67 case None{}: 68 next 69 case Some{c}: 70 match c: 71 case SF.Cd{slot, k1, k2, h}: 72 T.Switch{slot, t, T.trigger.name(t), h, mode.of(algo), None{}, None{}} 73 74 # Nothing qualified: the reason `best` would give. The decision is rebuilt as 75 # a NoSwitch whatever `d` is, so no proof has to look inside `D.none.cf`. 76 def nosw(+d: T.Decision) -> T.Decision: 77 match d: 78 case T.NoSwitch{r, detail}: 79 T.NoSwitch{r, detail} 80 case o: 81 T.NoSwitch{T.NoCandidates{}, ""} 82 83 def go.pick(+algo: SF.Algo, +via: Via, +a: Nat, +e: D.Env) -> T.Decision: 84 match e: 85 case D.Env{active, as, us, now, cfg, st, mem}: 86 sw(SF.pick(algo, as, active, us, now, D.cfg.models(cfg), need.of(algo, via, a, D.cfg.threshold(cfg))), algo, trig.via(via, a), 87 nosw(D.none.cf(T.Proactive{}, None{}, as, active, us, now, D.cfg.models(cfg)))) 88 89 def go.cool(+cool: Bool, +algo: SF.Algo, +via: Via, +a: Nat, +e: D.Env) -> T.Decision: 90 match cool: 91 case True{}: 92 T.NoSwitch{T.Cooldown{}, ""} 93 case False{}: 94 go.pick(algo, via, a, e) 95 96 # At limit ignores the cooldown; a proactive switch honours it. 97 def go.auto(+a: Nat, +algo: SF.Algo, +via: Via, +e: D.Env) -> T.Decision: 98 match a: 99 case 0n: 100 go.pick(algo, via, a, e) 101 case 1n+p: 102 go.cool(D.in_cooldown(D.st.last_at(D.env.st(e)), D.env.now(e), D.cfg.cooldown(D.env.cfg(e))), algo, via, a, e) 103 104 def go.via(+algo: SF.Algo, +via: Via, +a: Nat, +e: D.Env) -> T.Decision: 105 match via: 106 case VAuto{}: 107 go.auto(a, algo, via, e) 108 case VRotate{}: 109 go.pick(algo, via, a, e) 110 111 def go.below(+below: Bool, +algo: SF.Algo, +via: Via, +a: Nat, +e: D.Env) -> T.Decision: 112 match below: 113 case True{}: 114 T.NoSwitch{T.BelowThreshold{}, D.below.detail(a, D.env.cfg(e))} 115 case False{}: 116 go.via(algo, via, a, e) 117 118 def go.known(+ah: Maybe<&2, Nat>, +algo: SF.Algo, +via: Via, +e: D.Env) -> T.Decision: 119 match ah: 120 case None{}: 121 T.NoSwitch{T.ActiveUsageUnknown{}, "headroom unknown; a profile strategy never switches away from an account it cannot read (use 'cswap switch NUM')"} 122 case Some{a}: 123 go.below(via.auto(via) && Nat.is_lt(Nat.sub(T.full(), a), D.cfg.threshold(D.env.cfg(e))), algo, via, a, e) 124 125 def go.run(+algo: SF.Algo, +via: Via, +e: D.Env) -> T.Decision: 126 go.known(D.env.ah(e), algo, via, e) 127 128 def go.kind(+k: T.AcctKind, +algo: SF.Algo, +via: Via, +e: D.Env) -> T.Decision: 129 match k: 130 case T.ApiKey{}: 131 T.NoSwitch{T.ActiveApiKey{}, "API-key accounts have no quota to watch"} 132 case o: 133 go.run(algo, via, e) 134 135 def go.acct(+m: Maybe<&2, T.Account>, +algo: SF.Algo, +via: Via, +e: D.Env) -> T.Decision: 136 match m: 137 case None{}: 138 T.NoSwitch{T.UnmanagedActive{}, "run 'cswap --add-account' to include it in rotation"} 139 case Some{a}: 140 match a: 141 case T.Account{slot, email, kind, disabled}: 142 go.kind(kind, algo, via, e) 143 144 def core(+algo: SF.Algo, +via: Via, +e: D.Env) -> T.Decision: 145 go.acct(U.account.of(D.env.active(e), D.env.as(e)), algo, via, e) 146 147 # One autoswitch tick under `algo`. 148 def decide(+algo: SF.Algo, +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: 149 match algo: 150 case SF.ABest{}: 151 D.decide(active, accounts, usage, now, cfg, st, mem) 152 case o: 153 core(o, VAuto{}, D.Env{active, accounts, usage, now, cfg, st, mem}) 154 155 # A manual rotation under `algo`: the target now, whatever the active's 156 # utilization. `best` ranks by headroom, as `sticky` does. 157 def rotate(+algo: SF.Algo, +active: Nat, +accounts: List<&2, T.Account>, +usage: List<&2, T.Usage>, +now: Nat, +cfg: T.Config) -> T.Decision: 158 core(algo, VRotate{}, D.Env{active, accounts, usage, now, cfg, T.fresh_state(), T.fresh_mem()}) 159 160 # One tick under the strategy a name stands for; anything else (best, 161 # consume-first) is the reference engine. 162 def decide_cfg(+name: String, +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: 163 decide(Maybe.default(&2, SF.Algo, SF.parse(name), SF.ABest{}), active, accounts, usage, now, cfg, st, mem)