policy/usage.bend

cswap-bend-cli@0.1.0.0 · 378 lines · 56 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/policy/types.bend as T
3
4# Usage rows: relevant windows, trusted headroom, poll eligibility, backoff.
5
6# ------------------------------------------------------- percent labels
7
8def pct.two(+t: Nat, +o: Nat) -> String:
9  match o:
10    case 0n:
11      Nat.show(t)
12    case 1n+p:
13      Nat.show(t) ++ Nat.show(o)
14
15def pct.frac.pair(qr: Nat & Nat) -> String:
16  (t, o) = qr
17  pct.two(t, o)
18
19def pct.frac(+q: Nat, +r: Nat) -> String:
20  match r:
21    case 0n:
22      Nat.show(q)
23    case 1n+p:
24      Nat.show(q) ++ "." ++ pct.frac.pair(Nat.divmod(r, 10n))
25
26def pct.label.pair(qr: Nat & Nat) -> String:
27  (q, r) = qr
28  pct.frac(q, r)
29
30# 4000 -> "40", 8550 -> "85.5": the reference's `%.10g` for two-digit inputs.
31def pct.label(+c: Nat) -> String:
32  pct.label.pair(Nat.divmod(c, 100n))
33
34# ------------------------------------------------------------- windows
35
36def win.opt(+p: Maybe<&2, Nat>, +r: Maybe<&2, Nat>, +rest: List<&2, T.Win>) -> List<&2, T.Win>:
37  match p:
38    case None{}:
39      rest
40    case Some{x}:
41      Con{T.Win{x, r}, rest}
42
43def win.keep(+hit: Bool, +w: T.Win, +rest: List<&2, T.Win>) -> List<&2, T.Win>:
44  match hit:
45    case True{}:
46      Con{w, rest}
47    case False{}:
48      rest
49
50# `n` is lowercase; the configured name is lowercased here. `all` matches any.
51def model.wanted(+n: String, +ms: List<&2, String>) -> Bool:
52  match ms:
53    case Nil{}:
54      False{}
55    case Con{m, t}:
56      String.eq(n, String.to_lower(m)) || String.eq("all", String.to_lower(m)) || model.wanted(n, t)
57
58def win.scoped.one(+s: T.Scoped, +ms: List<&2, String>, +rest: List<&2, T.Win>) -> List<&2, T.Win>:
59  match s:
60    case T.Scoped{name, p, r}:
61      win.keep(model.wanted(String.to_lower(name), ms), T.Win{p, r}, rest)
62
63def win.scoped(+ss: List<&2, T.Scoped>, +ms: List<&2, String>) -> List<&2, T.Win>:
64  match ss:
65    case Nil{}:
66      Nil{}
67    case Con{s, t}:
68      win.scoped.one(s, ms, win.scoped(t, ms))
69
70# The relevant windows, in API order: 5h, 7d, then the wanted scoped ones.
71# Never spend.
72def windows(+u: T.Usage, +ms: List<&2, String>) -> List<&2, T.Win>:
73  match u:
74    case T.Usage{slot, five, fr, seven, sr, ss, fa, bo, er, nfl, np, pi, ad, hu, cu, se}:
75      win.opt(five, fr, win.opt(seven, sr, win.scoped(ss, ms)))
76
77def win.max.step(+acc: Maybe<&2, Nat>, +p: Nat) -> Maybe<&2, Nat>:
78  match acc:
79    case None{}:
80      Some{p}
81    case Some{x}:
82      Some{Nat.max(x, p)}
83
84def win.max(+ws: List<&2, T.Win>, +acc: Maybe<&2, Nat>) -> Maybe<&2, Nat>:
85  match ws:
86    case Nil{}:
87      acc
88    case Con{w, t}:
89      match w:
90        case T.Win{p, r}:
91          win.max(t, win.max.step(acc, p))
92
93def head.of(+m: Maybe<&2, Nat>) -> Maybe<&2, Nat>:
94  match m:
95    case None{}:
96      None{}
97    case Some{p}:
98      Some{Nat.sub(T.full(), p)}
99
100# 100 - max(pct) over the relevant windows; None with no windows.
101def headroom.raw(+u: T.Usage, +ms: List<&2, String>) -> Maybe<&2, Nat>:
102  head.of(win.max(windows(u, ms), None{}))
103
104# ----------------------------------------------------------- freshness
105
106def err.is_none(+e: T.ErrKind) -> Bool:
107  match e:
108    case T.ErrNone{}:
109      True{}
110    case o:
111      False{}
112
113def before.opt(+now: Nat, +m: Maybe<&2, Nat>) -> Bool:
114  match m:
115    case None{}:
116      False{}
117    case Some{n}:
118      Nat.is_lt(now, n)
119
120# Trust is extended while the row is failing (consecutive failures > 0, as the
121# reference), planned, held or claimed.
122def fresh.ext(+now: Nat, +nf: Nat, +np: Maybe<&2, Nat>, +hu: Nat, +cu: Nat) -> Bool:
123  Nat.is_gt(nf, 0n) || before.opt(now, np) || Nat.is_lt(now, hu) || Nat.is_lt(now, cu)
124
125def win.reset(+w: T.Win) -> Maybe<&2, Nat>:
126  match w:
127    case T.Win{p, r}:
128      r
129
130def soon.min(+a: Maybe<&2, Nat>, +b: Maybe<&2, Nat>) -> Maybe<&2, Nat>:
131  match a b:
132    case None{} _:
133      b
134    case Some{x} None{}:
135      a
136    case Some{x} Some{y}:
137      Some{Nat.min(x, y)}
138
139# The soonest reset among the relevant windows (past ones included): once it
140# passes, that window is zeroed and the snapshot is obsolete.
141def win.soonest(+ws: List<&2, T.Win>, +acc: Maybe<&2, Nat>) -> Maybe<&2, Nat>:
142  match ws:
143    case Nil{}:
144      acc
145    case Con{w, t}:
146      win.soonest(t, soon.min(acc, win.reset(w)))
147
148# No window reset known: only the age ceiling applies.
149def soon.ok(+now: Nat, +soonest: Maybe<&2, Nat>) -> Bool:
150  match soonest:
151    case None{}:
152      True{}
153    case Some{s}:
154      Nat.is_lt(now, s)
155
156# How long a failing row's reading may be trusted. After an http-429 the
157# account's windows did not move, so the reading is a valid lower bound until
158# the soonest window resets: now < min(earliest reset, fetched_at + 7200 s).
159# Any other failure is no evidence the reading still holds: at most 3600 s.
160def within.ceiling(+e: T.ErrKind, +age: Nat, +now: Nat, +soonest: Maybe<&2, Nat>) -> Bool:
161  match e:
162    case T.ErrHttp429{}:
163      Nat.is_lt(age, 7200n) && soon.ok(now, soonest)
164    case o:
165      Nat.is_le(age, 3600n)
166
167def fresh.age(+age: Nat, +ext: Bool, +within: Bool) -> Bool:
168  Nat.is_le(age, 300n) || (ext && within)
169
170# A reading is decision-grade when it is at most 300 s old, or its trust is
171# extended (the row is failing, planned, held or claimed) and within the
172# ceiling of `within.ceiling`.
173def fresh.at(+fa: Maybe<&2, Nat>, +now: Nat, +e: T.ErrKind, +nf: Nat, +np: Maybe<&2, Nat>, +hu: Nat, +cu: Nat, +soonest: Maybe<&2, Nat>) -> Bool:
174  match fa:
175    case None{}:
176      False{}
177    case Some{f}:
178      fresh.age(Nat.sub(now, f), fresh.ext(now, nf, np, hu, cu), within.ceiling(e, Nat.sub(now, f), now, soonest))
179
180def fresh(+u: T.Usage, +now: Nat, +ms: List<&2, String>) -> Bool:
181  match u:
182    case T.Usage{slot, five, fr, seven, sr, ss, fa, bo, +er, nfl, np, pi, ad, hu, cu, +se}:
183      fresh.at(fa, now, er, nfl, np, hu, cu, win.soonest(windows(u, ms), None{}))
184
185def sent.none(+s: T.Sentinel) -> Bool:
186  match s:
187    case T.NoSentinel{}:
188      True{}
189    case o:
190      False{}
191
192# A decision-grade reading: fresh, and not hidden behind a sentinel.
193def trusted(+u: T.Usage, +now: Nat, +ms: List<&2, String>) -> Bool:
194  match u:
195    case T.Usage{slot, five, fr, seven, sr, ss, fa, bo, +er, nfl, np, pi, ad, hu, cu, +se}:
196      sent.none(se) && fresh.at(fa, now, er, nfl, np, hu, cu, win.soonest(windows(u, ms), None{}))
197
198def head.if(+ok: Bool, +h: Maybe<&2, Nat>) -> Maybe<&2, Nat>:
199  match ok:
200    case True{}:
201      h
202    case False{}:
203      None{}
204
205# The decision value of an account: None when there is no trusted reading or
206# the reading carries no relevant window.
207def headroom(+u: T.Usage, +now: Nat, +ms: List<&2, String>) -> Maybe<&2, Nat>:
208  head.if(trusted(u, now, ms), headroom.raw(u, ms))
209
210def usage.slot(+u: T.Usage) -> Nat:
211  match u:
212    case T.Usage{slot, five, fr, seven, sr, ss, fa, bo, er, nfl, np, pi, ad, hu, cu, se}:
213      slot
214
215def usage.backoff_until(+u: T.Usage) -> Nat:
216  match u:
217    case T.Usage{slot, five, fr, seven, sr, ss, fa, bo, er, nfl, np, pi, ad, hu, cu, se}:
218      bo
219
220def in_backoff(+u: T.Usage, +now: Nat) -> Bool:
221  Nat.is_lt(now, usage.backoff_until(u))
222
223def usage.sentinel(+u: T.Usage) -> T.Sentinel:
224  match u:
225    case T.Usage{slot, five, fr, seven, sr, ss, fa, bo, er, nfl, np, pi, ad, hu, cu, se}:
226      se
227
228def usage.of.pick(+hit: Bool, +u: T.Usage, +rest: Maybe<&2, T.Usage>) -> Maybe<&2, T.Usage>:
229  match hit:
230    case True{}:
231      Some{u}
232    case False{}:
233      rest
234
235# The first usage row of a slot; None is "no reading".
236def usage.of(+slot: Nat, +us: List<&2, T.Usage>) -> Maybe<&2, T.Usage>:
237  match us:
238    case Nil{}:
239      None{}
240    case Con{u, t}:
241      usage.of.pick(Nat.is_eq(slot, usage.slot(u)), u, usage.of(slot, t))
242
243def account.slot(+a: T.Account) -> Nat:
244  match a:
245    case T.Account{slot, email, kind, disabled}:
246      slot
247
248def account.of.pick(+hit: Bool, +a: T.Account, +rest: Maybe<&2, T.Account>) -> Maybe<&2, T.Account>:
249  match hit:
250    case True{}:
251      Some{a}
252    case False{}:
253      rest
254
255def account.of(+slot: Nat, +as: List<&2, T.Account>) -> Maybe<&2, T.Account>:
256  match as:
257    case Nil{}:
258      None{}
259    case Con{a, t}:
260      account.of.pick(Nat.is_eq(slot, account.slot(a)), a, account.of(slot, t))
261
262# Headroom of a row, if there is a row.
263def head.row(+m: Maybe<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> Maybe<&2, Nat>:
264  match m:
265    case None{}:
266      None{}
267    case Some{u}:
268      headroom(u, now, ms)
269
270# ------------------------------------------------------- poll policy
271
272def poll.stale(+fa: Maybe<&2, Nat>, +now: Nat, +ttl: Nat) -> Bool:
273  match fa:
274    case None{}:
275      True{}
276    case Some{f}:
277      Nat.is_gt(Nat.sub(now, f), ttl)
278
279def poll.planned_due(+np: Maybe<&2, Nat>, +now: Nat) -> Bool:
280  match np:
281    case None{}:
282      False{}
283    case Some{n}:
284      Nat.is_ge(now, n)
285
286# next_poll_at lies further out than max(interval, 600) * 1.1 + 60 s.
287def poll.overslept(+np: Maybe<&2, Nat>, +now: Nat, +pi: Nat) -> Bool:
288  match np:
289    case None{}:
290      False{}
291    case Some{n}:
292      Nat.is_gt(Nat.mul(10n, Nat.sub(Nat.sub(n, now), 60n)), Nat.mul(11n, Nat.max(pi, 600n)))
293
294def poll.unplanned(+np: Maybe<&2, Nat>) -> Bool:
295  match np:
296    case None{}:
297      True{}
298    case Some{n}:
299      False{}
300
301def poll.ready(+np: Maybe<&2, Nat>, +now: Nat, +pi: Nat, +fa: Maybe<&2, Nat>, +ttl: Nat) -> Bool:
302  poll.planned_due(np, now)
303    || (poll.stale(fa, now, ttl) && (poll.unplanned(np) || poll.overslept(np, now, pi)))
304
305def poll.gate(+blocked: Bool, +ready: Bool) -> Bool:
306  match blocked:
307    case True{}:
308      False{}
309    case False{}:
310      ready
311
312# Is this row due a fetch on a scheduled auto tick? Never inside backoff,
313# dead (one auth strike), held or claimed; then the plan or staleness decides.
314def poll_due(+u: T.Usage, +now: Nat, +cfg: T.Config) -> Bool:
315  match u:
316    case T.Usage{slot, five, fr, seven, sr, ss, fa, +bo, er, nfl, np, pi, ad, hu, cu, se}:
317      match cfg:
318        case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}:
319          poll.gate(
320            Nat.is_lt(now, bo) || Nat.is_ge(ad, 1n) || Nat.is_lt(now, hu) || Nat.is_lt(now, cu),
321            poll.ready(np, now, pi, fa, ttl)
322          )
323
324# ------------------------------------------------------------ backoff
325
326def backoff.cap(+k: Nat) -> Nat:
327  Nat.min(600n, Nat.mul(30n, Nat.pow(2n, Nat.min(Nat.sub(k, 1n), 5n))))
328
329def backoff.zero(+rl: Bool, +computed: Nat) -> Nat:
330  match rl:
331    case True{}:
332      Nat.min(Nat.max(computed, 300n), 600n)
333    case False{}:
334      computed
335
336def backoff.bump(+big: Bool, +ra: Nat) -> Nat:
337  match big:
338    case True{}:
339      Nat.add(ra, 900n)
340    case False{}:
341      ra
342
343def backoff.asked(+rl: Bool, +ra: Nat) -> Nat:
344  match rl:
345    case True{}:
346      Nat.min(4500n, backoff.bump(Nat.is_gt(ra, 600n), ra))
347    case False{}:
348      Nat.min(3600n, ra)
349
350def backoff.some(+rl: Bool, +ra: Nat, +computed: Nat) -> Nat:
351  Nat.max(backoff.asked(rl, ra), computed)
352
353def backoff.retry.some(+r: Nat, +rl: Bool, +computed: Nat) -> Nat:
354  match r:
355    case 0n:
356      backoff.zero(rl, computed)
357    case 1n+p:
358      backoff.some(rl, r, computed)
359
360def backoff.rate_limited(+e: T.ErrKind) -> Bool:
361  match e:
362    case T.ErrHttp429{}:
363      True{}
364    case o:
365      False{}
366
367def backoff.retry(+rl: Bool, +ra: Maybe<&2, Nat>, +computed: Nat) -> Nat:
368  match ra:
369    case None{}:
370      computed
371    case Some{r}:
372      backoff.retry.some(r, rl, computed)
373
374# Seconds of backoff after the n-th consecutive failure: 30, 60, 120, 240,
375# 480, 600; a server's Retry-After raises it (Retry-After 0 on an http-429
376# means at least 300 s).
377def backoff_for(+n: Nat, +e: T.ErrKind, +ra: Maybe<&2, Nat>) -> Nat:
378  backoff.retry(backoff.rate_limited(e), ra, backoff.cap(n))