poll.bend

cswap-bend-cli@0.1.0.0 · 387 lines · 79 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/clock.bend as Clock
4import cswap-bend-cli@0.1.0.0/policy/usage.bend as PUsage
5import cswap-bend-cli@0.1.0.0/policy/types.bend as PT
6
7# Usage windows, poll planning, failure backoff and decision trust: the pure
8# rules of usage_store.py and poll_policy.py (claude-swap 0.27.2).
9# Epochs are unix seconds (U32). Percentages are tenths of a percent (U32),
10# so `42.5` is 425 and `100.0` is 1000. There is no jitter on the poll
11# deadline (Bend has no random source); the reference's is +-10%.
12
13def serve_ttl() -> U32:
14  180
15
16def decision_trust() -> U32:
17  300
18
19def trust_ceiling() -> U32:
20  3600
21
22def rate_limit_trust_ceiling() -> U32:
23  7200
24
25def claim_ttl() -> U32:
26  90
27
28def min_interval() -> U32:
29  180
30
31def urgent_interval() -> U32:
32  60
33
34def active_ceiling() -> U32:
35  300
36
37def candidate_default() -> U32:
38  300
39
40def candidate_ceiling() -> U32:
41  600
42
43def exhausted_interval() -> U32:
44  600
45
46def reset_slack() -> U32:
47  60
48
49def recent_429_window() -> U32:
50  3600
51
52def post_429_min() -> U32:
53  360
54
55def post_429_cap() -> U32:
56  1800
57
58def edge_backoff() -> U32:
59  300
60
61def backoff_base() -> U32:
62  30
63
64def backoff_cap() -> U32:
65  600
66
67def retry_margin() -> U32:
68  900
69
70def retry_floor_cap() -> U32:
71  4500
72
73def escalation_margin() -> U32:
74  150
75
76def min_u(+a: U32, +b: U32) -> U32:
77  Clock.sel(U32.is_lt(a, b), a, b)
78
79def max_u(+a: U32, +b: U32) -> U32:
80  Clock.sel(U32.is_gt(a, b), a, b)
81
82# a - b, floored at 0.
83def sub0(+a: U32, +b: U32) -> U32:
84  Clock.sel(U32.is_gt(a, b), (a - b : U32), 0)
85
86def absdiff(+a: U32, +b: U32) -> U32:
87  Clock.sel(U32.is_gt(a, b), (a - b : U32), (b - a : U32))
88
89def tenths.mk(+i: String, +f: String) -> Maybe<&2, U32>:
90  do Maybe<&2, U32>:
91    ip : U32 <- U32.read(i)
92    f1 : U32 <- Clock.num(f ++ "00", 0n, 1n)
93    f2 : U32 <- Clock.num(f ++ "00", 1n, 1n)
94    return (ip * 10 + f1 + Clock.sel(U32.is_ge(f2, 5), 1, 0) : U32)
95
96def tenths.pick(ok: Bool, +i: String, +f: String) -> Maybe<&2, U32>:
97  match ok:
98    case True{}:
99      tenths.mk(i, f)
100    case False{}:
101      None{}
102
103def tenths.parts(ps: List<&2, String>) -> Maybe<&2, U32>:
104  match ps:
105    case Con{+i, Nil{}}:
106      tenths.pick(Clock.all_digits(i) && Bool.not(String.is_empty(i)) && Nat.is_le(String.length(i), 7n), i, "")
107    case Con{+i, Con{+f, Nil{}}}:
108      tenths.pick(Clock.all_digits(i) && Clock.all_digits(f) && Bool.not(String.is_empty(i)) && Nat.is_le(String.length(i), 7n), i, f)
109    case _:
110      None{}
111
112# `42.5` -> 425, rounded to a tenth; None for anything but a plain decimal.
113def tenths(s: String) -> Maybe<&2, U32>:
114  tenths.parts(String.split(s, '.'))
115
116def tenths.json(j: Json.Json) -> Maybe<&2, U32>:
117  match j:
118    case Json.JNum{t}:
119      tenths(t)
120    case _:
121      None{}
122
123# One usage window: its percentage (tenths) and when it resets.
124type Win is Data:
125  Win{pct: U32, reset: Maybe<&2, U32>}
126
127def win.parse(m: Maybe<&2, String>) -> Maybe<&2, U32>:
128  match m:
129    case None{}:
130      None{}
131    case Some{s}:
132      Clock.parse_iso8601(s)
133
134def win.reset(+w: Json.Json) -> Maybe<&2, U32>:
135  win.parse(Json.as_string(Maybe.default(&2, Json.Json, Json.get("resets_at", w), Json.JNull{})))
136def min_acc(acc: Maybe<&2, U32>, +p: U32) -> Maybe<&2, U32>:
137  match acc:
138    case None{}:
139      Some{p}
140    case Some{a}:
141      Some{min_u(a, p)}
142
143def any.step(r: Maybe<&2, U32>, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
144  match r:
145    case Some{x}:
146      min_acc(acc, x)
147    case None{}:
148      acc
149
150# The earliest reset of any relevant window (past or future): trust ends there.
151def earliest_reset(ws: List<&2, Win>, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
152  match ws:
153    case Nil{}:
154      acc
155    case Con{Win{p, r}, t}:
156      earliest_reset(t, any.step(r, acc))
157
158# When to poll next and the interval it came from.
159type Sched is Data:
160  Sched{next: U32, interval: U32}
161
162def interval.moving(+base: U32) -> U32:
163  max_u(min_interval(), (base / 2 : U32))
164
165def interval.still(+base: U32, +ceil: U32) -> U32:
166  min_u(ceil, max_u(min_interval(), (base * 3 / 2 : U32)))
167
168# Default for unknown utilization, halve on movement, back off x1.5 otherwise.
169def interval.base(prev: Maybe<&2, U32>, +dflt: U32) -> U32:
170  match prev:
171    case Some{+b}:
172      Clock.sel(U32.is_eq(b, 0), dflt, b)
173    case None{}:
174      dflt
175
176def interval.pick(known: Bool, moving: Bool, +dflt: U32, +base: U32, +ceil: U32) -> U32:
177  match known moving:
178    case False{} _:
179      dflt
180    case True{} True{}:
181      interval.moving(base)
182    case True{} False{}:
183      interval.still(base, ceil)
184
185def is_moving(prev: Maybe<&2, U32>, new: Maybe<&2, U32>) -> Bool:
186  match prev new:
187    case Some{a} Some{b}:
188      U32.is_ge(absdiff(a, b), 10)
189    case _ _:
190      False{}
191
192def is_known(prev: Maybe<&2, U32>, new: Maybe<&2, U32>) -> Bool:
193  Maybe.is_some(&2, U32, prev) && Maybe.is_some(&2, U32, new)
194
195def new_ge(new: Maybe<&2, U32>, +floor: U32) -> Bool:
196  match new:
197    case Some{n}:
198      U32.is_ge(n, floor)
199    case None{}:
200      False{}
201
202def exhausted(new: Maybe<&2, U32>) -> Bool:
203  match new:
204    case Some{n}:
205      U32.is_ge(n, 1000)
206    case None{}:
207      False{}
208
209def plan.r429(recent: Bool, +base: U32, +i: U32) -> U32:
210  Clock.sel(recent, min_u(post_429_cap(), max_u(i, max_u((base * 3 / 2 : U32), post_429_min()))), i)
211
212def plan.exh(ex: Bool, +i: U32) -> U32:
213  Clock.sel(ex, max_u(i, exhausted_interval()), i)
214
215def plan.interval(+i0: U32, +urgent: Bool, +recent: Bool, +base: U32, +ex: Bool) -> U32:
216  plan.exh(ex, plan.r429(recent, base, Clock.sel(urgent, urgent_interval(), i0)))
217
218def plan.cap(+next: U32, r: Maybe<&2, U32>) -> U32:
219  match r:
220    case Some{x}:
221      min_u(next, (x + reset_slack() : U32))
222    case None{}:
223      next
224
225def keep.m(ok: Bool, m: Maybe<&2, U32>) -> Maybe<&2, U32>:
226  match ok:
227    case True{}:
228      m
229    case False{}:
230      None{}
231
232def plan.reset.pick(ex: Bool, lim: Maybe<&2, U32>, fut: Maybe<&2, U32>, +now: U32) -> Maybe<&2, U32>:
233  match ex lim:
234    case True{} Some{+x}:
235      keep.m(U32.is_gt(x, now), Some{x})
236    case True{} None{}:
237      None{}
238    case False{} _:
239      fut
240
241def plan.fin(+now: U32, +interval: U32, r: Maybe<&2, U32>) -> Sched:
242  Sched{plan.cap((now + interval : U32), r), interval}
243
244def nat_maybe(m: Maybe<&2, U32>) -> Maybe<&2, Nat>:
245  match m:
246    case Some{x}:
247      Some{U32.to_nat(x)}
248    case None{}:
249      None{}
250
251def reset_nat(+w: Json.Json) -> Maybe<&2, Nat>:
252  nat_maybe(win.reset(w))
253
254def centi(m: Maybe<&2, U32>) -> Maybe<&2, Nat>:
255  match m:
256    case Some{t}:
257      Some{U32.to_nat((t * 10 : U32))}
258    case None{}:
259      None{}
260
261def pct_centi(+w: Json.Json) -> Maybe<&2, Nat>:
262  centi(tenths.json(Maybe.default(&2, Json.Json, Json.get("pct", w), Json.JNull{})))
263
264def scoped.mk(n: Maybe<&2, String>, p: Maybe<&2, Nat>, r: Maybe<&2, Nat>) -> Maybe<&2, PT.Scoped>:
265  match n p:
266    case Some{name} Some{x}:
267      Some{PT.Scoped{name, x, r}}
268    case _ _:
269      None{}
270
271def scoped.item(+w: Json.Json) -> Maybe<&2, PT.Scoped>:
272  scoped.mk(Json.as_string(Maybe.default(&2, Json.Json, Json.get("name", w), Json.JNull{})), pct_centi(w), reset_nat(w))
273
274def scoped.cons(m: Maybe<&2, PT.Scoped>, rest: List<&2, PT.Scoped>) -> List<&2, PT.Scoped>:
275  match m:
276    case Some{x}:
277      x <> rest
278    case None{}:
279      rest
280
281def scoped.all(ws: List<&2, Json.Json>) -> List<&2, PT.Scoped>:
282  match ws:
283    case Nil{}:
284      []
285    case Con{+w, t}:
286      scoped.cons(scoped.item(w), scoped.all(t))
287
288def window_of(+u: Json.Json, +k: String) -> Json.Json:
289  Maybe.default(&2, Json.Json, Json.get(k, u), Json.JNull{})
290
291# The policy core's view of a `usage` object (centi-percent windows).
292def policy_usage(+u: Json.Json) -> PT.Usage:
293  PT.Usage{0n, pct_centi(window_of(u, "five_hour")), reset_nat(window_of(u, "five_hour")), pct_centi(window_of(u, "seven_day")), reset_nat(window_of(u, "seven_day")), scoped.all(Json.to_list(window_of(u, "scoped"))), None{}, 0n, PT.ErrNone{}, 0n, None{}, 0n, 0n, 0n, 0n, PT.NoSentinel{}}
294
295def maybe_u32(m: Maybe<&2, Nat>) -> Maybe<&2, U32>:
296  match m:
297    case Some{x}:
298      Some{U32.from_nat(x)}
299    case None{}:
300      None{}
301
302def win.back(ws: List<&2, PT.Win>) -> List<&2, Win>:
303  match ws:
304    case Nil{}:
305      []
306    case Con{PT.Win{p, r}, t}:
307      Win{U32.from_nat(Nat.div(p, 10n)), maybe_u32(r)} <> win.back(t)
308
309# Every window that gates an account (5h, 7d, and the scoped windows of the
310# named models): policy's `windows`, back as tenths for the planner.
311def relevant(+usage: Json.Json, +models: List<&2, String>) -> List<&2, Win>:
312  win.back(PUsage.windows(policy_usage(usage), models))
313
314def future.pick(ok: Bool, +x: U32, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
315  match ok:
316    case True{}:
317      min_acc(acc, x)
318    case False{}:
319      acc
320
321def future.step(r: Maybe<&2, U32>, +now: U32, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
322  match r:
323    case Some{+x}:
324      future.pick(U32.is_gt(x, now), x, acc)
325    case None{}:
326      acc
327
328def earliest_future(ws: List<&2, Win>, +now: U32, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
329  match ws:
330    case Nil{}:
331      acc
332    case Con{Win{p, r}, t}:
333      earliest_future(t, now, future.step(r, now, acc))
334
335def max_acc(acc: Maybe<&2, U32>, +p: U32) -> Maybe<&2, U32>:
336  match acc:
337    case None{}:
338      Some{p}
339    case Some{a}:
340      Some{max_u(a, p)}
341
342def limiting.step(full: Bool, r: Maybe<&2, U32>, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
343  match full r:
344    case True{} Some{x}:
345      max_acc(acc, x)
346    case _ _:
347      acc
348
349def limiting_reset(ws: List<&2, Win>, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
350  match ws:
351    case Nil{}:
352      acc
353    case Con{Win{p, r}, t}:
354      limiting_reset(t, limiting.step(U32.is_ge(p, 1000), r, acc))
355
356def plan.go(+usage: Json.Json, +models: List<&2, String>, +now: U32, +interval: U32, +ex: Bool) -> Sched:
357  plan.fin(now, interval, plan.reset.pick(ex, limiting_reset(relevant(usage, models), None{}), earliest_future(relevant(usage, models), now, None{}), now))
358
359def plan.with(+base: U32, +known: Bool, +moving: Bool, +ex: Bool, +in_band: Bool, +usage: Json.Json, +active: Bool, +models: List<&2, String>, +recent: Bool, +now: U32, +ceil: U32, +dflt: U32) -> Sched:
360  plan.go(usage, models, now,
361    plan.interval(interval.pick(known, moving, dflt, base, ceil), active && moving && Bool.not(recent) && in_band, recent, base, ex),
362    ex)
363
364def dflt_of(active: Bool) -> U32:
365  Clock.sel(active, min_interval(), candidate_default())
366
367def ceil_of(active: Bool) -> U32:
368  Clock.sel(active, active_ceiling(), candidate_ceiling())
369
370def plan.start(prev_interval: Maybe<&2, U32>, +prev: Maybe<&2, U32>, +new: Maybe<&2, U32>, +usage: Json.Json, +active: Bool, +threshold: U32, +models: List<&2, String>, recent: Bool, +now: U32) -> Sched:
371  plan.with(interval.base(prev_interval, dflt_of(active)), is_known(prev, new), is_moving(prev, new), exhausted(new), new_ge(new, sub0(threshold, escalation_margin())),
372    usage, active, models, recent, now, ceil_of(active), dflt_of(active))
373
374def max_pct(ws: List<&2, Win>, acc: Maybe<&2, U32>) -> Maybe<&2, U32>:
375  match ws:
376    case Nil{}:
377      acc
378    case Con{Win{p, r}, t}:
379      max_pct(t, max_acc(acc, p))
380
381def binding(usage: Json.Json, models: List<&2, String>) -> Maybe<&2, U32>:
382  max_pct(relevant(usage, models), None{})
383
384# plan_after_fetch (no jitter): `threshold` in tenths, `models` the scoped
385# names that gate the account, times in unix seconds.
386def plan_after_fetch(prev_interval: Maybe<&2, U32>, prev_usage: Json.Json, +new_usage: Json.Json, is_active: Bool, +threshold: U32, +models: List<&2, String>, recent_429: Bool, +now: U32) -> Sched:
387  plan.start(prev_interval, binding(prev_usage, models), binding(new_usage, models), new_usage, is_active, threshold, models, recent_429, now)