settings.bend

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

1import Base
2import cswap-bend-cli@0.1.0.0/fs/write.bend as FWrite
3import cswap-bend-cli@0.1.0.0/json.bend as Json
4import cswap-bend-cli@0.1.0.0/fs.bend as Fs
5import cswap-bend-cli@0.1.0.0/act.bend as Act
6import cswap-bend-cli@0.1.0.0/cmd.bend as Cmd
7
8# settings.json and `cswap config`, as the reference's settings.py and
9# cli.py `_config_command` (claude-swap 0.27.2).
10#
11# Numbers stay decimal TEXT. `Dec` is a canonical decimal (no leading zeros in
12# the integer part, no trailing zeros in the fraction); comparison is on the
13# digit strings, so clamping needs no floats. Python's float repr is rebuilt
14# from the text: `85` -> `85.0`, `85.50` -> `85.5`, `0.00001` -> `1e-05`.
15# Known gaps, all rare: `1_0` style underscores, more than 15 significant
16# digits (Python rounds to 17), and a file number such as `1.50` that the
17# reference would rewrite as `1.5` on an unrelated `set`.
18
19# ---- decimals --------------------------------------------------------------
20
21# ---- the settings registry -------------------------------------------------
22
23# ---- lenient load (settings.py `_clamped`, `load_settings`) -----------------
24
25# ---- accessors on a loaded (normalized) settings object ----------------------
26
27# ---- CLI override clamps (merged_with_cli) -----------------------------------
28
29# ---- strict write (settings.py `parse_setting_value`) -------------------------
30
31# ---- editing the raw object --------------------------------------------------
32
33# ---- effective settings and their text ----------------------------------------
34
35# ---- the config command ---------------------------------------------------------
36
37# ---- argv -----------------------------------------------------------------------
38
39type Dec is Data:
40  Dec{neg: Bool, i: String, f: String}
41
42type Exp is Data:
43  Exp{neg: Bool, n: Nat}
44
45def sel.s(c: Bool, x: String, y: String) -> String:
46  match c:
47    case True{}:
48      x
49    case False{}:
50      y
51
52def all_digits(s: String) -> Bool:
53  match s:
54    case SNil{}:
55      True{}
56    case SCon{c, t}:
57      Char.is_digit(c) && all_digits(t)
58
59def drop0.pick(z: Bool, c: Char, t: String, rest: String) -> String:
60  match z:
61    case True{}:
62      rest
63    case False{}:
64      SCon{c, t}
65
66# Leading '0' characters dropped.
67def drop0(s: String) -> String:
68  match s:
69    case SNil{}:
70      SNil{}
71    case SCon{+c, +t}:
72      drop0.pick(Char.is_eq(c, '0'), c, t, drop0(t))
73
74def canon(neg: Bool, +i: String, f: String) -> Dec:
75  Dec{neg, sel.s(String.is_empty(drop0(i)), "0", drop0(i)), String.reverse(drop0(String.reverse(f)))}
76
77# Move the point n digits right (digits come from the fraction, zeros pad).
78def shr(n: Nat, neg: Bool, i: String, f: String) -> Dec:
79  match n f:
80    case 0n _:
81      canon(neg, i, f)
82    case 1n+p SNil{}:
83      shr(p, neg, i ++ "0", "")
84    case 1n+p SCon{c, t}:
85      shr(p, neg, i ++ SCon{c, SNil{}}, t)
86
87# Move the point n digits left; `ri` is the integer part reversed.
88def shl(n: Nat, neg: Bool, ri: String, f: String) -> Dec:
89  match n ri:
90    case 0n _:
91      canon(neg, String.reverse(ri), f)
92    case 1n+p SNil{}:
93      shl(p, neg, "", "0" ++ f)
94    case 1n+p SCon{c, t}:
95      shl(p, neg, t, SCon{c, f})
96
97def shift.pick(eneg: Bool, n: Nat, neg: Bool, i: String, f: String) -> Dec:
98  match eneg:
99    case True{}:
100      shl(n, neg, String.reverse(i), f)
101    case False{}:
102      shr(n, neg, i, f)
103
104def shift(d: Dec, e: Exp) -> Dec:
105  match d e:
106    case Dec{neg, i, f} Exp{eneg, n}:
107      shift.pick(eneg, n, neg, i, f)
108
109def exp.cap2(+d: String) -> Nat:
110  U32.to_nat(U32.min(Maybe.default(&2, U32, U32.read(d), 400), 400))
111
112# The exponent digits as a Nat, capped at 400 (beyond that the value clamps
113# or vanishes the same way).
114def exp.cap(+d: String) -> Nat:
115  exp.cap2(sel.s(Nat.is_gt(String.length(d), 3n), "400", d))
116
117def exp.pick(ok: Bool, neg: Bool, d: String) -> Maybe<&2, Exp>:
118  match ok:
119    case True{}:
120      Some{Exp{neg, exp.cap(d)}}
121    case False{}:
122      None{}
123
124def exp.of3(neg: Bool, +d: String) -> Maybe<&2, Exp>:
125  exp.pick(all_digits(d) && Bool.not(String.is_empty(d)), neg, d)
126
127def exp.of2(+neg: Bool, plus: Bool, +x: String) -> Maybe<&2, Exp>:
128  exp.of3(neg, sel.s(neg || plus, String.drop(x, 1n), x))
129
130# The exponent text after `e`: an optional sign and at least one digit.
131def exp.of(+x: String) -> Maybe<&2, Exp>:
132  exp.of2(String.starts_with(x, "-"), String.starts_with(x, "+"), x)
133
134def mant.pick(ok: Bool, neg: Bool, i: String, f: String) -> Maybe<&2, Dec>:
135  match ok:
136    case True{}:
137      Some{canon(neg, i, f)}
138    case False{}:
139      None{}
140
141def mant.mk(neg: Bool, +i: String, +f: String) -> Maybe<&2, Dec>:
142  mant.pick(all_digits(i) && all_digits(f) && Bool.not(String.is_empty(i) && String.is_empty(f)), neg, i, f)
143
144def mant.parts(neg: Bool, ps: List<&2, String>) -> Maybe<&2, Dec>:
145  match ps:
146    case Con{i, Nil{}}:
147      mant.mk(neg, i, "")
148    case Con{i, Con{f, Nil{}}}:
149      mant.mk(neg, i, f)
150    case _:
151      None{}
152
153def num.join(m: Maybe<&2, Dec>, e: Maybe<&2, Exp>) -> Maybe<&2, Dec>:
154  match m e:
155    case Some{d} Some{x}:
156      Some{shift(d, x)}
157    case _ _:
158      None{}
159
160def num.parts(neg: Bool, ps: List<&2, String>) -> Maybe<&2, Dec>:
161  match ps:
162    case Con{m, Nil{}}:
163      num.join(mant.parts(neg, String.split(m, '.')), Some{Exp{False{}, 0n}})
164    case Con{m, Con{x, Nil{}}}:
165      num.join(mant.parts(neg, String.split(m, '.')), exp.of(x))
166    case _:
167      None{}
168
169def num.sign(+neg: Bool, plus: Bool, +s: String) -> Maybe<&2, Dec>:
170  num.parts(neg, String.split(String.to_lower(sel.s(neg || plus, String.drop(s, 1n), s)), 'e'))
171
172def num.trimmed(+s: String) -> Maybe<&2, Dec>:
173  num.sign(String.starts_with(s, "-"), String.starts_with(s, "+"), s)
174
175# Python's float() / JSON number text as a decimal: optional sign, digits with
176# an optional point, optional exponent; surrounding spaces ignored. Not `inf`
177# or `nan` (see `special`).
178def num(+s: String) -> Maybe<&2, Dec>:
179  num.trimmed(String.trim(s))
180
181def lit(+s: String) -> Dec:
182  Maybe.default(&2, Dec, num(s), Dec{False{}, "0", ""})
183
184def is_zero(d: Dec) -> Bool:
185  match d:
186    case Dec{neg, i, f}:
187      String.eq(i, "0") && String.is_empty(f)
188
189# a < b on magnitudes.
190def mag_lt(a: Dec, b: Dec) -> Bool:
191  match a b:
192    case Dec{na, +ia, +fa} Dec{nb, +ib, +fb}:
193      Nat.is_lt(String.length(ia), String.length(ib))
194        || (Nat.is_eq(String.length(ia), String.length(ib))
195          && (String.is_lt(ia, ib) || (String.eq(ia, ib) && String.is_lt(fa, fb))))
196
197def neg_nonzero(+d: Dec) -> Bool:
198  match d:
199    case Dec{neg, i, f}:
200      neg && Bool.not(is_zero(Dec{neg, i, f}))
201
202def below(+v: Dec, +lo: Dec) -> Bool:
203  neg_nonzero(v) || mag_lt(v, lo)
204
205def above(+v: Dec, +hi: Dec) -> Bool:
206  Bool.not(neg_nonzero(v)) && mag_lt(hi, v)
207
208def clamp.pick(b: Bool, a: Bool, v: Dec, lo: Dec, hi: Dec) -> Dec:
209  match b a:
210    case True{} _:
211      lo
212    case False{} True{}:
213      hi
214    case False{} False{}:
215      v
216
217# min(max(v, lo), hi)
218def clamp(+v: Dec, +lo: Dec, +hi: Dec) -> Dec:
219  clamp.pick(below(v, lo), above(v, hi), v, lo, hi)
220
221def lead0.pick(z: Bool, rest: Nat) -> Nat:
222  match z:
223    case True{}:
224      1n+rest
225    case False{}:
226      0n
227
228def lead0(s: String) -> Nat:
229  match s:
230    case SNil{}:
231      0n
232    case SCon{+c, +t}:
233      lead0.pick(Char.is_eq(c, '0'), lead0(t))
234
235def pad2(+n: Nat) -> String:
236  sel.s(Nat.is_lt(n, 10n), "0", "") ++ U32.show(U32.from_nat(n))
237
238def sci.tail(t: String) -> String:
239  match t:
240    case SNil{}:
241      ""
242    case SCon{h, r}:
243      "." ++ SCon{h, r}
244
245def sci.mant(sig: String) -> String:
246  match sig:
247    case SNil{}:
248      "0"
249    case SCon{h, r}:
250      SCon{h, sci.tail(r)}
251
252# 1e-05 style, for 0 < value < 1e-4 (the fraction has four leading zeros).
253def sci(neg: Bool, +f: String) -> String:
254  sel.s(neg, "-", "") ++ sci.mant(drop0(f)) ++ "e-" ++ pad2(1n+lead0(f))
255
256def repr.pick(tiny: Bool, neg: Bool, i: String, +f: String) -> String:
257  match tiny:
258    case True{}:
259      sci(neg, f)
260    case False{}:
261      sel.s(neg, "-", "") ++ i ++ "." ++ sel.s(String.is_empty(f), "0", f)
262
263# Python's repr(float) of the decimal.
264def repr(d: Dec) -> String:
265  match d:
266    case Dec{neg, +i, +f}:
267      repr.pick(String.eq(i, "0") && String.starts_with(f, "0000"), neg, i, f)
268
269def fmt.pick(whole: Bool, d: Dec) -> String:
270  match d:
271    case Dec{neg, +i, +f}:
272      sel.s(whole, i, repr(Dec{neg, i, f}))
273
274# format_setting_value for a float: `85.0` prints as `85`.
275def fmt(d: Dec) -> String:
276  match d:
277    case Dec{neg, i, +f}:
278      fmt.pick(String.is_empty(f), Dec{neg, i, f})
279
280# The integer part times ten plus the first fraction digit (tenths, floored).
281def tenths.fin(i: String, f: String) -> U32:
282  (Maybe.default(&2, U32, U32.read(i), 0) * 10 + Maybe.default(&2, U32, U32.read(String.take(f, 1n)), 0) : U32)
283
284def tenths.dec(d: Dec) -> U32:
285  match d:
286    case Dec{neg, i, f}:
287      tenths.fin(i, f)
288
289# Tenths of a decimal text (floored); 0 for text that is not a number.
290def tenths(s: String) -> U32:
291  tenths.dec(lit(s))
292
293type Sort is Data:
294  KFloat{}
295  KInt{}
296  KBool{}
297  KChoice{}
298  KString{}
299
300# `lo`/`hi` are decimal text exactly as the reference prints them; `choices`
301# is comma separated; `dflt` is the default as a JSON value.
302type Spec is Data:
303  Spec{section: String, key: String, kind: Sort, lo: String, hi: String, choices: String, dflt: Json.Json}
304
305def spec.num(key: String, kind: Sort, lo: String, hi: String, dflt: String) -> Spec:
306  Spec{"autoswitch", key, kind, lo, hi, "", Json.JNum{dflt}}
307
308# Registry order, as SETTING_SPECS.
309def specs() -> List<&2, Spec>:
310  [spec.num("threshold", KFloat{}, "50", "99.9", "90.0"),
311   spec.num("intervalSeconds", KFloat{}, "15", "3600", "60.0"),
312   spec.num("cooldownSeconds", KFloat{}, "0", "86400", "300.0"),
313   spec.num("hysteresisPct", KFloat{}, "0", "50", "10.0"),
314   Spec{"autoswitch", "strategy", KChoice{}, "", "", "best,consume-first", Json.JStr{"best"}},
315   Spec{"autoswitch", "includeApiKeyAccounts", KBool{}, "", "", "", Json.JBool{False{}}},
316   spec.num("unhealthyTicks", KInt{}, "1", "100", "3"),
317   Spec{"autoswitch", "model", KString{}, "", "", "", Json.JNull{}},
318   Spec{"ui", "theme", KChoice{}, "", "", "dark,light,auto", Json.JStr{"auto"}}]
319
320def dotted(+sp: Spec) -> String:
321  match sp:
322    case Spec{section, key, kind, lo, hi, choices, dflt}:
323      section ++ "." ++ key
324
325def find.pick(hit: Bool, sp: Spec, rest: Maybe<&2, Spec>) -> Maybe<&2, Spec>:
326  match hit:
327    case True{}:
328      Some{sp}
329    case False{}:
330      rest
331
332def find(+key: String, xs: List<&2, Spec>) -> Maybe<&2, Spec>:
333  match xs:
334    case Nil{}:
335      None{}
336    case Con{+h, t}:
337      find.pick(String.eq(dotted(h), key), h, find(key, t))
338
339def dotted_all(xs: List<&2, Spec>) -> List<&2, String>:
340  match xs:
341    case Nil{}:
342      []
343    case Con{+h, t}:
344      dotted(h) <> dotted_all(t)
345
346# setting_spec: the spec of a dotted key, or the reference's error text.
347def spec_of.pick(m: Maybe<&2, Spec>, key: String) -> Result<&2, &2, String, Spec>:
348  match m:
349    case Some{sp}:
350      Done{sp}
351    case None{}:
352      Fail{"unknown setting '" ++ key ++ "'\nValid keys: " ++ String.join(dotted_all(specs()), ", ")}
353
354def spec_of(+key: String) -> Result<&2, &2, String, Spec>:
355  spec_of.pick(find(key, specs()), key)