1 import Base 2 import ./json.bend as Json 3 import ./policy/safe.bend as SF 4 import ./daemon/config.bend as Cfg 5 import ./setload.bend as Setload 6 import ./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 23 type 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. 28 type Res is Data: 29 RNone{} 30 ROk{prof: Prof} 31 RUnknown{name: String} 32 33 type Upd is Data: 34 UBad{msg: String} 35 UOk{prof: Prof} 36 37 def keys() -> List<&2, String>: 38 ["strategy", "threshold", "models", "cooldown_s", "interval_s"] 39 40 def builtin_names() -> List<&2, String>: 41 ["default", "best", "round-robin", "soonest-reset", "sticky", "drain-first"] 42 43 def bare(+name: String, +algo: SF.Algo) -> Prof: 44 Prof{name, algo, None{}, None{}, None{}, None{}} 45 46 def 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 53 def 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 60 def builtin(+name: String) -> Maybe<&2, Prof>: 61 builtin.pick(String.eq(name, "default"), name) 62 63 def is_builtin(+name: String) -> Bool: 64 Setload.has_str(builtin_names(), name) 65 66 # ------------------------------------------------------------------ reading 67 68 def 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 75 def 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 84 def 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 93 def 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 100 def 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. 106 def 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 110 def 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 117 def stored(+raw: Json.Json, +name: String) -> Maybe<&2, Json.Json>: 118 stored_in(Json.get("profiles", raw), name) 119 120 def 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 127 def 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 134 def find(+raw: Json.Json, +name: String) -> Maybe<&2, Prof>: 135 find.pick(String.eq(name, "default"), raw, name) 136 137 def 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 144 def 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 151 def 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. 159 def names(+raw: Json.Json) -> List<&2, String>: 160 List.append(&2, String, builtin_names(), extra_names(profiles_keys(Json.get("profiles", raw)))) 161 162 def active.pick(+empty: Bool, +s: String) -> Maybe<&2, String>: 163 match empty: 164 case True{}: 165 None{} 166 case False{}: 167 Some{s} 168 169 def 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 176 def active(+raw: Json.Json) -> Maybe<&2, String>: 177 active.of(Json.get("active_profile", raw)) 178 179 def 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 186 def 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 193 def 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. 201 def resolve(+raw: Json.Json, +over: Maybe<&2, String>) -> Res: 202 resolve.name(raw, pick_name(over, active(raw))) 203 204 # ------------------------------------------------------- over the settings 205 206 def 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 213 def 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. 222 def 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. 230 def 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 237 def 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 244 def 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 251 def 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 258 def 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). 267 def 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 272 def name_of(+p: Prof) -> String: 273 match p: 274 case Prof{n, a, th, ms, cd, iv}: 275 n 276 277 def 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 284 def 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). 288 def 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 291 def 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 298 def 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. 307 def 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 310 def use(+raw: Json.Json, +name: String) -> Json.Json: 311 Json.set("active_profile", Json.JStr{name}, Setload.with_version(raw)) 312 313 def has_stored(+raw: Json.Json, +name: String) -> Bool: 314 Setload.has_str(profiles_keys(Json.get("profiles", raw)), name) 315 316 # ---------------------------------------------------------------- updating 317 318 type NumRes is Data: 319 NBad{msg: String} 320 NSet{t: Maybe<&2, String>} 321 322 def 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 331 def 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. 340 def num.of(+key: String, +raw: String, +lo: String, +hi: String) -> NumRes: 341 num.pick(String.eq(raw, "inherit"), key, raw, lo, hi) 342 343 def 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 350 def 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 357 def 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 364 def 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. 372 def 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 379 def models.of(+raw: String) -> Maybe<&2, String>: 380 models.pick(String.eq(raw, "inherit"), raw) 381 382 def 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 387 def 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`. 403 def 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 408 def 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 415 def 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. 423 def valid_name(+s: String) -> Bool: 424 first_ok(s) && chars_ok(s)