train-kit / src / ardegazu / train / rev.cljs
  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"))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/train-kit.git