;; The trainer: the only process that ever updates weights. One run ingests
;; the live bots' episode logs, plays fast gym self-play (peer-kit's pure
;; sims — thousands of episodes a minute, no networking), and atomically
;; rewrites the shared checkpoints the live bots reload.
;;
;; This is a skeleton — no games train yet. Filling it in is stage §5 of
;; docs/NEW-BOT-PROMPT.md, which delegates each brain to train-kit's
;; NEW-BRAIN-PROMPT.md. The game-agnostic core (forward pass, episode
;; ingestion, checkpoint/gate file logic, tf fit helpers) is all in
;; ardegazu-train-kit; what lands HERE is game knowledge — per-game
;; collection loops, evals and promotion thresholds. bot.git's
;; src/bot/rl/train.cljs is the full worked version. Note tfjs-node is an
;; optional peer of train-kit behind a dynamic import (`loadTf`) — install
;; @tensorflow/tfjs-node only once a net-fitting game arrives; tabular
;; games never need it.
;;
;; The require menu once games train:
;; ["ardegazu-peer-kit" :refer (SeanceGym NeonGridGym ValleyBlocksGym mulberry32)]
;; ["ardegazu-train-kit" :refer (readNewTransitions ; exactly-once ingestion (offsets)
;; ensureRow qUpdate ; tabular Q
;; netQs dqnTargets fitNet ; net fitting (lazy tfjs-node)
;; capReplay
;; resolveModel gatePromotion ; checkpoint discipline
;; expandDirs loadIngestState saveIngestState
;; mlpInit)]
;;
;; node dist/rl/train.js --config /etc/ardegazu-__NAME__/trainer.json
;; [--game <name>] [--gym-only] [--ingest-only]
(ns __NAME__.rl.train
(:require ["node:fs" :as fs]
["node:path" :as path]))
(def ^:private DEFAULTS
#js {:modelDir "/var/lib/ardegazu-__NAME__/models"
;; Episode dirs; a single "*" path segment globs (per-bot state dirs).
:episodeDirs #js ["/var/lib/ardegazu-__NAME__/*/episodes"]
;; TODO: one hyperparameter block per learned game, e.g.
;; :myGame #js {:gymEpisodes 400 :players 2 :gamma 0.95 …}
})
;; CLI contract (kept stable so the systemd unit and NEW-BRAIN-PROMPT flows
;; apply unchanged): --config <path> --game <name> --gym-only --ingest-only
(def ^:private argv (.slice js/process.argv 2))
(defn- flag? [name] (.includes argv (str "--" name)))
(defn- opt [name]
(let [i (.indexOf argv (str "--" name))]
(if (>= i 0) (aget argv (inc i)) js/undefined)))
(defn- load-trainer-config [cfg-path]
(let [^js file-cfg (if (some? cfg-path)
(js/JSON.parse (fs/readFileSync cfg-path "utf8"))
#js {})]
(js/Object.assign #js {} DEFAULTS file-cfg)))
(defn- run []
(let [^js cfg (load-trainer-config (opt "config"))
only-game (opt "game")]
(js/console.log
(str "trainer: modelDir=" (.-modelDir cfg)
" game=" (if (some? only-game) only-game "all")
(if (flag? "gym-only") " (gym only)" "")
(if (flag? "ingest-only") " (ingest only)" "")))
;; TODO per learned game: a train-<game> fn that
;; 1. ingests live transitions (readNewTransitions — offsets, exactly-once),
;; 2. runs gym self-play with the current frozen model (seat 0 learns
;; ε-greedy; other seats alternate net/stock so the learner feels real
;; opposition),
;; 3. fits (dqnTargets → fitNet → capReplay, or qUpdate for tabular),
;; 4. gates greedy-net-vs-stock and promotes via gatePromotion.
;; Then dispatch here on only-game / --gym-only / --ingest-only.
(throw (js/Error. "no games registered for training yet — see docs/NEW-BOT-PROMPT.md §5"))))
(defn init
"Run only as the process entry script (`node dist/rl/train.js …`) — an
import of this module (tests, tooling) must stay side-effect free."
[]
(when (identical? (some-> (aget js/process.argv 1) (path/basename)) "train.js")
(run)))