profile.bend

cswap-bend-cli@0.1.0.0 · 424 lines · 66 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/policy/safe.bend as SF
4import cswap-bend-cli@0.1.0.0/daemon/config.bend as Cfg
5import cswap-bend-cli@0.1.0.0/setload.bend as Setload
6import cswap-bend-cli@0.1.0.0/setwrite.bend as Setwrite
7
8# Behaviour profiles: a named bundle of a strategy, a threshold, a model list,
9# a cooldown and an interval, kept in settings.json next to the `autoswitch`
10# block as
11#
12#   "profiles": {"night": {"strategy": "drain-first", "threshold": 95.0,
13#                          "models": ["opus"], "cooldown_s": 60.0, "interval_s": 30.0}},
14#   "active_profile": "night"
15#
16# Every key but `strategy` is optional: a missing key inherits the matching
17# `autoswitch` setting. The built-in profiles need no entry: `default` is
18# today's behaviour from settings (strategy best, or consume-first when the
19# setting says so), and one per strategy named after it. A stored profile with
20# a built-in's name replaces that built-in; `default` cannot be replaced.
21# Unknown keys of the file, of `profiles` and of a profile survive a write.
22
23type Prof is Data:
24  Prof{name: String, algo: SF.Algo, threshold: Maybe<&2, String>, models: Maybe<&2, String>, cooldown: Maybe<&2, String>, interval: Maybe<&2, String>}
25
26# Which profile a run uses: none (the legacy behaviour), a found one, or a
27# name that matches nothing.
28type Res is Data:
29  RNone{}
30  ROk{prof: Prof}
31  RUnknown{name: String}
32
33type Upd is Data:
34  UBad{msg: String}
35  UOk{prof: Prof}
36
37def keys() -> List<&2, String>:
38  ["strategy", "threshold", "models", "cooldown_s", "interval_s"]
39
40def builtin_names() -> List<&2, String>:
41  ["default", "best", "round-robin", "soonest-reset", "sticky", "drain-first"]
42
43def bare(+name: String, +algo: SF.Algo) -> Prof:
44  Prof{name, algo, None{}, None{}, None{}, None{}}
45
46def builtin.of(+name: String, +m: Maybe<&2, SF.Algo>) -> Maybe<&2, Prof>:
47  match m:
48    case Some{a}:
49      Some{bare(name, a)}
50    case None{}:
51      None{}
52
53def builtin.pick(+is_default: Bool, +name: String) -> Maybe<&2, Prof>:
54  match is_default:
55    case True{}:
56      Some{bare(name, SF.ABest{})}
57    case False{}:
58      builtin.of(name, SF.parse(name))
59
60def builtin(+name: String) -> Maybe<&2, Prof>:
61  builtin.pick(String.eq(name, "default"), name)
62
63def is_builtin(+name: String) -> Bool:
64  Setload.has_str(builtin_names(), name)
65
66# ------------------------------------------------------------------ reading
67
68def num_text(+m: Maybe<&2, Json.Json>) -> Maybe<&2, String>:
69  match m:
70    case Some{Json.JNum{t}}:
71      Some{t}
72    case _:
73      None{}
74
75def join_strs(+xs: List<&2, Json.Json>) -> List<&2, String>:
76  match xs:
77    case Nil{}:
78      []
79    case Con{Json.JStr{s}, t}:
80      s <> join_strs(t)
81    case Con{h, t}:
82      join_strs(t)
83
84def models_text(+m: Maybe<&2, Json.Json>) -> Maybe<&2, String>:
85  match m:
86    case Some{Json.JStr{s}}:
87      Some{s}
88    case Some{Json.JArr{items}}:
89      Some{String.join(join_strs(Json.to_list(Json.JArr{items})), ",")}
90    case _:
91      None{}
92
93def algo_of(+m: Maybe<&2, Json.Json>, +dflt: SF.Algo) -> SF.Algo:
94  match m:
95    case Some{Json.JStr{s}}:
96      Maybe.default(&2, SF.Algo, SF.parse(s), dflt)
97    case _:
98      dflt
99
100def algo_base(+p: Prof) -> SF.Algo:
101  match p:
102    case Prof{n, a, th, ms, cd, iv}:
103      a
104
105# A stored profile over the algorithm of the built-in it replaces.
106def of_json(+name: String, +j: Json.Json, +base: SF.Algo) -> Prof:
107  Prof{name, algo_of(Json.get("strategy", j), base), num_text(Json.get("threshold", j)), models_text(Json.get("models", j)),
108    num_text(Json.get("cooldown_s", j)), num_text(Json.get("interval_s", j))}
109
110def stored_in(+m: Maybe<&2, Json.Json>, +name: String) -> Maybe<&2, Json.Json>:
111  match m:
112    case Some{ps}:
113      Json.get(name, ps)
114    case None{}:
115      None{}
116
117def stored(+raw: Json.Json, +name: String) -> Maybe<&2, Json.Json>:
118  stored_in(Json.get("profiles", raw), name)
119
120def found(+s: Maybe<&2, Json.Json>, +b: Maybe<&2, Prof>, +name: String) -> Maybe<&2, Prof>:
121  match s:
122    case Some{j}:
123      Some{of_json(name, j, algo_base(Maybe.default(&2, Prof, b, bare(name, SF.ABest{}))))}
124    case None{}:
125      b
126
127def find.pick(+is_default: Bool, +raw: Json.Json, +name: String) -> Maybe<&2, Prof>:
128  match is_default:
129    case True{}:
130      builtin(name)
131    case False{}:
132      found(stored(raw, name), builtin(name), name)
133
134def find(+raw: Json.Json, +name: String) -> Maybe<&2, Prof>:
135  find.pick(String.eq(name, "default"), raw, name)
136
137def extra_keep(+skip: Bool, +k: String, +rest: List<&2, String>) -> List<&2, String>:
138  match skip:
139    case True{}:
140      rest
141    case False{}:
142      k <> rest
143
144def extra_names(+ks: List<&2, String>) -> List<&2, String>:
145  match ks:
146    case Nil{}:
147      []
148    case Con{k, t}:
149      extra_keep(is_builtin(k), k, extra_names(t))
150
151def profiles_keys(+m: Maybe<&2, Json.Json>) -> List<&2, String>:
152  match m:
153    case Some{ps}:
154      Json.keys(ps)
155    case None{}:
156      []
157
158# The built-ins, then the stored profiles that are not built-ins, file order.
159def names(+raw: Json.Json) -> List<&2, String>:
160  List.append(&2, String, builtin_names(), extra_names(profiles_keys(Json.get("profiles", raw))))
161
162def active.pick(+empty: Bool, +s: String) -> Maybe<&2, String>:
163  match empty:
164    case True{}:
165      None{}
166    case False{}:
167      Some{s}
168
169def active.of(+m: Maybe<&2, Json.Json>) -> Maybe<&2, String>:
170  match m:
171    case Some{Json.JStr{s}}:
172      active.pick(String.is_empty(s), s)
173    case _:
174      None{}
175
176def active(+raw: Json.Json) -> Maybe<&2, String>:
177  active.of(Json.get("active_profile", raw))
178
179def resolve.found(+m: Maybe<&2, Prof>, +name: String) -> Res:
180  match m:
181    case Some{p}:
182      ROk{p}
183    case None{}:
184      RUnknown{name}
185
186def resolve.name(+raw: Json.Json, +n: Maybe<&2, String>) -> Res:
187  match n:
188    case None{}:
189      RNone{}
190    case Some{name}:
191      resolve.found(find(raw, name), name)
192
193def pick_name(+over: Maybe<&2, String>, +act: Maybe<&2, String>) -> Maybe<&2, String>:
194  match over:
195    case Some{o}:
196      over
197    case None{}:
198      act
199
200# `--profile NAME` over `active_profile` over nothing.
201def resolve(+raw: Json.Json, +over: Maybe<&2, String>) -> Res:
202  resolve.name(raw, pick_name(over, active(raw)))
203
204# ------------------------------------------------------- over the settings
205
206def over_thr(+m: Maybe<&2, String>, +d: U32) -> U32:
207  match m:
208    case Some{t}:
209      Cfg.field_centi2(Cfg.centi(t), d, 5000, 9990)
210    case None{}:
211      d
212
213def over_secs(+m: Maybe<&2, String>, +d: U32, +lo: U32, +hi: U32) -> U32:
214  match m:
215    case Some{t}:
216      (Cfg.field_centi2(Cfg.centi(t), (d * 100 : U32), (lo * 100 : U32), (hi * 100 : U32)) / 100 : U32)
217    case None{}:
218      d
219
220# `best` keeps the engine strategy of the settings (best or consume-first);
221# any other strategy replaces it.
222def strat_of(+algo: SF.Algo, +st: String) -> String:
223  match algo:
224    case SF.ABest{}:
225      st
226    case o:
227      SF.name(o)
228
229# The autoswitch settings with the profile's keys laid over them.
230def with_prof(+c: Cfg.Cfg, +p: Prof) -> Cfg.Cfg:
231  match c p:
232    case Cfg.Cfg{th, iv, cd, hy, st, ia, ut, ms} Prof{n, algo, pth, pms, pcd, piv}:
233      Cfg.Cfg{over_thr(pth, th), over_secs(piv, iv, 15, 3600), over_secs(pcd, cd, 0, 86400), hy, strat_of(algo, st), ia, ut, Cfg.over_models(pms, ms)}
234
235# ----------------------------------------------------------------- writing
236
237def num_json(+m: Maybe<&2, String>) -> Maybe<&2, Json.Json>:
238  match m:
239    case Some{t}:
240      Some{Json.JNum{t}}
241    case None{}:
242      None{}
243
244def strs_json(+xs: List<&2, String>) -> List<&2, Json.Json>:
245  match xs:
246    case Nil{}:
247      []
248    case Con{h, t}:
249      Json.JStr{h} <> strs_json(t)
250
251def models_json(+m: Maybe<&2, String>) -> Maybe<&2, Json.Json>:
252  match m:
253    case Some{t}:
254      Some{Json.arr(strs_json(Cfg.parse_models(t)))}
255    case None{}:
256      None{}
257
258def opt_set(+key: String, +m: Maybe<&2, Json.Json>, +o: Json.Json) -> Json.Json:
259  match m:
260    case Some{v}:
261      Json.set(key, v, o)
262    case None{}:
263      Json.del(key, o)
264
265# The profile as a JSON object laid over `base` (the stored object, so keys we
266# do not know survive).
267def to_json(+p: Prof, +base: Json.Json) -> Json.Json:
268  match p:
269    case Prof{n, algo, th, ms, cd, iv}:
270      opt_set("interval_s", num_json(iv), opt_set("cooldown_s", num_json(cd), opt_set("models", models_json(ms), opt_set("threshold", num_json(th), Json.set("strategy", Json.JStr{SF.name(algo)}, base)))))
271
272def name_of(+p: Prof) -> String:
273  match p:
274    case Prof{n, a, th, ms, cd, iv}:
275      n
276
277def obj_of(+m: Maybe<&2, Json.Json>) -> Json.Json:
278  match m:
279    case Some{Json.JObj{f}}:
280      Json.JObj{f}
281    case _:
282      Setload.empty()
283
284def store.at(+p: Prof, +ps: Json.Json) -> Json.Json:
285  Json.set(name_of(p), to_json(p, obj_of(Json.get(name_of(p), ps))), ps)
286
287# settings.json with the profile stored (created or replaced).
288def store(+raw: Json.Json, +p: Prof) -> Json.Json:
289  Json.set("profiles", store.at(p, obj_of(Json.get("profiles", raw))), Setload.with_version(raw))
290
291def drop.empty(+ps: Json.Json, +raw: Json.Json) -> Json.Json:
292  match ps:
293    case Json.JObj{Json.JNil{}}:
294      Json.del("profiles", raw)
295    case o:
296      Json.set("profiles", ps, raw)
297
298def drop.active(+is_active: Bool, +raw: Json.Json) -> Json.Json:
299  match is_active:
300    case True{}:
301      Json.del("active_profile", raw)
302    case False{}:
303      raw
304
305# settings.json without the stored profile; the active profile is cleared if
306# it was this one.
307def remove(+raw: Json.Json, +name: String) -> Json.Json:
308  drop.active(String.eq(Maybe.default(&2, String, active(raw), ""), name), drop.empty(Json.del(name, obj_of(Json.get("profiles", raw))), raw))
309
310def use(+raw: Json.Json, +name: String) -> Json.Json:
311  Json.set("active_profile", Json.JStr{name}, Setload.with_version(raw))
312
313def has_stored(+raw: Json.Json, +name: String) -> Bool:
314  Setload.has_str(profiles_keys(Json.get("profiles", raw)), name)
315
316# ---------------------------------------------------------------- updating
317
318type NumRes is Data:
319  NBad{msg: String}
320  NSet{t: Maybe<&2, String>}
321
322def num.good(+p: Setwrite.Parsed) -> NumRes:
323  match p:
324    case Setwrite.Bad{m}:
325      NBad{m}
326    case Setwrite.Good{Json.JNum{t}}:
327      NSet{Some{t}}
328    case Setwrite.Good{j}:
329      NBad{"expected a number"}
330
331def num.pick(+inherit: Bool, +key: String, +raw: String, +lo: String, +hi: String) -> NumRes:
332  match inherit:
333    case True{}:
334      NSet{None{}}
335    case False{}:
336      num.good(Setwrite.parse.number(False{}, key, raw, lo, hi))
337
338# A number key: "inherit" clears it, anything else is range-checked as
339# `cswap config set` checks the matching setting.
340def num.of(+key: String, +raw: String, +lo: String, +hi: String) -> NumRes:
341  num.pick(String.eq(raw, "inherit"), key, raw, lo, hi)
342
343def upd.th(+p: Prof, +n: NumRes) -> Upd:
344  match p n:
345    case Prof{nm, a, th, ms, cd, iv} NBad{m}:
346      UBad{m}
347    case Prof{nm, a, th, ms, cd, iv} NSet{t}:
348      UOk{Prof{nm, a, t, ms, cd, iv}}
349
350def upd.cd(+p: Prof, +n: NumRes) -> Upd:
351  match p n:
352    case Prof{nm, a, th, ms, cd, iv} NBad{m}:
353      UBad{m}
354    case Prof{nm, a, th, ms, cd, iv} NSet{t}:
355      UOk{Prof{nm, a, th, ms, t, iv}}
356
357def upd.iv(+p: Prof, +n: NumRes) -> Upd:
358  match p n:
359    case Prof{nm, a, th, ms, cd, iv} NBad{m}:
360      UBad{m}
361    case Prof{nm, a, th, ms, cd, iv} NSet{t}:
362      UOk{Prof{nm, a, th, ms, cd, t}}
363
364def upd.algo(+p: Prof, +m: Maybe<&2, SF.Algo>) -> Upd:
365  match p m:
366    case Prof{nm, a, th, ms, cd, iv} Some{x}:
367      UOk{Prof{nm, x, th, ms, cd, iv}}
368    case Prof{nm, a, th, ms, cd, iv} None{}:
369      UBad{"strategy must be one of: " ++ String.join(SF.names(), ", ")}
370
371# An empty list is an explicit "no model filter"; `inherit` follows settings.
372def models.pick(+inherit: Bool, +raw: String) -> Maybe<&2, String>:
373  match inherit:
374    case True{}:
375      None{}
376    case False{}:
377      Some{String.join(Setload.model_names(raw), ",")}
378
379def models.of(+raw: String) -> Maybe<&2, String>:
380  models.pick(String.eq(raw, "inherit"), raw)
381
382def upd.models(+p: Prof, +raw: String) -> Upd:
383  match p:
384    case Prof{nm, a, th, ms, cd, iv}:
385      UOk{Prof{nm, a, th, models.of(raw), cd, iv}}
386
387def upd.key(+k_strat: Bool, +k_thr: Bool, +k_mod: Bool, +k_cd: Bool, +k_iv: Bool, +key: String, +p: Prof, +raw: String) -> Upd:
388  match k_strat k_thr k_mod k_cd k_iv:
389    case True{} _ _ _ _:
390      upd.algo(p, SF.parse(raw))
391    case False{} True{} _ _ _:
392      upd.th(p, num.of("threshold", raw, "50", "99.9"))
393    case False{} False{} True{} _ _:
394      upd.models(p, raw)
395    case False{} False{} False{} True{} _:
396      upd.cd(p, num.of("cooldown_s", raw, "0", "86400"))
397    case False{} False{} False{} False{} True{}:
398      upd.iv(p, num.of("interval_s", raw, "15", "3600"))
399    case _ _ _ _ _:
400      UBad{"unknown profile key '" ++ key ++ "'; valid keys: " ++ String.join(keys(), ", ")}
401
402# `profile set NAME KEY VALUE` on `p`.
403def update(+p: Prof, +key: String, +raw: String) -> Upd:
404  upd.key(String.eq(key, "strategy"), String.eq(key, "threshold"), String.eq(key, "models"), String.eq(key, "cooldown_s"), String.eq(key, "interval_s"), key, p, raw)
405
406# ------------------------------------------------------------------- names
407
408def chars_ok(+s: String) -> Bool:
409  match s:
410    case SNil{}:
411      True{}
412    case SCon{c, t}:
413      (Char.is_alpha(c) || Char.is_digit(c) || Char.is_eq(c, '-') || Char.is_eq(c, '_')) && chars_ok(t)
414
415def first_ok(+s: String) -> Bool:
416  match s:
417    case SNil{}:
418      False{}
419    case SCon{c, t}:
420      Char.is_alpha(c) || Char.is_digit(c)
421
422# Letters, digits, `-` and `_`, starting with a letter or digit.
423def valid_name(+s: String) -> Bool:
424  first_ok(s) && chars_ok(s)