;; ported-from: src/episodes.ts @ v1.0.0 (extracted-from: bot/src/rl/episodes.ts @ fa686ee)
;;
;; Episode transitions as append-only JSONL, one file per game per month under
;; <dataDir>/episodes/. Live bots buffer in memory and flush at match end; the
;; trainer tails every bot's files, resuming from byte offsets it keeps.
;;
;; Transition shapes (KeyedTransition / FeatureTransition) are documented in
;; types/index.d.ts; the runtime treats them as opaque JSON lines.
(ns ardegazu.train.episodes
(:require ["node:fs" :as fs]
["node:path" :as path]
[shadow.cljs.modern :refer (defclass)]))
(defclass EpisodeWriter
(field dir)
(field buf)
(constructor [this data-dir]
(set! dir (path/join data-dir "episodes"))
(set! buf #js []))
Object
(add [this t]
(.push buf t)
js/undefined)
;; Write the buffered match to disk (one line per transition).
(flush [this]
(when (pos? (.-length buf))
(let [lines (str (.join (.map buf (fn [t] (js/JSON.stringify t))) "\n") "\n")
month (.slice (.toISOString (js/Date.)) 0 7)
^js head (aget buf 0)
game (.-g head)]
(set! buf #js [])
(try
(fs/mkdirSync dir #js {:recursive true})
(fs/appendFileSync (path/join dir (str game "-" month ".jsonl")) lines)
(catch :default _
;; a full disk never kills a match
nil))))
js/undefined))
;; `pending` is a getter in the TS API (w.pending, no call) — defclass has no
;; getter form, so it is attached to the prototype directly.
(js/Object.defineProperty
(.-prototype EpisodeWriter) "pending"
#js {:get (fn [] (this-as ^js self (.-length (.-buf self))))
:configurable true})
(defn read-new-transitions
"Trainer side: read new transitions from every bot's episode files."
[episode-dirs game ^js state]
(let [out #js []
offsets (.-offsets state)
prefix (str game "-")]
(dotimes [di (.-length ^js episode-dirs)]
(let [dir (aget episode-dirs di)
files (try
(.filter (fs/readdirSync dir)
(fn [f] (and (.startsWith ^js f prefix)
(.endsWith ^js f ".jsonl"))))
(catch :default _ nil))]
(when (some? files)
(dotimes [fi (.-length ^js files)]
(let [f (aget files fi)
file-path (path/join dir f)
start (let [v (unchecked-get offsets file-path)]
(if (nil? v) 0 v))
size (try
(.-size (fs/statSync file-path))
(catch :default _ nil))]
(when (and (some? size) (> size start))
(let [chunk (try
(.slice ^js (fs/readFileSync file-path "utf8") start)
(catch :default _ nil))]
(when (some? chunk)
(let [end (.lastIndexOf ^js chunk "\n")]
;; end < 0: no complete line yet
(when (>= end 0)
(let [lines (.split ^js (.slice ^js chunk 0 end) "\n")]
(dotimes [li (.-length lines)]
(let [line (aget lines li)]
(when (pos? (.-length ^js line))
(try
(let [t (js/JSON.parse line)]
(when (identical? (.-g ^js t) game)
(.push out t)))
(catch :default _
;; torn or foreign line — skip
nil))))))
(unchecked-set offsets file-path (+ (+ start end) 1))))))))))))
out))