1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112 | ;; new-in: v2.1.0
;;
;; Model version strings — the label a bot advertises for the brain it plays:
;;
;; "<episodes>@<rev8>" e.g. "13208@a1b2c3d4" (no model → "stock")
;;
;; `rev8` is the first 8 lowercase hex chars of a sha256 over a deterministic
;; serialization of the model's WEIGHTS ONLY. Metadata is deliberately outside
;; the hash: touching `updatedAt` (or `episodes`, or the stored `rev` itself)
;; leaves the rev alone, while any weight change moves it. So two bots quoting
;; the same rev really are running the same weights, and a promotion visibly
;; changes the string.
;;
;; The serialization (fixed — a rev is a cross-machine, cross-process identity;
;; changing these rules is a breaking change):
;;
;; * numbers String(n) — JS shortest-round-trip doubles: 1 → "1",
;; 0.5 → "0.5", 1e21 → "1e+21", 1e-7 → "1e-7". Note -0 and
;; 0 both serialize "0" (they are also indistinguishable
;; after a JSON round trip, so this is the honest choice).
;; * an array every element as above, joined with "," (no spaces)
;; * a blob newline-terminated lines, concatenated (trailing "\n")
;; * MLP model "mlp" line 1
;; "sizes:" + sizes line 2
;; then per layer, in the net's structural order:
;; "w:" + layer.w and "b:" + layer.b
;; * tabular model "tab" line 1
;; then one line per state key, keys sorted with the default
;; Array#sort (UTF-16 code-unit order, locale-independent):
;; JSON.stringify(key) + ":" + row
;; — the key is JSON-quoted so a key containing ":" or a
;; newline can never forge a line boundary.
;;
;; Nothing here depends on JS object key order: MLP weights are walked by
;; index, tabular state keys are sorted. Same weights ⇒ same bytes ⇒ same rev,
;; on any machine, in any process.
(ns ardegazu.train.rev
(:require ["node:crypto" :as crypto]
[ardegazu.train.model :as model]))
(defn- nums
"Every element as String(n), comma-separated."
[^js arr]
(let [n (.-length arr)
out (js/Array. n)]
(dotimes [i n]
(aset out i (js/String (aget arr i))))
(.join out ",")))
(defn- weights-blob
"The deterministic weights-only serialization documented above."
[m]
(let [lines #js []]
(cond
(model/is-mlp-model m)
(let [net (.-net ^js m)
layers (.-layers ^js net)]
(.push lines "mlp")
(.push lines (str "sizes:" (nums (.-sizes ^js net))))
(dotimes [i (.-length ^js layers)]
(let [layer (aget layers i)]
(.push lines (str "w:" (nums (.-w ^js layer))))
(.push lines (str "b:" (nums (.-b ^js layer)))))))
(model/is-tabular-model m)
(let [q (.-q ^js m)
ks (.sort (js/Object.keys q))]
(.push lines "tab")
(dotimes [i (.-length ^js ks)]
(let [k (aget ks i)]
(.push lines (str (js/JSON.stringify k) ":" (nums (unchecked-get q k)))))))
:else
(throw (js/Error. "modelRev: not a structurally valid checkpoint")))
(str (.join lines "\n") "\n")))
(defn model-rev
"The model's 8-char hex weights rev — sha256 of the weights-only
serialization, first 8 lowercase hex chars. Always COMPUTED: a `rev` field
stored in a checkpoint is traceability only and is never trusted here (the
live models predate the field and have none). Throws on a value that is not
a structurally valid checkpoint."
[m]
(-> (crypto/createHash "sha256")
(.update (weights-blob m) "utf8")
(.digest "hex")
(.slice 0 8)))
(defn- labelable?
"A checkpoint this can honestly label: structurally valid, with an episode
count that is a finite, non-negative number."
[m]
(and (model/is-model m)
(let [e (.-episodes ^js m)]
(and (number? e)
^boolean (js/Number.isFinite e)
(>= e 0)))))
(defn model-version
"\"<episodes>@<rev8>\" for a model, \"stock\" for anything this cannot label
— nil/undefined (the string bots use while no promoted model exists), a
structurally invalid checkpoint, or an episode count that is not a finite
non-negative number.
Deliberately total, unlike model-rev: this is the presentation function, and
it is called from live beacon paths where a throw would take down something
that matters. \"I can't identify a brain here\" is the honest output. The
trainer/gate side wants the loud failure and calls model-rev."
[m]
(if (labelable? m)
(str (js/String (.-episodes ^js m)) "@" (model-rev m))
"stock"))
|