chore: rename libs→common_libs, all bot dirs to _garage suffix, fix all path refs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Executable
BIN
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "SAC_LSTM_Bot",
|
||||
"version": "0.1.0",
|
||||
"authors": ["Davide Cappellini"],
|
||||
"description": "SAC+LSTM-trained Tank Royale bot (#37) — training/eval launch config",
|
||||
"homepage": "",
|
||||
"countryCodes": ["IT"],
|
||||
"gameTypes": ["classic", "melee", "1v1"],
|
||||
"platform": "Nim",
|
||||
"programmingLang": "Nim"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Package
|
||||
version = "0.1.0"
|
||||
author = "Davide Cappellini"
|
||||
description = "SAC+LSTM-trained Tank Royale bot"
|
||||
license = "MIT"
|
||||
srcDir = "src"
|
||||
bin = @["SAC_LSTM_Bot"]
|
||||
|
||||
# Dependencies
|
||||
requires "nim >= 2.0.0"
|
||||
# tankroyale_botapi is vendored in-tree (common_libs/tankroyale_botapi) and wired via
|
||||
# config.nims --path; no nimble dependency so builds never touch ~/.nimble/pkgs2.
|
||||
requires "arraymancer >= 0.7.0"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Launch config for tools/training_runner/RunTraining.java (#49): the runner
|
||||
# executes <json-basename>.sh inside the bot dir (sample-bots convention).
|
||||
exec "$(dirname "$0")/SAC_LSTM_Bot"
|
||||
@@ -0,0 +1,173 @@
|
||||
=== SAC_LSTM_Bot training harness ===
|
||||
Opponents: Corners:3,Crazy:2,RamFire:2,Target:1,SacTwin:1 | budget: 100 rounds in chunks of 10
|
||||
Eval: every 2 chunks, 10 rounds vs [Corners,Crazy,Target], MA-5 composite best-gating
|
||||
Weights: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/weights/sac_latest.zip
|
||||
Building SAC_LSTM_Bot/SAC_LSTM_Bot using c backend
|
||||
Info: compiling nim package using /nix/store/pq494yyknvhrvkzna9zgcqvma8fq3617-x86_64-unknown-linux-gnu-nim-wrapper-2.2.4/bin/x86_64-unknown-linux-gnu-nim
|
||||
=== Chunk 1/10: 10 rounds vs Corners ===
|
||||
Training: SAC_LSTM_Bot vs Corners for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/training_log.jsonl
|
||||
Round 1/10 — ticks:684 score:0 win:false
|
||||
Round 2/10 — ticks:631 score:0 win:false
|
||||
Round 3/10 — ticks:738 score:0 win:false
|
||||
Round 4/10 — ticks:920 score:31 win:false
|
||||
Round 5/10 — ticks:682 score:0 win:false
|
||||
Round 6/10 — ticks:650 score:0 win:false
|
||||
Round 7/10 — ticks:988 score:0 win:false
|
||||
Round 8/10 — ticks:365 score:2 win:false
|
||||
Round 9/10 — ticks:481 score:32 win:false
|
||||
Round 10/10 — ticks:703 score:16 win:false
|
||||
Counter check passed: 2291 == expected 2291
|
||||
=== Chunk 2/10: 10 rounds vs Crazy ===
|
||||
Training: SAC_LSTM_Bot vs Crazy for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/training_log.jsonl
|
||||
Round 1/10 — ticks:667 score:17 win:false
|
||||
Round 2/10 — ticks:709 score:0 win:false
|
||||
Round 3/10 — ticks:856 score:49 win:false
|
||||
Round 4/10 — ticks:794 score:2 win:false
|
||||
Round 5/10 — ticks:791 score:1 win:false
|
||||
Round 6/10 — ticks:936 score:19 win:false
|
||||
Round 7/10 — ticks:625 score:3 win:false
|
||||
Round 8/10 — ticks:977 score:21 win:false
|
||||
Round 9/10 — ticks:658 score:1 win:false
|
||||
Round 10/10 — ticks:825 score:1 win:false
|
||||
Counter check passed: 2301 == expected 2301
|
||||
>>> [eval] 10 deterministic rounds vs Corners
|
||||
Training: SAC_LSTM_Bot vs Corners for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/eval_log.jsonl.Corners.tmp
|
||||
Round 1/10 — ticks:3657 score:0 win:false
|
||||
Round 2/10 — ticks:3007 score:60 win:false
|
||||
Round 3/10 — ticks:3786 score:0 win:false
|
||||
Round 4/10 — ticks:1312 score:5 win:false
|
||||
Round 5/10 — ticks:1310 score:2 win:false
|
||||
Round 6/10 — ticks:1668 score:0 win:false
|
||||
Round 7/10 — ticks:1290 score:0 win:false
|
||||
Round 8/10 — ticks:1537 score:1 win:false
|
||||
Round 9/10 — ticks:1201 score:0 win:false
|
||||
Round 10/10 — ticks:4406 score:60 win:false
|
||||
Counter check passed: 2311 == expected 2311
|
||||
>>> [eval] win rate: 0/10 (0%) vs Corners
|
||||
>>> [eval] 10 deterministic rounds vs Crazy
|
||||
Training: SAC_LSTM_Bot vs Crazy for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/eval_log.jsonl.Crazy.tmp
|
||||
Round 1/10 — ticks:1898 score:1 win:false
|
||||
Round 2/10 — ticks:1327 score:11 win:false
|
||||
Round 3/10 — ticks:1669 score:5 win:false
|
||||
Round 4/10 — ticks:945 score:61 win:false
|
||||
Round 5/10 — ticks:1775 score:67 win:false
|
||||
Round 6/10 — ticks:1452 score:5 win:false
|
||||
Round 7/10 — ticks:1002 score:0 win:false
|
||||
Round 8/10 — ticks:2124 score:64 win:false
|
||||
Round 9/10 — ticks:1460 score:1 win:false
|
||||
Round 10/10 — ticks:935 score:61 win:false
|
||||
Counter check passed: 2321 == expected 2321
|
||||
>>> [eval] win rate: 0/10 (0%) vs Crazy
|
||||
>>> [eval] 10 deterministic rounds vs Target
|
||||
Training: SAC_LSTM_Bot vs Target for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/eval_log.jsonl.Target.tmp
|
||||
Round 1/10 — ticks:992 score:1 win:false
|
||||
Round 2/10 — ticks:1442 score:60 win:true
|
||||
Round 3/10 — ticks:1002 score:17 win:false
|
||||
Round 4/10 — ticks:1026 score:1 win:false
|
||||
Round 5/10 — ticks:992 score:7 win:false
|
||||
Round 6/10 — ticks:1422 score:60 win:false
|
||||
Round 7/10 — ticks:964 score:9 win:false
|
||||
Round 8/10 — ticks:1398 score:1 win:false
|
||||
Round 9/10 — ticks:1014 score:4 win:false
|
||||
Round 10/10 — ticks:1435 score:60 win:false
|
||||
Counter check passed: 2331 == expected 2331
|
||||
>>> [eval] win rate: 1/10 (10%) vs Target
|
||||
=== Chunk 3/10: 10 rounds vs Target ===
|
||||
Training: SAC_LSTM_Bot vs Target for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/training_log.jsonl
|
||||
Round 1/10 — ticks:1012 score:26 win:false
|
||||
Round 2/10 — ticks:653 score:0 win:false
|
||||
Round 3/10 — ticks:650 score:0 win:false
|
||||
Round 4/10 — ticks:676 score:0 win:false
|
||||
Round 5/10 — ticks:651 score:0 win:false
|
||||
Round 6/10 — ticks:931 score:22 win:false
|
||||
Round 7/10 — ticks:954 score:6 win:false
|
||||
Round 8/10 — ticks:670 score:0 win:false
|
||||
Round 9/10 — ticks:722 score:0 win:false
|
||||
Round 10/10 — ticks:897 score:21 win:false
|
||||
Counter check passed: 2341 == expected 2341
|
||||
=== Chunk 4/10: 10 rounds vs SacTwin ===
|
||||
Training: SAC_LSTM_Bot vs SacTwin for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/training_log.jsonl
|
||||
Round 1/10 — ticks:821 score:0 win:false
|
||||
Round 2/10 — ticks:837 score:3 win:false
|
||||
Round 3/10 — ticks:858 score:4 win:false
|
||||
Round 4/10 — ticks:934 score:14 win:false
|
||||
Round 5/10 — ticks:1058 score:36 win:false
|
||||
Round 6/10 — ticks:1241 score:76 win:false
|
||||
Round 7/10 — ticks:958 score:16 win:false
|
||||
Round 8/10 — ticks:940 score:33 win:false
|
||||
Round 9/10 — ticks:707 score:0 win:false
|
||||
Round 10/10 — ticks:689 score:0 win:false
|
||||
Counter check passed: 2351 == expected 2351
|
||||
>>> [eval] 10 deterministic rounds vs Corners
|
||||
Training: SAC_LSTM_Bot vs Corners for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/eval_log.jsonl.Corners.tmp
|
||||
Round 1/10 — ticks:1647 score:0 win:false
|
||||
Round 2/10 — ticks:1518 score:0 win:false
|
||||
Round 3/10 — ticks:1733 score:0 win:false
|
||||
Round 4/10 — ticks:1731 score:0 win:false
|
||||
Round 5/10 — ticks:1077 score:17 win:false
|
||||
Round 6/10 — ticks:1745 score:0 win:false
|
||||
Round 7/10 — ticks:1634 score:0 win:false
|
||||
Round 8/10 — ticks:1710 score:1 win:false
|
||||
Round 9/10 — ticks:2180 score:60 win:false
|
||||
Round 10/10 — ticks:1364 score:0 win:false
|
||||
Counter check passed: 2361 == expected 2361
|
||||
>>> [eval] win rate: 0/10 (0%) vs Corners
|
||||
>>> [eval] 10 deterministic rounds vs Crazy
|
||||
Training: SAC_LSTM_Bot vs Crazy for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/eval_log.jsonl.Crazy.tmp
|
||||
Round 1/10 — ticks:1426 score:68 win:true
|
||||
Round 2/10 — ticks:1649 score:70 win:true
|
||||
Round 3/10 — ticks:1456 score:64 win:true
|
||||
Round 4/10 — ticks:1032 score:61 win:true
|
||||
Round 5/10 — ticks:1710 score:1 win:true
|
||||
Round 6/10 — ticks:941 score:61 win:true
|
||||
Round 7/10 — ticks:1455 score:61 win:true
|
||||
Round 8/10 — ticks:1323 score:66 win:true
|
||||
Round 9/10 — ticks:1035 score:5 win:true
|
||||
Round 10/10 — ticks:1457 score:65 win:true
|
||||
Counter check passed: 2371 == expected 2371
|
||||
>>> [eval] win rate: 10/10 (100%) vs Crazy
|
||||
>>> [eval] 10 deterministic rounds vs Target
|
||||
Training: SAC_LSTM_Bot vs Target for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/eval_log.jsonl.Target.tmp
|
||||
Round 1/10 — ticks:1014 score:14 win:false
|
||||
Round 2/10 — ticks:1074 score:20 win:false
|
||||
Round 3/10 — ticks:996 score:6 win:false
|
||||
Round 4/10 — ticks:1451 score:0 win:false
|
||||
Round 5/10 — ticks:970 score:25 win:false
|
||||
Round 6/10 — ticks:1451 score:0 win:false
|
||||
Round 7/10 — ticks:1044 score:1 win:false
|
||||
Round 8/10 — ticks:1119 score:13 win:false
|
||||
Round 9/10 — ticks:1184 score:15 win:false
|
||||
Round 10/10 — ticks:1028 score:9 win:false
|
||||
Counter check passed: 2381 == expected 2381
|
||||
>>> [eval] win rate: 0/10 (0%) vs Target
|
||||
=== Chunk 5/10: 10 rounds vs Crazy ===
|
||||
Training: SAC_LSTM_Bot vs Crazy for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/training_log.jsonl
|
||||
Round 1/10 — ticks:882 score:0 win:false
|
||||
Round 2/10 — ticks:865 score:0 win:false
|
||||
Round 3/10 — ticks:908 score:0 win:false
|
||||
Round 4/10 — ticks:720 score:1 win:false
|
||||
Round 5/10 — ticks:832 score:17 win:false
|
||||
Round 6/10 — ticks:957 score:16 win:false
|
||||
Round 7/10 — ticks:723 score:3 win:false
|
||||
Round 8/10 — ticks:645 score:0 win:false
|
||||
Round 9/10 — ticks:963 score:0 win:false
|
||||
Round 10/10 — ticks:1032 score:16 win:false
|
||||
Counter check passed: 2391 == expected 2391
|
||||
=== Chunk 6/10: 10 rounds vs Corners ===
|
||||
Training: SAC_LSTM_Bot vs Corners for 10 rounds
|
||||
Log: /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot/training_log.jsonl
|
||||
Round 1/10 — ticks:818 score:0 win:false
|
||||
Round 2/10 — ticks:905 score:0 win:false
|
||||
Round 3/10 — ticks:851 score:0 win:false
|
||||
Round 4/10 — ticks:381 score:32 win:false
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
# Static-link OpenBLAS for portable deployment
|
||||
# ponytail: adjust path per machine, or use pkg-config
|
||||
switch("passL", "-L/nix/store/v07svn2y92bvzjl51aj7c9ca1cwg7rw7-openblas-0.3.32/lib -lopenblas")
|
||||
# libzip for weight checkpoint zip files
|
||||
# ponytail: nix store path; adjust per machine, or use pkg-config
|
||||
switch("passL", "-L/nix/store/wqvz31s598bvj3zb747943xhl38hjc6h-libzip-1.11.4/lib -lzip")
|
||||
switch("threads", "on")
|
||||
# Submodules import each other as SAC_LSTM_Bot/<mod>; make that resolvable for
|
||||
# the binary build too (tests already add ../src via tests/config.nims).
|
||||
switch("path", thisDir() & "/src")
|
||||
# begin Nimble config (version 2)
|
||||
when withDir(thisDir(), system.fileExists("nimble.paths")):
|
||||
include "nimble.paths"
|
||||
# end Nimble config
|
||||
# Use the repo-vendored Tank Royale bot API (common_libs/) instead of the nimble pkg.
|
||||
# Must come AFTER the nimble.paths include: later --path wins the import search.
|
||||
switch("path", thisDir() & "/../common_libs/tankroyale_botapi")
|
||||
switch("path", thisDir() & "/../common_libs/radar_lock")
|
||||
@@ -0,0 +1,98 @@
|
||||
# Implementazione Agente DRL Recurrent-SAC (SAC-GRU) Nativo per Robocode TankRoyale
|
||||
|
||||
Bot nativo basato sull'algoritmo **Recurrent Soft Actor-Critic (SAC-GRU)** addestrato per combattere nell'ambiente **Robocode TankRoyale**.
|
||||
|
||||
Il bot deve connettersi direttamente al server WebSocket di TankRoyale tramite un'interfaccia client API nativa, senza l'uso di middleware o socket intermediari extra. Il sistema deve garantire il rispetto del limite rigido di **30 ms per turno** imposto dal simulatore, eseguendo l'inferenza e l'addestramento in modo concorrente all'interno dello stesso processo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architettura Multi-Thread e Concorrenza (30 ms Tick Constraint)
|
||||
|
||||
Per evitare il fenomeno degli "skipped turns", l'applicazione deve essere suddivisa in due thread principali coordinati nello stesso processo:
|
||||
|
||||
1. **Thread 1: Realtime Async Event Loop (WebSocket Client)**
|
||||
* Riceve i messaggi di evento dal server TankRoyale via WebSocket ad ogni tick.
|
||||
* Estragga e normalizza il vettore di stato a 17 dimensioni ($S_t$).
|
||||
* Esegue l'inferenza rapida dell'Actor ($< 2 \text{ ms}$) fornendo lo stato $S_t$ e lo stato nascosto corrente della GRU ($h_{t-1}$).
|
||||
* Mappa le azioni restituite e invia immediatamente il `BotIntent` al server WebSocket.
|
||||
* Invia la transizione $(S_t, A_t, R_t, S_{t+1}, D_t)$ a una coda/canale thread-safe (`Thread-safe Channel`).
|
||||
|
||||
2. **Thread 2: Background Trainer Thread (SAC-GRU Engine)**
|
||||
* Preleva le transizioni dal canale e le accumula in un **Sequential Replay Buffer**.
|
||||
* Quando il buffer contiene un numero sufficiente di esperienze, estrae mini-batch di sequenze temporali.
|
||||
* Esegue l'addestramento in background (Forward/Backward Pass dell'Actor, dei Dual Critic e dell'Alpha autotuning) sfruttando l'accelerazione hardware (GPU/CPU).
|
||||
* Aggiorna periodicamente i pesi della rete Actor usata dal Thread 1 in modo thread-safe (es. scambio atomico di puntatori o mutua esclusione leggera).
|
||||
|
||||
---
|
||||
|
||||
## 2. Specifiche dell'Ambiente DRL (POMDP)
|
||||
|
||||
### 2.1 Vettore di Stato ($S \in \mathbb{R}^{17}$, Normalizzato in $[-1, 1]$)
|
||||
|
||||
* `s[0]`: Posizione X propria ($X / \text{width}$)
|
||||
* `s[1]`: Posizione Y propria ($Y / \text{height}$)
|
||||
* `s[2]`: Orientamento scafo ($[-\pi, \pi] / \pi$)
|
||||
* `s[3]`: Velocità lineare propria ($[-8, 8] / 8$)
|
||||
* `s[4]`: Orientamento cannone ($[-\pi, \pi] / \pi$)
|
||||
* `s[5]`: Orientamento radar ($[-\pi, \pi] / \pi$)
|
||||
* `s[6]`: Temperatura del cannone ($[0, 3] / 3$)
|
||||
* `s[7]`: Energia propria ($[0, 100] / 100$)
|
||||
* `s[8]`: Distanza dal muro NORD ($(\text{height} - Y) / \text{height}$)
|
||||
* `s[9]`: Distanza dal muro SUD ($Y / \text{height}$)
|
||||
* `s[10]`: Distanza dal muro EST ($(\text{width} - X) / \text{width}$)
|
||||
* `s[11]`: Distanza dal muro OVEST ($X / \text{width}$)
|
||||
* `s[12]`: Ultima distanza rilevata del nemico ($[0, \text{max\_dist}] / \text{max\_dist}$)
|
||||
* `s[13]`: Angolo relativo (bearing) del nemico ($[-\pi, \pi] / \pi$)
|
||||
* `s[14]`: Orientamento del nemico ($[-\pi, \pi] / \pi$)
|
||||
* `s[15]`: Velocità del nemico ($[-8, 8] / 8$)
|
||||
* `s[16]`: Energia residua del nemico ($[0, 100] / 100$)
|
||||
|
||||
### 2.2 Vettore delle Azioni Continuo ($A \in \mathbb{R}^4$, Output $[-1, 1]$)
|
||||
|
||||
* `a[0]`: Rotazione scafo $\rightarrow$ Mappato su $[-10^\circ, +10^\circ]$ per tick.
|
||||
* `a[1]`: Traslazione $\rightarrow$ Mappato su $[-8, +8]$ px/tick.
|
||||
* `a[2]`: Rotazione cannone $\rightarrow$ Mappato su $[-20^\circ, +20^\circ]$ per tick.
|
||||
* `a[3]`: Potenza di sparo $\rightarrow$ $\text{ReLU}(a_3) \times 3.0$ (Spara solo se $> 0.1$).
|
||||
|
||||
### 2.3 Reward Function
|
||||
|
||||
$$R_t = R_{\text{danno\_inflitto}} - R_{\text{danno\_subito}} + R_{\text{vittoria/sconfitta}} - R_{\text{muri}} - R_{\text{sparo\_vuoto}}$$
|
||||
|
||||
* Danno inflitto: $+ (4p + 2(p - 1))$ con $p \le 3$.
|
||||
* Danno subito: $- (4p_{\text{nemico}} + 2(p_{\text{nemico}} - 1))$.
|
||||
* Muri: $-5.0$ per tick di impatto.
|
||||
* Sparo a vuoto: $-0.1 \times p$.
|
||||
* Vittoria/Sconfitta: $+20.0$ / $-10.0$.
|
||||
|
||||
---
|
||||
|
||||
## 3. Modelli Neurali e Meccanismi di Addestramento
|
||||
|
||||
Tutte le reti devono integrare una cella ricorsiva **GRU (Gated Recurrent Unit)** per gestire la parziale osservabilità dell'arena (radar in rotazione):
|
||||
|
||||
* **Recurrent Actor Network $\pi_\phi(a_t | s_{:t}, h_{t-1})$:**
|
||||
* Feature Extractor: Linear/Dense ($17 \rightarrow 128$) + Attivazione
|
||||
* Memory: GRU Layer (Input: $128$, Hidden: $128$)
|
||||
* Heads: Linear/Dense ($128 \rightarrow 64$) $\rightarrow$ Outputs: $\mu \in \mathbb{R}^4$ e $\log \sigma \in \mathbb{R}^4$ (Clamped $[-20, 2]$)
|
||||
* Sampling: Reparameterization Trick con squashing $\tanh$.
|
||||
|
||||
* **Recurrent Dual-Critic Networks $Q_{\theta_1, \theta_2}(s_{:t}, a_{:t}, h_{t-1})$:**
|
||||
* Fusion: Concatenazione di Stato e Azione ($17 + 4 = 21$)
|
||||
* Feature Extractor: Linear/Dense ($21 \rightarrow 128$) + Attivazione
|
||||
* Memory: GRU Layer (Input: $128$, Hidden: $128$)
|
||||
* Q-Head: Linear/Dense ($128 \rightarrow 64$) $\rightarrow$ Output Q-Value scalare.
|
||||
|
||||
* **Sequential Replay Buffer & Burn-in Strategy:**
|
||||
* Campionamento di sequenze temporali contigue di lunghezza $L = 24$.
|
||||
* **Burn-in ($L_{\text{burn}} = 8$ step):** I primi 8 step vengono usati unicamente per aggiornare lo stato nascosto $h$ della GRU, senza calcolo di loss o backpropagation.
|
||||
* **Training ($L_{\text{train}} = 16$ step):** I successivi 16 step calcolano le loss dell'Actor, dei Critic e l'autotuning del parametro di temperatura $\alpha$ (con target $H_{\text{target}} = -4.0$).
|
||||
|
||||
---
|
||||
|
||||
## 4. Requisiti di Strutturazione del Codice
|
||||
|
||||
Fornisci il codice sorgente completo, modulare e pronto all'uso articolato nelle seguenti componenti:
|
||||
|
||||
1. **Model Definitions:** Classi/Strutture per Actor, Critic e Target Networks basate su GRU.
|
||||
2. **Sequential Replay Buffer:** Struttura dati thread-safe con supporto per estrazione di sequenze e gestione Burn-in.
|
||||
3. **SAC-GRU Trainer Engine:** Algoritmo di aggiornamento, calcolo loss, gradient clipping ($1.0$) e soft update ($\tau = 0.005$).
|
||||
@@ -0,0 +1,229 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1850" viewBox="0 0 1400 1850" font-family="sans-serif">
|
||||
<rect width="1400" height="1850" fill="white"/>
|
||||
<text x="700" y="32" text-anchor="middle" font-size="21" font-weight="bold">SAC-LSTM campaign dashboard - live run (current only)</text>
|
||||
<text x="700" y="56" text-anchor="middle" font-size="12" fill="#555">generated 2026-08-24 12:15:51 - auto-reloads every 60 s (open this file in Chrome)</text>
|
||||
<text x="70" y="100" font-size="15" font-weight="bold">Test matches - win % vs opponents</text>
|
||||
<text x="70" y="115" font-size="11" fill="#555">raw dots = single test matches, thick = rolling-mean-10</text>
|
||||
<text x="70" y="133" font-size="12" font-style="italic" fill="#555">What: How often the bot wins against each opponent in test battles.</text>
|
||||
<text x="70" y="149" font-size="12" font-style="italic" fill="#555">Axes: X = training progress (match number); Y = win rate, 0-100%.</text>
|
||||
<text x="70" y="165" font-size="12" font-style="italic" fill="#555">Better: higher is better.</text>
|
||||
<line x1="70" y1="784.0" x2="697" y2="784.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="664.0" x2="697" y2="664.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="544.0" x2="697" y2="544.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="424.0" x2="697" y2="424.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="304.0" x2="697" y2="304.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="184.0" x2="697" y2="184.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="784" x2="697" y2="784" stroke="black"/>
|
||||
<line x1="70" y1="784" x2="70" y2="184" stroke="black"/>
|
||||
<line x1="70.0" y1="784" x2="70.0" y2="788" stroke="black"/>
|
||||
<text x="70.0" y="801" text-anchor="middle" font-size="11">1</text>
|
||||
<line x1="697.0" y1="784" x2="697.0" y2="788" stroke="black"/>
|
||||
<text x="697.0" y="801" text-anchor="middle" font-size="11">2</text>
|
||||
<line x1="66" y1="784.0" x2="70" y2="784.0" stroke="black"/>
|
||||
<text x="63" y="788.0" text-anchor="end" font-size="11">0</text>
|
||||
<line x1="66" y1="664.0" x2="70" y2="664.0" stroke="black"/>
|
||||
<text x="63" y="668.0" text-anchor="end" font-size="11">20</text>
|
||||
<line x1="66" y1="544.0" x2="70" y2="544.0" stroke="black"/>
|
||||
<text x="63" y="548.0" text-anchor="end" font-size="11">40</text>
|
||||
<line x1="66" y1="424.0" x2="70" y2="424.0" stroke="black"/>
|
||||
<text x="63" y="428.0" text-anchor="end" font-size="11">60</text>
|
||||
<line x1="66" y1="304.0" x2="70" y2="304.0" stroke="black"/>
|
||||
<text x="63" y="308.0" text-anchor="end" font-size="11">80</text>
|
||||
<line x1="66" y1="184.0" x2="70" y2="184.0" stroke="black"/>
|
||||
<text x="63" y="188.0" text-anchor="end" font-size="11">100</text>
|
||||
<text x="383" y="817" text-anchor="middle" font-size="12">test match number (each opponent)</text>
|
||||
<text x="16" y="484" text-anchor="middle" font-size="12" transform="rotate(-90 16 484)">win rate (%)</text>
|
||||
<circle cx="70.0" cy="784.0" r="2" fill="#d62728" opacity="0.25"/>
|
||||
<circle cx="697.0" cy="784.0" r="2" fill="#d62728" opacity="0.25"/>
|
||||
<polyline points="70.0,784.0 697.0,784.0" fill="none" stroke="#d62728" stroke-width="3.5" opacity="1.0"/>
|
||||
<circle cx="70.0" cy="784.0" r="2" fill="#1f77b4" opacity="0.25"/>
|
||||
<circle cx="697.0" cy="184.0" r="2" fill="#1f77b4" opacity="0.25"/>
|
||||
<polyline points="70.0,784.0 697.0,484.0" fill="none" stroke="#1f77b4" stroke-width="3.5" opacity="1.0"/>
|
||||
<circle cx="70.0" cy="724.0" r="2" fill="#2ca02c" opacity="0.25"/>
|
||||
<circle cx="697.0" cy="784.0" r="2" fill="#2ca02c" opacity="0.25"/>
|
||||
<polyline points="70.0,724.0 697.0,754.0" fill="none" stroke="#2ca02c" stroke-width="3.5" opacity="1.0"/>
|
||||
<line x1="82" y1="836" x2="110" y2="836" stroke="#d62728" stroke-width="3"/>
|
||||
<text x="116" y="840" font-size="12">Corners - 2 evals</text>
|
||||
<line x1="82" y1="854" x2="110" y2="854" stroke="#1f77b4" stroke-width="3"/>
|
||||
<text x="116" y="858" font-size="12">Crazy - 2 evals</text>
|
||||
<line x1="82" y1="872" x2="110" y2="872" stroke="#2ca02c" stroke-width="3"/>
|
||||
<text x="116" y="876" font-size="12">Target - 2 evals</text>
|
||||
<text x="747" y="100" font-size="15" font-weight="bold">Training losses (log) & alpha (linear)</text>
|
||||
<text x="747" y="115" font-size="11" fill="#555">training_metrics.jsonl - big early spikes are normal</text>
|
||||
<text x="747" y="133" font-size="12" font-style="italic" fill="#555">What: How well the brain is learning: losses should fall; alpha sets explore/exploit.</text>
|
||||
<text x="747" y="149" font-size="12" font-style="italic" fill="#555">Axes: X = training progress; left Y (log) = losses; right Y (linear) = alpha.</text>
|
||||
<text x="747" y="165" font-size="12" font-style="italic" fill="#555">Better: lower losses = better; alpha falls over time as the bot gets confident.</text>
|
||||
<line x1="747" y1="784.0" x2="1329" y2="784.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="634.0" x2="1329" y2="634.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="484.0" x2="1329" y2="484.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="334.0" x2="1329" y2="334.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="184.0" x2="1329" y2="184.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="784" x2="1329" y2="784" stroke="black"/>
|
||||
<line x1="747" y1="784" x2="747" y2="184" stroke="black"/>
|
||||
<line x1="747.0" y1="784" x2="747.0" y2="788" stroke="black"/>
|
||||
<text x="747.0" y="801" text-anchor="middle" font-size="11">1</text>
|
||||
<line x1="892.5" y1="784" x2="892.5" y2="788" stroke="black"/>
|
||||
<text x="892.5" y="801" text-anchor="middle" font-size="11">2</text>
|
||||
<line x1="1038.0" y1="784" x2="1038.0" y2="788" stroke="black"/>
|
||||
<text x="1038.0" y="801" text-anchor="middle" font-size="11">4</text>
|
||||
<line x1="1183.5" y1="784" x2="1183.5" y2="788" stroke="black"/>
|
||||
<text x="1183.5" y="801" text-anchor="middle" font-size="11">5</text>
|
||||
<line x1="1329.0" y1="784" x2="1329.0" y2="788" stroke="black"/>
|
||||
<text x="1329.0" y="801" text-anchor="middle" font-size="11">6</text>
|
||||
<line x1="743" y1="784.0" x2="747" y2="784.0" stroke="black"/>
|
||||
<text x="740" y="788.0" text-anchor="end" font-size="11">0.01</text>
|
||||
<line x1="743" y1="634.0" x2="747" y2="634.0" stroke="black"/>
|
||||
<text x="740" y="638.0" text-anchor="end" font-size="11">0.1</text>
|
||||
<line x1="743" y1="484.0" x2="747" y2="484.0" stroke="black"/>
|
||||
<text x="740" y="488.0" text-anchor="end" font-size="11">1</text>
|
||||
<line x1="743" y1="334.0" x2="747" y2="334.0" stroke="black"/>
|
||||
<text x="740" y="338.0" text-anchor="end" font-size="11">10</text>
|
||||
<line x1="743" y1="184.0" x2="747" y2="184.0" stroke="black"/>
|
||||
<text x="740" y="188.0" text-anchor="end" font-size="11">100</text>
|
||||
<text x="1038" y="817" text-anchor="middle" font-size="12">metric line number</text>
|
||||
<text x="16" y="484" text-anchor="middle" font-size="12" transform="rotate(-90 16 484)">loss (log)</text>
|
||||
<polyline points="747.0,357.9 863.4,677.5 979.8,539.5 1096.2,560.5 1212.6,408.6 1329.0,434.1" fill="none" stroke="#1f77b4" stroke-width="1.8" opacity="1.0"/>
|
||||
<polyline points="747.0,314.8 863.4,317.5 979.8,308.2 1096.2,305.6 1212.6,281.0 1329.0,282.6" fill="none" stroke="#ff7f0e" stroke-width="1.8" opacity="1.0"/>
|
||||
<line x1="747" y1="784.0" x2="1329" y2="784.0" stroke="#e9dcf5"/>
|
||||
<line x1="747" y1="634.0" x2="1329" y2="634.0" stroke="#e9dcf5"/>
|
||||
<line x1="747" y1="484.0" x2="1329" y2="484.0" stroke="#e9dcf5"/>
|
||||
<line x1="747" y1="334.0" x2="1329" y2="334.0" stroke="#e9dcf5"/>
|
||||
<line x1="747" y1="184.0" x2="1329" y2="184.0" stroke="#e9dcf5"/>
|
||||
<line x1="1329" y1="784" x2="1329" y2="184" stroke="#9467bd"/>
|
||||
<line x1="1329" y1="784.0" x2="1333" y2="784.0" stroke="#9467bd"/>
|
||||
<text x="1336" y="788.0" font-size="11" fill="#9467bd">0</text>
|
||||
<line x1="1329" y1="634.0" x2="1333" y2="634.0" stroke="#9467bd"/>
|
||||
<text x="1336" y="638.0" font-size="11" fill="#9467bd">0.25</text>
|
||||
<line x1="1329" y1="484.0" x2="1333" y2="484.0" stroke="#9467bd"/>
|
||||
<text x="1336" y="488.0" font-size="11" fill="#9467bd">0.5</text>
|
||||
<line x1="1329" y1="334.0" x2="1333" y2="334.0" stroke="#9467bd"/>
|
||||
<text x="1336" y="338.0" font-size="11" fill="#9467bd">0.75</text>
|
||||
<line x1="1329" y1="184.0" x2="1333" y2="184.0" stroke="#9467bd"/>
|
||||
<text x="1336" y="188.0" font-size="11" fill="#9467bd">1</text>
|
||||
<text x="1346" y="484" text-anchor="middle" font-size="12" fill="#9467bd" transform="rotate(90 1346 484)">alpha</text>
|
||||
<polyline points="747.0,198.8 863.4,198.2 979.8,197.6 1096.2,196.7 1212.6,195.1 1329.0,194.2" fill="none" stroke="#9467bd" stroke-width="1.8" opacity="1.0"/>
|
||||
<line x1="759" y1="836" x2="787" y2="836" stroke="#1f77b4" stroke-width="3"/>
|
||||
<text x="793" y="840" font-size="12">critic_loss</text>
|
||||
<line x1="759" y1="854" x2="787" y2="854" stroke="#ff7f0e" stroke-width="3"/>
|
||||
<text x="793" y="858" font-size="12">|actor_loss|</text>
|
||||
<line x1="759" y1="872" x2="787" y2="872" stroke="#9467bd" stroke-width="3"/>
|
||||
<text x="793" y="876" font-size="12">alpha</text>
|
||||
<text x="70" y="940" font-size="15" font-weight="bold">Throughput - games per hour</text>
|
||||
<text x="70" y="955" font-size="11" fill="#555">method: training_metrics.jsonl 'epoch' deltas; 1 row = one 10-game chunk</text>
|
||||
<text x="70" y="973" font-size="12" font-style="italic" fill="#555">What: How many games per hour the bot trains (its learning speed).</text>
|
||||
<text x="70" y="989" font-size="12" font-style="italic" fill="#555">Axes: X = training progress (10-game chunks); Y = games per hour.</text>
|
||||
<text x="70" y="1005" font-size="12" font-style="italic" fill="#555">Better: higher = faster learning; a steady line beats a spiky one.</text>
|
||||
<line x1="70" y1="1494.0" x2="697" y2="1494.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="1376.5" x2="697" y2="1376.5" stroke="#dddddd"/>
|
||||
<line x1="70" y1="1259.0" x2="697" y2="1259.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="1141.5" x2="697" y2="1141.5" stroke="#dddddd"/>
|
||||
<line x1="70" y1="1024.0" x2="697" y2="1024.0" stroke="#dddddd"/>
|
||||
<line x1="70" y1="1494" x2="697" y2="1494" stroke="black"/>
|
||||
<line x1="70" y1="1494" x2="70" y2="1024" stroke="black"/>
|
||||
<line x1="70.0" y1="1494" x2="70.0" y2="1498" stroke="black"/>
|
||||
<text x="70.0" y="1511" text-anchor="middle" font-size="11">1</text>
|
||||
<line x1="226.8" y1="1494" x2="226.8" y2="1498" stroke="black"/>
|
||||
<text x="226.8" y="1511" text-anchor="middle" font-size="11">2</text>
|
||||
<line x1="383.5" y1="1494" x2="383.5" y2="1498" stroke="black"/>
|
||||
<text x="383.5" y="1511" text-anchor="middle" font-size="11">3</text>
|
||||
<line x1="540.2" y1="1494" x2="540.2" y2="1498" stroke="black"/>
|
||||
<text x="540.2" y="1511" text-anchor="middle" font-size="11">4</text>
|
||||
<line x1="697.0" y1="1494" x2="697.0" y2="1498" stroke="black"/>
|
||||
<text x="697.0" y="1511" text-anchor="middle" font-size="11">5</text>
|
||||
<line x1="66" y1="1494.0" x2="70" y2="1494.0" stroke="black"/>
|
||||
<text x="63" y="1498.0" text-anchor="end" font-size="11">0</text>
|
||||
<line x1="66" y1="1376.5" x2="70" y2="1376.5" stroke="black"/>
|
||||
<text x="63" y="1380.5" text-anchor="end" font-size="11">1083</text>
|
||||
<line x1="66" y1="1259.0" x2="70" y2="1259.0" stroke="black"/>
|
||||
<text x="63" y="1263.0" text-anchor="end" font-size="11">2166</text>
|
||||
<line x1="66" y1="1141.5" x2="70" y2="1141.5" stroke="black"/>
|
||||
<text x="63" y="1145.5" text-anchor="end" font-size="11">3249</text>
|
||||
<line x1="66" y1="1024.0" x2="70" y2="1024.0" stroke="black"/>
|
||||
<text x="63" y="1028.0" text-anchor="end" font-size="11">4332</text>
|
||||
<text x="383" y="1527" text-anchor="middle" font-size="12">chunk interval (10-game chunks)</text>
|
||||
<text x="16" y="1259" text-anchor="middle" font-size="12" transform="rotate(-90 16 1259)">games / hour</text>
|
||||
<circle cx="70.0" cy="1066.7" r="1.6" fill="#7f7f7f" opacity="0.25"/>
|
||||
<circle cx="226.8" cy="1467.5" r="1.6" fill="#7f7f7f" opacity="0.25"/>
|
||||
<circle cx="383.5" cy="1105.4" r="1.6" fill="#7f7f7f" opacity="0.25"/>
|
||||
<circle cx="540.2" cy="1474.6" r="1.6" fill="#7f7f7f" opacity="0.25"/>
|
||||
<circle cx="697.0" cy="1161.4" r="1.6" fill="#7f7f7f" opacity="0.25"/>
|
||||
<polyline points="70.0,1066.7 226.8,1467.5 226.8,1105.4 540.2,1474.6 540.2,1161.4" fill="none" stroke="#2ca02c" stroke-width="3.5" opacity="1.0"/>
|
||||
<text x="747" y="940" font-size="15" font-weight="bold">Max score per eval cycle</text>
|
||||
<text x="747" y="955" font-size="11" fill="#555">campaign_v4_stdout.log - max of the 10 deterministic round scores per eval</text>
|
||||
<text x="747" y="973" font-size="12" font-style="italic" fill="#555">What: Best single-round score the bot managed in each test cycle.</text>
|
||||
<text x="747" y="989" font-size="12" font-style="italic" fill="#555">Axes: X = eval cycle number; Y = best score achieved.</text>
|
||||
<text x="747" y="1005" font-size="12" font-style="italic" fill="#555">Better: higher is better; a rising trend means the bot is improving.</text>
|
||||
<line x1="747" y1="1494.0" x2="1375" y2="1494.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="1376.5" x2="1375" y2="1376.5" stroke="#dddddd"/>
|
||||
<line x1="747" y1="1259.0" x2="1375" y2="1259.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="1141.5" x2="1375" y2="1141.5" stroke="#dddddd"/>
|
||||
<line x1="747" y1="1024.0" x2="1375" y2="1024.0" stroke="#dddddd"/>
|
||||
<line x1="747" y1="1494" x2="1375" y2="1494" stroke="black"/>
|
||||
<line x1="747" y1="1494" x2="747" y2="1024" stroke="black"/>
|
||||
<line x1="747.0" y1="1494" x2="747.0" y2="1498" stroke="black"/>
|
||||
<text x="747.0" y="1511" text-anchor="middle" font-size="11">1</text>
|
||||
<line x1="1375.0" y1="1494" x2="1375.0" y2="1498" stroke="black"/>
|
||||
<text x="1375.0" y="1511" text-anchor="middle" font-size="11">2</text>
|
||||
<line x1="743" y1="1494.0" x2="747" y2="1494.0" stroke="black"/>
|
||||
<text x="740" y="1498.0" text-anchor="end" font-size="11">0</text>
|
||||
<line x1="743" y1="1376.5" x2="747" y2="1376.5" stroke="black"/>
|
||||
<text x="740" y="1380.5" text-anchor="end" font-size="11">19</text>
|
||||
<line x1="743" y1="1259.0" x2="747" y2="1259.0" stroke="black"/>
|
||||
<text x="740" y="1263.0" text-anchor="end" font-size="11">38</text>
|
||||
<line x1="743" y1="1141.5" x2="747" y2="1141.5" stroke="black"/>
|
||||
<text x="740" y="1145.5" text-anchor="end" font-size="11">58</text>
|
||||
<line x1="743" y1="1024.0" x2="747" y2="1024.0" stroke="black"/>
|
||||
<text x="740" y="1028.0" text-anchor="end" font-size="11">77</text>
|
||||
<text x="1061" y="1527" text-anchor="middle" font-size="12">eval cycle number</text>
|
||||
<text x="16" y="1259" text-anchor="middle" font-size="12" transform="rotate(-90 16 1259)">best single-round score</text>
|
||||
<circle cx="747.0" cy="1127.8" r="2" fill="#d62728" opacity="0.25"/>
|
||||
<circle cx="1375.0" cy="1127.8" r="2" fill="#d62728" opacity="0.25"/>
|
||||
<polyline points="747.0,1127.8 1375.0,1127.8" fill="none" stroke="#d62728" stroke-width="2.5" opacity="1.0"/>
|
||||
<circle cx="747.0" cy="1085.0" r="2" fill="#1f77b4" opacity="0.25"/>
|
||||
<circle cx="1375.0" cy="1066.7" r="2" fill="#1f77b4" opacity="0.25"/>
|
||||
<polyline points="747.0,1085.0 1375.0,1066.7" fill="none" stroke="#1f77b4" stroke-width="2.5" opacity="1.0"/>
|
||||
<circle cx="747.0" cy="1127.8" r="2" fill="#2ca02c" opacity="0.25"/>
|
||||
<circle cx="1375.0" cy="1341.4" r="2" fill="#2ca02c" opacity="0.25"/>
|
||||
<polyline points="747.0,1127.8 1375.0,1341.4" fill="none" stroke="#2ca02c" stroke-width="2.5" opacity="1.0"/>
|
||||
<polyline points="747.0,1085.0 1375.0,1066.7" fill="none" stroke="#555555" stroke-width="2.5" opacity="1.0" stroke-dasharray="6 4"/>
|
||||
<line x1="759" y1="1546" x2="787" y2="1546" stroke="#d62728" stroke-width="3"/>
|
||||
<text x="793" y="1550" font-size="12">Corners - 2 evals</text>
|
||||
<line x1="759" y1="1564" x2="787" y2="1564" stroke="#1f77b4" stroke-width="3"/>
|
||||
<text x="793" y="1568" font-size="12">Crazy - 2 evals</text>
|
||||
<line x1="759" y1="1582" x2="787" y2="1582" stroke="#2ca02c" stroke-width="3"/>
|
||||
<text x="793" y="1586" font-size="12">Target - 2 evals</text>
|
||||
<line x1="759" y1="1600" x2="787" y2="1600" stroke="#555555" stroke-width="3"/>
|
||||
<text x="793" y="1604" font-size="12">combined max</text>
|
||||
<rect x="0" y="1645" width="1400" height="205" fill="#f2f2f2"/>
|
||||
<text x="16" y="1666" font-size="15" font-weight="bold">Reading the signs</text>
|
||||
<text x="1384" y="1665" text-anchor="end" font-size="11" fill="#666">Regenerate anytime: python3 tools/plot_progress.py</text>
|
||||
<text x="16" y="1688" font-size="13" font-weight="bold">Healthy patterns ✅</text>
|
||||
<text x="16" y="1706" font-size="12">• Both critic and actor losses trending down over time</text>
|
||||
<text x="16" y="1720" font-size="12">• Alpha decaying slowly from ~1.0,</text>
|
||||
<text x="16" y="1734" font-size="12"> then plateauing — this is normal</text>
|
||||
<text x="16" y="1748" font-size="12">• Win rates appearing and increasing in the eval panel</text>
|
||||
<text x="16" y="1762" font-size="12">• Max scores rising in the max-score panel</text>
|
||||
<text x="16" y="1776" font-size="12">• Alpha plateauing is NOT a problem — it means</text>
|
||||
<text x="16" y="1790" font-size="12"> exploration level is stable</text>
|
||||
<text x="500" y="1688" font-size="13" font-weight="bold">Warning signs ⚠️</text>
|
||||
<text x="500" y="1706" font-size="12">• Losses exploding (suddenly jumping to</text>
|
||||
<text x="500" y="1720" font-size="12"> millions or billions)</text>
|
||||
<text x="500" y="1734" font-size="12">• Win rates staying at 0% for a long time</text>
|
||||
<text x="500" y="1748" font-size="12"> after the first ~20 eval cycles</text>
|
||||
<text x="500" y="1762" font-size="12">• Alpha reaching 0 — bot stops exploring entirely</text>
|
||||
<text x="500" y="1776" font-size="12"> (gets stuck)</text>
|
||||
<text x="500" y="1790" font-size="12">• Max scores flatlining (no improvement</text>
|
||||
<text x="500" y="1804" font-size="12"> over many eval cycles)</text>
|
||||
<text x="500" y="1818" font-size="12">• Any single loss value above 1e6</text>
|
||||
<text x="985" y="1688" font-size="13" font-weight="bold">What each metric means (brief)</text>
|
||||
<text x="985" y="1706" font-size="12">• Critic loss: how wrong the bot's value</text>
|
||||
<text x="985" y="1720" font-size="12"> estimates are — should go down</text>
|
||||
<text x="985" y="1734" font-size="12">• Actor loss: how well the bot's action policy</text>
|
||||
<text x="985" y="1748" font-size="12"> is doing — should go down overall</text>
|
||||
<text x="985" y="1762" font-size="12"> (some bumps are normal)</text>
|
||||
<text x="985" y="1776" font-size="12">• Alpha: exploration-exploitation tradeoff — starts</text>
|
||||
<text x="985" y="1790" font-size="12"> high, settles at a positive value (NOT zero)</text>
|
||||
<text x="985" y="1804" font-size="12">• Max score: best score achieved per eval cycle —</text>
|
||||
<text x="985" y="1818" font-size="12"> rising trend = learning</text>
|
||||
<script type="text/javascript"><![CDATA[ setTimeout(function(){ location.reload(); }, 60000); ]]></script>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,332 @@
|
||||
## The story so far, in simple words
|
||||
|
||||
This project trains a robot tank. It plays many fights against other tanks.
|
||||
After each fight it changes itself a little. It keeps the changes that helped it win.
|
||||
|
||||
Night 1 (run 1) finished without problems. It ran for 14 hours alone. It never crashed.
|
||||
It beat an old copy of itself most of the time. It beat Crazy about half the time.
|
||||
Three things went badly. First, learning was not stable. Good skill appeared, then disappeared again.
|
||||
Second, the saved "best" version came from one lucky perfect score. It was not really its best.
|
||||
Third, the bot learned to hide and survive. It almost never shot back.
|
||||
|
||||
The human approved five fixes. All five were put into the code.
|
||||
Run 2 used these fixes. Its error numbers grew far too big. Learning broke.
|
||||
We made one speed number smaller. This number sets how fast one part learns.
|
||||
Then we dropped the broken progress and started clean. This is run 3. It is running now.
|
||||
|
||||
Next we watch run 3. One of three doors will open.
|
||||
Door 1: it stays steady. We let it run to the end.
|
||||
Door 2: the numbers grow too big again. We turn the next speed number down.
|
||||
Door 3: it stays steady but still fights badly. We teach aiming as a separate, direct lesson.
|
||||
|
||||
Updated: 2026-08-23 — this section is refreshed at every major step.
|
||||
|
||||
## Small dictionary
|
||||
|
||||
- **training**: the time when the bot plays fights and changes itself to improve. It learns only during training.
|
||||
- **battle**: one group of fights against one opponent. The bot restarts between groups.
|
||||
- **round**: one single fight. Win it by destroying the enemy tank or outliving it.
|
||||
- **chunk**: one work block: a battle of up to 10 rounds, then some learning from it.
|
||||
- **eval (test match)**: a test match. The bot does not learn during these. We use them only to measure.
|
||||
- **win rate**: how many test matches were won, as a percent. 8 wins in 10 matches = 80%.
|
||||
- **checkpoint**: a saved copy of the bot's brain (a zip file). Written every few learning steps.
|
||||
- **"best" checkpoint**: the saved copy we currently call best. Run 1 picked one from a lucky score, hence the quotes.
|
||||
- **replay buffer**: the bot's memory of past moments: what it saw, did, and received. Learning picks old moments from it.
|
||||
- **loss (critic/actor)**: a number saying how wrong the bot's inner guesses are. Lower usually means better. Losses growing huge mean trouble.
|
||||
- **alpha**: a dial setting how much the bot tries new moves instead of repeating known good ones.
|
||||
- **MA / composite score**: MA is the average of the last few win rates; it smooths luck. Composite is the average of MAs across all test opponents.
|
||||
- **twin (SacTwin)**: a frozen copy of our own bot, used as a practice partner. Beating it proves real improvement.
|
||||
- **lever**: one numbered change we prepared, waiting for approval. There are levers 1 to 5.
|
||||
- **watchman**: a helper who checks the running training at set times and stops it if something breaks.
|
||||
|
||||
---
|
||||
|
||||
# Campaign Notebook — campaign-v1 (SAC_LSTM_Bot)
|
||||
|
||||
Overnight training campaign on branch `research/goto-controller`.
|
||||
Companion tickets: config+launch = **#56**, morning verdict = **#57**, map = **#53**.
|
||||
Monitoring contract: observability inventory in **#55 comment 461** (9 signals, thresholds, four-way discrimination).
|
||||
|
||||
## Locked config (campaign-v1)
|
||||
|
||||
Launch command (tmux session `sac_campaign`, stdout teed to `campaign_stdout.log`):
|
||||
|
||||
```bash
|
||||
SAC_OPPONENTS='Corners:3,Crazy:2,RamFire:1,Target:1,SacTwin:1' \
|
||||
SAC_EVAL_OPPONENT=Corners \
|
||||
SAC_TOTAL_ROUNDS=25000 \
|
||||
SAC_CHUNK_SIZE=10 \
|
||||
SAC_EVAL_INTERVAL=2 \
|
||||
SAC_EVAL_ROUNDS=10 \
|
||||
SAC_MAX_CRASHES=5 \
|
||||
SACLSTM_HIDDEN_SIZE=256 \
|
||||
SACLSTM_BATCH_SIZE=16 \
|
||||
SACLSTM_UTD_RATIO=1 \
|
||||
SACLSTM_SAVE_INTERVAL=5 \
|
||||
./sac_train.sh 2>&1 | tee -a campaign_stdout.log
|
||||
```
|
||||
|
||||
| Knob | Value | Source / rationale |
|
||||
|------|-------|--------------------|
|
||||
| `SAC_OPPONENTS` | `Corners:3,Crazy:2,RamFire:1,Target:1,SacTwin:1` | Working recommendation kept. Twin pinned at **1 not 2**: #54 showed mirror battles end early ⇒ fewer transitions per chunk; weight 2 would starve the replay buffer. |
|
||||
| `SAC_EVAL_OPPONENT` | `Corners` | Pinned explicitly (= first pool entry default, #54 note) — removes reorder footgun. |
|
||||
| `SAC_TOTAL_ROUNDS` | `25000` | Sized so the **wall-clock ceiling binds first**: measured throughput ~3400 rounds/h early (drops as battles lengthen) ⇒ 2000 would have exhausted in ~1 h. |
|
||||
| `SAC_CHUNK_SIZE` | `10` | Harness default. |
|
||||
| `SAC_EVAL_INTERVAL` | `2` | Harness default — eval every ~20 rounds. |
|
||||
| `SAC_EVAL_ROUNDS` | `10` | Harness default. |
|
||||
| `SAC_MAX_CRASHES` | `5` | Harness self-abort; monitor intervenes earlier at ≥3 consecutive crashes (#55). |
|
||||
| `SACLSTM_HIDDEN_SIZE` | `256` | Module default (`network.nim`); real capacity vs #49 smoke's 32; under `MaxHidden`=512 cap. |
|
||||
| `SACLSTM_BATCH_SIZE` | `16` | Module default (`integration.nim`). |
|
||||
| `SACLSTM_UTD_RATIO` | `1` | Module default. |
|
||||
| `SACLSTM_SAVE_INTERVAL` | `5` | **Deviation** from default 500 and from #54's "10–20": at hidden 256 a gradient step takes ~1 s and a chunk process fits only ~10–18 steps (see incident below) — interval must sit **inside the per-process step budget**. 5 ⇒ checkpoint every ~5–10 s of active training; IO trivial (10.5 MB zip, atomic replace). |
|
||||
| *(not pinned)* | module defaults | `LR_ACTOR/LR_CRITIC/LR_ALPHA=3e-4`, `GAMMA=0.99`, `TAU=0.005`, `TARGET_ENTROPY=-4.0`, `BUFFER_CAPACITY=500000`, `BURN_IN=8`, `TRAIN_WINDOW=16`. |
|
||||
|
||||
**Budget**: generous wall-clock **ceiling, not a deadline** — originally **T+12 h** from launch 00:21:55 CEST 2026-08-22 ⇒ 12:22 CEST (epoch 1787394115); **extended 2026-08-22 ~07:35 by orchestrator decision on human mandate** ("no deadlines — let the 25000-round budget complete", ~14:35 projected) ⇒ ceiling now **16:30 CEST 2026-08-22 (epoch 1787409000)**, enforced by a hard user-systemd net unit `sac-ceiling-net` (sleeps to the epoch, then kills the tmux session and any straggler harness processes). While HEALTHY per #55 discrimination rules the run continues; a monitor kills the tmux session at the ceiling or on an intervene threshold.
|
||||
|
||||
**Fresh start**: pre-campaign `weights/` held #49-smoke 32-hidden checkpoints, incompatible with hidden=256. Archived to `weights_smoke49_backup/`; campaign baseline re-established by probe battles vs Corners (random-init hidden-256 checkpoint, `best_score.txt` reset then re-raised to 20 by a genuine eval). Twin regenerated via `./make_twin.sh` from that baseline (md5 `61521cff…` verified seed).
|
||||
|
||||
## Phase log
|
||||
|
||||
Every entry below starts with a plain-language first sentence. Technical detail follows for those who want it.
|
||||
|
||||
- **2026-08-21 23:22** — Claim posted on #56 (comment 471). Config locked, notebook committed (`2f49cb2`).
|
||||
- **2026-08-21 23:27** — Smoke weights archived; release build; bootstrap + probe battles vs Corners established a hidden-256 baseline checkpoint (`sac_latest.zip`, 10.5 MB) and twin seed.
|
||||
- **2026-08-21 23:32** — Twin regenerated (md5-verified). **Launch attempt 1** (SAVE_INTERVAL=20, TOTAL_ROUNDS=2000): ran 16+ chunks, evals every 2 chunks — but **zero checkpoints persisted** (see incident). Killed 23:48.
|
||||
- **2026-08-21 23:52–00:10** — Diagnosis (see incident): interval=1 fired, interval=2/20 never; instrumentation + /proc thread forensics ⇒ per-process step budget ~10–18 at ~1 s/step; save check ran only between drain-burst passes.
|
||||
- **2026-08-22 00:12** — Fix: save check moved inside the gradient-step loop, committed `2653671`. Validated: interval=5 save fired ~12 s into a battle.
|
||||
- **2026-08-22 00:21:55** — **Launch (final)**: tmux `sac_campaign`, config above. First campaign save on disk at t+54 s; eval #1 on cadence.
|
||||
- **2026-08-22 00:30** — HEALTHY checklist passed (see below).
|
||||
- **2026-08-22 03:45** — Watch shift 1 (00:34–03:35): liveness flawless (10/10 HEALTHY, zero banners, zip ≤30 s). Learning signal: steady-state eval vs Corners 0–5% with two isolated 10/10 spikes (~01:15) → capability emerged, then lost. Eval-regression intervene threshold fired per #55; intervention DEFERRED to Campaign verdict (#57) — rationale: n=2 evidence, no loss metrics, buffer-loss on restart, run completes ~08:05 anyway. Milestone issue: #58 "Campaign-v1 watch: eval-regression threshold fired — intervention deferred to verdict".
|
||||
- **2026-08-22 ~07:15** — Morning audit: policy demonstrably learning off-benchmark (SacTwin 73→100%, Crazy 26→56%) while Corners eval stays ~0–9% with 3 transient 10/10s; wall-clock ceiling extended to let the 25k complete (~14:35 projected); instability-vs-plateau question left to the curve.
|
||||
- **2026-08-22 ~07:50 — score:60 anatomy**: Tank Royale survival(50)+last-survivor(10) awarded when opponent dies while we survive; exactly-60 ⇒ zero damage dealt by us that round (opponent self-destructed via wasted shots + 0.1/turn inactivity drain). 3,878 rounds (19%); modal vs SacTwin; vs Corners 986 damageless outlives vs 242 true wins; combined with 43% of rounds being score:0, texture = survivor-not-fighter against walls. RL rewards are event-driven (rewards module), so behavioral evidence, not reward poisoning. Feeds #57 levers: aggression shaping / specialist-vs-generalist.
|
||||
- **2026-08-22 ~08:05 — `.part` debris forensics + sweep**: 99 `*.zip.tmp.*.part` (483 MB) are NOT weights.nim debris (that proc uses fixed `.tmp` + finally-cleanup, working); naming matches an external write-temp→rename copier killed mid-write, bursts correlating with kill events; possible culprit: a folder-sync client fighting a file that changes every ~20 s (**human asked to confirm**). Swept with `-mmin +10` age guard (protects in-flight writes); 483 MB freed, real zips untouched — note 2 fresh `.part` reappeared minutes later, copier still active.
|
||||
- **2026-08-22 ~08:00 — ceiling defused**: the 12:22 'self-kill' was notebook prose instructing watchmen — never an OS mechanism; rewritten to 16:30 CEST AND armed a real systemd --user net `sac-ceiling-net` firing 16:30:00 (epoch 1787409000); training uninterrupted (counter +99/147 s verified); annotated #56 comment 487.
|
||||
- **2026-08-22 ~14:28 — CAMPAIGN ENDED NATURALLY**: banner `>>> training complete: 2500 chunks` after **14 h 07 m** (00:21:55 → ~14:28 CEST); round_counter **38912**; **zero crash banners across the whole run**; the `sac-ceiling-net` backstop never fired — cancelled unneeded. Budget note: the harness loop is **chunk-based** — `SAC_TOTAL_ROUNDS=25000` ÷ `CHUNK_SIZE=10` ⇒ **2500 chunk battles** of ≤10 rounds each; the "25k-rounds" label was a misnomer (the counter also accrues 1287×10 eval rounds and rerun chunks). Final eval vs Corners: 10%.
|
||||
- **2026-08-22 ~14:50 — VERDICT posted (→ #57)**: **RETUNE BEFORE SCALING.** Ops layer PROVEN (14 h autonomous, zero crashes, self-healing restarts, natural completion — the harness scales); learning REAL BUT NARROW (within-opponent gains genuine — SacTwin 90.7%, Crazy 26→48% — but specialist-not-generalist, walls untouched; 12 eval spikes ≥8/10 incl. 5×10/10, none retained); benchmark pathology: Corners-only deterministic eval + single-max best gating froze `sac_best.zip` at 01:01:57 on a fluke 10/10. Five code-level levers staged **awaiting human sign-off**; v2 NOT launched. Full rationale: Results below + #57 resolution comment.
|
||||
- **2026-08-22 ~14:50 — HYGIENE (campaign over, no live writers — safe)**: `sac-ceiling-net` stopped + `reset-failed` (backstop obsolete); `.part` corpse sweep **48 → 0** (no age guard needed — nothing writes anymore); future-run guard added to `sac_train.sh`: startup `rm -f "$WEIGHTS_DIR"/sac_latest.zip.tmp.*.part "$WEIGHTS_DIR"/sac_latest.zip.tmp` so a SIGKILLed run's libzip modify-path corpses can't accumulate again.
|
||||
|
||||
## Decision-issue index
|
||||
|
||||
| Issue | What it decided |
|
||||
|-------|-----------------|
|
||||
| #37–#48 | Bot built: skeleton, state, actions, rewards, LSTM network, weights, SAC+LSTM training, integration. |
|
||||
| #49 | Training harness + smoke run (toy hyperparams: hidden 32). |
|
||||
| #54 | Mirror-twin sparring partner; SAVE_INTERVAL persistence rule; eval opponent = first pool entry. |
|
||||
| #55 | 9-signal observability inventory; CRASHED/STALLED/SLOW-LEARNER/HEALTHY discriminators; monitor thresholds. |
|
||||
| #56 | This campaign: locked config + launch + the save-check fix (`2653671`). |
|
||||
| #57 | Morning verdict — consumes this notebook + logs. |
|
||||
|
||||
## Incidents & checks
|
||||
|
||||
### Incident 1 — zero checkpoint persistence at production sizes (launch blockers, fixed)
|
||||
|
||||
**Symptom**: campaign ran 26+ chunk processes across two attempts without a single `sac_latest.zip` update, while rounds/evals flowed normally. #54's rule ("keep `SACLSTM_SAVE_INTERVAL` well below per-chunk gradient-step counts, 10–20 fired in smokes") silently broke at hidden 256.
|
||||
|
||||
**Diagnosis chain** (all reproducible):
|
||||
1. Interval=1 saved within seconds; interval=2 and 20 never saved — through the *same* harness ⇒ not env propagation.
|
||||
2. Temporary step instrumentation (bot stderr via a one-line `SAC_LSTM_Bot.sh` redirect — the vendored runner swallows bot stderr, #55 gap S9-adjacent): steps cost **~1.06 s each**; a drain burst queued 53 steps; logging stopped mid-pass while rounds kept completing.
|
||||
3. `/proc/<pid>/task` sampling: training thread alive and RUNNING (~13 s CPU per ~40 s process) — not deadlocked, just slow ⇒ **per-process step budget ≈ 10–18 steps**.
|
||||
4. The save check lived *between* drain-burst passes; with bursts queueing minutes of steps, `stepCount` never reached `nextSave` before process teardown. Smoke runs masked this: hidden 32 steps were sub-millisecond, so hundreds of steps fit per chunk.
|
||||
|
||||
**Fix** (commit `2653671`): save check relocated **inside** the step loop (checked every gradient step; `packFull`+`trySend` unchanged). Validated: interval=5 save fires ~12 s into a battle; campaign save fired 54 s after launch.
|
||||
|
||||
**Config consequences**: `SACLSTM_SAVE_INTERVAL=5` (inside the per-process budget; #54's 10–20 was derived at smoke speeds). `SAC_TOTAL_ROUNDS=25000` (throughput measured ~3400 rounds/h, so 2000 was a 1-hour budget, not an overnight one). OMP_NUM_THREADS=1 tested and **not** needed (hang was step-budget exhaustion, not OpenMP).
|
||||
|
||||
### Launch health check (t+9 min, 00:30:16) — **HEALTHY** per #55 checklist
|
||||
|
||||
| Signal | Reading | Verdict |
|
||||
|--------|---------|---------|
|
||||
| S1 harness stdout | teed to `campaign_stdout.log`; **0** `crash`/`aborted` banners | ✓ |
|
||||
| S4 round_counter | 1890, +460 in 9 min (~51 rounds/min) | ✓ advancing |
|
||||
| S6 sac_latest.zip mtime | **6 s old**; first save at t+54 s | ✓ fresh |
|
||||
| S2 training_log.jsonl | 1492 lines, growing; last ticks=668, plausible | ✓ |
|
||||
| S3 eval_log.jsonl | age 3 s (atomic replace); eval every 2 chunks | ✓ on cadence |
|
||||
| S5 best_score | 20 (from a genuine campaign-1 eval; non-decreasing) | ✓ |
|
||||
| Sampling | 112 chunks: Corners 41 / Crazy 25 / RamFire 15 / SacTwin 16 / Target 15 ≈ weights 3:2:1:1:1 | ✓ plausible |
|
||||
| Disk | 418 GB free | ✓ |
|
||||
|
||||
Early evals 0% vs Corners — expected for a near-random policy minutes in; SLOW-LEARNER watch rule (flat ≥5 evals = watch) applies, never intervene.
|
||||
|
||||
## Check-in procedure (for monitor sessions)
|
||||
|
||||
```bash
|
||||
tmux capture-pane -p -t sac_campaign | tail -5 # S1: banners, crashes
|
||||
cat ~/Projects/SirRoboGarage/SAC_LSTM_Bot/weights/round_counter.txt
|
||||
stat -c '%Y' ~/Projects/SirRoboGarage/SAC_LSTM_Bot/weights/sac_latest.zip # age <~600s = training alive
|
||||
tail -1 ~/Projects/SirRoboGarage/SAC_LSTM_Bot/training_log.jsonl
|
||||
tail -3 ~/Projects/SirRoboGarage/SAC_LSTM_Bot/campaign_stdout.log # eval results / new best
|
||||
cat ~/Projects/SirRoboGarage/SAC_LSTM_Bot/weights/best_score.txt
|
||||
```
|
||||
|
||||
Intervene per #55 thresholds: ≥3 consecutive `crash #N` banners; ΔS4=0 over ≥15 min; zip mtime >10 min stale while S4 advances (STALLED); disk <1 GB. At the **ceiling (16:30 CEST Aug 22, epoch 1787409000 — extended from 12:22 per human mandate)**: `tmux kill-session -t sac_campaign` if still running (the hard net unit `sac-ceiling-net` fires at the same epoch regardless of monitors) — final state is in `weights/`, logs, and this notebook.
|
||||
|
||||
## Results (campaign-v1 — filled by #57)
|
||||
|
||||
**Run**: 2500/2500 chunks · **14 h 07 m** autonomous (00:21:55 → ~14:28 CEST 2026-08-22) · round_counter 38912 · **zero crashes** · graceful banner `>>> training complete: 2500 chunks` · systemd net never fired. Budget was chunk-based (see phase log) — the "25k-rounds" label was a misnomer.
|
||||
|
||||
**Per-opponent training win rates** (run-3 slice of `training_log.jsonl`):
|
||||
|
||||
| Opponent | Win rate | Record | Note |
|
||||
|----------|----------|--------|------|
|
||||
| SacTwin | **90.7%** | 2693/2970 | vs frozen past-self — genuine self-play gain |
|
||||
| Crazy | 48.3% | 2965/6140 | doubled from 26% early-run |
|
||||
| Target | 9.6% | — | static, barely moved |
|
||||
| Corners | 7.2% | — | walls untouched |
|
||||
| RamFire | **0%** | 0/2920 | mirrors the PPO-era ladder — ram-class needs dedicated pressure |
|
||||
|
||||
**Eval vs Corners (pinned benchmark)**: 1287 evals · overall mean **7.7%** · histogram headline: `0/10 = 867 (67%)`, spikes ≥8/10 = **12** (incl. **5× perfect 10/10**) · final eval 10%. Stdout log carries no timestamps; timing reconstructed from file mtimes.
|
||||
|
||||
**Best-zip paradox**: `sac_best.zip` frozen since **01:01:57** — a single lucky 10/10 at ~round 3.5k wrote `best_score=100`, and no later eval could outrank a perfect score (even genuine ~50%-winrate stretches elsewhere). Best checkpoint = lottery ticket, decoupled from the steady-state policy (which sat at 0–10% vs Corners).
|
||||
|
||||
**Verdict: RETUNE BEFORE SCALING** — full rationale in #57 resolution comment. Five code-level levers staged for human sign-off: (1) eval rotation across pool + moving-average best gating; (2) reward shaping toward damage/aggression incl. anti-ram signal; (3) training-loss/step metrics logged from the training thread (#55 gap #1); (4) gate `sendTrainingMsg` off in eval mode (#55 gap #3); (5) optional stability knobs (lower LR / entropy coeff) once loss curves exist.
|
||||
|
||||
---
|
||||
|
||||
# Campaign Notebook — campaign-v2 (SAC_LSTM_Bot)
|
||||
|
||||
## Locked config (campaign-v2)
|
||||
|
||||
Launched verbatim from orchestrator mandate (umbrella ticket #59, all five levers approved & implemented in `a07e530` + `6fc01eb`):
|
||||
|
||||
```bash
|
||||
cd /home/davide/Projects/SirRoboGarage/SAC_LSTM_Bot && \
|
||||
SAC_OPPONENTS='Corners:3,Crazy:2,RamFire:2,Target:1,SacTwin:1' \
|
||||
SAC_EVAL_OPPONENTS='Corners,Crazy,Target' \
|
||||
SAC_EVAL_INTERVAL=2 SAC_EVAL_ROUNDS=10 \
|
||||
SAC_TOTAL_ROUNDS=25000 SAC_CHUNK_SIZE=10 SAC_MAX_CRASHES=5 \
|
||||
SACLSTM_HIDDEN_SIZE=256 SACLSTM_BATCH_SIZE=16 SACLSTM_SAVE_INTERVAL=5 \
|
||||
./sac_train.sh 2>&1 | tee -a campaign_v2_stdout.log
|
||||
```
|
||||
|
||||
| Knob | Value | Rationale |
|
||||
|------|-------|-----------|
|
||||
| `SAC_OPPONENTS` | Corners:3, Crazy:2, **RamFire:2**, Target:1, SacTwin:1 | RamFire bumped 1→2 vs v1: anti-ram shaping (lever 2, `6fc01eb`) needs exposure to fire; without samples there is no gradient signal against ram-class |
|
||||
| `SAC_EVAL_OPPONENTS` | Corners,Crazy,Target | Lever-1 rotation set (default); composite = mean of per-opponent MA-5 win rates |
|
||||
| `SAC_EVAL_INTERVAL/ROUNDS` | 2 / 10 | Unchanged from v1 cadence |
|
||||
| `SAC_TOTAL_ROUNDS/CHUNK_SIZE/MAX_CRASHES` | 25000 / 10 / 5 | Same budget semantics as v1 (chunk-based) |
|
||||
| `SACLSTM_HIDDEN_SIZE/BATCH_SIZE/SAVE_INTERVAL` | 256 / 16 / 5 | Architecture + throughput knobs carried over; LR/entropy defaults untouched = **lever-5 conditional posture** (activation decided by loss-curve evidence, not upfront) |
|
||||
|
||||
## Fresh start & archive
|
||||
|
||||
v1 state archived intact (notebook references preserved) into `SAC_LSTM_Bot/weights_v1_archive/`: `sac_latest.zip`, `sac_best.zip`, `best_score.txt`, `round_counter.txt`, `training_log.jsonl`, `eval_log.jsonl`, `campaign_stdout.log`, plus the lever-3 smoke leftover `training_metrics.jsonl` (from `src/SAC_LSTM_Bot/`, moved so v2 loss curves start clean for lever-5 reading). Main `weights/` verified empty afterwards ⇒ main bot takes the genuine random-init path (`loadOrInitFull` → `randomFull()`).
|
||||
|
||||
**Twin reseed**: `make_twin.sh` requires a seed zip, but the fresh-start baseline has none. Generated a fresh random-init checkpoint (hidden=256, alpha=1.0 matching `logAlpha=0`) via a throwaway Nim script against `network.nim`/`weights.nim`, seeded it as `sac_best.zip` transiently, ran `./make_twin.sh`, removed the transient copy. Verified twin dir got byte-identical fresh zips (`cmp` OK; NOT v1 zips) + `round_counter.txt=0`. No script changes needed.
|
||||
|
||||
## Safety net
|
||||
|
||||
`systemd-run --user --unit=sac-ceiling-net-v2` armed at launch: sleeps 72000 s then `tmux kill-session -t sac_campaign_v2; sleep 5; pkill -f sac_train.sh`. Unit active at 19:59:40 CEST 2026-08-22, fires **15:59:40 CEST 2026-08-23** (epoch 1787493580).
|
||||
|
||||
## Launch & health evidence (first ~45 min)
|
||||
|
||||
Launched 20:00:19 CEST 2026-08-22 (epoch 1787421619), tmux session `sac_campaign_v2`.
|
||||
|
||||
| Check | Evidence |
|
||||
|-------|----------|
|
||||
| Round counter advances | `round_counter.txt` 95→100→465 across polls; RunTraining `Counter check passed: N == N` every chunk |
|
||||
| Metrics JSONL with scalars | `training_metrics.jsonl` growing (20 lines @ t+45m): full `{epoch, steps, buffer_size, drained, grad_steps, critic_loss, actor_loss, alpha_loss, alpha}` per line |
|
||||
| Eval rotation cycles ≥2 | All 3 opponents EVERY cycle: Corners→Crazy→Target ×4+ cycles in `eval_log.jsonl` (10 games each per cycle) |
|
||||
| MA files written | `weights/ma_history_{Corners,Crazy,Target}.txt` created at first cycle, appended since |
|
||||
| Best-gate on composite only | First write exactly when composite 0.0000 > −1 (missing-file default); later 0% cycles correctly did NOT rewrite (strict improvement enforced) |
|
||||
| No transitions during eval windows | Lever-4 gate active by construction (`sendTrainingMsg` drops all msgs under `SACLSTM_EVAL_MODE=1`, unit-tested); metrics epochs cluster at chunk boundaries |
|
||||
| Zero crash banners | `grep -c 'crash #'` = 0 through 20 chunks |
|
||||
| Sampling distribution plausible | 20 chunks: Corners 10, RamFire 5, Crazy 3, SacTwin 2, Target 0 — within small-n noise of weights (3/2/2/1/1)/9; RamFire already sampled (exposure goal met) |
|
||||
|
||||
Twin liveness: own `round_counter.txt` advancing, own `sac_latest.zip` updating during SacTwin chunks, own metrics file separate from the main bot's.
|
||||
|
||||
### Watch items (not blockers)
|
||||
|
||||
1. **Training-throughput signature**: metrics lines consistently show `steps=1, buffer_size=24 (= burnIn 8 + trainWindow 16, i.e. exact canSample threshold), drained=1` — one gradient step per pass at threshold-crossing moments rather than large drain bursts. Mechanism unexplained by static code read (per-tick sends should yield bigger bursts); v1 learned to its score-60 state under the same integration code without instrumentation, so learning is not obviously broken — but effective grad-steps/hour is THE number to check at first review. This is precisely what lever-3 instrumentation exists to surface.
|
||||
2. **Early critic-loss spikes**: two `1.56e16` outliers (t+6:16, t+12:04) amid otherwise sane values (~8–35) — same class as the random-init artifact flagged in #59 phase-2 notes (~1e12 there); expect decay. If persistent past early chunks, feeds the lever-5 decision.
|
||||
3. **`.part` corpses**: three `sac_latest.zip.tmp.*.part` files accumulated mid-run (libzip interrupted-write artifact, #57 forensics); harmless — atomic renames keep the main zips valid, startup sweep clears them next restart.
|
||||
|
||||
## ~21:30 — v2 attempt-1 checkpoint finding
|
||||
|
||||
Attempt-1's rolling zip was dead ~40 min at birth: `SACLSTM_SAVE_INTERVAL` counts **gradient steps**, and each training pass inside the short battle processes carries exactly **1 step** (the `steps=1, drained=1` signature of watch item 1) ⇒ interval-5 never reached its trigger within a process lifetime — no `sac_latest.zip` roll ever fired despite healthy training. Same class as v1's 23:32 incident, resurfacing through a different seam (per-process step budget vs per-pass step count).
|
||||
|
||||
**Fix (env-level, no rebuild)**: `SACLSTM_SAVE_INTERVAL=1`, restart 21:09:30 CEST. Saves verified flowing: 21:09 / 21:13 / 21:16 (and still flowing at audit time — `sac_latest.zip` mtime 21:22:28, `sac_best.zip` 21:12:50). The locked-config table above keeps the original attempt-1 launch line for the record; live relaunch differs only in this knob.
|
||||
|
||||
## ~21:30 — twin-freeze contract corrected
|
||||
|
||||
`make_twin.sh` twin launcher now exports `SACLSTM_EVAL_MODE=1` (commit `19f34ab`). Without lever-4's gate on the twin side, the **v1 SacTwin had been TRAINING throughout**, not frozen — every mirror battle updated the twin's own weights. Consequence: v1's headline "**90.7% vs twin**" is retroactively an **arms-race win rate** (both policies co-evolving), not a fixed-benchmark score, and the #57 verdict phrasing implying a frozen sparring partner is corrected by this entry. No numbers change; the story does.
|
||||
|
||||
## ~21:30 — net extended + pacing decision
|
||||
|
||||
Slowdown attribution complete: **89% of wall clock = eval rotation by design** (`SAC_EVAL_INTERVAL=2` × 3 opponents × 10 rounds each); trainer exonerated at **~500 ticks/s**. Decision recorded in issue **#60**.
|
||||
|
||||
Ceiling net re-armed to match the extended budget: fires **Thu 2026-08-27 07:59:40 CEST (epoch 1787810380)** — supersedes the 2026-08-23 15:59:40 fire noted under Safety net. Attempt-1 artifacts preserved in `/tmp/v2_attempt1_backup/`.
|
||||
|
||||
# Campaign v2 — attempt-3 (2026-08-23)
|
||||
|
||||
## ~06:45 — LEVER-5 TRIGGERED (watchman shift 2, 02:11–06:22 CEST)
|
||||
|
||||
Evidence-gated activation of the lever-5 conditional posture (`SACLSTM_LR_CRITIC`), human signed off. Trigger evidence:
|
||||
|
||||
| Signal | Observation |
|
||||
|--------|-------------|
|
||||
| actor_loss | \|34M\| → \|106M\| monotone (+15M/h), **no plateau** |
|
||||
| critic_loss | spikes >1e12 in **100%** of NEW metric records; campaign share 80.2% |
|
||||
| Signature | exact `1.5625e16` recurring (= float32 saturation neighborhood) |
|
||||
| alpha | decayed 0.951 → 0.711 (entropy collapse under runaway Q scale) |
|
||||
| best_score.txt | frozen 00:32 (=83.3333) through 5h50m of composite oscillation 3.3–43.3 |
|
||||
| Per-opponent MAs | whipsawing (Crazy 30→100→90→0→60→90→10→0) |
|
||||
|
||||
## DECISION — LR_CRITIC=1e-4, single knob, fresh start
|
||||
|
||||
- **Adjudication**: primary suspect is critic/Q value-scale growth. Actor loss inherits Q magnitude through the policy gradient, so the actor explosion is downstream; alpha decay is a *symptom* (entropy temperature chasing a blown value scale), not a cause. Therefore ONE knob moves: `SACLSTM_LR_CRITIC=1e-4` (critic learns slower → Q estimates stay estimable); LR_ACTOR/LR_ALPHA stay at default 3e-4, TARGET_ENTROPY −4. Multi-knob changes would confound attempt-3's attribution.
|
||||
- **Fresh start over warm start**: attempt-2's weights carry saturated Q representations; warm-starting them under a new LR would inherit the pathology we're trying to escape (contamination risk). Attempt-2 archived intact, nothing discarded.
|
||||
|
||||
## MA-wipe root cause FOUND & FIXED (`167bcc4`)
|
||||
|
||||
Watchman anomaly explained: `ma_history_*.txt` held exactly one leading-space value per file (e.g. `[ 10 ]`, `[ 20 ]`, `[ 0 ]` in the attempt-2 backup) instead of the designed 5-value history, degrading the composite best-gate to last-cycle mean — which fully accounts for the composite whipsaw 3.3–43.3.
|
||||
|
||||
Root cause: in `eval_checkpoint`, the append line was
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$(cat "$(ma_hist_file "$opp")")" "$wr" | tail -n 5 | tr '\n' ' ' > "$(ma_hist_file "$opp")"
|
||||
```
|
||||
|
||||
Bash sets up the `>` redirect **before** running the command substitution, so `$(cat f)` always read the freshly truncated file ⇒ every cycle wiped history to `" $wr "`. Reproduced standalone on bash 5.3 before touching the script; no other writer exists (grep). Fix hoists the read into its own statement and word-splits it so `tail -n 5` keeps exactly the last MA_WINDOW values. Verified live in attempt-3: two eval cycles produced `[0 0 ]` per opponent (previously impossible).
|
||||
|
||||
## Attempt-3 launch (2026-08-23 07:01:28 CEST, epoch 1787461288)
|
||||
|
||||
- Attempt-2 stopped cleanly (tmux kill; no stray procs); artifacts (76 items, 336 MB incl. `.part` corpses + MA/best/latest/counter) → `/tmp/v2_attempt2_backup/`; final round_counter **8452**; `weights/` verified empty.
|
||||
- Twin regenerated via documented transient-seed method: throwaway Nim script (`initSACTrainer(35,4)` + `saveWeights`, hidden=256 env, alpha=1.0) seeded as transient `weights/sac_best.zip` → `./make_twin.sh` → twin zips byte-identical to seed (`cmp` OK), twin counter=0 → transient removed.
|
||||
- Launch line = locked config verbatim plus the two live deltas:
|
||||
|
||||
```bash
|
||||
SAC_OPPONENTS='Corners:3,Crazy:2,RamFire:2,Target:1,SacTwin:1' \
|
||||
SAC_EVAL_OPPONENTS='Corners,Crazy,Target' \
|
||||
SAC_EVAL_INTERVAL=2 SAC_EVAL_ROUNDS=10 \
|
||||
SAC_TOTAL_ROUNDS=25000 SAC_CHUNK_SIZE=10 SAC_MAX_CRASHES=5 \
|
||||
SACLSTM_HIDDEN_SIZE=256 SACLSTM_BATCH_SIZE=16 SACLSTM_SAVE_INTERVAL=1 \
|
||||
SACLSTM_LR_CRITIC=1e-4 \
|
||||
./sac_train.sh 2>&1 | tee -a campaign_v2_stdout.log
|
||||
```
|
||||
|
||||
- Safety net: existing transient unit `sac-ceiling-net-v2.service` re-verified active; fire epoch start+384587 s = **1787810380 = Thu 2026-08-27 07:59:40 CEST exactly** (delta 0 vs mandate) — kept, not duplicated.
|
||||
|
||||
### Health baseline (t+15 m)
|
||||
|
||||
| Check | Evidence |
|
||||
|-------|----------|
|
||||
| Counter | 33 (t+8m) → **125** (t+15m) |
|
||||
| Metrics JSONL | flowing (6 lines); baseline line: `critic_loss=23.09, actor_loss=-3.228, alpha_loss=0.0, alpha=0.99970` (random-init magnitudes — attempt-3's divergence curve starts here); t+15m line: critic 84.5, actor −9.99, alpha 0.9937 |
|
||||
| Saves | `SAVE_INTERVAL=1`: `sac_latest.zip` written from first grad-step onward |
|
||||
| Eval rotation | full cycle landed: Corners/Crazy/Target ×10 games each in `eval_log.jsonl` |
|
||||
| MA files | `[0 0 ]` per opponent after two cycles — fix confirmed in vivo |
|
||||
| Best gate | `best_score.txt=0.0000` written on first composite (0 > −1 default) |
|
||||
| Crash banners | 0 |
|
||||
|
||||
|
||||
|
||||
### Progress graphs
|
||||
|
||||
One live dashboard: `docs/campaign_dashboard.svg` (current run only — test wins, real-fight wins, losses, alpha, throughput; auto-reloads every 60 s when open in Chrome). Keep it fresh with `tools/watch_dashboard.sh` (regenerates every 60 s), or one-shot `python3 tools/plot_progress.py` (pure stdlib; paths overridable via argv, `--selftest` for sanity check).
|
||||
|
||||
## ~10:47 — the dashboard's axes were upside-down since creation
|
||||
|
||||
The progress graphs have been lying since they were made: 0% was drawn at the TOP of every panel and the newest games appeared on the LEFT. The cause is a one-line formula bug in `tools/plot_progress.py`: `map_fn` interpolated as `p1 - t*(p1-p0)` instead of `p0 + t*(p1-p0)`, so all five panels plotted `100 - value` on y and reversed time on x. Tick labels were computed by separate (correct) code, which is why the numbers on the axes never matched the ink.
|
||||
|
||||
Fix + guard: formula corrected; every call site audited (panels 1/2/5 use `map_fn` for both axes and are fixed by the same line; panels 3/4 already used a correct local x-lambda; no other consumer of `map_fn` exists in the repo). `--selftest` now renders a known rising series through the full build path and fails loudly unless higher value = smaller SVG y and newer data = further right — proven to catch this exact bug when the old formula is re-injected. Dashboard regenerated from live logs.
|
||||
|
||||
Honest status while reading the now-correct charts: run 3 is only hours old and winning ~0% — recent evals are 0/10 vs Corners, Crazy and Target alike, and real-fight buckets sit at 0–1 wins per 100 games. Expected for a fresh brain. Night-1's gains were real but were intentionally reset by the stability restart that began attempt-3; the curve starts from zero again here.
|
||||
@@ -0,0 +1,30 @@
|
||||
{"type":"game","round":1,"ticks":1647,"score":0,"total_score":0,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":2,"ticks":1518,"score":0,"total_score":0,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":3,"ticks":1733,"score":0,"total_score":0,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":4,"ticks":1731,"score":0,"total_score":0,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":5,"ticks":1077,"score":17,"total_score":17,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":6,"ticks":1745,"score":0,"total_score":17,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":7,"ticks":1634,"score":0,"total_score":17,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":8,"ticks":1710,"score":1,"total_score":18,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":9,"ticks":2180,"score":60,"total_score":78,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":10,"ticks":1364,"score":0,"total_score":78,"win":false,"opponent":"Corners"}
|
||||
{"type":"game","round":1,"ticks":1426,"score":68,"total_score":68,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":2,"ticks":1649,"score":70,"total_score":138,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":3,"ticks":1456,"score":64,"total_score":202,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":4,"ticks":1032,"score":61,"total_score":263,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":5,"ticks":1710,"score":1,"total_score":264,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":6,"ticks":941,"score":61,"total_score":325,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":7,"ticks":1455,"score":61,"total_score":386,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":8,"ticks":1323,"score":66,"total_score":452,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":9,"ticks":1035,"score":5,"total_score":457,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":10,"ticks":1457,"score":65,"total_score":522,"win":true,"opponent":"Crazy"}
|
||||
{"type":"game","round":1,"ticks":1014,"score":14,"total_score":14,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":2,"ticks":1074,"score":20,"total_score":34,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":3,"ticks":996,"score":6,"total_score":40,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":4,"ticks":1451,"score":0,"total_score":40,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":5,"ticks":970,"score":25,"total_score":65,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":6,"ticks":1451,"score":0,"total_score":65,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":7,"ticks":1044,"score":1,"total_score":66,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":8,"ticks":1119,"score":13,"total_score":79,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":9,"ticks":1184,"score":15,"total_score":94,"win":false,"opponent":"Target"}
|
||||
{"type":"game","round":10,"ticks":1028,"score":9,"total_score":103,"win":false,"opponent":"Target"}
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# make_twin.sh — #54 mirror-twin sparring partner generator.
|
||||
#
|
||||
# Builds a self-contained `SacTwin` bot dir inside the sample-bots archive so
|
||||
# RunTraining.java resolves it like any sample bot ($SAMPLE_BOTS_DIR/<name>).
|
||||
# The twin is the SAME binary as SAC_LSTM_Bot but with:
|
||||
# - distinct identity (SacTwin.json; SACLSTM_BOT_JSON overrides the baked-in
|
||||
# src json — loadBotInfo gives json total precedence over env, #49 lesson)
|
||||
# - its OWN weights dir, seeded from a FROZEN copy of weights/sac_best.zip
|
||||
# (no checkpoint write races with the main bot)
|
||||
# - its OWN round_counter.txt (main liveness guard untouched)
|
||||
#
|
||||
# Re-running resets the twin to the frozen baseline (reproducible opponent).
|
||||
#
|
||||
# Usage: ./make_twin.sh [target-archive-dir]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TARGET="${1:-${SAMPLE_BOTS_DIR:-/home/davide/Projects/tank-royale/sample-bots/java/build/archive}}/SacTwin"
|
||||
BIN="$SCRIPT_DIR/SAC_LSTM_Bot" # nimble build -d:release output
|
||||
SEED="$SCRIPT_DIR/weights/sac_best.zip" # frozen baseline
|
||||
|
||||
[ -x "$BIN" ] || { echo ">>> $BIN missing — run 'nimble build -d:release' first"; exit 1; }
|
||||
[ -f "$SEED" ] || { echo ">>> $SEED missing — need at least one eval'd checkpoint"; exit 1; }
|
||||
|
||||
mkdir -p "$TARGET/weights"
|
||||
cp "$BIN" "$TARGET/SAC_LSTM_Bot"
|
||||
|
||||
cat > "$TARGET/SacTwin.json" <<EOF
|
||||
{
|
||||
"name": "SacTwin",
|
||||
"version": "0.1.0",
|
||||
"authors": ["Davide Cappellini"],
|
||||
"description": "Mirror-twin sparring partner of SAC_LSTM_Bot (#54), generated by make_twin.sh — own weights dir, frozen seed",
|
||||
"homepage": "",
|
||||
"countryCodes": ["IT"],
|
||||
"gameTypes": ["classic", "melee", "1v1"],
|
||||
"platform": "Nim",
|
||||
"programmingLang": "Nim"
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$TARGET/SacTwin.sh" <<'EOF'
|
||||
#!/bin/sh
|
||||
# Twin launcher (#54): identity + weights fully decoupled from the main bot.
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export SACLSTM_BOT_JSON="$DIR/SacTwin.json"
|
||||
export SACLSTM_WEIGHTS_PATH="$DIR/weights/sac_latest.zip"
|
||||
# Freeze contract (#54): the twin is a FROZEN sparring partner, not a
|
||||
# co-learner. Eval-mode gate (lever 4) suppresses all sendTrainingMsg traffic,
|
||||
# so the twin never trains — not even in-RAM within a battle. Without this,
|
||||
# any main-bot checkpoint-interval change would let twin drift accumulate.
|
||||
export SACLSTM_EVAL_MODE=1
|
||||
exec "$DIR/SAC_LSTM_Bot"
|
||||
EOF
|
||||
chmod +x "$TARGET/SacTwin.sh" "$TARGET/SAC_LSTM_Bot"
|
||||
|
||||
cp "$SEED" "$TARGET/weights/sac_latest.zip"
|
||||
cp "$SEED" "$TARGET/weights/sac_best.zip"
|
||||
echo 0 > "$TARGET/weights/round_counter.txt"
|
||||
echo ">>> twin ready: $TARGET (seeded from $(stat -c %y "$SEED" | cut -d. -f1) snapshot of sac_best.zip)"
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# sac_train.sh — #49 training orchestration for SAC_LSTM_Bot.
|
||||
#
|
||||
# Drives chunked self-play via tools/training_runner/RunTraining.java (which
|
||||
# owns server lifecycle, opponent connection and dead-bot liveness detection
|
||||
# through weights/round_counter.txt), samples opponents by weight per chunk,
|
||||
# runs deterministic evaluation (SACLSTM_EVAL_MODE=1) every N chunks, and keeps
|
||||
# the best checkpoint (weights/sac_best.zip) by a moving-average composite over
|
||||
# the eval opponent set (campaign v2 lever 1, #59).
|
||||
#
|
||||
# Config (env vars):
|
||||
# SAC_OPPONENTS "Name:weight,Name:weight,..." (default below)
|
||||
# SAC_TOTAL_ROUNDS total training-round budget (default 100)
|
||||
# SAC_CHUNK_SIZE rounds per RunTraining battle (default 10)
|
||||
# SAC_EVAL_INTERVAL eval every N chunks (default 2)
|
||||
# SAC_EVAL_ROUNDS rounds per evaluation battle (default 10)
|
||||
# SAC_EVAL_OPPONENTS comma-separated eval set (default Corners,Crazy,Target)
|
||||
# — each cycle evaluates EVERY one; results all land in
|
||||
# eval_log.jsonl (lines carry "opponent":"Name")
|
||||
# SAC_MAX_CRASHES consecutive crashes before abort (default 5)
|
||||
# SAC_LOG_FILE / SAC_EVAL_LOG_FILE (JSON-lines logs)
|
||||
# SACLSTM_* passed through to the bot (UTD_RATIO, BATCH_SIZE, ...)
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
RUNNER_DIR="$REPO_ROOT/tools/training_runner"
|
||||
JAR="${TANK_ROYALE_JAR:-/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar}"
|
||||
|
||||
export PPO_BOT_DIR="$SCRIPT_DIR" # runner launches THIS bot dir
|
||||
export BOT_NAME="${BOT_NAME:-SAC_LSTM_Bot}" # RunTraining result matching
|
||||
export SAMPLE_BOTS_DIR="${SAMPLE_BOTS_DIR:-/home/davide/Projects/tank-royale/sample-bots/java/build/archive}"
|
||||
# Liveness contract (#49): RunTraining.java watches $BOT_DIR/weights/round_counter.txt
|
||||
# and integration.bumpRoundCounter() writes it next to the weights — so the bot's
|
||||
# weights path is pinned here, NOT env-overridable.
|
||||
export SACLSTM_WEIGHTS_PATH="$SCRIPT_DIR/weights/sac_latest.zip"
|
||||
WEIGHTS_DIR="$(dirname "$SACLSTM_WEIGHTS_PATH")"
|
||||
|
||||
OPPONENTS="${SAC_OPPONENTS:-Corners:3,Crazy:2,RamFire:1,Target:1}"
|
||||
TOTAL_ROUNDS="${SAC_TOTAL_ROUNDS:-100}"
|
||||
CHUNK_SIZE="${SAC_CHUNK_SIZE:-10}"
|
||||
EVAL_INTERVAL="${SAC_EVAL_INTERVAL:-2}"
|
||||
EVAL_ROUNDS="${SAC_EVAL_ROUNDS:-10}"
|
||||
EVAL_OPPONENTS="${SAC_EVAL_OPPONENTS:-Corners,Crazy,Target}"
|
||||
MA_WINDOW=5 # lever 1 (#59): per-opponent moving average over last N evals
|
||||
MAX_CRASHES="${SAC_MAX_CRASHES:-5}"
|
||||
LOG_FILE="${SAC_LOG_FILE:-$SCRIPT_DIR/training_log.jsonl}"
|
||||
EVAL_LOG_FILE="${SAC_EVAL_LOG_FILE:-$SCRIPT_DIR/eval_log.jsonl}"
|
||||
CLASSES_DIR="/tmp/opencode/sac_train_classes"
|
||||
|
||||
echo "=== SAC_LSTM_Bot training harness ==="
|
||||
echo "Opponents: $OPPONENTS | budget: $TOTAL_ROUNDS rounds in chunks of $CHUNK_SIZE"
|
||||
echo "Eval: every $EVAL_INTERVAL chunks, $EVAL_ROUNDS rounds vs [$EVAL_OPPONENTS], MA-$MA_WINDOW composite best-gating"
|
||||
echo "Weights: $SACLSTM_WEIGHTS_PATH"
|
||||
|
||||
# ── compile bot + java runner ─────────────────────────────────────────────────
|
||||
(cd "$SCRIPT_DIR" && nimble build -d:release) || { echo ">>> bot build failed"; exit 1; }
|
||||
mkdir -p "$WEIGHTS_DIR" "$CLASSES_DIR"
|
||||
# Startup sweep: atomic saves leave sac_latest.zip.tmp.*.part corpses behind if
|
||||
# a run is SIGKILLed (libzip modify-path, see notebook forensics) — clear them.
|
||||
rm -f "$WEIGHTS_DIR"/sac_latest.zip.tmp.*.part "$WEIGHTS_DIR"/sac_latest.zip.tmp
|
||||
javac -cp "$JAR" -d "$CLASSES_DIR" "$RUNNER_DIR/RunTraining.java" || { echo ">>> javac failed"; exit 1; }
|
||||
|
||||
# ── weighted opponent pick over "Name:w,Name:w" ───────────────────────────────
|
||||
pick_opponent() {
|
||||
local total=0 p name w r
|
||||
local pairs
|
||||
IFS=',' read -ra pairs <<< "$OPPONENTS"
|
||||
for p in "${pairs[@]}"; do total=$(( total + ${p##*:} )); done
|
||||
r=$(( RANDOM % total ))
|
||||
for p in "${pairs[@]}"; do
|
||||
name="${p%%:*}"; w="${p##*:}"
|
||||
if (( r < w )); then echo "$name"; return; fi
|
||||
r=$(( r - w ))
|
||||
done
|
||||
echo "${pairs[0]%%:*}"
|
||||
}
|
||||
|
||||
run_battle() { # $1=opponent $2=rounds $3=log file
|
||||
PPOB_LOG_FILE="$3" java -cp "$CLASSES_DIR:$JAR" RunTraining "$1" "$2"
|
||||
}
|
||||
|
||||
# ── Lever 1 (#59): eval rotation + MA best-gating ─────────────────────────────
|
||||
# best_score.txt FORMAT CHANGE: it used to store the single-opponent integer
|
||||
# win rate (%); that semantics is retired. It now stores the COMPOSITE score —
|
||||
# the mean over SAC_EVAL_OPPONENTS of each opponent's moving average (last
|
||||
# MA_WINDOW eval win rates, %). sac_best.zip is rewritten only when the
|
||||
# composite strictly improves.
|
||||
ma_hist_file() { echo "$WEIGHTS_DIR/ma_history_$1.txt"; }
|
||||
|
||||
composite_of() { # reads one "w w w ..." history line per opponent on stdin
|
||||
awk -v W="$MA_WINDOW" '
|
||||
NF > 0 { n=NF; k=(n>W)?W:n; s=0; for(j=n-k+1;j<=n;j++) s+=$j; tot+=s/k; c++ }
|
||||
END { if (c>0) printf "%.4f", tot/c; else print "-1" }'
|
||||
}
|
||||
|
||||
eval_checkpoint() {
|
||||
# ponytail: opponent names are split by whitespace — fine for Tank Royale bot
|
||||
# names (no spaces); switch to a mapfile IFS=',\n' read if that ever changes.
|
||||
local opps=(${EVAL_OPPONENTS//,/ })
|
||||
local tmp="$EVAL_LOG_FILE.tmp" otmp opp wins rounds wr composite best
|
||||
: > "$tmp"
|
||||
for opp in "${opps[@]}"; do
|
||||
otmp="$EVAL_LOG_FILE.$opp.tmp"
|
||||
: > "$otmp"
|
||||
echo ">>> [eval] $EVAL_ROUNDS deterministic rounds vs $opp"
|
||||
if ! SACLSTM_EVAL_MODE=1 run_battle "$opp" "$EVAL_ROUNDS" "$otmp"; then
|
||||
rm -f "$otmp" "$tmp"
|
||||
echo ">>> [eval] crashed vs $opp — keeping previous best"
|
||||
return 0
|
||||
fi
|
||||
wins=$(grep -c '"win":true' "$otmp" || true)
|
||||
rounds=$(grep -c '"type":"game"' "$otmp" || true)
|
||||
if (( rounds == 0 )); then
|
||||
rm -f "$otmp" "$tmp"
|
||||
echo ">>> [eval] no results vs $opp — keeping previous best"
|
||||
return 0
|
||||
fi
|
||||
wr=$(( 100 * wins / rounds ))
|
||||
echo ">>> [eval] win rate: $wins/$rounds ($wr%) vs $opp"
|
||||
cat "$otmp" >> "$tmp"; rm -f "$otmp"
|
||||
# Per-opponent history: append this cycle's win rate, keep last MA_WINDOW
|
||||
# values on one line. Read FIRST, separately: `$(cat f)` inside a command
|
||||
# redirected `> f` executes against the already-truncated file (bash sets
|
||||
# up the redirect before running the substitution) — every cycle wiped the
|
||||
# history back to a single leading-space value (#60). $hist is UNQUOTED on
|
||||
# purpose: word-splitting turns the stored line into one value per line so
|
||||
# tail keeps the last MA_WINDOW values.
|
||||
local hist
|
||||
hist="$(cat "$(ma_hist_file "$opp")" 2>/dev/null)"
|
||||
printf '%s\n' $hist "$wr" \
|
||||
| tail -n "$MA_WINDOW" | tr '\n' ' ' > "$(ma_hist_file "$opp")"
|
||||
done
|
||||
mv "$tmp" "$EVAL_LOG_FILE"
|
||||
composite=$(for opp in "${opps[@]}"; do cat "$(ma_hist_file "$opp")"; echo; done | composite_of)
|
||||
# ponytail: best-score state is a plain file next to the checkpoint; survives
|
||||
# harness restarts, no lock needed (single harness instance assumed).
|
||||
best=$(cat "$WEIGHTS_DIR/best_score.txt" 2>/dev/null)
|
||||
[ -z "$best" ] && best=-1
|
||||
if awk -v a="$composite" -v b="$best" 'BEGIN{exit !(a+0 > b+0)}' \
|
||||
&& [ -f "$SACLSTM_WEIGHTS_PATH" ]; then
|
||||
echo "$composite" > "$WEIGHTS_DIR/best_score.txt"
|
||||
cp "$SACLSTM_WEIGHTS_PATH" "$WEIGHTS_DIR/sac_best.zip"
|
||||
echo ">>> [eval] new best composite ($composite) -> sac_best.zip"
|
||||
fi
|
||||
}
|
||||
|
||||
NUM_CHUNKS=$(( (TOTAL_ROUNDS + CHUNK_SIZE - 1) / CHUNK_SIZE ))
|
||||
fails=0
|
||||
chunk=1
|
||||
# while, not `for chunk in $(seq ...)`: a crash on the FINAL chunk must rerun
|
||||
# it (#54 — seq list is exhausted by then, so ((chunk--));continue fell through
|
||||
# and the harness exited 0 with the budget incomplete).
|
||||
while (( chunk <= NUM_CHUNKS )); do
|
||||
ROUNDS=$CHUNK_SIZE
|
||||
(( TOTAL_ROUNDS - (chunk - 1) * CHUNK_SIZE < CHUNK_SIZE )) && \
|
||||
ROUNDS=$(( TOTAL_ROUNDS - (chunk - 1) * CHUNK_SIZE ))
|
||||
OPP=$(pick_opponent)
|
||||
echo "=== Chunk $chunk/$NUM_CHUNKS: $ROUNDS rounds vs $OPP ==="
|
||||
if ! run_battle "$OPP" "$ROUNDS" "$LOG_FILE"; then
|
||||
fails=$(( fails + 1 ))
|
||||
if (( fails >= MAX_CRASHES )); then
|
||||
echo ">>> aborted: $fails consecutive crashes (bot process dying?)"
|
||||
exit 1
|
||||
fi
|
||||
# Crash recovery: RunTraining's liveness detection exited; the bot reloads
|
||||
# its latest checkpoint on restart, so just rerun this chunk.
|
||||
echo ">>> crash #$fails — restarting chunk from latest checkpoint"
|
||||
(( chunk-- )); continue
|
||||
fi
|
||||
fails=0
|
||||
(( chunk % EVAL_INTERVAL == 0 )) && eval_checkpoint
|
||||
((chunk += 1))
|
||||
done
|
||||
|
||||
echo ">>> training complete: $NUM_CHUNKS chunks. Logs:"
|
||||
echo " training: $LOG_FILE"
|
||||
echo " eval: $EVAL_LOG_FILE"
|
||||
[ -f "$WEIGHTS_DIR/sac_best.zip" ] && \
|
||||
echo " best: $WEIGHTS_DIR/sac_best.zip (composite $(cat "$WEIGHTS_DIR/best_score.txt"))"
|
||||
exit 0
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "SAC_LSTM_Bot",
|
||||
"version": "0.1.0",
|
||||
"authors": ["Davide Cappellini"],
|
||||
"description": "SAC+LSTM Tank Royale bot — self-reported identity; MUST match the name in ../SAC_LSTM_Bot.json (booter identity) or the training runner never sees this bot join",
|
||||
"homepage": "",
|
||||
"countryCodes": ["IT"],
|
||||
"gameTypes": ["classic", "melee", "1v1"],
|
||||
"platform": "Nim",
|
||||
"programmingLang": "Nim"
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
## SAC_LSTM_Bot — Recurrent SAC-v2 bot (Gitea #48).
|
||||
##
|
||||
## Thread layout (decisions Q1–Q14, see integration.nim for plumbing):
|
||||
## bot thread — this file's run(): inference only, <2ms/tick.
|
||||
## training thread — permanent background SAC updates (integration.nim).
|
||||
## I/O thread — atomic weight saves (integration.nim).
|
||||
## No Arraymancer tensor ever crosses a thread boundary: the bot object and
|
||||
## channels carry plain scalars / fixed arrays / plain seqs only.
|
||||
|
||||
import std/[os, math, random, algorithm]
|
||||
import arraymancer except Linear
|
||||
import tankroyale_botapi
|
||||
import radar_lock
|
||||
import SAC_LSTM_Bot/state
|
||||
import SAC_LSTM_Bot/network
|
||||
import SAC_LSTM_Bot/actions
|
||||
import SAC_LSTM_Bot/rewards
|
||||
import SAC_LSTM_Bot/integration
|
||||
|
||||
# Identity json: baked-in src json by default; SACLSTM_BOT_JSON lets a mirror
|
||||
# twin (#54) boot the same binary under its own name (loadBotInfo gives the
|
||||
# json total precedence over env, so the twin must point at its own file).
|
||||
let botJsonPath = getEnv("SACLSTM_BOT_JSON",
|
||||
currentSourcePath().parentDir / "SAC_LSTM_Bot.json")
|
||||
|
||||
# ── Colors (Recurrent Royalty palette) ───────────────────────────────────────
|
||||
|
||||
const
|
||||
ColBody = fromHex("#7B2FBE")
|
||||
ColTurret = fromHex("#FFD700")
|
||||
ColGun = fromHex("#4A0E6B")
|
||||
ColRadar = fromHex("#FFD700")
|
||||
ColScan = fromHex("#FFB000")
|
||||
ColBullet = fromHex("#FFC125")
|
||||
ColTracks = fromHex("#2C2C34")
|
||||
|
||||
proc applyColors() =
|
||||
setBodyColor(ColBody)
|
||||
setTurretColor(ColTurret)
|
||||
setGunColor(ColGun)
|
||||
setRadarColor(ColRadar)
|
||||
setScanColor(ColScan)
|
||||
setBulletColor(ColBullet)
|
||||
setTracksColor(ColTracks)
|
||||
|
||||
# ── Enemy bullet tracking ────────────────────────────────────────────────────
|
||||
# PPO_Bot-proven pattern: dead-reckoned fixed buffer. getBulletStates() is NOT
|
||||
# used from the bot thread — its seq refcount is shared with the main thread.
|
||||
|
||||
type InFlightBullet = object
|
||||
x, y, vx, vy, power: float64
|
||||
|
||||
const MaxBotBullets = 4
|
||||
const MaxHidden = 512 # ponytail: cap for the plain-array LSTM persistence; raise if SACLSTM_HIDDEN_SIZE > 512
|
||||
|
||||
# ── Bot type — PLAIN DATA ONLY on the shared object (no tensors/heap seqs:
|
||||
# each round runs a fresh bot thread; heap blocks owned by the previous
|
||||
# round's thread must not be freed from another thread) ─────────────────────
|
||||
|
||||
type SacBot = ref object of Bot
|
||||
enemyBearing: float # last known absolute bearing to enemy
|
||||
battleId: int # main thread bumps in onGameStarted; bot thread compares
|
||||
seenBattle: int # bot-thread copy for battle-change detection
|
||||
newBattleSent: bool # first scan of THIS battle emits NewBattle
|
||||
hasContact: bool
|
||||
enemy: EnemyData
|
||||
ticksSinceScan: int
|
||||
# per-step reward accumulators (consumed by the next tick's transition)
|
||||
dmgDealt, dmgTaken, wastedPower: float64
|
||||
wallHits, hits, ramTaken: int
|
||||
# pending transition (episode spans the whole battle; round end is NOT a boundary)
|
||||
hasLastTrans: bool
|
||||
lastState: array[STATE_DIM, float32]
|
||||
lastAction: array[ACTION_DIM, float32]
|
||||
rn: RewardNormalizer # Welford running stats, persists across battles
|
||||
bullets: array[MaxBotBullets, InFlightBullet]
|
||||
bulletCount: int
|
||||
hArr, cArr: array[MaxHidden, float32] # LSTM state across rounds; zeros at battle start
|
||||
|
||||
# ── Plain-array <-> tensor helpers (bot thread only) ──────────────────────────
|
||||
|
||||
proc stateToArr(t: Tensor[float32]): array[STATE_DIM, float32] =
|
||||
for i in 0 ..< STATE_DIM: result[i] = t[i]
|
||||
|
||||
proc actionToArr(t: Tensor[float32]): array[ACTION_DIM, float32] =
|
||||
for i in 0 ..< ACTION_DIM: result[i] = t[i]
|
||||
|
||||
proc hiddenToTensor(arr: array[MaxHidden, float32]; n: int): Tensor[float32] =
|
||||
result = newTensor[float32](n)
|
||||
for i in 0 ..< n: result[i] = arr[i]
|
||||
|
||||
proc tensorToHidden(t: Tensor[float32]; arr: var array[MaxHidden, float32]) =
|
||||
for i in 0 ..< t.shape[0]: arr[i] = t[i]
|
||||
|
||||
# ── Reward ────────────────────────────────────────────────────────────────────
|
||||
|
||||
proc takeReward(bot: SacBot; win = false, loss = false): float32 =
|
||||
## Consume accumulated step events -> Welford-normalized reward (#44).
|
||||
## Lever 2 (#59): pass enemy distance (frac of arena diagonal) for the
|
||||
## anti-charge term; sentinel 2.0 (> ChargeDistFrac) when no contact.
|
||||
var distFrac = 2.0
|
||||
if bot.hasContact:
|
||||
let diag = hypot(getArenaWidth().float64, getArenaHeight().float64)
|
||||
distFrac = hypot(bot.enemy.x - getX(), bot.enemy.y - getY()) / diag
|
||||
let raw = computeReward(
|
||||
damageInflicted = bot.dmgDealt,
|
||||
damageReceived = bot.dmgTaken,
|
||||
wallHitTicks = bot.wallHits,
|
||||
wastedShotPower = bot.wastedPower,
|
||||
hitCount = bot.hits,
|
||||
ramTakenCount = bot.ramTaken,
|
||||
enemyDistFrac = distFrac,
|
||||
win = win, loss = loss)
|
||||
# Lever-2 (#59) observability: env-gated one-liner for smoke/calibration
|
||||
# greps — proves hit/ram/charge terms fire and shows raw magnitudes. File
|
||||
# (not stderr): the battle runner swallows bot process streams.
|
||||
# ponytail: grows unbounded if left on; keep off outside smokes.
|
||||
if getEnv("SACLSTM_REWARD_DEBUG") == "1" and
|
||||
(bot.hits > 0 or bot.ramTaken > 0 or (distFrac < ChargeDistFrac and bot.dmgDealt <= 0.0)):
|
||||
try:
|
||||
let f = open(getWeightsPath().parentDir.parentDir / "reward_debug.log", fmAppend)
|
||||
f.writeLine("raw=" & $raw & " hits=" & $bot.hits & " ram=" & $bot.ramTaken &
|
||||
" distFrac=" & $distFrac)
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
bot.dmgDealt = 0; bot.dmgTaken = 0; bot.wastedPower = 0; bot.wallHits = 0
|
||||
bot.hits = 0; bot.ramTaken = 0
|
||||
let norm = rewards.normalize(bot.rn, raw)
|
||||
rewards.update(bot.rn, raw)
|
||||
norm.float32
|
||||
|
||||
# ── Event handlers ────────────────────────────────────────────────────────────
|
||||
# onGameStarted/onRoundStarted fire on the MAIN thread (bot thread not yet
|
||||
# started or already joined) — plain-field writes only, no tensors here.
|
||||
|
||||
method onGameStarted*(bot: SacBot, e: GameStartedEventForBot) =
|
||||
inc bot.battleId # bot thread zeroes LSTM + per-battle flags at next tick (Q4/Q12)
|
||||
|
||||
method onRoundStarted*(bot: SacBot, e: RoundStartedEvent) =
|
||||
setAdjustRadarForBodyTurn(true)
|
||||
setAdjustRadarForGunTurn(true)
|
||||
radar_lock.init()
|
||||
applyColors()
|
||||
# Per-round reset ONLY. NOT the LSTM hidden state (persists across rounds, Q4);
|
||||
# NOT hasLastTrans (the pending transition spans the round boundary — episode
|
||||
# ends at battle end only).
|
||||
bot.hasContact = false
|
||||
bot.enemy = EnemyData()
|
||||
bot.ticksSinceScan = 0
|
||||
bot.bulletCount = 0
|
||||
|
||||
method onScannedBot*(bot: SacBot, e: ScannedBotEvent) =
|
||||
bot.enemyBearing = directionTo(getX(), getY(), e.x, e.y)
|
||||
# Same-tick radar lock: apply turn rate immediately so it takes effect this tick.
|
||||
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
|
||||
# Fire detection: energy drop in [0.1, 3.0] between scans (PPO_Bot heuristic).
|
||||
let prevE = if bot.hasContact: bot.enemy.energy else: e.energy
|
||||
let drop = prevE - e.energy
|
||||
bot.enemy.hasFired = bot.hasContact and drop >= 0.1 and drop <= 3.0
|
||||
if bot.enemy.hasFired:
|
||||
bot.enemy.lastFirePower = drop
|
||||
# Keep previous-scan deltas before overwriting (state.nim derives accel/turn rate).
|
||||
bot.enemy.prevSpeed = bot.enemy.speed
|
||||
bot.enemy.prevDirection = bot.enemy.direction
|
||||
bot.enemy.hasPrevScan = bot.hasContact
|
||||
bot.enemy.x = e.x
|
||||
bot.enemy.y = e.y
|
||||
bot.enemy.direction = e.direction
|
||||
bot.enemy.speed = e.speed
|
||||
bot.enemy.energy = e.energy
|
||||
bot.hasContact = true
|
||||
bot.ticksSinceScan = 0
|
||||
# Q12b/Q14+#49: one NewBattle per battle, numeric scannedBotId. Identity is
|
||||
# keyed on getBotName(id) training-side (integration.opponentKey), numeric
|
||||
# fallback in the pre-BotListUpdate window.
|
||||
if not bot.newBattleSent:
|
||||
bot.newBattleSent = true
|
||||
discard sendTrainingMsg(TrainingMsg(kind: tmkNewBattle, enemyId: e.scannedBotId))
|
||||
|
||||
method onBulletHit*(bot: SacBot, e: BulletHitBotEvent) =
|
||||
bot.dmgDealt += e.damage
|
||||
inc bot.hits
|
||||
|
||||
method onHitByBullet*(bot: SacBot, e: HitByBulletEvent) =
|
||||
bot.dmgTaken += e.damage
|
||||
|
||||
# Lever 2 (#59): anti-ram — every BotHitBotEvent receipt means a bot-bot
|
||||
# collision happened and we ate RAM_DAMAGE (server deals 0.6 to both parties;
|
||||
# only the hitter gets notified). Flat per-event penalty; being rammed without
|
||||
# hitting back stays event-invisible.
|
||||
# ponytail: enemy-initiated rams undetected — add energy-residual detection if
|
||||
# v2 battle data shows ram-heavy losses.
|
||||
method onHitBot*(bot: SacBot, e: BotHitBotEvent) =
|
||||
inc bot.ramTaken
|
||||
|
||||
method onHitWall*(bot: SacBot, e: BotHitWallEvent) =
|
||||
inc bot.wallHits
|
||||
|
||||
method onBulletHitWall*(bot: SacBot, e: BulletHitWallEvent) =
|
||||
if e.bullet.ownerId == getMyId():
|
||||
bot.wastedPower += e.bullet.power
|
||||
|
||||
method onGameAborted*(bot: SacBot) =
|
||||
# Mid-round abort: drop the pending transition rather than leak it into the
|
||||
# next battle's data.
|
||||
bot.hasLastTrans = false
|
||||
bot.dmgDealt = 0; bot.dmgTaken = 0; bot.wastedPower = 0; bot.wallHits = 0
|
||||
bot.hits = 0; bot.ramTaken = 0
|
||||
|
||||
method onRoundEnded*(bot: SacBot, e: RoundEndedEventForBot) =
|
||||
## Harness liveness signal (#49): RunTraining.java watches round_counter.txt
|
||||
## and aborts the battle if it freezes (dead bot process). Main thread.
|
||||
bumpRoundCounter()
|
||||
|
||||
method onGameEnded*(bot: SacBot, e: GameEndedEventForBot) =
|
||||
## Battle end -> terminal transition with done=true. Main thread; the API has
|
||||
## joined the bot thread before this fires, so these plain fields are quiescent.
|
||||
if bot.hasLastTrans:
|
||||
let r = bot.takeReward(win = e.results.rank == 1, loss = e.results.rank != 1)
|
||||
discard sendTrainingMsg(TrainingMsg(kind: tmkTransition,
|
||||
state: bot.lastState, action: bot.lastAction,
|
||||
reward: r, nextState: bot.lastState, done: true))
|
||||
bot.hasLastTrans = false
|
||||
|
||||
# ── Run loop (bot thread) ─────────────────────────────────────────────────────
|
||||
|
||||
method run(bot: SacBot) =
|
||||
randomize()
|
||||
# Per-thread locals: born and freed on THIS thread, every round. Nothing
|
||||
# heap-owned survives the round boundary except the bot object's plain fields.
|
||||
var actor: ActorNet
|
||||
var actorReady = false
|
||||
var myVersion = 0
|
||||
var myHidden = 0
|
||||
var flat: seq[float32]
|
||||
|
||||
while isRunning():
|
||||
# Battle boundary (Q4/Q12): zero LSTM persistence + per-battle flags.
|
||||
if bot.battleId != bot.seenBattle:
|
||||
bot.seenBattle = bot.battleId
|
||||
bot.newBattleSent = false
|
||||
zeroMem(addr bot.hArr, sizeof(bot.hArr))
|
||||
zeroMem(addr bot.cArr, sizeof(bot.cArr))
|
||||
|
||||
# Weight sync (Q7/Q11): always-latest; rebuild this thread's tensors on change.
|
||||
if pullWeights(myVersion, myHidden, flat):
|
||||
if myHidden > MaxHidden:
|
||||
# hArr/cArr are fixed-capacity; a bigger SACLSTM_HIDDEN_SIZE would
|
||||
# heap-overflow them in tensorToHidden. Loud misconfig beats corruption.
|
||||
raise newException(ValueError, "SACLSTM_HIDDEN_SIZE=" & $myHidden &
|
||||
" exceeds MaxHidden=" & $MaxHidden & " (bot-side LSTM persistence cap)")
|
||||
var cur = 0
|
||||
actor = actorFromFlat(flat, cur, myHidden)
|
||||
actorReady = true
|
||||
|
||||
# Spawn an enemy bullet when a fresh scan shows they fired; then advance and
|
||||
# prune the tracked bullets (positions feed state slots 22–33).
|
||||
if bot.hasContact and bot.enemy.hasFired and bot.bulletCount < MaxBotBullets:
|
||||
let p = bot.enemy.lastFirePower
|
||||
let spd = 20.0 - 3.0 * p
|
||||
let ang = arctan2(getY() - bot.enemy.y, getX() - bot.enemy.x)
|
||||
bot.bullets[bot.bulletCount] = InFlightBullet(x: bot.enemy.x, y: bot.enemy.y,
|
||||
vx: spd * cos(ang), vy: spd * sin(ang), power: p)
|
||||
inc bot.bulletCount
|
||||
let aW = float64(getArenaWidth())
|
||||
let aH = float64(getArenaHeight())
|
||||
var alive = 0
|
||||
for i in 0 ..< bot.bulletCount:
|
||||
let b = bot.bullets[i]
|
||||
let nx = b.x + b.vx
|
||||
let ny = b.y + b.vy
|
||||
if nx >= 0.0 and nx <= aW and ny >= 0.0 and ny <= aH:
|
||||
bot.bullets[alive] = InFlightBullet(x: nx, y: ny, vx: b.vx, vy: b.vy, power: b.power)
|
||||
inc alive
|
||||
bot.bulletCount = alive
|
||||
|
||||
# State build (35-dim, this thread's tensor).
|
||||
inc bot.ticksSinceScan
|
||||
var gs: GameState
|
||||
gs.x = getX()
|
||||
gs.y = getY()
|
||||
gs.direction = getDirection()
|
||||
gs.speed = getSpeed()
|
||||
gs.energy = getEnergy()
|
||||
gs.gunDirection = getGunDirection()
|
||||
gs.gunHeat = getGunHeat()
|
||||
gs.arenaWidth = aW
|
||||
gs.arenaHeight = aH
|
||||
gs.hasContact = bot.hasContact
|
||||
gs.enemy = bot.enemy
|
||||
gs.ticksSinceLastScan = bot.ticksSinceScan
|
||||
var bd: array[MaxBotBullets, BulletData]
|
||||
for i in 0 ..< bot.bulletCount:
|
||||
bd[i] = BulletData(x: bot.bullets[i].x, y: bot.bullets[i].y, power: bot.bullets[i].power)
|
||||
if bot.bulletCount > 1: # closest threats fill slots 0-2
|
||||
bd.toOpenArray(0, bot.bulletCount - 1).sort(proc(a, b: BulletData): int =
|
||||
cmp(hypot(a.x - gs.x, a.y - gs.y), hypot(b.x - gs.x, b.y - gs.y)))
|
||||
gs.bulletCount = min(bot.bulletCount, 3)
|
||||
for i in 0 ..< gs.bulletCount:
|
||||
gs.bullets[i] = bd[i]
|
||||
# Consume the fired pulse AFTER the state saw it (one shot -> one bullet).
|
||||
bot.enemy.hasFired = false
|
||||
|
||||
let stateT = buildState(gs)
|
||||
|
||||
# Finalize the PREVIOUS transition: reward from events since the last tick,
|
||||
# nextState is this tick's observation (PPO_Bot alignment).
|
||||
if actorReady and bot.hasLastTrans:
|
||||
discard sendTrainingMsg(TrainingMsg(kind: tmkTransition,
|
||||
state: bot.lastState, action: bot.lastAction,
|
||||
reward: bot.takeReward(), nextState: stateToArr(stateT), done: false))
|
||||
bot.lastState = stateToArr(stateT)
|
||||
|
||||
if not actorReady:
|
||||
setRadarTurnRate(45.0) # no weights yet (defensive; main pre-inits) — just sweep
|
||||
go()
|
||||
continue
|
||||
|
||||
# Inference: hidden state lives as plain arrays on the bot object (persists
|
||||
# across rounds); tensors are rebuilt per tick on this thread.
|
||||
let h = hiddenToTensor(bot.hArr, myHidden)
|
||||
let c = hiddenToTensor(bot.cArr, myHidden)
|
||||
let fwd = actor.actorForward(stateT, (h: h, c: c))
|
||||
tensorToHidden(fwd.lstm.h, bot.hArr)
|
||||
tensorToHidden(fwd.lstm.c, bot.cArr)
|
||||
|
||||
bot.lastAction = actionToArr(fwd.actions)
|
||||
bot.hasLastTrans = true
|
||||
|
||||
# Actions -> intents (go() snapshots them at send time).
|
||||
let mapped = mapActions(fwd.actions, getSpeed(), getGunHeat())
|
||||
setTurnRate(mapped.turnRate)
|
||||
setTargetSpeed(getSpeed() + mapped.acceleration) # actions.nim contract
|
||||
setGunTurnRate(mapped.gunTurnRate)
|
||||
if mapped.firePower > 0.0:
|
||||
discard setFire(mapped.firePower)
|
||||
if not bot.hasContact:
|
||||
setRadarTurnRate(45.0) # sweep until first lock (onScannedBot overrides same-tick)
|
||||
|
||||
go()
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
when isMainModule:
|
||||
initIntegration() # spawn training + I/O threads, seed weight snapshot
|
||||
var bot = SacBot()
|
||||
start(bot, botJsonPath) # blocks until server disconnect
|
||||
shutdownIntegration() # Shutdown msg -> final save -> joins
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
## actions.nim — map raw network output (4 tanh values) to bot intent fields.
|
||||
##
|
||||
## Note on acceleration vs targetSpeed:
|
||||
## TankRoyale uses setTargetSpeed(), not setAcceleration().
|
||||
## The mapped `acceleration` field is a delta; callers must compute:
|
||||
## newTargetSpeed = clamp(currentSpeed + acceleration, -8.0, 8.0)
|
||||
## and call setTargetSpeed(newTargetSpeed).
|
||||
|
||||
import arraymancer
|
||||
|
||||
const ACTION_DIM* = 4
|
||||
|
||||
type
|
||||
MappedActions* = object
|
||||
turnRate*: float ## degrees/tick, speed-aware; [-10, 10] at speed 0
|
||||
acceleration*: float ## delta speed in [-2, +1]; caller adds to currentSpeed
|
||||
gunTurnRate*: float ## degrees/tick in [-20, 20]
|
||||
firePower*: float ## 0 = don't fire; (0.1, 3.0] = fire with this power
|
||||
|
||||
proc mapActions*(networkOutput: Tensor[float32],
|
||||
currentSpeed: float,
|
||||
gunHeat: float): MappedActions =
|
||||
## networkOutput: [4] tensor of tanh values in [-1, 1].
|
||||
let a0 = networkOutput[0].float
|
||||
let a1 = networkOutput[1].float
|
||||
let a2 = networkOutput[2].float
|
||||
let a3 = networkOutput[3].float
|
||||
|
||||
result.turnRate = a0 * (10.0 - 0.75 * abs(currentSpeed))
|
||||
# asymmetric accel: [-1,1] -> [-2, +1] via (value * 1.5 - 0.5)
|
||||
result.acceleration = a1 * 1.5 - 0.5
|
||||
result.gunTurnRate = a2 * 20.0
|
||||
if a3 > 0.0 and gunHeat <= 0.0:
|
||||
result.firePower = a3 * 2.9 + 0.1
|
||||
else:
|
||||
result.firePower = 0.0
|
||||
@@ -0,0 +1,463 @@
|
||||
## integration.nim — #48 thread plumbing for SAC_LSTM_Bot.
|
||||
##
|
||||
## Threads added by the bot (on top of the bot API's main/bot/sender):
|
||||
## training thread — permanent, drain-then-train loop, owns SACTrainer +
|
||||
## ReplayBuffer. Q1/Q2/Q10/Q12.
|
||||
## I/O thread — cap-1 channel of weight snapshots, atomic zip saves. Q5.
|
||||
##
|
||||
## Cross-thread payloads are plain arrays/seqs ONLY. No Arraymancer tensor ever
|
||||
## crosses a thread boundary: Tensor is a ref type and ORC refcounts are
|
||||
## non-atomic — sharing them across threads is SIGSEGV territory (PPO_Bot,
|
||||
## empirically confirmed). Each thread builds its own tensors from plain data.
|
||||
## Decisions Q1–Q14: Gitea #48.
|
||||
|
||||
import arraymancer except Linear
|
||||
import std/[locks, os, math, random, strutils, times]
|
||||
import tankroyale_botapi # getBotName (#49 name-based opponent identity)
|
||||
import SAC_LSTM_Bot/network
|
||||
import SAC_LSTM_Bot/state # STATE_DIM
|
||||
import SAC_LSTM_Bot/actions # ACTION_DIM
|
||||
import SAC_LSTM_Bot/replay_buffer
|
||||
import SAC_LSTM_Bot/training
|
||||
import SAC_LSTM_Bot/weights
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
proc getUtdRatio*(): int =
|
||||
## Gradient steps per drained transition (Q2). 200 ticks -> 200 steps at 1.
|
||||
parseInt(getEnv("SACLSTM_UTD_RATIO", "1"))
|
||||
|
||||
proc getBatchSize*(): int =
|
||||
# ponytail: 16 is an unprofiled guess sized so UTD=1 keeps up with 30tps;
|
||||
# env knob is the calibration point if steps/sec falls short.
|
||||
parseInt(getEnv("SACLSTM_BATCH_SIZE", "16"))
|
||||
|
||||
proc getSaveInterval*(): int =
|
||||
parseInt(getEnv("SACLSTM_SAVE_INTERVAL", "500"))
|
||||
|
||||
proc getWeightsPath*(): string =
|
||||
getEnv("SACLSTM_WEIGHTS_PATH",
|
||||
currentSourcePath().parentDir / "weights" / "sac_latest.zip")
|
||||
|
||||
proc opponentKey*(enemyId: int): string =
|
||||
## Q14 follow-up (#49): name-based opponent identity from the v1.0.1
|
||||
## BotListUpdate table; numeric-id fallback for the window before the first
|
||||
## update arrives (getBotName still returns "" then). Called on the training
|
||||
## thread — the lookup is lock-guarded in the API, no cross-thread refs.
|
||||
let name = getBotName(enemyId)
|
||||
if name.len > 0: name else: $enemyId
|
||||
|
||||
proc bumpRoundCounter*() =
|
||||
## Liveness signal for tools/training_runner/RunTraining.java (#49): one
|
||||
## increment per round end; the runner aborts when it freezes (dead bot).
|
||||
# ponytail: non-atomic read-modify-write; single writer (main thread) and the
|
||||
# runner re-polls every 500ms with multi-round tolerance, torn reads self-heal.
|
||||
let p = getWeightsPath().parentDir / "round_counter.txt"
|
||||
var n = 0
|
||||
try:
|
||||
n = parseInt(readFile(p).strip())
|
||||
except CatchableError:
|
||||
discard # absent/garbage -> start at 1
|
||||
try:
|
||||
createDir(p.parentDir)
|
||||
writeFile(p, $(n + 1))
|
||||
except CatchableError:
|
||||
discard # counter is best-effort liveness; never kill an event handler
|
||||
|
||||
# ── Channel message (plain data only) ────────────────────────────────────────
|
||||
|
||||
type
|
||||
TrainingMsgKind* = enum tmkTransition, tmkNewBattle, tmkShutdown
|
||||
|
||||
TrainingMsg* = object
|
||||
case kind*: TrainingMsgKind
|
||||
of tmkTransition:
|
||||
state*: array[STATE_DIM, float32] # Q13 plain arrays, no tensors
|
||||
action*: array[ACTION_DIM, float32]
|
||||
reward*: float32
|
||||
nextState*: array[STATE_DIM, float32]
|
||||
done*: bool # true only at battle end
|
||||
of tmkNewBattle:
|
||||
enemyId*: int # Q14 numeric scannedBotId
|
||||
of tmkShutdown:
|
||||
discard
|
||||
|
||||
proc arrToTensor*[N: static int](arr: array[N, float32]): Tensor[float32] =
|
||||
result = newTensor[float32](N)
|
||||
for i in 0 ..< N: result[i] = arr[i]
|
||||
|
||||
# ── Flat weight snapshots ─────────────────────────────────────────────────────
|
||||
# Layout mirrors network.nim's fixed architecture (fc1 -> hiddenDim-wide LSTM
|
||||
# with [4h, 2h] combined weights, 128-wide fc2, 4-out heads). The asserts catch
|
||||
# layout drift if network.nim shapes ever change.
|
||||
|
||||
proc actorSize*(h: int): int = 8*h*h + 168*h + 1160
|
||||
proc criticSize*(h: int): int = 8*h*h + 172*h + 257
|
||||
|
||||
proc putT(t: Tensor[float32]; dst: var seq[float32]; c: var int) =
|
||||
for v in t:
|
||||
dst[c] = v
|
||||
inc c
|
||||
|
||||
proc takeT(src: seq[float32]; c: var int; rows, cols: int): Tensor[float32] =
|
||||
# seq slice copies, then toTensor copies again: result owns its memory —
|
||||
# never a view into src (src may be a cross-thread buffer).
|
||||
let n = rows * cols
|
||||
result = src[c ..< c + n].toTensor().reshape(rows, cols)
|
||||
c += n
|
||||
|
||||
proc takeV(src: seq[float32]; c: var int; n: int): Tensor[float32] =
|
||||
## Rank-1 vector (biases) — reshape(n) keeps rank 1.
|
||||
result = src[c ..< c + n].toTensor().reshape(n)
|
||||
c += n
|
||||
|
||||
proc packActor*(a: ActorNet; dst: var seq[float32]; c: var int) =
|
||||
putT(a.fc1.w, dst, c); putT(a.fc1.b, dst, c)
|
||||
putT(a.lstm.wCombined, dst, c); putT(a.lstm.bCombined, dst, c)
|
||||
putT(a.fc2.w, dst, c); putT(a.fc2.b, dst, c)
|
||||
putT(a.muHead.w, dst, c); putT(a.muHead.b, dst, c)
|
||||
putT(a.logStdHead.w, dst, c); putT(a.logStdHead.b, dst, c)
|
||||
|
||||
proc packCritic*(net: CriticNet; dst: var seq[float32]; c: var int) =
|
||||
putT(net.fc1.w, dst, c); putT(net.fc1.b, dst, c)
|
||||
putT(net.lstm.wCombined, dst, c); putT(net.lstm.bCombined, dst, c)
|
||||
putT(net.fc2.w, dst, c); putT(net.fc2.b, dst, c)
|
||||
putT(net.fc3.w, dst, c); putT(net.fc3.b, dst, c)
|
||||
|
||||
proc actorFromFlat*(src: seq[float32]; c: var int; h: int): ActorNet =
|
||||
result.fc1.w = takeT(src, c, h, 35)
|
||||
result.fc1.b = takeV(src, c, h)
|
||||
result.lstm.wCombined = takeT(src, c, 4*h, 2*h)
|
||||
result.lstm.bCombined = takeV(src, c, 4*h)
|
||||
result.fc2.w = takeT(src, c, 128, h)
|
||||
result.fc2.b = takeV(src, c, 128)
|
||||
result.muHead.w = takeT(src, c, 4, 128)
|
||||
result.muHead.b = takeV(src, c, 4)
|
||||
result.logStdHead.w = takeT(src, c, 4, 128)
|
||||
result.logStdHead.b = takeV(src, c, 4)
|
||||
result.lstm.hiddenDim = result.lstm.bCombined.size div 4 # same as weights.nim loadLSTMCell
|
||||
result.hiddenDim = h
|
||||
assert c == actorSize(h), "actor flat layout drift"
|
||||
|
||||
proc criticFromFlat*(src: seq[float32]; c: var int; h: int): CriticNet =
|
||||
result.fc1.w = takeT(src, c, h, 39)
|
||||
result.fc1.b = takeV(src, c, h)
|
||||
result.lstm.wCombined = takeT(src, c, 4*h, 2*h)
|
||||
result.lstm.bCombined = takeV(src, c, 4*h)
|
||||
result.fc2.w = takeT(src, c, 128, h)
|
||||
result.fc2.b = takeV(src, c, 128)
|
||||
result.fc3.w = takeT(src, c, 1, 128)
|
||||
result.fc3.b = takeV(src, c, 1)
|
||||
result.lstm.hiddenDim = result.lstm.bCombined.size div 4
|
||||
result.hiddenDim = h
|
||||
|
||||
type
|
||||
FullSnap* = object
|
||||
hiddenDim*: int
|
||||
data*: seq[float32] # actor | critic1 | critic2 | targetCritic1 | targetCritic2 | alpha
|
||||
|
||||
proc packFull*(t: SACTrainer): FullSnap =
|
||||
let h = t.actor.hiddenDim
|
||||
result.hiddenDim = h
|
||||
result.data = newSeq[float32](actorSize(h) + 4 * criticSize(h) + 1)
|
||||
var c = 0
|
||||
packActor(t.actor, result.data, c)
|
||||
packCritic(t.critic1, result.data, c)
|
||||
packCritic(t.critic2, result.data, c)
|
||||
packCritic(t.targetCritic1, result.data, c)
|
||||
packCritic(t.targetCritic2, result.data, c)
|
||||
assert abs(t.alpha().float64 - exp(t.logAlpha.float64)) < 1e-6
|
||||
result.data[c] = t.alpha()
|
||||
inc c
|
||||
assert c == result.data.len, "full snapshot layout drift"
|
||||
|
||||
proc unpackFull*(fs: FullSnap):
|
||||
tuple[a: ActorNet, c1, c2, t1, t2: CriticNet, alpha: float32] =
|
||||
var c = 0
|
||||
result.a = actorFromFlat(fs.data, c, fs.hiddenDim)
|
||||
result.c1 = criticFromFlat(fs.data, c, fs.hiddenDim)
|
||||
result.c2 = criticFromFlat(fs.data, c, fs.hiddenDim)
|
||||
result.t1 = criticFromFlat(fs.data, c, fs.hiddenDim)
|
||||
result.t2 = criticFromFlat(fs.data, c, fs.hiddenDim)
|
||||
result.alpha = fs.data[c]
|
||||
|
||||
# ── Shared weight snapshot (training thread writes, bot thread copies out) ────
|
||||
# Q7/Q11: Lock + always-latest semantics. `data` is allocated ONCE and written
|
||||
# IN PLACE under gWeightLock — it is never reassigned, so the shared heap block
|
||||
# never sees cross-thread refcount traffic. Readers copy element-wise out.
|
||||
|
||||
type
|
||||
WeightSnapshot* = object
|
||||
hiddenDim*: int
|
||||
version*: int
|
||||
data*: seq[float32]
|
||||
|
||||
var gWeightLock: Lock
|
||||
var gSharedSnap: WeightSnapshot
|
||||
var gTrainChan: Channel[TrainingMsg]
|
||||
var gSaveChan: Channel[FullSnap]
|
||||
var gTrainingThread: Thread[void]
|
||||
var gIoThread: Thread[void]
|
||||
var gInitialFull: FullSnap # built on main before spawn; read-once after (happens-before)
|
||||
var gWeightsPath: string
|
||||
|
||||
proc pullWeights*(myVersion: var int; hidden: var int;
|
||||
flat: var seq[float32]): bool =
|
||||
## Copy the latest actor snapshot out under the lock. Returns true when a new
|
||||
## version arrived (caller rebuilds its tensors on ITS OWN thread).
|
||||
withLock(gWeightLock):
|
||||
if gSharedSnap.version == myVersion:
|
||||
return false
|
||||
if flat.len != gSharedSnap.data.len:
|
||||
flat = newSeq[float32](gSharedSnap.data.len) # caller-thread-owned buffer
|
||||
for i in 0 ..< flat.len:
|
||||
flat[i] = gSharedSnap.data[i]
|
||||
myVersion = gSharedSnap.version
|
||||
hidden = gSharedSnap.hiddenDim
|
||||
true
|
||||
|
||||
proc evalModeActive*(): bool {.inline.} =
|
||||
## Lever 4 (#59): the harness's deterministic eval battles already run the bot
|
||||
## with SACLSTM_EVAL_MODE=1 (sac_train.sh eval_checkpoint, mechanism from #49).
|
||||
## While set, eval ticks must NOT feed the trainer — transitions would pollute
|
||||
## the replay buffer with eval-only data and trigger gradient updates.
|
||||
getEnv("SACLSTM_EVAL_MODE") == "1"
|
||||
|
||||
proc sendTrainingMsg*(msg: TrainingMsg): bool {.inline.} =
|
||||
## Bot-side enqueue (cap-256, drops on overflow per Q10). Thread-safe.
|
||||
## Lever 4 (#59): fully suppressed in eval mode — NewBattle drops too, so an
|
||||
## eval battle can neither add transitions nor clear/retarget the buffer.
|
||||
if evalModeActive(): return false
|
||||
gTrainChan.trySend(msg)
|
||||
|
||||
# ── Training state (testable without threads) ─────────────────────────────────
|
||||
|
||||
type
|
||||
TrainState* = object
|
||||
trainer*: SACTrainer
|
||||
buf*: ReplayBuffer
|
||||
lastEnemyKey*: string # opponent identity key (#49 name-based, Q14)
|
||||
stepCount*: int
|
||||
nextSave*: int
|
||||
|
||||
proc trainerFromFull*(initial: FullSnap): SACTrainer =
|
||||
let (a, c1, c2, t1, t2, alpha) = unpackFull(initial)
|
||||
result.actor = a
|
||||
result.critic1 = c1
|
||||
result.critic2 = c2
|
||||
result.targetCritic1 = t1
|
||||
result.targetCritic2 = t2
|
||||
assert alpha > 0.0'f32, "checkpoint alpha must be positive"
|
||||
result.logAlpha = ln(alpha)
|
||||
result.targetEntropy = getTargetEntropy()
|
||||
result.tau = getTau()
|
||||
result.lrActor = getLrActor()
|
||||
result.lrCritic = getLrCritic()
|
||||
result.lrAlpha = getLrAlpha()
|
||||
result.gamma = getGamma()
|
||||
result.adam = initSACAdamStates(result.actor, result.critic1, result.critic2)
|
||||
# ponytail: adam momentum not carried in the flat snapshot — optimizer restarts
|
||||
# fresh each process; switch to saveCheckpoint/loadCheckpoint end-to-end when
|
||||
# resume quality matters (#49 harness owns checkpoint management).
|
||||
|
||||
proc initTrainState*(initial: FullSnap): TrainState =
|
||||
result.trainer = trainerFromFull(initial)
|
||||
result.buf = newReplayBuffer(getBufferCapacity(), STATE_DIM, ACTION_DIM)
|
||||
result.lastEnemyKey = ""
|
||||
result.nextSave = getSaveInterval()
|
||||
|
||||
# ── Training-loss metrics (campaign v2 lever 3, #59) ──────────────────────────
|
||||
|
||||
proc metricsFilePath*(): string =
|
||||
## Sits next to the weights dir's parent: SAC_LSTM_Bot/training_metrics.jsonl
|
||||
## under the #49 harness (weights live in SAC_LSTM_Bot/weights/).
|
||||
getWeightsPath().parentDir.parentDir / "training_metrics.jsonl"
|
||||
|
||||
proc metricsLine*(epoch: float64; stepCount, bufferLen, drained, gradSteps: int;
|
||||
m: SACMetrics): string =
|
||||
## One JSONL line with exactly the scalars SACTrainer.sacUpdate exposes
|
||||
## (#59 lever 3 — SACMetrics was already returned, no trainer change needed):
|
||||
## losses/alpha averaged over this pass's gradient steps, buffer size from
|
||||
## replay_buffer.len, cumulative step count and drained transition count.
|
||||
"{\"epoch\":" & $epoch &
|
||||
",\"steps\":" & $stepCount &
|
||||
",\"buffer_size\":" & $bufferLen &
|
||||
",\"drained\":" & $drained &
|
||||
",\"grad_steps\":" & $gradSteps &
|
||||
",\"critic_loss\":" & $m.criticLoss &
|
||||
",\"actor_loss\":" & $m.actorLoss &
|
||||
",\"alpha_loss\":" & $m.alphaLoss &
|
||||
",\"alpha\":" & $m.alpha & "}"
|
||||
|
||||
proc appendMetricsLine(st: TrainState; drained, gradSteps: int; m: SACMetrics) =
|
||||
## Lever 3 (#59): one append per trainPass (never per gradient step). Open,
|
||||
## write, close — cheap and crash-tolerant; a metrics failure never kills
|
||||
## training.
|
||||
try:
|
||||
let f = open(metricsFilePath(), fmAppend)
|
||||
f.writeLine(metricsLine(epochTime(), st.stepCount, st.buf.len,
|
||||
drained, gradSteps, m))
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
|
||||
proc handleTrainingMsg*(st: var TrainState; msg: TrainingMsg): bool =
|
||||
## Process one message. Returns false for Shutdown (caller stops).
|
||||
## Tensors are born HERE from the message's plain arrays — training thread only.
|
||||
case msg.kind
|
||||
of tmkTransition:
|
||||
st.buf.add(Transition(
|
||||
state: msg.state.arrToTensor,
|
||||
action: msg.action.arrToTensor,
|
||||
reward: msg.reward,
|
||||
nextState: msg.nextState.arrToTensor,
|
||||
done: msg.done))
|
||||
of tmkNewBattle:
|
||||
# Q12a: keep the buffer if the opponent is unchanged, clear otherwise.
|
||||
# Identity keyed on NAME (#49); numeric-id fallback pre-BotListUpdate.
|
||||
let key = opponentKey(msg.enemyId)
|
||||
if key != st.lastEnemyKey:
|
||||
st.buf.clear()
|
||||
st.lastEnemyKey = key
|
||||
of tmkShutdown:
|
||||
return false
|
||||
true
|
||||
|
||||
proc trainPass*(st: var TrainState; drained: int) =
|
||||
## UTD gradient steps for the transitions drained this pass, then publish the
|
||||
## latest actor to the shared snapshot and request periodic disk saves.
|
||||
if drained <= 0 or not st.buf.canSample:
|
||||
return
|
||||
let steps = drained * getUtdRatio() # Q2
|
||||
var gradSteps = 0
|
||||
var sumCritic, sumActor, sumAlphaLoss, sumAlpha = 0.0'f32
|
||||
for i in 1 .. steps:
|
||||
let seqs = st.buf.sampleSequences(getBatchSize())
|
||||
if seqs.len == 0:
|
||||
break
|
||||
let m = sacUpdate(st.trainer, seqs)
|
||||
sumCritic += m.criticLoss; sumActor += m.actorLoss
|
||||
sumAlphaLoss += m.alphaLoss; sumAlpha += m.alpha
|
||||
inc gradSteps
|
||||
inc st.stepCount
|
||||
# Save check INSIDE the step loop (#56 launch finding): at production sizes
|
||||
# (hidden 256 ⇒ ~1 s/step) a drain burst queues minutes of steps; checking
|
||||
# only between passes meant the process died mid-loop before stepCount ever
|
||||
# reached nextSave — zero checkpoints persisted for the whole campaign.
|
||||
# Mid-loop checks + SAVE_INTERVAL≤20 (#54) keep saves ~20 s apart.
|
||||
if st.stepCount >= st.nextSave:
|
||||
st.nextSave += getSaveInterval()
|
||||
var full = packFull(st.trainer)
|
||||
discard gSaveChan.trySend(move(full)) # cap-1: drop if I/O thread is busy (Q5)
|
||||
if gradSteps > 0:
|
||||
# Lever 3 (#59): one metrics line per pass, losses averaged over its steps.
|
||||
appendMetricsLine(st, drained, gradSteps, SACMetrics(
|
||||
criticLoss: sumCritic / gradSteps.float32,
|
||||
actorLoss: sumActor / gradSteps.float32,
|
||||
alphaLoss: sumAlphaLoss / gradSteps.float32,
|
||||
alpha: sumAlpha / gradSteps.float32))
|
||||
# Publish latest actor (Q7): in-place write under the lock, bump version.
|
||||
withLock(gWeightLock):
|
||||
assert gSharedSnap.hiddenDim == st.trainer.actor.hiddenDim,
|
||||
"snapshot/trainer hidden size mismatch"
|
||||
var c = 0
|
||||
packActor(st.trainer.actor, gSharedSnap.data, c)
|
||||
inc gSharedSnap.version
|
||||
|
||||
# ── Threads ───────────────────────────────────────────────────────────────────
|
||||
|
||||
proc trainingThreadEntry() {.thread.} =
|
||||
{.cast(gcsafe).}:
|
||||
randomize()
|
||||
var st = initTrainState(gInitialFull)
|
||||
var running = true
|
||||
while running:
|
||||
let first = gTrainChan.recv() # block until traffic (no busy spin)
|
||||
var drained = 0
|
||||
var msg = first
|
||||
while running:
|
||||
if msg.kind == tmkTransition:
|
||||
inc drained
|
||||
if not handleTrainingMsg(st, msg):
|
||||
running = false # Shutdown
|
||||
break
|
||||
let (more, nxt) = gTrainChan.tryRecv()
|
||||
if not more:
|
||||
break # drained — now train (Q10)
|
||||
msg = nxt
|
||||
if not running:
|
||||
var full = packFull(st.trainer) # final save request, then exit
|
||||
discard gSaveChan.trySend(move(full))
|
||||
break
|
||||
trainPass(st, drained)
|
||||
|
||||
proc ioThreadEntry() {.thread.} =
|
||||
{.cast(gcsafe).}:
|
||||
while true:
|
||||
let fs = gSaveChan.recv() # blocks; exits via empty-data sentinel
|
||||
if fs.data.len == 0:
|
||||
break
|
||||
let (a, c1, c2, t1, t2, alpha) = unpackFull(fs)
|
||||
saveWeights(gWeightsPath, a, c1, c2, t1, t2, alpha) # atomic zip (weights.nim)
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
proc randomFull(): FullSnap =
|
||||
let t = initSACTrainer(STATE_DIM, ACTION_DIM) # random nets, born + freed here
|
||||
packFull(t)
|
||||
|
||||
proc loadOrInitFull(): FullSnap =
|
||||
let path = getWeightsPath()
|
||||
if fileExists(path):
|
||||
try:
|
||||
let cp = loadCheckpoint(path)
|
||||
var t = initSACTrainer(STATE_DIM, ACTION_DIM) # env hyperparams
|
||||
t.actor = cp.actor
|
||||
t.critic1 = cp.critic1
|
||||
t.critic2 = cp.critic2
|
||||
t.targetCritic1 = cp.targetCritic1
|
||||
t.targetCritic2 = cp.targetCritic2
|
||||
assert cp.alpha > 0.0'f32, "checkpoint alpha must be positive"
|
||||
t.logAlpha = ln(cp.alpha)
|
||||
result = packFull(t)
|
||||
except Exception as e:
|
||||
stderr.writeLine "[sac] checkpoint load failed (" & e.msg & ") — random init"
|
||||
result = randomFull()
|
||||
else:
|
||||
result = randomFull()
|
||||
|
||||
proc initIntegration*() =
|
||||
## Open channels, build the initial weight snapshot, spawn both threads.
|
||||
## Call once from the main module before start().
|
||||
gWeightsPath = getWeightsPath()
|
||||
gInitialFull = loadOrInitFull()
|
||||
gSharedSnap.hiddenDim = gInitialFull.hiddenDim
|
||||
gSharedSnap.data = newSeq[float32](actorSize(gInitialFull.hiddenDim))
|
||||
for i in 0 ..< gSharedSnap.data.len: # element-wise: no refcount traffic
|
||||
gSharedSnap.data[i] = gInitialFull.data[i]
|
||||
gSharedSnap.version = 1
|
||||
initLock(gWeightLock)
|
||||
gTrainChan.open(256) # Q10 cap-256
|
||||
gSaveChan.open(1) # Q5 cap-1
|
||||
# Lever 4 (#59): one-time visibility for the suppression gate (see
|
||||
# sendTrainingMsg) — the eval bot trains nothing by design.
|
||||
if evalModeActive():
|
||||
stderr.writeLine "[sac] SACLSTM_EVAL_MODE=1 — training input suppressed (lever 4, #59)"
|
||||
createThread(gTrainingThread, trainingThreadEntry)
|
||||
createThread(gIoThread, ioThreadEntry)
|
||||
|
||||
proc shutdownIntegration*() =
|
||||
## Stop both threads cleanly. Called after the bot disconnects (start returned).
|
||||
while gTrainChan.tryRecv().dataAvailable:
|
||||
discard # drop pending transitions — process exiting
|
||||
discard gTrainChan.trySend(TrainingMsg(kind: tmkShutdown))
|
||||
joinThread(gTrainingThread) # training requests one final save
|
||||
# Nim's recv() blocks even on closed channels, so the I/O thread exits via an
|
||||
# empty-data sentinel. Retry while it is still busy saving: every failed
|
||||
# trySend means a save is in flight and will be consumed, so this terminates.
|
||||
var stop = FullSnap(hiddenDim: -1)
|
||||
while not gSaveChan.trySend(move(stop)):
|
||||
sleep(50)
|
||||
stop = FullSnap(hiddenDim: -1)
|
||||
joinThread(gIoThread)
|
||||
gTrainChan.close()
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,161 @@
|
||||
## network.nim — LSTM-based Actor and dual Critic for SAC-v2.
|
||||
## No autograd; inference only. Manual LSTM cell from scratch.
|
||||
|
||||
import arraymancer
|
||||
import std/[math, random, os, strutils]
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
proc getHiddenSize*(): int =
|
||||
let s = getEnv("SACLSTM_HIDDEN_SIZE", "256")
|
||||
result = parseInt(s)
|
||||
|
||||
proc isEvalMode*(): bool =
|
||||
getEnv("SACLSTM_EVAL_MODE", "0") == "1"
|
||||
|
||||
# ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
Linear* = object
|
||||
w*, b*: Tensor[float32] # w: [out, in], b: [out]
|
||||
|
||||
LSTMCell* = object
|
||||
## Combined weight matrix Wi|Wf|Wg|Wo stacked: [4*hidden, input+hidden]
|
||||
## Combined bias stacked: [4*hidden]
|
||||
wCombined*: Tensor[float32]
|
||||
bCombined*: Tensor[float32]
|
||||
hiddenDim*: int
|
||||
|
||||
ActorNet* = object
|
||||
fc1*: Linear
|
||||
lstm*: LSTMCell
|
||||
fc2*: Linear
|
||||
muHead*: Linear
|
||||
logStdHead*: Linear
|
||||
hiddenDim*: int
|
||||
|
||||
CriticNet* = object
|
||||
fc1*: Linear
|
||||
lstm*: LSTMCell
|
||||
fc2*: Linear
|
||||
fc3*: Linear
|
||||
hiddenDim*: int
|
||||
|
||||
LSTMState* = tuple[h, c: Tensor[float32]] # each [hiddenDim]
|
||||
|
||||
# ── Init helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
proc initLinear*(inDim, outDim: int; scale: float32): Linear =
|
||||
result.w = randomNormalTensor[float32]([outDim, inDim]) *. scale
|
||||
result.b = zeros[float32](outDim)
|
||||
|
||||
proc initLinearHe*(inDim, outDim: int): Linear =
|
||||
initLinear(inDim, outDim, sqrt(2.0'f32 / inDim.float32))
|
||||
|
||||
proc initLinearOut*(inDim, outDim: int): Linear =
|
||||
initLinear(inDim, outDim, sqrt(1.0'f32 / inDim.float32))
|
||||
|
||||
proc initLSTMCell*(inputDim, hiddenDim: int): LSTMCell =
|
||||
result.hiddenDim = hiddenDim
|
||||
let fanIn = (inputDim + hiddenDim).float32
|
||||
let scale = sqrt(1.0'f32 / fanIn)
|
||||
result.wCombined = randomNormalTensor[float32]([4 * hiddenDim, inputDim + hiddenDim]) *. scale
|
||||
result.bCombined = zeros[float32](4 * hiddenDim)
|
||||
|
||||
proc zeroState*(hiddenDim: int): LSTMState =
|
||||
result = (h: zeros[float32](hiddenDim), c: zeros[float32](hiddenDim))
|
||||
|
||||
proc initActorNet*(stateDim: int): ActorNet =
|
||||
let h = getHiddenSize()
|
||||
result.hiddenDim = h
|
||||
result.fc1 = initLinearHe(stateDim, h)
|
||||
result.lstm = initLSTMCell(h, h)
|
||||
result.fc2 = initLinearHe(h, 128)
|
||||
result.muHead = initLinearOut(128, 4)
|
||||
result.logStdHead = initLinearOut(128, 4)
|
||||
|
||||
proc initCriticNet*(stateDim, actionDim: int): CriticNet =
|
||||
let h = getHiddenSize()
|
||||
result.hiddenDim = h
|
||||
result.fc1 = initLinearHe(stateDim + actionDim, h)
|
||||
result.lstm = initLSTMCell(h, h)
|
||||
result.fc2 = initLinearHe(h, 128)
|
||||
result.fc3 = initLinearOut(128, 1)
|
||||
|
||||
# ── Forward helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
proc linear*(l: Linear; x: Tensor[float32]): Tensor[float32] =
|
||||
l.w * x + l.b
|
||||
|
||||
proc relu*(x: Tensor[float32]): Tensor[float32] =
|
||||
x.map(proc(v: float32): float32 = max(0.0'f32, v))
|
||||
|
||||
proc sigmoid*(x: Tensor[float32]): Tensor[float32] =
|
||||
x.map(proc(v: float32): float32 = 1.0'f32 / (1.0'f32 + exp(-v)))
|
||||
|
||||
proc tanhT*(x: Tensor[float32]): Tensor[float32] =
|
||||
x.map(proc(v: float32): float32 = tanh(v))
|
||||
|
||||
proc lstmStep*(cell: LSTMCell; x, h, c: Tensor[float32]): LSTMState =
|
||||
## x: [inputDim], h/c: [hiddenDim] → h', c': [hiddenDim]
|
||||
let xh = concat(x, h, axis = 0) # [inputDim + hiddenDim]
|
||||
let gates = cell.wCombined * xh + cell.bCombined # [4*hidden]
|
||||
let hd = cell.hiddenDim
|
||||
let iGate = sigmoid(gates[0 ..< hd])
|
||||
let fGate = sigmoid(gates[hd ..< 2*hd])
|
||||
let gGate = tanhT(gates[2*hd ..< 3*hd])
|
||||
let oGate = sigmoid(gates[3*hd ..< 4*hd])
|
||||
let cPrime = fGate *. c + iGate *. gGate
|
||||
let hPrime = oGate *. tanhT(cPrime)
|
||||
result = (h: hPrime, c: cPrime)
|
||||
|
||||
# ── Actor forward ─────────────────────────────────────────────────────────────
|
||||
|
||||
const
|
||||
LOG_STD_MIN = -5.0'f32
|
||||
LOG_STD_MAX = 2.0'f32
|
||||
LOG_PROB_EPS = 1e-6'f32
|
||||
|
||||
proc actorForward*(net: ActorNet; state: Tensor[float32]; lstm: LSTMState;
|
||||
deterministic = false):
|
||||
tuple[actions: Tensor[float32]; logProb: float32; lstm: LSTMState] =
|
||||
## state: [stateDim], lstm: (h,c) each [hiddenDim]
|
||||
## Returns actions [4], scalar logProb, updated (h',c').
|
||||
let h1 = relu(net.fc1.linear(state))
|
||||
let lstmOut = lstmStep(net.lstm, h1, lstm.h, lstm.c)
|
||||
let h2 = relu(net.fc2.linear(lstmOut.h))
|
||||
let mu = net.muHead.linear(h2)
|
||||
let logStdRaw = net.logStdHead.linear(h2)
|
||||
let logStd = logStdRaw.map(proc(v: float32): float32 = clamp(v, LOG_STD_MIN, LOG_STD_MAX))
|
||||
|
||||
if deterministic or isEvalMode():
|
||||
let actions = tanhT(mu)
|
||||
return (actions: actions, logProb: 0.0'f32, lstm: lstmOut)
|
||||
|
||||
# Reparameterization: z = mu + std * eps, action = tanh(z)
|
||||
let std = logStd.map(proc(v: float32): float32 = exp(v))
|
||||
var actions = newTensor[float32](4)
|
||||
var logProb = 0.0'f32
|
||||
let twoPiLog = 0.5'f32 * ln(2.0'f32 * PI.float32)
|
||||
for i in 0 ..< 4:
|
||||
let eps = gauss(0.0'f64, 1.0'f64).float32
|
||||
let z = mu[i] + std[i] * eps
|
||||
actions[i] = tanh(z)
|
||||
# log N(z | mu, std) - log(1 - tanh²(z) + eps)
|
||||
let diff = (z - mu[i]) / std[i]
|
||||
let logNorm = -0.5'f32 * diff * diff - ln(std[i]) - twoPiLog
|
||||
let tanhCorr = ln(1.0'f32 - actions[i] * actions[i] + LOG_PROB_EPS)
|
||||
logProb += logNorm - tanhCorr
|
||||
|
||||
result = (actions: actions, logProb: logProb, lstm: lstmOut)
|
||||
|
||||
# ── Critic forward ────────────────────────────────────────────────────────────
|
||||
|
||||
proc criticForward*(net: CriticNet; stateAction: Tensor[float32]; lstm: LSTMState):
|
||||
tuple[q: float32; lstm: LSTMState] =
|
||||
## stateAction: [stateDim + actionDim], lstm: (h,c) each [hiddenDim]
|
||||
let h1 = relu(net.fc1.linear(stateAction))
|
||||
let lstmOut = lstmStep(net.lstm, h1, lstm.h, lstm.c)
|
||||
let h2 = relu(net.fc2.linear(lstmOut.h))
|
||||
let q = net.fc3.linear(h2)
|
||||
result = (q: q[0], lstm: lstmOut)
|
||||
@@ -0,0 +1,136 @@
|
||||
## replay_buffer.nim — sequential ring buffer for off-policy SAC+LSTM training.
|
||||
##
|
||||
## Stores transitions and samples contiguous sequences for recurrent training.
|
||||
## Sequences NEVER cross battle boundaries (done=true).
|
||||
##
|
||||
## Config env vars:
|
||||
## SACLSTM_BUFFER_CAPACITY (default: 500_000)
|
||||
## SACLSTM_BURN_IN (default: 8)
|
||||
## SACLSTM_TRAIN_WINDOW (default: 16)
|
||||
|
||||
import arraymancer
|
||||
import std/[os, strutils, random]
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
proc getBufferCapacity*(): int =
|
||||
parseInt(getEnv("SACLSTM_BUFFER_CAPACITY", "500000"))
|
||||
|
||||
proc getBurnIn*(): int =
|
||||
parseInt(getEnv("SACLSTM_BURN_IN", "8"))
|
||||
|
||||
proc getTrainWindow*(): int =
|
||||
parseInt(getEnv("SACLSTM_TRAIN_WINDOW", "16"))
|
||||
|
||||
# ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
Transition* = object
|
||||
state*: Tensor[float32] # [stateDim]
|
||||
action*: Tensor[float32] # [actionDim]
|
||||
reward*: float32
|
||||
nextState*: Tensor[float32] # [stateDim]
|
||||
done*: bool # true = battle end
|
||||
|
||||
Sequence* = object
|
||||
burnIn*: seq[Transition] # first burnIn steps (for LSTM warm-up)
|
||||
train*: seq[Transition] # next trainWindow steps (for gradient computation)
|
||||
|
||||
ReplayBuffer* = object
|
||||
## Ring buffer. `head` is the next write position. `count` tracks fill level.
|
||||
transitions: seq[Transition]
|
||||
capacity: int
|
||||
stateDim: int
|
||||
actionDim: int
|
||||
head: int # next write index
|
||||
count: int # number of valid transitions stored
|
||||
burnIn: int
|
||||
trainWindow: int
|
||||
|
||||
# ── Construction ──────────────────────────────────────────────────────────────
|
||||
|
||||
proc newReplayBuffer*(capacity, stateDim, actionDim: int;
|
||||
burnIn = getBurnIn();
|
||||
trainWindow = getTrainWindow()): ReplayBuffer =
|
||||
result.capacity = capacity
|
||||
result.stateDim = stateDim
|
||||
result.actionDim = actionDim
|
||||
result.burnIn = burnIn
|
||||
result.trainWindow = trainWindow
|
||||
result.head = 0
|
||||
result.count = 0
|
||||
result.transitions = newSeq[Transition](capacity)
|
||||
|
||||
# ── Core operations ───────────────────────────────────────────────────────────
|
||||
|
||||
proc add*(buf: var ReplayBuffer; t: Transition) =
|
||||
buf.transitions[buf.head] = t
|
||||
buf.head = (buf.head + 1) mod buf.capacity
|
||||
if buf.count < buf.capacity:
|
||||
inc buf.count
|
||||
|
||||
proc len*(buf: ReplayBuffer): int = buf.count
|
||||
|
||||
proc clear*(buf: var ReplayBuffer) =
|
||||
## Drop all transitions (#48: opponent changed across battles).
|
||||
## Old slots keep stale tensors until the ring overwrites them.
|
||||
buf.head = 0
|
||||
buf.count = 0
|
||||
|
||||
proc canSample*(buf: ReplayBuffer): bool =
|
||||
buf.count >= buf.burnIn + buf.trainWindow
|
||||
|
||||
# ── Sampling ──────────────────────────────────────────────────────────────────
|
||||
|
||||
proc sampleSequences*(buf: ReplayBuffer; batchSize: int): seq[Sequence] =
|
||||
## Sample `batchSize` contiguous sequences of length burnIn+trainWindow.
|
||||
## Sequences never cross a done=true boundary and never wrap the ring buffer.
|
||||
##
|
||||
## Returns fewer than batchSize sequences if not enough valid starts exist.
|
||||
## Returns empty seq if canSample is false.
|
||||
if not buf.canSample: return @[]
|
||||
|
||||
let seqLen = buf.burnIn + buf.trainWindow
|
||||
let oldest = if buf.count < buf.capacity: 0
|
||||
else: buf.head # oldest valid index when full
|
||||
|
||||
# Build valid starting indices.
|
||||
# ponytail: O(count) scan per sample call; upgrade to an indexed set of
|
||||
# boundary positions if count reaches hundreds of thousands and profiling shows
|
||||
# this is a bottleneck.
|
||||
var validStarts: seq[int]
|
||||
for i in 0 ..< buf.count - seqLen + 1:
|
||||
# Absolute ring-buffer index for the i-th oldest transition
|
||||
let startIdx = (oldest + i) mod buf.capacity
|
||||
# Check: the sequence [startIdx .. startIdx+seqLen-2] must not contain done=true
|
||||
# (a done at position k means the battle ended there; the next transition is
|
||||
# from a new battle, so the sequence would cross a boundary).
|
||||
# Also, the sequence must not wrap around the ring buffer.
|
||||
let endIdx = startIdx + seqLen - 1 # exclusive of wrap check
|
||||
if endIdx >= buf.capacity:
|
||||
# Sequence wraps the ring buffer — invalid starting point.
|
||||
continue
|
||||
var crosses = false
|
||||
for j in 0 ..< seqLen - 1:
|
||||
if buf.transitions[startIdx + j].done:
|
||||
crosses = true
|
||||
break
|
||||
if not crosses:
|
||||
validStarts.add(startIdx)
|
||||
|
||||
if validStarts.len == 0: return @[]
|
||||
|
||||
result = newSeq[Sequence](min(batchSize, validStarts.len))
|
||||
# Sample with replacement if batchSize > validStarts.len, else sample without.
|
||||
# ponytail: sampling with replacement for simplicity; shuffle+take for
|
||||
# without-replacement if the caller needs it.
|
||||
for i in 0 ..< result.len:
|
||||
let startIdx = validStarts[rand(validStarts.len - 1)]
|
||||
var s: Sequence
|
||||
s.burnIn = newSeq[Transition](buf.burnIn)
|
||||
s.train = newSeq[Transition](buf.trainWindow)
|
||||
for j in 0 ..< buf.burnIn:
|
||||
s.burnIn[j] = buf.transitions[startIdx + j]
|
||||
for j in 0 ..< buf.trainWindow:
|
||||
s.train[j] = buf.transitions[startIdx + buf.burnIn + j]
|
||||
result[i] = s
|
||||
@@ -0,0 +1,84 @@
|
||||
## rewards.nim — Raw reward computation + running mean/variance normalizer.
|
||||
## Welford online algorithm; safe cold-start (0 or 1 samples).
|
||||
|
||||
import std/math
|
||||
|
||||
# ── Lever-2 shaping constants (#59, campaign v2) — TUNABLE ────────────────────
|
||||
# Scale discipline: commensurate with existing magnitudes (dealt p=1 was +4,
|
||||
# wall tick -5/tick, win +20). Death/loss and win terms stay dominant; these
|
||||
# only re-rank mid-band behaviors (fight vs outlive vs get-rammed).
|
||||
|
||||
const
|
||||
# Multiplier on the bullet-damage-dealt term: p=1 hit +4 -> +5. Low-power
|
||||
# spam stays unprofitable (6*0.1-2 = -1.4 < 0 even after x1.25).
|
||||
AggressionMult* = 1.25 # ponytail: TUNABLE — raise toward 1.5 if v2 bot still passivity-leaning
|
||||
# Flat per landed shot on top of damage: discrete accuracy signal.
|
||||
HitBonus* = 0.5 # ponytail: TUNABLE — keep < 6p-2 at min viable power (~0.34)
|
||||
# Per bot-bot collision (BotHitBotEvent): server deals RAM_DAMAGE=0.6 to
|
||||
# both parties but only notifies the hitter — each receipt = damage taken.
|
||||
RamTakenPenalty* = 3.0 # ponytail: TUNABLE — vs p=0.8 bullet received (-6.8)
|
||||
# Enemy-charging deterrent: distance/diagonal below this => escalating
|
||||
# negative (max at zero distance), suppressed while we deal damage that step.
|
||||
ChargeDistFrac* = 0.12 # ponytail: TUNABLE — ~120u of 800x600 diag (1000)
|
||||
ChargePenalty* = 2.0 # ponytail: TUNABLE — per-tick ceiling, milder than wall (-5/tick)
|
||||
|
||||
# ── Raw reward ────────────────────────────────────────────────────────────────
|
||||
|
||||
proc computeReward*(
|
||||
damageInflicted: float64 = 0.0, # fire power p of own shot that hit
|
||||
damageReceived: float64 = 0.0, # fire power p_e of enemy shot that hit
|
||||
wallHitTicks: int = 0, # ticks in wall contact this step
|
||||
wastedShotPower: float64 = 0.0, # fire power of shot that missed/hit wall
|
||||
hitCount: int = 0, # own bullets that hit the enemy this step (#59)
|
||||
ramTakenCount: int = 0, # collisions where we were the victim (#59)
|
||||
enemyDistFrac: float64 = 2.0, # enemy dist / arena diag; >ChargeDistFrac when no contact (#59)
|
||||
win: bool = false,
|
||||
loss: bool = false
|
||||
): float64 =
|
||||
## Returns the raw (un-normalized) reward for one decision step.
|
||||
## Damage formula: 4p + 2(p-1) = 6p - 2 (matches Tank Royale bullet rules).
|
||||
let p = damageInflicted
|
||||
let pe = damageReceived
|
||||
if p > 0.0:
|
||||
result += AggressionMult * (6.0 * p - 2.0)
|
||||
if hitCount > 0: result += HitBonus * hitCount.float64
|
||||
if pe > 0.0: result -= 6.0 * pe - 2.0
|
||||
result -= RamTakenPenalty * ramTakenCount.float64
|
||||
if enemyDistFrac < ChargeDistFrac and p <= 0.0:
|
||||
result -= ChargePenalty * (1.0 - enemyDistFrac / ChargeDistFrac)
|
||||
result -= 5.0 * wallHitTicks.float64
|
||||
if wastedShotPower > 0.0: result -= 0.1 * wastedShotPower
|
||||
if win: result += 20.0
|
||||
if loss: result -= 10.0
|
||||
|
||||
# ── Running normalizer (Welford) ──────────────────────────────────────────────
|
||||
|
||||
const NormEps = 1e-8
|
||||
|
||||
type
|
||||
RewardNormalizer* = object
|
||||
n*: int # samples seen
|
||||
mean*: float64
|
||||
m2*: float64 # sum of squared deviations (Welford M2)
|
||||
|
||||
proc update*(rn: var RewardNormalizer; r: float64) =
|
||||
rn.n += 1
|
||||
let delta = r - rn.mean
|
||||
rn.mean += delta / rn.n.float64
|
||||
let delta2 = r - rn.mean
|
||||
rn.m2 += delta * delta2
|
||||
|
||||
proc normalize*(rn: RewardNormalizer; r: float64): float64 =
|
||||
## Returns (r - mean) / (std + eps) once statistics are meaningful
|
||||
## (n >= 4 and spread well above zero). Before that, returns the RAW
|
||||
## reward unchanged — Welford M2 collapses to exactly 0 when early raw
|
||||
## rewards are identical, and dividing by the 1e-8 floor then z-scores
|
||||
## the first differing reward to ~1e8, poisoning TD targets.
|
||||
if rn.n < 4: return r
|
||||
let variance = rn.m2 / rn.n.float64 # ponytail: population var; switch to n-1 if bias matters
|
||||
let stddev = sqrt(variance)
|
||||
if stddev <= 1e-3 * (abs(rn.mean) + 1.0): return r
|
||||
# ponytail: warm-up pass-through ceiling — raw rewards bypass normalization
|
||||
# until stats are meaningful; upgrade = persist Welford state in checkpoint
|
||||
# if warm-up noise ever hurts learning.
|
||||
result = (r - rn.mean) / (stddev + NormEps)
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,117 @@
|
||||
## State vector module — produces a 35-dimensional normalized tensor for SAC+LSTM policy.
|
||||
## No bot API imports; takes plain data structs populated from game events.
|
||||
## The LSTM handles temporal context, so no explicit history window here.
|
||||
|
||||
import std/math
|
||||
import arraymancer
|
||||
|
||||
const STATE_DIM* = 35
|
||||
|
||||
type
|
||||
BulletData* = object
|
||||
## Enemy bullet in flight (absolute arena coords + fire power).
|
||||
x*, y*: float64
|
||||
power*: float64 # fire power in [0.1, 3.0]; speed = 20 - 3*power
|
||||
|
||||
EnemyData* = object
|
||||
## Current enemy state, from the most recent onScannedBot event.
|
||||
x*, y*: float64
|
||||
direction*: float64
|
||||
speed*: float64
|
||||
energy*: float64
|
||||
hasFired*: bool
|
||||
lastFirePower*: float64
|
||||
prevSpeed*: float64 # speed from the previous scan (for acceleration)
|
||||
prevDirection*: float64 # direction from the previous scan (for turn rate)
|
||||
hasPrevScan*: bool # true once we have at least two scans
|
||||
|
||||
GameState* = object
|
||||
## Accumulates data from bot events. Populate fields before calling buildState.
|
||||
# Own bot
|
||||
x*, y*: float64
|
||||
direction*: float64
|
||||
speed*: float64
|
||||
energy*: float64
|
||||
gunDirection*: float64
|
||||
gunHeat*: float64
|
||||
arenaWidth*, arenaHeight*: float64
|
||||
# Enemy
|
||||
hasContact*: bool
|
||||
enemy*: EnemyData
|
||||
ticksSinceLastScan*: int
|
||||
# Bullets in flight (up to 3 tracked)
|
||||
bullets*: array[3, BulletData]
|
||||
bulletCount*: int
|
||||
|
||||
proc buildState*(gs: GameState): Tensor[float32] =
|
||||
## Build the 35-float normalized state tensor.
|
||||
##
|
||||
## Layout:
|
||||
## [0-6] own bot: x/aW, y/aH, dir/360, speed/8, energy/100, gunDir/360, gunHeat/1.8
|
||||
## [7-13] enemy: x/aW, y/aH, dir/360, speed/8, energy/100, hasFired, lastFirePower/3
|
||||
## [14-17] derived: enemyAccel/8, enemyTurnRate/180, relBearing/180, distance/diag
|
||||
## [18-21] walls: top, bottom, left, right — each / max(aW,aH)
|
||||
## [22-33] bullets: up to 3 × (relX/aW, relY/aH, speed/20, ticksToImpact clamped to 1)
|
||||
## [34] scan staleness: ticksSinceLastScan/30 clamped to 1
|
||||
result = zeros[float32](STATE_DIM)
|
||||
|
||||
let aW = gs.arenaWidth
|
||||
let aH = gs.arenaHeight
|
||||
let diag = sqrt(aW * aW + aH * aH)
|
||||
let wMax = max(aW, aH)
|
||||
|
||||
# --- Own bot (0-6) ---
|
||||
result[0] = float32(gs.x / aW)
|
||||
result[1] = float32(gs.y / aH)
|
||||
result[2] = float32(gs.direction / 360.0)
|
||||
result[3] = float32(gs.speed / 8.0)
|
||||
result[4] = float32(gs.energy / 100.0)
|
||||
result[5] = float32(gs.gunDirection / 360.0)
|
||||
result[6] = float32(gs.gunHeat / 1.8)
|
||||
|
||||
# --- Enemy current (7-13) ---
|
||||
if gs.hasContact:
|
||||
result[7] = float32(gs.enemy.x / aW)
|
||||
result[8] = float32(gs.enemy.y / aH)
|
||||
result[9] = float32(gs.enemy.direction / 360.0)
|
||||
result[10] = float32(gs.enemy.speed / 8.0)
|
||||
result[11] = float32(gs.enemy.energy / 100.0)
|
||||
result[12] = float32(if gs.enemy.hasFired: 1.0 else: 0.0)
|
||||
result[13] = float32(gs.enemy.lastFirePower / 3.0)
|
||||
|
||||
# --- Derived (14-17) ---
|
||||
if gs.hasContact:
|
||||
if gs.enemy.hasPrevScan:
|
||||
result[14] = float32((gs.enemy.speed - gs.enemy.prevSpeed) / 8.0)
|
||||
let dDir = ((gs.enemy.direction - gs.enemy.prevDirection) + 540.0) mod 360.0 - 180.0
|
||||
result[15] = float32(dDir / 180.0)
|
||||
let dx = gs.enemy.x - gs.x
|
||||
let dy = gs.enemy.y - gs.y
|
||||
let absDir = (180.0 * arctan2(dx, dy) / PI + 360.0) mod 360.0
|
||||
let relBearing = ((absDir - gs.direction) + 540.0) mod 360.0 - 180.0
|
||||
result[16] = float32(relBearing / 180.0)
|
||||
result[17] = float32(sqrt(dx * dx + dy * dy) / diag)
|
||||
|
||||
# --- Wall distances (18-21): top, bottom, left, right ---
|
||||
result[18] = float32((aH - gs.y) / wMax)
|
||||
result[19] = float32(gs.y / wMax)
|
||||
result[20] = float32(gs.x / wMax)
|
||||
result[21] = float32((aW - gs.x) / wMax)
|
||||
|
||||
# --- Bullet tracking (22-33): up to 3 bullets × 4 floats ---
|
||||
# Per slot: relX/aW, relY/aH, speed/20, ticksToImpact/diag (clamped to 1)
|
||||
for i in 0 ..< min(gs.bulletCount, 3):
|
||||
let b = gs.bullets[i]
|
||||
let bSpd = 20.0 - 3.0 * b.power
|
||||
let bdx = b.x - gs.x
|
||||
let bdy = b.y - gs.y
|
||||
let bdist = sqrt(bdx * bdx + bdy * bdy)
|
||||
let ticks = if bSpd > 0.0: min(bdist / bSpd / diag, 1.0) else: 0.0
|
||||
let base = 22 + i * 4
|
||||
result[base + 0] = float32(bdx / aW)
|
||||
result[base + 1] = float32(bdy / aH)
|
||||
result[base + 2] = float32(bSpd / 20.0)
|
||||
result[base + 3] = float32(ticks)
|
||||
|
||||
# --- Scan staleness (34) ---
|
||||
result[34] = float32(min(gs.ticksSinceLastScan.float64 / 30.0, 1.0))
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,611 @@
|
||||
## training.nim — SAC-v2 update for the LSTM Actor + twin Critic.
|
||||
## Manual backprop; no autograd. Uses Arraymancer tensors throughout.
|
||||
##
|
||||
## Config env vars:
|
||||
## SACLSTM_LR_ACTOR (default: 3e-4)
|
||||
## SACLSTM_LR_CRITIC (default: 3e-4)
|
||||
## SACLSTM_LR_ALPHA (default: 3e-4)
|
||||
## SACLSTM_GAMMA (default: 0.99)
|
||||
## SACLSTM_TAU (default: 0.005)
|
||||
## SACLSTM_TARGET_ENTROPY (default: -4.0)
|
||||
|
||||
import arraymancer except Linear
|
||||
import std/[math, os, strutils]
|
||||
import SAC_LSTM_Bot/network
|
||||
import SAC_LSTM_Bot/weights
|
||||
import SAC_LSTM_Bot/replay_buffer
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
proc getLrActor*(): float32 = parseFloat(getEnv("SACLSTM_LR_ACTOR", "3e-4")).float32
|
||||
proc getLrCritic*(): float32 = parseFloat(getEnv("SACLSTM_LR_CRITIC", "3e-4")).float32
|
||||
proc getLrAlpha*(): float32 = parseFloat(getEnv("SACLSTM_LR_ALPHA", "3e-4")).float32
|
||||
proc getGamma*(): float32 = parseFloat(getEnv("SACLSTM_GAMMA", "0.99")).float32
|
||||
proc getTau*(): float32 = parseFloat(getEnv("SACLSTM_TAU", "0.005")).float32
|
||||
proc getTargetEntropy*(): float32 =
|
||||
parseFloat(getEnv("SACLSTM_TARGET_ENTROPY", "-4.0")).float32
|
||||
|
||||
# ── SACTrainer ────────────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
SACTrainer* = object
|
||||
actor*: ActorNet
|
||||
critic1*: CriticNet
|
||||
critic2*: CriticNet
|
||||
targetCritic1*: CriticNet
|
||||
targetCritic2*: CriticNet
|
||||
logAlpha*: float32 ## log of entropy temperature; alpha = exp(logAlpha)
|
||||
targetEntropy*: float32
|
||||
tau*: float32
|
||||
lrActor*: float32
|
||||
lrCritic*: float32
|
||||
lrAlpha*: float32
|
||||
gamma*: float32
|
||||
adam*: SACAdamStates
|
||||
|
||||
SACMetrics* = object
|
||||
criticLoss*: float32
|
||||
actorLoss*: float32
|
||||
alphaLoss*: float32
|
||||
alpha*: float32
|
||||
|
||||
proc initSACTrainer*(stateDim, actionDim: int): SACTrainer =
|
||||
result.actor = initActorNet(stateDim)
|
||||
result.critic1 = initCriticNet(stateDim, actionDim)
|
||||
result.critic2 = initCriticNet(stateDim, actionDim)
|
||||
result.targetCritic1 = result.critic1
|
||||
result.targetCritic2 = result.critic2
|
||||
result.logAlpha = 0.0'f32
|
||||
result.targetEntropy = getTargetEntropy()
|
||||
result.tau = getTau()
|
||||
result.lrActor = getLrActor()
|
||||
result.lrCritic = getLrCritic()
|
||||
result.lrAlpha = getLrAlpha()
|
||||
result.gamma = getGamma()
|
||||
result.adam = initSACAdamStates(result.actor, result.critic1, result.critic2)
|
||||
|
||||
proc alpha*(t: SACTrainer): float32 = exp(t.logAlpha)
|
||||
|
||||
# ── Adam steps ────────────────────────────────────────────────────────────────
|
||||
|
||||
proc adamStepScalar(param: var float32; grad: float32;
|
||||
state: var AdamVar; lr: float32) =
|
||||
## Scalar Adam for logAlpha (state.m/v are shape [1] tensors).
|
||||
inc state.t
|
||||
let b1 = 0.9'f32; let b2 = 0.999'f32; let eps = 1e-8'f32
|
||||
state.m[0] = b1 * state.m[0] + (1.0'f32 - b1) * grad
|
||||
state.v[0] = b2 * state.v[0] + (1.0'f32 - b2) * grad * grad
|
||||
let mHat = state.m[0] / (1.0'f32 - b1 ^ state.t.float32)
|
||||
let vHat = state.v[0] / (1.0'f32 - b2 ^ state.t.float32)
|
||||
param -= lr * mHat / (sqrt(vHat) + eps)
|
||||
|
||||
proc adamStepTensor(param: var Tensor[float32]; grad: Tensor[float32];
|
||||
state: var AdamVar; lr: float32) =
|
||||
## Tensor Adam (same pattern as PPO_Bot/training.nim adamStep).
|
||||
inc state.t
|
||||
let b1 = 0.9'f32; let b2 = 0.999'f32; let eps = 1e-8'f32
|
||||
state.m = b1 *. state.m + (1.0'f32 - b1) *. grad
|
||||
state.v = b2 *. state.v + (1.0'f32 - b2) *. (grad *. grad)
|
||||
let mHat = state.m /. (1.0'f32 - b1 ^ state.t.float32)
|
||||
let vHat = state.v /. (1.0'f32 - b2 ^ state.t.float32)
|
||||
param -= lr *. mHat /. vHat.map(proc(x: float32): float32 = sqrt(x) + eps)
|
||||
|
||||
# ── Gradient clipping ─────────────────────────────────────────────────────────
|
||||
|
||||
proc globalNorm(grads: seq[Tensor[float32]]): float32 =
|
||||
var sumSq = 0.0'f32
|
||||
for g in grads:
|
||||
for v in g: sumSq += v * v
|
||||
sqrt(sumSq)
|
||||
|
||||
proc clipGrads(grads: var seq[Tensor[float32]]; maxNorm: float32) =
|
||||
let norm = globalNorm(grads)
|
||||
if norm > maxNorm and norm == norm:
|
||||
let scale = maxNorm / norm
|
||||
for g in grads.mitems: g = g *. scale
|
||||
|
||||
# ── Forward caches (for backprop) ─────────────────────────────────────────────
|
||||
|
||||
type
|
||||
LinearFwd = object
|
||||
inp, pre, act: Tensor[float32] # input, pre-relu, post-relu (or linear)
|
||||
|
||||
proc linearReluFwd(l: Linear; x: Tensor[float32]): LinearFwd =
|
||||
result.inp = x
|
||||
result.pre = l.w * x + l.b
|
||||
result.act = relu(result.pre)
|
||||
|
||||
proc linearFwd(l: Linear; x: Tensor[float32]): LinearFwd =
|
||||
result.inp = x
|
||||
result.pre = l.w * x + l.b
|
||||
result.act = result.pre # no nonlinearity
|
||||
|
||||
type
|
||||
LSTMFwdCache = object
|
||||
xh, gatesPre: Tensor[float32] # [inputDim+hd], [4*hd]
|
||||
iGate, fGate, gGate, oGate: Tensor[float32] # [hd] each
|
||||
cPrev, cPrime, hPrime: Tensor[float32] # [hd] each
|
||||
|
||||
proc lstmStepCached(cell: LSTMCell; x, h, c: Tensor[float32]): LSTMFwdCache =
|
||||
result.cPrev = c
|
||||
result.xh = concat(x, h, axis = 0)
|
||||
result.gatesPre = cell.wCombined * result.xh + cell.bCombined
|
||||
let hd = cell.hiddenDim
|
||||
result.iGate = sigmoid(result.gatesPre[0 ..< hd])
|
||||
result.fGate = sigmoid(result.gatesPre[hd ..< 2*hd])
|
||||
result.gGate = tanhT(result.gatesPre[2*hd ..< 3*hd])
|
||||
result.oGate = sigmoid(result.gatesPre[3*hd ..< 4*hd])
|
||||
result.cPrime = result.fGate *. c + result.iGate *. result.gGate
|
||||
result.hPrime = result.oGate *. tanhT(result.cPrime)
|
||||
|
||||
# ── Backward helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
proc reluGrad(pre, dAct: Tensor[float32]): Tensor[float32] =
|
||||
result = newTensor[float32](dAct.shape)
|
||||
for i in 0 ..< dAct.shape[0]:
|
||||
result[i] = if pre[i] > 0.0'f32: dAct[i] else: 0.0'f32
|
||||
|
||||
## Linear layer backward: returns (dx, dw, db) given upstream grad dAct.
|
||||
## If hasRelu, applies relu' gate before computing gradients.
|
||||
proc linearBack(w: Tensor[float32]; fwd: LinearFwd;
|
||||
dAct: Tensor[float32]; hasRelu: bool):
|
||||
tuple[dx, dw, db: Tensor[float32]] =
|
||||
let dPre = if hasRelu: reluGrad(fwd.pre, dAct) else: dAct
|
||||
result.dw = dPre.unsqueeze(1) * fwd.inp.unsqueeze(0) # [out, in]
|
||||
result.db = dPre
|
||||
result.dx = w.transpose * dPre # [in]
|
||||
|
||||
## LSTM single-step backward. dHPrime: [hd], dCPrime: [hd] (use zeros for truncated BPTT).
|
||||
## Returns (dwCombined, dbCombined, dxh).
|
||||
proc lstmBack(cell: LSTMCell; cache: LSTMFwdCache;
|
||||
dHPrime, dCPrime: Tensor[float32]):
|
||||
tuple[dwCombined, dbCombined, dxh: Tensor[float32]] =
|
||||
let hd = cell.hiddenDim
|
||||
let tanhCPrime = tanhT(cache.cPrime)
|
||||
|
||||
# Output gate
|
||||
let dOGate_post = dHPrime *. tanhCPrime
|
||||
# Cell state: gradient from h' and from downstream dCPrime
|
||||
let dCPrimeTotal = dHPrime *. cache.oGate *.
|
||||
(ones[float32](hd) - tanhCPrime *. tanhCPrime) + dCPrime
|
||||
|
||||
# Gate post-activation gradients
|
||||
let dFGate_post = dCPrimeTotal *. cache.cPrev
|
||||
let dIGate_post = dCPrimeTotal *. cache.gGate
|
||||
let dGGate_post = dCPrimeTotal *. cache.iGate
|
||||
|
||||
# Gate pre-activation gradients (sigmoid', tanh')
|
||||
let dIPre = dIGate_post *. cache.iGate *. (ones[float32](hd) - cache.iGate)
|
||||
let dFPre = dFGate_post *. cache.fGate *. (ones[float32](hd) - cache.fGate)
|
||||
let dGPre = dGGate_post *. (ones[float32](hd) - cache.gGate *. cache.gGate)
|
||||
let dOPre = dOGate_post *. cache.oGate *. (ones[float32](hd) - cache.oGate)
|
||||
|
||||
# Concatenated gate gradient [4*hd]
|
||||
let dGatesPre = concat(dIPre, dFPre, dGPre, dOPre, axis = 0)
|
||||
|
||||
result.dwCombined = dGatesPre.unsqueeze(1) * cache.xh.unsqueeze(0)
|
||||
result.dbCombined = dGatesPre
|
||||
result.dxh = cell.wCombined.transpose * dGatesPre
|
||||
|
||||
# ── Squashed-Gaussian log-prob and its gradients ──────────────────────────────
|
||||
|
||||
const
|
||||
LOG_PROB_EPS = 1e-6'f32
|
||||
LOG_STD_MIN = -5.0'f32
|
||||
LOG_STD_MAX = 2.0'f32
|
||||
|
||||
## Given stored mu, clamped logStd, and sampled action = tanh(z), recover
|
||||
## log π(a|s) and gradients w.r.t. mu and logStd.
|
||||
proc squashedLogProb(mu, logStd, action: Tensor[float32]):
|
||||
tuple[logProb: float32;
|
||||
dLogProbDMu, dLogProbDLogStd: Tensor[float32]] =
|
||||
let std = logStd.map(proc(v: float32): float32 = exp(v))
|
||||
let z = mu # deterministic reparam: action = tanh(mu), so z ≡ mu, diff ≡ 0
|
||||
result.dLogProbDMu = newTensor[float32](4)
|
||||
result.dLogProbDLogStd = newTensor[float32](4)
|
||||
let twoPiLog = 0.5'f32 * ln(2.0'f32 * PI.float32)
|
||||
var lp = 0.0'f32
|
||||
for i in 0 ..< 4:
|
||||
let diff = (z[i] - mu[i]) / std[i]
|
||||
let logNorm = -0.5'f32 * diff * diff - ln(std[i]) - twoPiLog
|
||||
let tanhCorr = ln(1.0'f32 - action[i] * action[i] + LOG_PROB_EPS)
|
||||
lp += logNorm - tanhCorr
|
||||
result.dLogProbDMu[i] = diff / std[i] # (z-mu)/std²
|
||||
result.dLogProbDLogStd[i] = diff * diff - 1.0'f32 # d logN / d logStd
|
||||
result.logProb = lp
|
||||
|
||||
# ── Critic forward with activation cache ──────────────────────────────────────
|
||||
|
||||
type
|
||||
CriticFwdCache = object
|
||||
fc1: LinearFwd
|
||||
lstm: LSTMFwdCache
|
||||
fc2: LinearFwd
|
||||
fc3: LinearFwd
|
||||
q: float32
|
||||
|
||||
proc criticFwdCached(net: CriticNet; stateAction: Tensor[float32];
|
||||
h, c: Tensor[float32]): CriticFwdCache =
|
||||
result.fc1 = linearReluFwd(net.fc1, stateAction)
|
||||
result.lstm = lstmStepCached(net.lstm, result.fc1.act, h, c)
|
||||
result.fc2 = linearReluFwd(net.fc2, result.lstm.hPrime)
|
||||
result.fc3 = linearFwd(net.fc3, result.fc2.act)
|
||||
result.q = result.fc3.act[0]
|
||||
|
||||
# ── Critic backward ───────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
CriticGrads = object
|
||||
dFc1W, dFc1B: Tensor[float32]
|
||||
dFc2W, dFc2B: Tensor[float32]
|
||||
dFc3W, dFc3B: Tensor[float32]
|
||||
dLstmW, dLstmB: Tensor[float32]
|
||||
dInput: Tensor[float32] ## grad w.r.t. stateAction input
|
||||
|
||||
proc criticBack(net: CriticNet; cache: CriticFwdCache; dQ: float32): CriticGrads =
|
||||
let dFc3Act = [dQ].toTensor()
|
||||
let fc3b = linearBack(net.fc3.w, cache.fc3, dFc3Act, hasRelu = false)
|
||||
result.dFc3W = fc3b.dw; result.dFc3B = fc3b.db
|
||||
|
||||
let fc2b = linearBack(net.fc2.w, cache.fc2, fc3b.dx, hasRelu = true)
|
||||
result.dFc2W = fc2b.dw; result.dFc2B = fc2b.db
|
||||
|
||||
let zeros_hd = zeros[float32](net.hiddenDim)
|
||||
let lstmb = lstmBack(net.lstm, cache.lstm, fc2b.dx, zeros_hd)
|
||||
result.dLstmW = lstmb.dwCombined; result.dLstmB = lstmb.dbCombined
|
||||
|
||||
# xh = [fc1.act | h_prev], dx is the x-part (fc1 output dim = hiddenDim)
|
||||
let dLstmX = lstmb.dxh[0 ..< cache.fc1.act.shape[0]]
|
||||
let fc1b = linearBack(net.fc1.w, cache.fc1, dLstmX, hasRelu = true)
|
||||
result.dFc1W = fc1b.dw; result.dFc1B = fc1b.db
|
||||
result.dInput = fc1b.dx # [stateDim + actionDim]
|
||||
|
||||
# ── Actor forward with activation cache ───────────────────────────────────────
|
||||
|
||||
type
|
||||
ActorFwdCache = object
|
||||
fc1: LinearFwd
|
||||
lstm: LSTMFwdCache
|
||||
fc2: LinearFwd
|
||||
muHead: LinearFwd
|
||||
lsHead: LinearFwd ## logStd head
|
||||
mu: Tensor[float32] ## [4]
|
||||
logStd: Tensor[float32] ## [4] clamped
|
||||
action: Tensor[float32] ## [4] tanh(mu) — deterministic for gradient
|
||||
|
||||
proc actorFwdCached(net: ActorNet; state: Tensor[float32];
|
||||
h, c: Tensor[float32]): ActorFwdCache =
|
||||
result.fc1 = linearReluFwd(net.fc1, state)
|
||||
result.lstm = lstmStepCached(net.lstm, result.fc1.act, h, c)
|
||||
result.fc2 = linearReluFwd(net.fc2, result.lstm.hPrime)
|
||||
result.muHead = linearFwd(net.muHead, result.fc2.act)
|
||||
result.lsHead = linearFwd(net.logStdHead, result.fc2.act)
|
||||
result.mu = result.muHead.act
|
||||
result.logStd = result.lsHead.act.map(
|
||||
proc(v: float32): float32 = clamp(v, LOG_STD_MIN, LOG_STD_MAX))
|
||||
# Use tanh(mu) as the action for gradient computation (reparameterization).
|
||||
# ponytail: deterministic here; add stochastic sample if off-policy bias matters.
|
||||
result.action = tanhT(result.mu)
|
||||
|
||||
# ── Actor backward ────────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
ActorGrads = object
|
||||
dFc1W, dFc1B: Tensor[float32]
|
||||
dFc2W, dFc2B: Tensor[float32]
|
||||
dMuW, dMuB: Tensor[float32]
|
||||
dLogStdW, dLogStdB: Tensor[float32]
|
||||
dLstmW, dLstmB: Tensor[float32]
|
||||
|
||||
proc actorBack(net: ActorNet; cache: ActorFwdCache;
|
||||
dMu, dLogStd: Tensor[float32]): ActorGrads =
|
||||
let muBack = linearBack(net.muHead.w, cache.muHead, dMu, hasRelu = false)
|
||||
result.dMuW = muBack.dw; result.dMuB = muBack.db
|
||||
|
||||
let lsBack = linearBack(net.logStdHead.w, cache.lsHead, dLogStd, hasRelu = false)
|
||||
result.dLogStdW = lsBack.dw; result.dLogStdB = lsBack.db
|
||||
|
||||
# fc2 gets grads from both output heads
|
||||
let fc2b = linearBack(net.fc2.w, cache.fc2, muBack.dx + lsBack.dx, hasRelu = true)
|
||||
result.dFc2W = fc2b.dw; result.dFc2B = fc2b.db
|
||||
|
||||
let zeros_hd = zeros[float32](net.hiddenDim)
|
||||
let lstmb = lstmBack(net.lstm, cache.lstm, fc2b.dx, zeros_hd)
|
||||
result.dLstmW = lstmb.dwCombined; result.dLstmB = lstmb.dbCombined
|
||||
|
||||
let dLstmX = lstmb.dxh[0 ..< cache.fc1.act.shape[0]]
|
||||
let fc1b = linearBack(net.fc1.w, cache.fc1, dLstmX, hasRelu = true)
|
||||
result.dFc1W = fc1b.dw; result.dFc1B = fc1b.db
|
||||
|
||||
# ── Adam application ──────────────────────────────────────────────────────────
|
||||
|
||||
proc applyActorAdam(net: var ActorNet; g: ActorGrads;
|
||||
adam: var ActorAdam; lr: float32) =
|
||||
adamStepTensor(net.fc1.w, g.dFc1W, adam.fc1.w, lr)
|
||||
adamStepTensor(net.fc1.b, g.dFc1B, adam.fc1.b, lr)
|
||||
adamStepTensor(net.fc2.w, g.dFc2W, adam.fc2.w, lr)
|
||||
adamStepTensor(net.fc2.b, g.dFc2B, adam.fc2.b, lr)
|
||||
adamStepTensor(net.muHead.w, g.dMuW, adam.muHead.w, lr)
|
||||
adamStepTensor(net.muHead.b, g.dMuB, adam.muHead.b, lr)
|
||||
adamStepTensor(net.logStdHead.w, g.dLogStdW, adam.logStdHead.w, lr)
|
||||
adamStepTensor(net.logStdHead.b, g.dLogStdB, adam.logStdHead.b, lr)
|
||||
adamStepTensor(net.lstm.wCombined, g.dLstmW, adam.lstm.wCombined, lr)
|
||||
adamStepTensor(net.lstm.bCombined, g.dLstmB, adam.lstm.bCombined, lr)
|
||||
|
||||
proc applyCriticAdam(net: var CriticNet; g: CriticGrads;
|
||||
adam: var CriticAdam; lr: float32) =
|
||||
adamStepTensor(net.fc1.w, g.dFc1W, adam.fc1.w, lr)
|
||||
adamStepTensor(net.fc1.b, g.dFc1B, adam.fc1.b, lr)
|
||||
adamStepTensor(net.fc2.w, g.dFc2W, adam.fc2.w, lr)
|
||||
adamStepTensor(net.fc2.b, g.dFc2B, adam.fc2.b, lr)
|
||||
adamStepTensor(net.fc3.w, g.dFc3W, adam.fc3.w, lr)
|
||||
adamStepTensor(net.fc3.b, g.dFc3B, adam.fc3.b, lr)
|
||||
adamStepTensor(net.lstm.wCombined, g.dLstmW, adam.lstm.wCombined, lr)
|
||||
adamStepTensor(net.lstm.bCombined, g.dLstmB, adam.lstm.bCombined, lr)
|
||||
|
||||
# ── Soft target update ────────────────────────────────────────────────────────
|
||||
|
||||
proc softUpdateLinear(target: var Linear; src: Linear; tau: float32) =
|
||||
target.w = tau *. src.w + (1.0'f32 - tau) *. target.w
|
||||
target.b = tau *. src.b + (1.0'f32 - tau) *. target.b
|
||||
|
||||
proc softUpdateLSTM(target: var LSTMCell; src: LSTMCell; tau: float32) =
|
||||
target.wCombined = tau *. src.wCombined + (1.0'f32 - tau) *. target.wCombined
|
||||
target.bCombined = tau *. src.bCombined + (1.0'f32 - tau) *. target.bCombined
|
||||
|
||||
proc softUpdateCritic(target: var CriticNet; src: CriticNet; tau: float32) =
|
||||
softUpdateLinear(target.fc1, src.fc1, tau)
|
||||
softUpdateLSTM(target.lstm, src.lstm, tau)
|
||||
softUpdateLinear(target.fc2, src.fc2, tau)
|
||||
softUpdateLinear(target.fc3, src.fc3, tau)
|
||||
|
||||
# ── Gradient accumulators ─────────────────────────────────────────────────────
|
||||
|
||||
proc zeroCriticGrads(net: CriticNet): CriticGrads =
|
||||
result.dFc1W = zeros[float32](net.fc1.w.shape)
|
||||
result.dFc1B = zeros[float32](net.fc1.b.shape)
|
||||
result.dFc2W = zeros[float32](net.fc2.w.shape)
|
||||
result.dFc2B = zeros[float32](net.fc2.b.shape)
|
||||
result.dFc3W = zeros[float32](net.fc3.w.shape)
|
||||
result.dFc3B = zeros[float32](net.fc3.b.shape)
|
||||
result.dLstmW = zeros[float32](net.lstm.wCombined.shape)
|
||||
result.dLstmB = zeros[float32](net.lstm.bCombined.shape)
|
||||
result.dInput = zeros[float32](net.fc1.w.shape[1]) # [stateDim+actionDim]
|
||||
|
||||
proc zeroActorGrads(net: ActorNet): ActorGrads =
|
||||
result.dFc1W = zeros[float32](net.fc1.w.shape)
|
||||
result.dFc1B = zeros[float32](net.fc1.b.shape)
|
||||
result.dFc2W = zeros[float32](net.fc2.w.shape)
|
||||
result.dFc2B = zeros[float32](net.fc2.b.shape)
|
||||
result.dMuW = zeros[float32](net.muHead.w.shape)
|
||||
result.dMuB = zeros[float32](net.muHead.b.shape)
|
||||
result.dLogStdW = zeros[float32](net.logStdHead.w.shape)
|
||||
result.dLogStdB = zeros[float32](net.logStdHead.b.shape)
|
||||
result.dLstmW = zeros[float32](net.lstm.wCombined.shape)
|
||||
result.dLstmB = zeros[float32](net.lstm.bCombined.shape)
|
||||
|
||||
proc addCriticGrads(a: var CriticGrads; b: CriticGrads) =
|
||||
a.dFc1W += b.dFc1W; a.dFc1B += b.dFc1B
|
||||
a.dFc2W += b.dFc2W; a.dFc2B += b.dFc2B
|
||||
a.dFc3W += b.dFc3W; a.dFc3B += b.dFc3B
|
||||
a.dLstmW += b.dLstmW; a.dLstmB += b.dLstmB
|
||||
# dInput not accumulated (not used for parameter update)
|
||||
|
||||
proc addActorGrads(a: var ActorGrads; b: ActorGrads) =
|
||||
a.dFc1W += b.dFc1W; a.dFc1B += b.dFc1B
|
||||
a.dFc2W += b.dFc2W; a.dFc2B += b.dFc2B
|
||||
a.dMuW += b.dMuW; a.dMuB += b.dMuB
|
||||
a.dLogStdW += b.dLogStdW; a.dLogStdB += b.dLogStdB
|
||||
a.dLstmW += b.dLstmW; a.dLstmB += b.dLstmB
|
||||
|
||||
proc scaleCriticGrads(g: var CriticGrads; s: float32) =
|
||||
g.dFc1W = g.dFc1W *. s; g.dFc1B = g.dFc1B *. s
|
||||
g.dFc2W = g.dFc2W *. s; g.dFc2B = g.dFc2B *. s
|
||||
g.dFc3W = g.dFc3W *. s; g.dFc3B = g.dFc3B *. s
|
||||
g.dLstmW = g.dLstmW *. s; g.dLstmB = g.dLstmB *. s
|
||||
|
||||
proc scaleActorGrads(g: var ActorGrads; s: float32) =
|
||||
g.dFc1W = g.dFc1W *. s; g.dFc1B = g.dFc1B *. s
|
||||
g.dFc2W = g.dFc2W *. s; g.dFc2B = g.dFc2B *. s
|
||||
g.dMuW = g.dMuW *. s; g.dMuB = g.dMuB *. s
|
||||
g.dLogStdW = g.dLogStdW *. s; g.dLogStdB = g.dLogStdB *. s
|
||||
g.dLstmW = g.dLstmW *. s; g.dLstmB = g.dLstmB *. s
|
||||
|
||||
proc criticGradsAsSeq(g: CriticGrads): seq[Tensor[float32]] =
|
||||
@[g.dFc1W, g.dFc1B, g.dFc2W, g.dFc2B, g.dFc3W, g.dFc3B, g.dLstmW, g.dLstmB]
|
||||
|
||||
proc applyClipToCritic(g: var CriticGrads; maxNorm: float32) =
|
||||
var gs = criticGradsAsSeq(g)
|
||||
clipGrads(gs, maxNorm)
|
||||
g.dFc1W = gs[0]; g.dFc1B = gs[1]
|
||||
g.dFc2W = gs[2]; g.dFc2B = gs[3]
|
||||
g.dFc3W = gs[4]; g.dFc3B = gs[5]
|
||||
g.dLstmW = gs[6]; g.dLstmB = gs[7]
|
||||
|
||||
proc applyClipToActor(g: var ActorGrads; maxNorm: float32) =
|
||||
var gs = @[g.dFc1W, g.dFc1B, g.dFc2W, g.dFc2B,
|
||||
g.dMuW, g.dMuB, g.dLogStdW, g.dLogStdB, g.dLstmW, g.dLstmB]
|
||||
clipGrads(gs, maxNorm)
|
||||
g.dFc1W = gs[0]; g.dFc1B = gs[1]
|
||||
g.dFc2W = gs[2]; g.dFc2B = gs[3]
|
||||
g.dMuW = gs[4]; g.dMuB = gs[5]
|
||||
g.dLogStdW = gs[6]; g.dLogStdB = gs[7]
|
||||
g.dLstmW = gs[8]; g.dLstmB = gs[9]
|
||||
|
||||
# ── SAC update ────────────────────────────────────────────────────────────────
|
||||
|
||||
proc sacUpdate*(trainer: var SACTrainer; sequences: seq[Sequence]): SACMetrics =
|
||||
## One SAC-v2 update given a batch of sequences. No-op if empty.
|
||||
if sequences.len == 0: return
|
||||
|
||||
let N = sequences.len.float32
|
||||
let alph = trainer.alpha()
|
||||
let gamma = trainer.gamma
|
||||
|
||||
var totalCriticLoss = 0.0'f32
|
||||
var totalActorLoss = 0.0'f32
|
||||
var totalAlphaLoss = 0.0'f32
|
||||
|
||||
var accC1Grads = zeroCriticGrads(trainer.critic1)
|
||||
var accC2Grads = zeroCriticGrads(trainer.critic2)
|
||||
var accAGrads = zeroActorGrads(trainer.actor)
|
||||
var dLogAlpha = 0.0'f32
|
||||
|
||||
for sq in sequences:
|
||||
# ── 1. Burn-in: warm up hidden states, no gradient ──────────────────────
|
||||
var actorH = zeros[float32](trainer.actor.hiddenDim)
|
||||
var actorC = zeros[float32](trainer.actor.hiddenDim)
|
||||
var c1H = zeros[float32](trainer.critic1.hiddenDim)
|
||||
var c1C = zeros[float32](trainer.critic1.hiddenDim)
|
||||
var c2H = zeros[float32](trainer.critic2.hiddenDim)
|
||||
var c2C = zeros[float32](trainer.critic2.hiddenDim)
|
||||
var tc1H = zeros[float32](trainer.targetCritic1.hiddenDim)
|
||||
var tc1C = zeros[float32](trainer.targetCritic1.hiddenDim)
|
||||
var tc2H = zeros[float32](trainer.targetCritic2.hiddenDim)
|
||||
var tc2C = zeros[float32](trainer.targetCritic2.hiddenDim)
|
||||
|
||||
for tr in sq.burnIn:
|
||||
let sa = concat(tr.state, tr.action, axis = 0)
|
||||
let af = lstmStepCached(trainer.actor.lstm,
|
||||
relu(trainer.actor.fc1.linear(tr.state)), actorH, actorC)
|
||||
actorH = af.hPrime; actorC = af.cPrime
|
||||
let c1f = lstmStepCached(trainer.critic1.lstm,
|
||||
relu(trainer.critic1.fc1.linear(sa)), c1H, c1C)
|
||||
c1H = c1f.hPrime; c1C = c1f.cPrime
|
||||
let c2f = lstmStepCached(trainer.critic2.lstm,
|
||||
relu(trainer.critic2.fc1.linear(sa)), c2H, c2C)
|
||||
c2H = c2f.hPrime; c2C = c2f.cPrime
|
||||
let tc1f = lstmStepCached(trainer.targetCritic1.lstm,
|
||||
relu(trainer.targetCritic1.fc1.linear(sa)), tc1H, tc1C)
|
||||
tc1H = tc1f.hPrime; tc1C = tc1f.cPrime
|
||||
let tc2f = lstmStepCached(trainer.targetCritic2.lstm,
|
||||
relu(trainer.targetCritic2.fc1.linear(sa)), tc2H, tc2C)
|
||||
tc2H = tc2f.hPrime; tc2C = tc2f.cPrime
|
||||
|
||||
# ── 2–4. Training window ─────────────────────────────────────────────────
|
||||
let T = sq.train.len.float32
|
||||
|
||||
var seqC1Grads = zeroCriticGrads(trainer.critic1)
|
||||
var seqC2Grads = zeroCriticGrads(trainer.critic2)
|
||||
var seqAGrads = zeroActorGrads(trainer.actor)
|
||||
var seqDLogAlpha = 0.0'f32
|
||||
|
||||
for tr in sq.train:
|
||||
let s = tr.state
|
||||
let a = tr.action
|
||||
let r = tr.reward
|
||||
let sn = tr.nextState
|
||||
let d = if tr.done: 0.0'f32 else: 1.0'f32
|
||||
let sa = concat(s, a, axis = 0)
|
||||
|
||||
# ── 2. Critic update ─────────────────────────────────────────────────
|
||||
|
||||
let c1Cache = criticFwdCached(trainer.critic1, sa, c1H, c1C)
|
||||
let c2Cache = criticFwdCached(trainer.critic2, sa, c2H, c2C)
|
||||
|
||||
# ── 3. Actor update (run first to get actorFwd on s before advancing h/c) ─
|
||||
|
||||
let actorFwd = actorFwdCached(trainer.actor, s, actorH, actorC)
|
||||
let aCurr = actorFwd.action
|
||||
let lpResult = squashedLogProb(actorFwd.mu, actorFwd.logStd, aCurr)
|
||||
let logProbA = lpResult.logProb
|
||||
|
||||
# Advance actor hidden state from s → sn before computing actorNxt
|
||||
actorH = actorFwd.lstm.hPrime; actorC = actorFwd.lstm.cPrime
|
||||
|
||||
# Next-state action from current actor (uses h/c advanced through s)
|
||||
let actorNxt = actorFwdCached(trainer.actor, sn, actorH, actorC)
|
||||
let aN = actorNxt.action
|
||||
let lpN = squashedLogProb(actorNxt.mu, actorNxt.logStd, aN).logProb
|
||||
let saN = concat(sn, aN, axis = 0)
|
||||
|
||||
# Target Q
|
||||
let tc1Cache = criticFwdCached(trainer.targetCritic1, saN, tc1H, tc1C)
|
||||
let tc2Cache = criticFwdCached(trainer.targetCritic2, saN, tc2H, tc2C)
|
||||
let minQTarg = min(tc1Cache.q, tc2Cache.q)
|
||||
|
||||
# Bellman target
|
||||
let y = r + gamma * d * (minQTarg - alph * lpN)
|
||||
let errQ1 = c1Cache.q - y
|
||||
let errQ2 = c2Cache.q - y
|
||||
totalCriticLoss += 0.5'f32 * (errQ1 * errQ1 + errQ2 * errQ2)
|
||||
|
||||
# MSE gradient: d_loss/d_q = (q - y) [scaling applied at accumulation]
|
||||
addCriticGrads(seqC1Grads, criticBack(trainer.critic1, c1Cache, errQ1))
|
||||
addCriticGrads(seqC2Grads, criticBack(trainer.critic2, c2Cache, errQ2))
|
||||
|
||||
# Advance critic hidden states
|
||||
c1H = c1Cache.lstm.hPrime; c1C = c1Cache.lstm.cPrime
|
||||
c2H = c2Cache.lstm.hPrime; c2C = c2Cache.lstm.cPrime
|
||||
tc1H = tc1Cache.lstm.hPrime; tc1C = tc1Cache.lstm.cPrime
|
||||
tc2H = tc2Cache.lstm.hPrime; tc2C = tc2Cache.lstm.cPrime
|
||||
|
||||
# ── 3 (cont). Actor gradient via critic ─────────────────────────────────
|
||||
|
||||
# Q-values for current policy action (critics used as frozen estimators)
|
||||
let saCurr = concat(s, aCurr, axis = 0)
|
||||
let qA1Cache = criticFwdCached(trainer.critic1, saCurr, c1H, c1C)
|
||||
let qA2Cache = criticFwdCached(trainer.critic2, saCurr, c2H, c2C)
|
||||
let q1Val = qA1Cache.q
|
||||
let q2Val = qA2Cache.q
|
||||
let qA1Back = criticBack(trainer.critic1, qA1Cache, -1.0'f32)
|
||||
let qA2Back = criticBack(trainer.critic2, qA2Cache, -1.0'f32)
|
||||
totalActorLoss += alph * logProbA - min(q1Val, q2Val)
|
||||
|
||||
# Gradient of -minQ w.r.t. action = dInput[stateDim ..< stateDim+actionDim]
|
||||
# from the critic whose Q was smaller.
|
||||
let minQBack = if q1Val <= q2Val: qA1Back else: qA2Back
|
||||
let stateDim = s.shape[0]
|
||||
let actionDim = aCurr.shape[0]
|
||||
let dQdA = minQBack.dInput[stateDim ..< stateDim + actionDim]
|
||||
|
||||
# Chain through tanh: d(tanh(mu))/d(mu) = 1 - action²
|
||||
let dTanh = aCurr.map(proc(a: float32): float32 = 1.0'f32 - a * a)
|
||||
|
||||
# Total gradient w.r.t. mu: (alpha * dLogP/dMu + dQ/dA) * dTanh/dMu
|
||||
let dMu = (alph *. lpResult.dLogProbDMu + dQdA) *. dTanh
|
||||
let dLogStd = alph *. lpResult.dLogProbDLogStd
|
||||
|
||||
addActorGrads(seqAGrads, actorBack(trainer.actor, actorFwd, dMu, dLogStd))
|
||||
|
||||
# ── 4. Alpha update ──────────────────────────────────────────────────
|
||||
# Loss = -log_alpha * stop_grad(logProb + targetEntropy)
|
||||
# d_loss/d_log_alpha = -(logProb + targetEntropy)
|
||||
totalAlphaLoss += -trainer.logAlpha * (logProbA + trainer.targetEntropy)
|
||||
seqDLogAlpha += -(logProbA + trainer.targetEntropy)
|
||||
|
||||
# Average sequence grads over T steps, accumulate over batch
|
||||
scaleCriticGrads(seqC1Grads, 1.0'f32 / T)
|
||||
scaleCriticGrads(seqC2Grads, 1.0'f32 / T)
|
||||
scaleActorGrads(seqAGrads, 1.0'f32 / T)
|
||||
addCriticGrads(accC1Grads, seqC1Grads)
|
||||
addCriticGrads(accC2Grads, seqC2Grads)
|
||||
addActorGrads(accAGrads, seqAGrads)
|
||||
dLogAlpha += seqDLogAlpha / T
|
||||
|
||||
# Average over batch
|
||||
scaleCriticGrads(accC1Grads, 1.0'f32 / N)
|
||||
scaleCriticGrads(accC2Grads, 1.0'f32 / N)
|
||||
scaleActorGrads(accAGrads, 1.0'f32 / N)
|
||||
dLogAlpha /= N
|
||||
|
||||
# Gradient clipping (max_norm = 1.0)
|
||||
applyClipToCritic(accC1Grads, 1.0'f32)
|
||||
applyClipToCritic(accC2Grads, 1.0'f32)
|
||||
applyClipToActor(accAGrads, 1.0'f32)
|
||||
|
||||
# Apply Adam updates
|
||||
applyCriticAdam(trainer.critic1, accC1Grads, trainer.adam.critic1, trainer.lrCritic)
|
||||
applyCriticAdam(trainer.critic2, accC2Grads, trainer.adam.critic2, trainer.lrCritic)
|
||||
applyActorAdam(trainer.actor, accAGrads, trainer.adam.actor, trainer.lrActor)
|
||||
adamStepScalar(trainer.logAlpha, dLogAlpha, trainer.adam.alpha, trainer.lrAlpha)
|
||||
|
||||
# ── 5. Soft target update ──────────────────────────────────────────────────
|
||||
softUpdateCritic(trainer.targetCritic1, trainer.critic1, trainer.tau)
|
||||
softUpdateCritic(trainer.targetCritic2, trainer.critic2, trainer.tau)
|
||||
|
||||
let totalSteps = N * sequences[0].train.len.float32
|
||||
result.criticLoss = totalCriticLoss / totalSteps
|
||||
result.actorLoss = totalActorLoss / totalSteps
|
||||
result.alphaLoss = totalAlphaLoss / totalSteps
|
||||
result.alpha = trainer.alpha()
|
||||
@@ -0,0 +1,352 @@
|
||||
## weights.nim — save/load all SAC-LSTM network tensors as .npy inside a .zip.
|
||||
##
|
||||
## Strategy: write_npy writes to paths; zip/zipfiles.addFile reads from paths.
|
||||
## So we write each tensor to a temp .npy, add it to the zip, then delete temps.
|
||||
## Load reverses: extract each entry to a temp .npy, read_npy, delete.
|
||||
## Atomic save: build the zip in a temp path, then rename over the target.
|
||||
|
||||
import arraymancer except Linear
|
||||
import zip/zipfiles
|
||||
import std/[os, times, strutils]
|
||||
import SAC_LSTM_Bot/network
|
||||
|
||||
# ── Adam state types (used by training.nim) ───────────────────────────────────
|
||||
|
||||
type
|
||||
AdamVar* = object
|
||||
m*, v*: Tensor[float32]
|
||||
t*: int
|
||||
|
||||
## Adam states for one Linear layer (w and b).
|
||||
LinearAdam* = object
|
||||
w*, b*: AdamVar
|
||||
|
||||
## Adam states for one LSTMCell (wCombined and bCombined).
|
||||
LSTMCellAdam* = object
|
||||
wCombined*, bCombined*: AdamVar
|
||||
|
||||
## Adam states for one ActorNet.
|
||||
ActorAdam* = object
|
||||
fc1*, fc2*, muHead*, logStdHead*: LinearAdam
|
||||
lstm*: LSTMCellAdam
|
||||
|
||||
## Adam states for one CriticNet.
|
||||
CriticAdam* = object
|
||||
fc1*, fc2*, fc3*: LinearAdam
|
||||
lstm*: LSTMCellAdam
|
||||
|
||||
SACAdamStates* = object
|
||||
actor*: ActorAdam
|
||||
critic1*: CriticAdam
|
||||
critic2*: CriticAdam
|
||||
alpha*: AdamVar # scalar, shape [1]
|
||||
initialized*: bool
|
||||
|
||||
# ── Init helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
proc initAdamVar(t: Tensor[float32]): AdamVar =
|
||||
AdamVar(m: zeros[float32](t.shape), v: zeros[float32](t.shape), t: 0)
|
||||
|
||||
proc initLinearAdam*(l: Linear): LinearAdam =
|
||||
LinearAdam(w: initAdamVar(l.w), b: initAdamVar(l.b))
|
||||
|
||||
proc initLSTMCellAdam*(c: LSTMCell): LSTMCellAdam =
|
||||
LSTMCellAdam(
|
||||
wCombined: initAdamVar(c.wCombined),
|
||||
bCombined: initAdamVar(c.bCombined))
|
||||
|
||||
proc initActorAdam*(a: ActorNet): ActorAdam =
|
||||
ActorAdam(
|
||||
fc1: initLinearAdam(a.fc1),
|
||||
fc2: initLinearAdam(a.fc2),
|
||||
muHead: initLinearAdam(a.muHead),
|
||||
logStdHead: initLinearAdam(a.logStdHead),
|
||||
lstm: initLSTMCellAdam(a.lstm))
|
||||
|
||||
proc initCriticAdam*(c: CriticNet): CriticAdam =
|
||||
CriticAdam(
|
||||
fc1: initLinearAdam(c.fc1),
|
||||
fc2: initLinearAdam(c.fc2),
|
||||
fc3: initLinearAdam(c.fc3),
|
||||
lstm: initLSTMCellAdam(c.lstm))
|
||||
|
||||
proc initSACAdamStates*(actor: ActorNet; critic1, critic2: CriticNet): SACAdamStates =
|
||||
result.actor = initActorAdam(actor)
|
||||
result.critic1 = initCriticAdam(critic1)
|
||||
result.critic2 = initCriticAdam(critic2)
|
||||
result.alpha = initAdamVar(ones[float32](1))
|
||||
result.initialized = true
|
||||
|
||||
# ── Internal: temp dir per save ───────────────────────────────────────────────
|
||||
|
||||
proc tmpDir(): string =
|
||||
getTempDir() / ("sacw_" & $int(epochTime() * 1000))
|
||||
|
||||
# ── Save helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
template addT(z: var ZipArchive; name: string; t: Tensor[float32]; tmp: string) =
|
||||
## Write tensor to a temp file, add to zip, delete temp file.
|
||||
let p = tmp / name
|
||||
t.write_npy(p)
|
||||
z.addFile(name, p)
|
||||
|
||||
proc addLinear(z: var ZipArchive; prefix: string; l: Linear; tmp: string) =
|
||||
addT(z, prefix & "_w.npy", l.w, tmp)
|
||||
addT(z, prefix & "_b.npy", l.b, tmp)
|
||||
|
||||
proc addLSTMCell(z: var ZipArchive; prefix: string; c: LSTMCell; tmp: string) =
|
||||
addT(z, prefix & "_wc.npy", c.wCombined, tmp)
|
||||
addT(z, prefix & "_bc.npy", c.bCombined, tmp)
|
||||
|
||||
proc addActorNet(z: var ZipArchive; prefix: string; a: ActorNet; tmp: string) =
|
||||
addLinear(z, prefix & "_fc1", a.fc1, tmp)
|
||||
addLSTMCell(z, prefix & "_lstm", a.lstm, tmp)
|
||||
addLinear(z, prefix & "_fc2", a.fc2, tmp)
|
||||
addLinear(z, prefix & "_mu", a.muHead, tmp)
|
||||
addLinear(z, prefix & "_logstd", a.logStdHead, tmp)
|
||||
|
||||
proc addCriticNet(z: var ZipArchive; prefix: string; c: CriticNet; tmp: string) =
|
||||
addLinear(z, prefix & "_fc1", c.fc1, tmp)
|
||||
addLSTMCell(z, prefix & "_lstm", c.lstm, tmp)
|
||||
addLinear(z, prefix & "_fc2", c.fc2, tmp)
|
||||
addLinear(z, prefix & "_fc3", c.fc3, tmp)
|
||||
|
||||
proc addAdamVar(z: var ZipArchive; prefix: string; v: AdamVar; tmp: string) =
|
||||
addT(z, prefix & "_m.npy", v.m, tmp)
|
||||
addT(z, prefix & "_v.npy", v.v, tmp)
|
||||
|
||||
proc addLinearAdam(z: var ZipArchive; prefix: string; la: LinearAdam; tmp: string) =
|
||||
addAdamVar(z, prefix & "_w", la.w, tmp)
|
||||
addAdamVar(z, prefix & "_b", la.b, tmp)
|
||||
|
||||
proc addLSTMCellAdam(z: var ZipArchive; prefix: string; la: LSTMCellAdam; tmp: string) =
|
||||
addAdamVar(z, prefix & "_wc", la.wCombined, tmp)
|
||||
addAdamVar(z, prefix & "_bc", la.bCombined, tmp)
|
||||
|
||||
proc addActorAdam(z: var ZipArchive; prefix: string; a: ActorAdam; tmp: string) =
|
||||
addLinearAdam(z, prefix & "_fc1", a.fc1, tmp)
|
||||
addLSTMCellAdam(z, prefix & "_lstm", a.lstm, tmp)
|
||||
addLinearAdam(z, prefix & "_fc2", a.fc2, tmp)
|
||||
addLinearAdam(z, prefix & "_mu", a.muHead, tmp)
|
||||
addLinearAdam(z, prefix & "_logstd", a.logStdHead, tmp)
|
||||
|
||||
proc addCriticAdam(z: var ZipArchive; prefix: string; c: CriticAdam; tmp: string) =
|
||||
addLinearAdam(z, prefix & "_fc1", c.fc1, tmp)
|
||||
addLSTMCellAdam(z, prefix & "_lstm", c.lstm, tmp)
|
||||
addLinearAdam(z, prefix & "_fc2", c.fc2, tmp)
|
||||
addLinearAdam(z, prefix & "_fc3", c.fc3, tmp)
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
proc saveWeights*(path: string;
|
||||
actor: ActorNet;
|
||||
critic1, critic2: CriticNet;
|
||||
targetCritic1, targetCritic2: CriticNet;
|
||||
alpha: float32) =
|
||||
## Save network tensors (no Adam states) to `path` (.zip).
|
||||
## Atomic: writes to a temp path first, then renames.
|
||||
let tmp = tmpDir()
|
||||
createDir(tmp)
|
||||
let tmpZip = path & ".tmp"
|
||||
try:
|
||||
var z: ZipArchive
|
||||
if not z.open(tmpZip, fmWrite):
|
||||
raise newException(IOError, "cannot create zip: " & tmpZip)
|
||||
addActorNet(z, "actor", actor, tmp)
|
||||
addCriticNet(z, "c1", critic1, tmp)
|
||||
addCriticNet(z, "c2", critic2, tmp)
|
||||
addCriticNet(z, "tc1", targetCritic1,tmp)
|
||||
addCriticNet(z, "tc2", targetCritic2,tmp)
|
||||
# alpha: store as a 1-element tensor
|
||||
addT(z, "alpha.npy", [alpha].toTensor.asType(float32), tmp)
|
||||
z.close()
|
||||
createDir(path.parentDir)
|
||||
moveFile(tmpZip, path)
|
||||
finally:
|
||||
removeDir(tmp)
|
||||
if fileExists(tmpZip): removeFile(tmpZip)
|
||||
|
||||
proc saveCheckpoint*(path: string;
|
||||
actor: ActorNet;
|
||||
critic1, critic2: CriticNet;
|
||||
targetCritic1, targetCritic2: CriticNet;
|
||||
alpha: float32;
|
||||
adam: SACAdamStates) =
|
||||
## Save networks + Adam states to `path` (.zip). Atomic.
|
||||
let tmp = tmpDir()
|
||||
createDir(tmp)
|
||||
let tmpZip = path & ".tmp"
|
||||
try:
|
||||
var z: ZipArchive
|
||||
if not z.open(tmpZip, fmWrite):
|
||||
raise newException(IOError, "cannot create zip: " & tmpZip)
|
||||
addActorNet(z, "actor", actor, tmp)
|
||||
addCriticNet(z, "c1", critic1, tmp)
|
||||
addCriticNet(z, "c2", critic2, tmp)
|
||||
addCriticNet(z, "tc1", targetCritic1,tmp)
|
||||
addCriticNet(z, "tc2", targetCritic2,tmp)
|
||||
addT(z, "alpha.npy", [alpha].toTensor.asType(float32), tmp)
|
||||
if adam.initialized:
|
||||
addActorAdam(z, "adam_actor", adam.actor, tmp)
|
||||
addCriticAdam(z, "adam_c1", adam.critic1, tmp)
|
||||
addCriticAdam(z, "adam_c2", adam.critic2, tmp)
|
||||
addAdamVar(z, "adam_alpha", adam.alpha, tmp)
|
||||
# t counters (all in lockstep; store as text)
|
||||
writeFile(tmp / "adam_t.txt",
|
||||
$adam.actor.fc1.w.t & "\n" &
|
||||
$adam.critic1.fc1.w.t & "\n" &
|
||||
$adam.critic2.fc1.w.t & "\n" &
|
||||
$adam.alpha.t)
|
||||
z.addFile("adam_t.txt", tmp / "adam_t.txt")
|
||||
z.close()
|
||||
createDir(path.parentDir)
|
||||
moveFile(tmpZip, path)
|
||||
finally:
|
||||
removeDir(tmp)
|
||||
if fileExists(tmpZip): removeFile(tmpZip)
|
||||
|
||||
# ── Load helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
template loadT(name: string; tmp: string): Tensor[float32] =
|
||||
read_npy[float32](tmp / name)
|
||||
|
||||
proc loadLinear(z: var ZipArchive; prefix, tmp: string): Linear =
|
||||
z.extractFile(prefix & "_w.npy", tmp / (prefix & "_w.npy"))
|
||||
z.extractFile(prefix & "_b.npy", tmp / (prefix & "_b.npy"))
|
||||
result.w = read_npy[float32](tmp / (prefix & "_w.npy"))
|
||||
result.b = read_npy[float32](tmp / (prefix & "_b.npy"))
|
||||
|
||||
proc loadLSTMCell(z: var ZipArchive; prefix, tmp: string): LSTMCell =
|
||||
z.extractFile(prefix & "_wc.npy", tmp / (prefix & "_wc.npy"))
|
||||
z.extractFile(prefix & "_bc.npy", tmp / (prefix & "_bc.npy"))
|
||||
result.wCombined = read_npy[float32](tmp / (prefix & "_wc.npy"))
|
||||
result.bCombined = read_npy[float32](tmp / (prefix & "_bc.npy"))
|
||||
result.hiddenDim = result.bCombined.shape[0] div 4
|
||||
|
||||
proc loadActorNet(z: var ZipArchive; prefix, tmp: string): ActorNet =
|
||||
result.fc1 = loadLinear(z, prefix & "_fc1", tmp)
|
||||
result.lstm = loadLSTMCell(z, prefix & "_lstm", tmp)
|
||||
result.fc2 = loadLinear(z, prefix & "_fc2", tmp)
|
||||
result.muHead = loadLinear(z, prefix & "_mu", tmp)
|
||||
result.logStdHead = loadLinear(z, prefix & "_logstd", tmp)
|
||||
result.hiddenDim = result.lstm.hiddenDim
|
||||
|
||||
proc loadCriticNet(z: var ZipArchive; prefix, tmp: string): CriticNet =
|
||||
result.fc1 = loadLinear(z, prefix & "_fc1", tmp)
|
||||
result.lstm = loadLSTMCell(z, prefix & "_lstm", tmp)
|
||||
result.fc2 = loadLinear(z, prefix & "_fc2", tmp)
|
||||
result.fc3 = loadLinear(z, prefix & "_fc3", tmp)
|
||||
result.hiddenDim = result.lstm.hiddenDim
|
||||
|
||||
proc loadAdamVarFromZip(z: var ZipArchive; prefix, tmp: string): AdamVar =
|
||||
z.extractFile(prefix & "_m.npy", tmp / (prefix & "_m.npy"))
|
||||
z.extractFile(prefix & "_v.npy", tmp / (prefix & "_v.npy"))
|
||||
result.m = read_npy[float32](tmp / (prefix & "_m.npy"))
|
||||
result.v = read_npy[float32](tmp / (prefix & "_v.npy"))
|
||||
|
||||
proc loadLinearAdam(z: var ZipArchive; prefix, tmp: string): LinearAdam =
|
||||
result.w = loadAdamVarFromZip(z, prefix & "_w", tmp)
|
||||
result.b = loadAdamVarFromZip(z, prefix & "_b", tmp)
|
||||
|
||||
proc loadLSTMCellAdam(z: var ZipArchive; prefix, tmp: string): LSTMCellAdam =
|
||||
result.wCombined = loadAdamVarFromZip(z, prefix & "_wc", tmp)
|
||||
result.bCombined = loadAdamVarFromZip(z, prefix & "_bc", tmp)
|
||||
|
||||
proc loadActorAdam(z: var ZipArchive; prefix, tmp: string): ActorAdam =
|
||||
result.fc1 = loadLinearAdam(z, prefix & "_fc1", tmp)
|
||||
result.lstm = loadLSTMCellAdam(z, prefix & "_lstm", tmp)
|
||||
result.fc2 = loadLinearAdam(z, prefix & "_fc2", tmp)
|
||||
result.muHead = loadLinearAdam(z, prefix & "_mu", tmp)
|
||||
result.logStdHead = loadLinearAdam(z, prefix & "_logstd", tmp)
|
||||
|
||||
proc loadCriticAdam(z: var ZipArchive; prefix, tmp: string): CriticAdam =
|
||||
result.fc1 = loadLinearAdam(z, prefix & "_fc1", tmp)
|
||||
result.lstm = loadLSTMCellAdam(z, prefix & "_lstm", tmp)
|
||||
result.fc2 = loadLinearAdam(z, prefix & "_fc2", tmp)
|
||||
result.fc3 = loadLinearAdam(z, prefix & "_fc3", tmp)
|
||||
|
||||
type
|
||||
WeightCheckpoint* = object
|
||||
actor*: ActorNet
|
||||
critic1*: CriticNet
|
||||
critic2*: CriticNet
|
||||
targetCritic1*: CriticNet
|
||||
targetCritic2*: CriticNet
|
||||
alpha*: float32
|
||||
adam*: SACAdamStates ## initialized=false if not present in zip
|
||||
|
||||
proc loadCheckpoint*(path: string): WeightCheckpoint =
|
||||
## Load all tensors from `path` (.zip). Raises IOError if file not found.
|
||||
## Adam states loaded only if present; result.adam.initialized reflects this.
|
||||
if not fileExists(path):
|
||||
raise newException(IOError, "checkpoint not found: " & path)
|
||||
let tmp = tmpDir()
|
||||
createDir(tmp)
|
||||
try:
|
||||
var z: ZipArchive
|
||||
if not z.open(path, fmRead):
|
||||
raise newException(IOError, "cannot open zip: " & path)
|
||||
|
||||
result.actor = loadActorNet(z, "actor", tmp)
|
||||
result.critic1 = loadCriticNet(z, "c1", tmp)
|
||||
result.critic2 = loadCriticNet(z, "c2", tmp)
|
||||
result.targetCritic1 = loadCriticNet(z, "tc1", tmp)
|
||||
result.targetCritic2 = loadCriticNet(z, "tc2", tmp)
|
||||
|
||||
z.extractFile("alpha.npy", tmp / "alpha.npy")
|
||||
let alphaTensor = read_npy[float32](tmp / "alpha.npy")
|
||||
result.alpha = alphaTensor[0]
|
||||
|
||||
# Adam states — optional
|
||||
var hasAdam = false
|
||||
for f in z.walkFiles:
|
||||
if f.startsWith("adam_"):
|
||||
hasAdam = true
|
||||
break
|
||||
if hasAdam:
|
||||
result.adam.actor = loadActorAdam(z, "adam_actor", tmp)
|
||||
result.adam.critic1 = loadCriticAdam(z, "adam_c1", tmp)
|
||||
result.adam.critic2 = loadCriticAdam(z, "adam_c2", tmp)
|
||||
result.adam.alpha = loadAdamVarFromZip(z, "adam_alpha", tmp)
|
||||
# t counters
|
||||
z.extractFile("adam_t.txt", tmp / "adam_t.txt")
|
||||
let ts = readFile(tmp / "adam_t.txt").strip().splitLines()
|
||||
if ts.len >= 4:
|
||||
let tActor = parseInt(ts[0])
|
||||
let tCritic1 = parseInt(ts[1])
|
||||
let tCritic2 = parseInt(ts[2])
|
||||
let tAlpha = parseInt(ts[3])
|
||||
# propagate t to all Adam vars
|
||||
template setT(v: var AdamVar; tval: int) = v.t = tval
|
||||
setT(result.adam.actor.fc1.w, tActor)
|
||||
setT(result.adam.actor.fc1.b, tActor)
|
||||
setT(result.adam.actor.lstm.wCombined,tActor)
|
||||
setT(result.adam.actor.lstm.bCombined,tActor)
|
||||
setT(result.adam.actor.fc2.w, tActor)
|
||||
setT(result.adam.actor.fc2.b, tActor)
|
||||
setT(result.adam.actor.muHead.w, tActor)
|
||||
setT(result.adam.actor.muHead.b, tActor)
|
||||
setT(result.adam.actor.logStdHead.w, tActor)
|
||||
setT(result.adam.actor.logStdHead.b, tActor)
|
||||
setT(result.adam.critic1.fc1.w, tCritic1)
|
||||
setT(result.adam.critic1.fc1.b, tCritic1)
|
||||
setT(result.adam.critic1.lstm.wCombined,tCritic1)
|
||||
setT(result.adam.critic1.lstm.bCombined,tCritic1)
|
||||
setT(result.adam.critic1.fc2.w, tCritic1)
|
||||
setT(result.adam.critic1.fc2.b, tCritic1)
|
||||
setT(result.adam.critic1.fc3.w, tCritic1)
|
||||
setT(result.adam.critic1.fc3.b, tCritic1)
|
||||
setT(result.adam.critic2.fc1.w, tCritic2)
|
||||
setT(result.adam.critic2.fc1.b, tCritic2)
|
||||
setT(result.adam.critic2.lstm.wCombined,tCritic2)
|
||||
setT(result.adam.critic2.lstm.bCombined,tCritic2)
|
||||
setT(result.adam.critic2.fc2.w, tCritic2)
|
||||
setT(result.adam.critic2.fc2.b, tCritic2)
|
||||
setT(result.adam.critic2.fc3.w, tCritic2)
|
||||
setT(result.adam.critic2.fc3.b, tCritic2)
|
||||
setT(result.adam.alpha, tAlpha)
|
||||
result.adam.initialized = true
|
||||
|
||||
z.close()
|
||||
finally:
|
||||
removeDir(tmp)
|
||||
@@ -0,0 +1,2 @@
|
||||
switch("path", "../src")
|
||||
switch("path", "../../libs")
|
||||
@@ -0,0 +1,97 @@
|
||||
import unittest
|
||||
import arraymancer
|
||||
import std/math
|
||||
import SAC_LSTM_Bot/actions
|
||||
|
||||
proc makeOutput(a0, a1, a2, a3: float): Tensor[float32] =
|
||||
result = newTensor[float32](4)
|
||||
result[0] = a0.float32
|
||||
result[1] = a1.float32
|
||||
result[2] = a2.float32
|
||||
result[3] = a3.float32
|
||||
|
||||
suite "mapActions":
|
||||
|
||||
test "ACTION_DIM is 4":
|
||||
check ACTION_DIM == 4
|
||||
|
||||
# Speed-aware turn rate
|
||||
test "turn: output +1 at speed 0 -> +10 degrees":
|
||||
let m = mapActions(makeOutput(1.0, 0.0, 0.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.turnRate - 10.0) < 1e-6
|
||||
|
||||
test "turn: output -1 at speed 0 -> -10 degrees":
|
||||
let m = mapActions(makeOutput(-1.0, 0.0, 0.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.turnRate - (-10.0)) < 1e-6
|
||||
|
||||
test "turn: output +1 at speed 8 -> +4 degrees":
|
||||
let m = mapActions(makeOutput(1.0, 0.0, 0.0, -1.0), 8.0, 0.0)
|
||||
check abs(m.turnRate - 4.0) < 1e-6
|
||||
|
||||
test "turn: output -1 at speed 8 -> -4 degrees":
|
||||
let m = mapActions(makeOutput(-1.0, 0.0, 0.0, -1.0), 8.0, 0.0)
|
||||
check abs(m.turnRate - (-4.0)) < 1e-6
|
||||
|
||||
test "turn: output 0 -> 0 regardless of speed":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, -1.0), 5.0, 0.0)
|
||||
check abs(m.turnRate) < 1e-6
|
||||
|
||||
# Acceleration range
|
||||
test "accel: output -1 -> -2.0":
|
||||
let m = mapActions(makeOutput(0.0, -1.0, 0.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.acceleration - (-2.0)) < 1e-6
|
||||
|
||||
test "accel: output +1 -> +1.0":
|
||||
let m = mapActions(makeOutput(0.0, 1.0, 0.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.acceleration - 1.0) < 1e-6
|
||||
|
||||
test "accel: output 0 -> midpoint -0.5":
|
||||
# value*1.5 - 0.5 at value=0 -> -0.5 (correct midpoint between -2 and +1)
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.acceleration - (-0.5)) < 1e-6
|
||||
|
||||
# Gun turn rate
|
||||
test "gun turn: output +1 -> +20 degrees":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 1.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.gunTurnRate - 20.0) < 1e-6
|
||||
|
||||
test "gun turn: output -1 -> -20 degrees":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, -1.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.gunTurnRate - (-20.0)) < 1e-6
|
||||
|
||||
test "gun turn: output 0 -> 0":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, -1.0), 0.0, 0.0)
|
||||
check abs(m.gunTurnRate) < 1e-6
|
||||
|
||||
# Fire threshold
|
||||
test "fire: output -0.5 -> no fire (firePower == 0)":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, -0.5), 0.0, 0.0)
|
||||
check m.firePower == 0.0
|
||||
|
||||
test "fire: output 0.0 -> no fire (boundary, not positive)":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, 0.0), 0.0, 0.0)
|
||||
check m.firePower == 0.0
|
||||
|
||||
test "fire: output +0.5 -> fire with correct power":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, 0.5), 0.0, 0.0)
|
||||
# 0.5 * 2.9 + 0.1 = 1.55
|
||||
check abs(m.firePower - 1.55) < 1e-5
|
||||
|
||||
test "fire: output +1.0 -> fire power near 3.0":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, 1.0), 0.0, 0.0)
|
||||
check abs(m.firePower - 3.0) < 1e-5
|
||||
|
||||
test "fire: output +1.0 but gunHeat > 0 -> no fire":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, 1.0), 0.0, 1.5)
|
||||
check m.firePower == 0.0
|
||||
|
||||
test "fire: output +0.001 (just above 0) -> fires with power near 0.1":
|
||||
let m = mapActions(makeOutput(0.0, 0.0, 0.0, 0.001), 0.0, 0.0)
|
||||
check m.firePower > 0.0
|
||||
check m.firePower < 0.2
|
||||
|
||||
# Speed-aware turn with negative speed (reverse)
|
||||
test "turn: speed -8 (reversing) -> same magnitude as speed +8":
|
||||
let fwd = mapActions(makeOutput(1.0, 0.0, 0.0, -1.0), 8.0, 0.0)
|
||||
let rev = mapActions(makeOutput(1.0, 0.0, 0.0, -1.0), -8.0, 0.0)
|
||||
check abs(fwd.turnRate - rev.turnRate) < 1e-6
|
||||
@@ -0,0 +1,150 @@
|
||||
## Tests for integration.nim (#48) — assert-based, no framework.
|
||||
## Covers: TrainingMsg channel round-trip (plain arrays through a channel),
|
||||
## drain-then-train NewBattle/Shutdown handling, trainPass safety below canSample.
|
||||
|
||||
import arraymancer except Linear
|
||||
import std/[locks, json, os]
|
||||
import tankroyale_botapi # updateBotNames: seed the vendored id->name table
|
||||
import SAC_LSTM_Bot/integration
|
||||
import SAC_LSTM_Bot/state # STATE_DIM
|
||||
import SAC_LSTM_Bot/actions # ACTION_DIM
|
||||
import SAC_LSTM_Bot/training # initSACTrainer
|
||||
import SAC_LSTM_Bot/replay_buffer
|
||||
|
||||
# ── 1. TrainingMsg round-trips through a channel with arrays intact ──────────
|
||||
block:
|
||||
var ch: Channel[TrainingMsg]
|
||||
ch.open(4)
|
||||
var msg = TrainingMsg(kind: tmkTransition)
|
||||
for i in 0 ..< STATE_DIM:
|
||||
msg.state[i] = float32(i) * 0.5'f32
|
||||
msg.nextState[i] = float32(i) * 2.0'f32
|
||||
for i in 0 ..< ACTION_DIM:
|
||||
msg.action[i] = float32(i) - 2.0'f32
|
||||
msg.reward = -1.25'f32
|
||||
msg.done = true
|
||||
assert ch.trySend(msg)
|
||||
assert ch.trySend(TrainingMsg(kind: tmkNewBattle, enemyId: 4242))
|
||||
assert ch.trySend(TrainingMsg(kind: tmkShutdown))
|
||||
|
||||
let r1 = ch.recv()
|
||||
assert r1.kind == tmkTransition, "first msg is a transition"
|
||||
for i in 0 ..< STATE_DIM:
|
||||
assert r1.state[i] == float32(i) * 0.5'f32, "state round-trip at " & $i
|
||||
assert r1.nextState[i] == float32(i) * 2.0'f32, "nextState round-trip at " & $i
|
||||
for i in 0 ..< ACTION_DIM:
|
||||
assert r1.action[i] == float32(i) - 2.0'f32, "action round-trip at " & $i
|
||||
assert r1.reward == -1.25'f32 and r1.done
|
||||
|
||||
let r2 = ch.recv()
|
||||
assert r2.kind == tmkNewBattle and r2.enemyId == 4242
|
||||
let r3 = ch.recv()
|
||||
assert r3.kind == tmkShutdown
|
||||
|
||||
ch.close()
|
||||
# closed + empty -> tryRecv reports no data (Nim 2.2: recv would block forever)
|
||||
let (ok4, _) = ch.tryRecv()
|
||||
assert not ok4, "closed channel must report dataAvailable=false"
|
||||
echo "PASS TrainingMsg channel round-trip"
|
||||
|
||||
# ── 2. NewBattle clears only when the opponent NAME changes; Shutdown stops ───
|
||||
block:
|
||||
# Seed the API's id->name table (v1.0.1) for name-based identity (#49).
|
||||
updateBotNames(parseJson(
|
||||
"""{"bots":[{"id":7,"name":"Corners"},{"id":8,"name":"Crazy"}]}"""))
|
||||
assert opponentKey(7) == "Corners", "known id resolves to name"
|
||||
assert opponentKey(99) == "99", "unknown id falls back to numeric string"
|
||||
|
||||
var st: TrainState
|
||||
st.trainer = initSACTrainer(STATE_DIM, ACTION_DIM)
|
||||
st.buf = newReplayBuffer(64, STATE_DIM, ACTION_DIM, burnIn = 2, trainWindow = 3)
|
||||
|
||||
assert handleTrainingMsg(st, TrainingMsg(kind: tmkNewBattle, enemyId: 7))
|
||||
assert st.lastEnemyKey == "Corners"
|
||||
|
||||
for i in 0 ..< 5:
|
||||
var m = TrainingMsg(kind: tmkTransition)
|
||||
m.reward = float32(i)
|
||||
assert handleTrainingMsg(st, m)
|
||||
assert st.buf.len == 5, "transitions stored"
|
||||
|
||||
# Same opponent -> buffer kept (Q12a).
|
||||
assert handleTrainingMsg(st, TrainingMsg(kind: tmkNewBattle, enemyId: 7))
|
||||
assert st.buf.len == 5, "same opponent must NOT clear"
|
||||
|
||||
# Opponent changed -> clear.
|
||||
assert handleTrainingMsg(st, TrainingMsg(kind: tmkNewBattle, enemyId: 8))
|
||||
assert st.buf.len == 0, "opponent change must clear"
|
||||
assert st.lastEnemyKey == "Crazy"
|
||||
|
||||
# Nameless window (pre-BotListUpdate) is a distinct key.
|
||||
assert handleTrainingMsg(st, TrainingMsg(kind: tmkNewBattle, enemyId: 99))
|
||||
assert st.lastEnemyKey == "99" and st.buf.len == 0
|
||||
|
||||
# Shutdown stops the caller's loop.
|
||||
assert not handleTrainingMsg(st, TrainingMsg(kind: tmkShutdown))
|
||||
echo "PASS name-keyed NewBattle clear / Shutdown"
|
||||
|
||||
# ── 2b. bumpRoundCounter increments per call, cold-starts at 1 ────────────────
|
||||
block:
|
||||
let tmp = getTempDir() / "sac_test_weights_" & $getCurrentProcessId()
|
||||
putEnv("SACLSTM_WEIGHTS_PATH", tmp / "sac_latest.zip")
|
||||
bumpRoundCounter()
|
||||
bumpRoundCounter()
|
||||
assert readFile(tmp / "round_counter.txt") == "2", "counter increments per round"
|
||||
echo "PASS bumpRoundCounter"
|
||||
|
||||
# ── 3. trainPass is a safe no-op below canSample (no steps, no publish) ───────
|
||||
block:
|
||||
var st: TrainState
|
||||
st.trainer = initSACTrainer(STATE_DIM, ACTION_DIM)
|
||||
st.buf = newReplayBuffer(64, STATE_DIM, ACTION_DIM, burnIn = 2, trainWindow = 3)
|
||||
for i in 0 ..< 4:
|
||||
assert handleTrainingMsg(st, TrainingMsg(kind: tmkTransition))
|
||||
trainPass(st, 4) # 4 < burnIn+trainWindow = 5
|
||||
assert st.stepCount == 0, "no gradient steps below canSample"
|
||||
echo "PASS trainPass no-op below canSample"
|
||||
|
||||
# ── 4. Flat snapshot layout: pack/unpack round-trips weights exactly ──────────
|
||||
block:
|
||||
let t0 = initSACTrainer(STATE_DIM, ACTION_DIM)
|
||||
let fs = packFull(t0)
|
||||
assert fs.data.len == actorSize(t0.actor.hiddenDim) + 4 * criticSize(t0.actor.hiddenDim) + 1
|
||||
let (a, c1, c2, tc1, tc2, alpha) = unpackFull(fs)
|
||||
assert a.hiddenDim == t0.actor.hiddenDim
|
||||
assert c1.fc3.b.shape[0] == 1
|
||||
let fw = a.muHead.w.flatten()
|
||||
let fw0 = t0.actor.muHead.w.flatten()
|
||||
for i in 0 ..< fw.size:
|
||||
assert fw[i] == fw0[i], "actor mu weights round-trip"
|
||||
for i in 0 ..< c2.lstm.bCombined.size:
|
||||
assert c2.lstm.bCombined[i] == t0.critic2.lstm.bCombined[i], "critic lstm bias round-trip"
|
||||
assert alpha == t0.alpha()
|
||||
discard tc1
|
||||
echo "PASS flat snapshot pack/unpack round-trip"
|
||||
|
||||
# ── 5. Lever 3 (#59): metricsLine emits exactly the exposed trainer scalars ───
|
||||
block:
|
||||
let line = metricsLine(1787394115.123, 42, 500, 20, 20,
|
||||
SACMetrics(criticLoss: 0.5'f32, actorLoss: -1.5'f32,
|
||||
alphaLoss: 0.25'f32, alpha: 2.0'f32))
|
||||
let j = parseJson(line) # throws on malformed JSONL
|
||||
assert j["steps"].getInt() == 42 and j["buffer_size"].getInt() == 500
|
||||
assert j["drained"].getInt() == 20 and j["grad_steps"].getInt() == 20
|
||||
assert abs(j["critic_loss"].getFloat() - 0.5) < 1e-3
|
||||
assert abs(j["actor_loss"].getFloat() + 1.5) < 1e-3
|
||||
assert abs(j["alpha_loss"].getFloat() - 0.25) < 1e-3
|
||||
assert abs(j["alpha"].getFloat() - 2.0) < 1e-3
|
||||
assert j["epoch"].getFloat() > 1e9
|
||||
echo "PASS metricsLine JSONL scalars"
|
||||
|
||||
# ── 6. Lever 4 (#59): SACLSTM_EVAL_MODE=1 suppresses training input ───────────
|
||||
block:
|
||||
putEnv("SACLSTM_EVAL_MODE", "1")
|
||||
# Gate fires before any channel traffic: false = dropped, nothing enqueued.
|
||||
assert not sendTrainingMsg(TrainingMsg(kind: tmkTransition)),
|
||||
"eval mode must drop transitions"
|
||||
assert not sendTrainingMsg(TrainingMsg(kind: tmkNewBattle, enemyId: 7)),
|
||||
"eval mode must drop NewBattle (no buffer clears from eval)"
|
||||
delEnv("SACLSTM_EVAL_MODE")
|
||||
echo "PASS eval-mode training-input suppression"
|
||||
@@ -0,0 +1,99 @@
|
||||
import unittest
|
||||
import arraymancer
|
||||
import std/[math, os]
|
||||
import SAC_LSTM_Bot/network
|
||||
|
||||
const
|
||||
STATE_DIM = 20
|
||||
ACTION_DIM = 4
|
||||
|
||||
suite "ActorNet":
|
||||
setup:
|
||||
let actor = initActorNet(STATE_DIM)
|
||||
let state = randomNormalTensor[float32](STATE_DIM)
|
||||
let ls0 = zeroState(actor.hiddenDim)
|
||||
|
||||
test "forward output shapes":
|
||||
let (actions, lp, ls1) = actorForward(actor, state, ls0)
|
||||
check actions.shape == [4]
|
||||
check ls1.h.shape == [actor.hiddenDim]
|
||||
check ls1.c.shape == [actor.hiddenDim]
|
||||
check not lp.isNaN
|
||||
|
||||
test "actions clamped in (-1, 1) — tanh output":
|
||||
let (actions, _, _) = actorForward(actor, state, ls0)
|
||||
for i in 0 ..< 4:
|
||||
check actions[i] > -1.0'f32
|
||||
check actions[i] < 1.0'f32
|
||||
|
||||
test "hidden state propagates (h/c change after step)":
|
||||
let (_, _, ls1) = actorForward(actor, state, ls0)
|
||||
# h' should differ from zero init for non-trivial input
|
||||
var hChanged = false
|
||||
for i in 0 ..< actor.hiddenDim:
|
||||
if abs(ls1.h[i] - ls0.h[i]) > 1e-7'f32:
|
||||
hChanged = true
|
||||
break
|
||||
check hChanged
|
||||
|
||||
test "deterministic mode: same input → same output":
|
||||
let (a1, _, _) = actorForward(actor, state, ls0, deterministic = true)
|
||||
let (a2, _, _) = actorForward(actor, state, ls0, deterministic = true)
|
||||
for i in 0 ..< 4:
|
||||
check abs(a1[i] - a2[i]) < 1e-7'f32
|
||||
|
||||
test "stochastic mode: outputs may differ (sampling)":
|
||||
# Run many times; at least one pair should differ
|
||||
let (a1, _, _) = actorForward(actor, state, ls0)
|
||||
let (a2, _, _) = actorForward(actor, state, ls0)
|
||||
var anyDiff = false
|
||||
for i in 0 ..< 4:
|
||||
if abs(a1[i] - a2[i]) > 1e-7'f32:
|
||||
anyDiff = true
|
||||
break
|
||||
check anyDiff
|
||||
|
||||
suite "CriticNet":
|
||||
setup:
|
||||
let critic = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let state = randomNormalTensor[float32](STATE_DIM)
|
||||
let actions = randomNormalTensor[float32](ACTION_DIM)
|
||||
let stateAct = concat(state, actions, axis = 0)
|
||||
let ls0 = zeroState(critic.hiddenDim)
|
||||
|
||||
test "forward output shape":
|
||||
let (q, ls1) = criticForward(critic, stateAct, ls0)
|
||||
check ls1.h.shape == [critic.hiddenDim]
|
||||
check ls1.c.shape == [critic.hiddenDim]
|
||||
# q is scalar float — just ensure it doesn't NaN
|
||||
check not q.isNaN
|
||||
|
||||
test "hidden state propagates":
|
||||
let (_, ls1) = criticForward(critic, stateAct, ls0)
|
||||
var hChanged = false
|
||||
for i in 0 ..< critic.hiddenDim:
|
||||
if abs(ls1.h[i] - ls0.h[i]) > 1e-7'f32:
|
||||
hChanged = true
|
||||
break
|
||||
check hChanged
|
||||
|
||||
suite "Dual critics":
|
||||
test "two independent critics produce different Q values":
|
||||
let c1 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let c2 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let sa = randomNormalTensor[float32](STATE_DIM + ACTION_DIM)
|
||||
let ls = zeroState(c1.hiddenDim)
|
||||
let (q1, _) = criticForward(c1, sa, ls)
|
||||
let (q2, _) = criticForward(c2, sa, ls)
|
||||
check abs(q1 - q2) > 1e-7'f32
|
||||
|
||||
suite "Hidden size config":
|
||||
test "hidden size 128 works":
|
||||
putEnv("SACLSTM_HIDDEN_SIZE", "128")
|
||||
let actor = initActorNet(STATE_DIM)
|
||||
let state = randomNormalTensor[float32](STATE_DIM)
|
||||
let ls0 = zeroState(128)
|
||||
let (actions, _, ls1) = actorForward(actor, state, ls0)
|
||||
check actions.shape == [4]
|
||||
check ls1.h.shape == [128]
|
||||
putEnv("SACLSTM_HIDDEN_SIZE", "256")
|
||||
@@ -0,0 +1,156 @@
|
||||
## Tests for replay_buffer.nim — assert-based, no framework.
|
||||
|
||||
import arraymancer
|
||||
import std/sequtils
|
||||
import SAC_LSTM_Bot/replay_buffer
|
||||
import SAC_LSTM_Bot/state # STATE_DIM
|
||||
|
||||
const
|
||||
S_DIM = STATE_DIM # 35
|
||||
A_DIM = 4
|
||||
|
||||
proc makeTrans(reward: float32; done: bool): Transition =
|
||||
Transition(
|
||||
state: zeros[float32](S_DIM),
|
||||
action: zeros[float32](A_DIM),
|
||||
reward: reward,
|
||||
nextState: zeros[float32](S_DIM),
|
||||
done: done
|
||||
)
|
||||
|
||||
# ── 1. len and canSample on empty buffer ──────────────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(100, S_DIM, A_DIM, burnIn = 8, trainWindow = 16)
|
||||
assert buf.len == 0, "empty len"
|
||||
assert not buf.canSample, "empty canSample"
|
||||
echo "PASS empty buffer"
|
||||
|
||||
# ── 2. len grows, canSample becomes true ──────────────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(100, S_DIM, A_DIM, burnIn = 2, trainWindow = 3)
|
||||
for i in 0 ..< 4:
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
assert buf.len == 4
|
||||
assert not buf.canSample, "needs 5 to sample"
|
||||
buf.add(makeTrans(99, false))
|
||||
assert buf.len == 5
|
||||
assert buf.canSample, "5 transitions, seqLen=5 → canSample"
|
||||
echo "PASS len + canSample"
|
||||
|
||||
# ── 3. Ring buffer wraps at capacity ─────────────────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(10, S_DIM, A_DIM, burnIn = 2, trainWindow = 3)
|
||||
for i in 0 ..< 15:
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
assert buf.len == 10, "wraps at capacity, len stays 10"
|
||||
echo "PASS wrap at capacity"
|
||||
|
||||
# ── 4. Sampled sequences have correct length ──────────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(200, S_DIM, A_DIM, burnIn = 4, trainWindow = 6)
|
||||
for i in 0 ..< 50:
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
let seqs = buf.sampleSequences(8)
|
||||
assert seqs.len > 0, "should have valid starts"
|
||||
for s in seqs:
|
||||
assert s.burnIn.len == 4, "burnIn len"
|
||||
assert s.train.len == 6, "train len"
|
||||
echo "PASS sequence length"
|
||||
|
||||
# ── 5. Burn-in / train split is correct ───────────────────────────────────────
|
||||
block:
|
||||
# Fill with distinct rewards so we can identify positions
|
||||
var buf = newReplayBuffer(200, S_DIM, A_DIM, burnIn = 3, trainWindow = 4)
|
||||
for i in 0 ..< 30:
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
let seqs = buf.sampleSequences(1)
|
||||
assert seqs.len == 1
|
||||
let s = seqs[0]
|
||||
# The 4th reward of the sequence (index 3) must match s.train[0].reward
|
||||
# and s.burnIn[2].reward must be s.burnIn[2].reward (just check no overlap)
|
||||
let allRewards = s.burnIn.mapIt(it.reward) & s.train.mapIt(it.reward)
|
||||
# consecutive integer rewards → each must be strictly increasing by 1
|
||||
var ok = true
|
||||
for i in 1 ..< allRewards.len:
|
||||
if allRewards[i] != allRewards[i-1] + 1.0f32:
|
||||
ok = false
|
||||
break
|
||||
assert ok, "burn-in and train must form a contiguous sequence"
|
||||
echo "PASS burn-in/train split"
|
||||
|
||||
# ── 6. Sequences never cross a done=true boundary ─────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(200, S_DIM, A_DIM, burnIn = 2, trainWindow = 3)
|
||||
# Episode 1: transitions 0..4 (done at index 4)
|
||||
for i in 0 ..< 4:
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
buf.add(makeTrans(99, true)) # battle end at index 4
|
||||
# Episode 2: transitions 5..14
|
||||
for i in 5 ..< 15:
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
|
||||
let seqs = buf.sampleSequences(20)
|
||||
for s in seqs:
|
||||
# No transition in burnIn (except the last) or train (except the last)
|
||||
# may have done=true, since that would mean the next step crosses a boundary.
|
||||
for i in 0 ..< s.burnIn.len - 1:
|
||||
assert not s.burnIn[i].done, "done in middle of burnIn"
|
||||
for i in 0 ..< s.train.len - 1:
|
||||
assert not s.train[i].done, "done in middle of train"
|
||||
# The join between burnIn and train must not cross a done=true
|
||||
if s.burnIn.len > 0:
|
||||
assert not s.burnIn[^1].done, "done at end of burnIn crosses boundary to train"
|
||||
echo "PASS no cross-boundary sequences"
|
||||
|
||||
# ── 7. canSample false when buffer too small ───────────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(100, S_DIM, A_DIM, burnIn = 8, trainWindow = 16)
|
||||
for i in 0 ..< 23: # seqLen = 24; 23 < 24
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
assert not buf.canSample, "23 < 24 seqLen"
|
||||
buf.add(makeTrans(23, false))
|
||||
assert buf.canSample, "24 == seqLen"
|
||||
echo "PASS canSample threshold"
|
||||
|
||||
# ── 8. Empty buffer doesn't crash on sample ────────────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(100, S_DIM, A_DIM, burnIn = 8, trainWindow = 16)
|
||||
let seqs = buf.sampleSequences(4)
|
||||
assert seqs.len == 0, "empty buffer → empty result"
|
||||
echo "PASS empty sample"
|
||||
|
||||
# ── 9. Single episode (no done except at very end) ────────────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(200, S_DIM, A_DIM, burnIn = 3, trainWindow = 5)
|
||||
for i in 0 ..< 19:
|
||||
buf.add(makeTrans(float32(i), false))
|
||||
buf.add(makeTrans(19, true)) # last
|
||||
let seqs = buf.sampleSequences(5)
|
||||
assert seqs.len > 0, "should find valid starts"
|
||||
for s in seqs:
|
||||
assert s.burnIn.len == 3
|
||||
assert s.train.len == 5
|
||||
echo "PASS single episode"
|
||||
|
||||
# ── 10. Multiple short episodes: all boundaries respected ─────────────────────
|
||||
block:
|
||||
var buf = newReplayBuffer(300, S_DIM, A_DIM, burnIn = 2, trainWindow = 3)
|
||||
# 10 episodes of 5 transitions each (done at end of each episode)
|
||||
var reward = 0'f32
|
||||
for ep in 0 ..< 10:
|
||||
for i in 0 ..< 4:
|
||||
buf.add(makeTrans(reward, false))
|
||||
reward += 1
|
||||
buf.add(makeTrans(reward, true)) # battle end
|
||||
reward += 1
|
||||
|
||||
let seqs = buf.sampleSequences(30)
|
||||
assert seqs.len > 0
|
||||
for s in seqs:
|
||||
# No done in the middle of any sequence
|
||||
let all = s.burnIn & s.train
|
||||
for i in 0 ..< all.len - 1:
|
||||
assert not all[i].done, "boundary crossed in multi-episode test"
|
||||
echo "PASS multiple episodes"
|
||||
|
||||
echo "ALL TESTS PASSED"
|
||||
@@ -0,0 +1,109 @@
|
||||
## Assert-based tests for rewards.nim.
|
||||
## Run: nim c -r tests/test_rewards.nim
|
||||
|
||||
import std/[math, strformat]
|
||||
import SAC_LSTM_Bot/rewards
|
||||
|
||||
template check(cond: bool, msg: string) =
|
||||
if not cond:
|
||||
quit("FAIL: " & msg, 1)
|
||||
|
||||
# ── computeReward ─────────────────────────────────────────────────────────────
|
||||
|
||||
block damageInflicted:
|
||||
# p=1: 1.25 * (6*1 - 2) = 5 (lever 2 aggression mult, #59)
|
||||
check abs(computeReward(damageInflicted = 1.0) - 5.0) < 1e-9, "p=1 damage = +5"
|
||||
# p=3: 1.25 * (6*3 - 2) = 20
|
||||
check abs(computeReward(damageInflicted = 3.0) - 20.0) < 1e-9, "p=3 damage = +20"
|
||||
# low-power spam stays unprofitable: 1.25*(6*0.1-2) < 0
|
||||
check computeReward(damageInflicted = 0.1) < 0.0, "p=0.1 spam still negative"
|
||||
|
||||
block damageReceived:
|
||||
# p_e=1: -(6*1 - 2) = -4 (unchanged by lever 2)
|
||||
check abs(computeReward(damageReceived = 1.0) - (-4.0)) < 1e-9, "p_e=1 received = -4"
|
||||
# p_e=3: -(6*3 - 2) = -16
|
||||
check abs(computeReward(damageReceived = 3.0) - (-16.0)) < 1e-9, "p_e=3 received = -16"
|
||||
|
||||
block hitBonus:
|
||||
# flat +0.5 per landed shot: p=1 hit -> 5.0 + 0.5
|
||||
check abs(computeReward(damageInflicted = 1.0, hitCount = 1) - 5.5) < 1e-9,
|
||||
"p=1 hit = +5.5"
|
||||
# two hits in one step: 1.25*(6*2-2) + 2*0.5 = 12.5 + 1.0 = 13.5
|
||||
check abs(computeReward(damageInflicted = 2.0, hitCount = 2) - 13.5) < 1e-9,
|
||||
"two hits = +13.5"
|
||||
|
||||
block ramTaken:
|
||||
# flat per victim collision (#59)
|
||||
check abs(computeReward(ramTakenCount = 1) - (-3.0)) < 1e-9, "ram taken x1 = -3"
|
||||
check abs(computeReward(ramTakenCount = 2) - (-6.0)) < 1e-9, "ram taken x2 = -6"
|
||||
|
||||
block chargeDeterrent:
|
||||
# zero-damage case at half threshold depth: -2 * (1 - 0.06/0.12) = -1
|
||||
let rHalf = computeReward(enemyDistFrac = 0.06)
|
||||
check abs(rHalf - (-1.0)) < 1e-9, "charge at frac 0.06 = -1"
|
||||
# at zero distance: full ceiling
|
||||
check abs(computeReward(enemyDistFrac = 0.0) - (-2.0)) < 1e-9, "charge at frac 0 = -2"
|
||||
# at/beyond threshold and no-contact sentinel: no penalty
|
||||
check abs(computeReward(enemyDistFrac = 0.12)) < 1e-9, "at threshold = 0"
|
||||
check abs(computeReward(enemyDistFrac = 0.5)) < 1e-9, "beyond threshold = 0"
|
||||
check abs(computeReward(enemyDistFrac = 2.0)) < 1e-9, "no-contact sentinel = 0"
|
||||
# suppressed while dealing damage that step (fighting back at close range is fine)
|
||||
let rFight = computeReward(damageInflicted = 1.0, enemyDistFrac = 0.06)
|
||||
check abs(rFight - 5.0) < 1e-9, "dealing damage cancels charge penalty"
|
||||
|
||||
block wallHit:
|
||||
check abs(computeReward(wallHitTicks = 1) - (-5.0)) < 1e-9, "1 wall tick = -5"
|
||||
|
||||
block wastedShot:
|
||||
# p=2: -0.1 * 2 = -0.2
|
||||
check abs(computeReward(wastedShotPower = 2.0) - (-0.2)) < 1e-9, "missed p=2 = -0.2"
|
||||
|
||||
block winLoss:
|
||||
# terminal terms stay dominant over shaping (#59 scale discipline)
|
||||
check abs(computeReward(win = true) - 20.0) < 1e-9, "win = +20"
|
||||
check abs(computeReward(loss = true) - (-10.0)) < 1e-9, "loss = -10"
|
||||
|
||||
# ── RewardNormalizer cold start ───────────────────────────────────────────────
|
||||
|
||||
block coldStart:
|
||||
var rn: RewardNormalizer
|
||||
# 0 samples
|
||||
let v0 = rn.normalize(99.0)
|
||||
check not isNaN(v0), "0 samples: not NaN"
|
||||
check classify(v0) != fcInf and classify(v0) != fcNegInf, "0 samples: not inf"
|
||||
check abs(v0) < 1e-9, "0 samples: returns 0"
|
||||
# 1 sample (variance undefined)
|
||||
rn.update(5.0)
|
||||
let v1 = rn.normalize(5.0)
|
||||
check not isNaN(v1), "1 sample: not NaN"
|
||||
check classify(v1) != fcInf and classify(v1) != fcNegInf, "1 sample: not inf"
|
||||
check abs(v1) < 1e-9, "1 sample: returns 0"
|
||||
|
||||
# ── Running normalization convergence ─────────────────────────────────────────
|
||||
|
||||
block convergence:
|
||||
var rn: RewardNormalizer
|
||||
# Feed 1000 identical samples of 5.0 — mean=5.0, std=0 → normalizer returns ~0
|
||||
for _ in 0 ..< 1000:
|
||||
rn.update(5.0)
|
||||
let v = rn.normalize(5.0)
|
||||
check not isNaN(v), "convergence: not NaN"
|
||||
check classify(v) != fcInf and classify(v) != fcNegInf, "convergence: not inf"
|
||||
# (5 - 5) / (0 + eps) = 0
|
||||
check abs(v) < 1e-6, "convergence to mean: normalized ≈ 0"
|
||||
|
||||
block knownMeanStd:
|
||||
# Insert samples -1 and +1 repeatedly → mean=0, std=1
|
||||
var rn: RewardNormalizer
|
||||
for _ in 0 ..< 500:
|
||||
rn.update(-1.0)
|
||||
rn.update( 1.0)
|
||||
# normalize(1.0) ≈ (1 - 0) / (1 + eps) ≈ 1
|
||||
let vPos = rn.normalize(1.0)
|
||||
check abs(vPos - 1.0) < 1e-4, &"normalize(+1) ≈ +1, got {vPos}"
|
||||
let vNeg = rn.normalize(-1.0)
|
||||
check abs(vNeg - (-1.0)) < 1e-4, &"normalize(-1) ≈ -1, got {vNeg}"
|
||||
let vMid = rn.normalize(0.0)
|
||||
check abs(vMid) < 1e-4, &"normalize(0) ≈ 0, got {vMid}"
|
||||
|
||||
echo "test_rewards: all passed"
|
||||
@@ -0,0 +1,87 @@
|
||||
## Tests for state.nim — assert-based, no framework.
|
||||
|
||||
import std/math
|
||||
import arraymancer
|
||||
import SAC_LSTM_Bot/state
|
||||
|
||||
proc makeBase(): GameState =
|
||||
result.arenaWidth = 1200.0
|
||||
result.arenaHeight = 800.0
|
||||
result.x = 600.0; result.y = 400.0
|
||||
result.direction = 90.0; result.speed = 4.0
|
||||
result.energy = 50.0
|
||||
result.gunDirection = 90.0; result.gunHeat = 0.5
|
||||
|
||||
proc allInRange(t: Tensor[float32]): bool =
|
||||
for v in t:
|
||||
if v < -1.01f32 or v > 1.01f32: return false
|
||||
true
|
||||
|
||||
proc hasNaN(t: Tensor[float32]): bool =
|
||||
for v in t:
|
||||
if v.float64.isNaN: return true
|
||||
false
|
||||
|
||||
# 1. Correct shape
|
||||
block:
|
||||
let gs = makeBase()
|
||||
let t = buildState(gs)
|
||||
assert t.shape[0] == STATE_DIM, "wrong dim: " & $t.shape[0]
|
||||
echo "PASS shape"
|
||||
|
||||
# 2. All values in [-1, 1] for typical input
|
||||
block:
|
||||
var gs = makeBase()
|
||||
gs.hasContact = true
|
||||
gs.enemy = EnemyData(x: 800.0, y: 300.0, direction: 45.0, speed: 6.0,
|
||||
energy: 80.0, hasFired: true, lastFirePower: 2.0,
|
||||
prevSpeed: 5.0, prevDirection: 40.0, hasPrevScan: true)
|
||||
gs.bulletCount = 1
|
||||
gs.bullets[0] = BulletData(x: 610.0, y: 410.0, power: 1.0)
|
||||
gs.ticksSinceLastScan = 10
|
||||
let t = buildState(gs)
|
||||
assert not hasNaN(t), "NaN in tensor"
|
||||
assert allInRange(t), "value out of [-1,1]"
|
||||
echo "PASS range"
|
||||
|
||||
# 3. Missing scan → valid tensor, no NaN, zeros for enemy fields
|
||||
block:
|
||||
let gs = makeBase() # hasContact = false
|
||||
let t = buildState(gs)
|
||||
assert not hasNaN(t), "NaN with no scan"
|
||||
for i in 7 .. 17:
|
||||
assert t[i] == 0.0f32, "enemy slot " & $i & " should be 0"
|
||||
echo "PASS no-scan zeros"
|
||||
|
||||
# 4. Bullet tracking: 0, 1, 2, 3 bullets
|
||||
block:
|
||||
for n in 0 .. 3:
|
||||
var gs = makeBase()
|
||||
gs.bulletCount = n
|
||||
for i in 0 ..< n:
|
||||
gs.bullets[i] = BulletData(x: float64(600 + i * 10), y: float64(400 + i * 5), power: 1.0)
|
||||
let t = buildState(gs)
|
||||
assert not hasNaN(t), "NaN with " & $n & " bullets"
|
||||
# slots beyond bulletCount must be 0
|
||||
for i in n ..< 3:
|
||||
let base = 22 + i * 4
|
||||
for j in 0 ..< 4:
|
||||
assert t[base + j] == 0.0f32, "bullet slot " & $i & " slot " & $j & " should be 0"
|
||||
echo "PASS bullet tracking 0-3"
|
||||
|
||||
# 5. Scan staleness increments and clamps
|
||||
block:
|
||||
var gs = makeBase()
|
||||
gs.hasContact = true
|
||||
gs.ticksSinceLastScan = 0
|
||||
assert buildState(gs)[34] == 0.0f32, "staleness at 0"
|
||||
gs.ticksSinceLastScan = 15
|
||||
let mid = buildState(gs)[34]
|
||||
assert mid > 0.0f32 and mid < 1.0f32, "staleness mid not in (0,1)"
|
||||
gs.ticksSinceLastScan = 30
|
||||
assert buildState(gs)[34] == 1.0f32, "staleness at 30"
|
||||
gs.ticksSinceLastScan = 60
|
||||
assert buildState(gs)[34] == 1.0f32, "staleness clamp at 60"
|
||||
echo "PASS staleness"
|
||||
|
||||
echo "ALL TESTS PASSED"
|
||||
@@ -0,0 +1,159 @@
|
||||
## test_training.nim — stdlib unittest for SAC training module.
|
||||
import unittest
|
||||
import arraymancer
|
||||
import std/[math, random, os]
|
||||
import SAC_LSTM_Bot/network
|
||||
import SAC_LSTM_Bot/weights
|
||||
import SAC_LSTM_Bot/replay_buffer
|
||||
import SAC_LSTM_Bot/training
|
||||
|
||||
const
|
||||
STATE_DIM = 10
|
||||
ACTION_DIM = 4
|
||||
HIDDEN = 16 # small for speed; override via env not needed in tests
|
||||
|
||||
proc makeTrainer(): SACTrainer =
|
||||
## Small trainer for tests — override hidden size via env before calling.
|
||||
putEnv("SACLSTM_HIDDEN_SIZE", "16")
|
||||
initSACTrainer(STATE_DIM, ACTION_DIM)
|
||||
|
||||
proc makeBuffer(): ReplayBuffer =
|
||||
newReplayBuffer(capacity = 2000, stateDim = STATE_DIM, actionDim = ACTION_DIM,
|
||||
burnIn = 4, trainWindow = 8)
|
||||
|
||||
proc randState(): Tensor[float32] =
|
||||
randomNormalTensor[float32](STATE_DIM)
|
||||
|
||||
proc randAction(): Tensor[float32] =
|
||||
randomTensor[float32](ACTION_DIM, 1.0'f32) *. 2.0'f32 -. 1.0'f32 # uniform [-1,1]
|
||||
|
||||
proc fillBuffer(buf: var ReplayBuffer; n: int; donePeriod = 20) =
|
||||
for i in 0 ..< n:
|
||||
let t = Transition(
|
||||
state: randState(),
|
||||
action: randAction(),
|
||||
reward: rand(-1.0'f32 .. 1.0'f32),
|
||||
nextState: randState(),
|
||||
done: (i mod donePeriod == donePeriod - 1))
|
||||
buf.add(t)
|
||||
|
||||
proc isFinite(x: float32): bool =
|
||||
not (x != x) and x < Inf and x > -Inf # not NaN and not Inf
|
||||
|
||||
suite "SACTrainer — basic update":
|
||||
|
||||
setup:
|
||||
randomize(42)
|
||||
var trainer = makeTrainer()
|
||||
var buf = makeBuffer()
|
||||
fillBuffer(buf, 1000)
|
||||
let seqs = buf.sampleSequences(4)
|
||||
|
||||
test "sampleSequences returns non-empty batch":
|
||||
check seqs.len > 0
|
||||
|
||||
test "sacUpdate returns finite losses":
|
||||
let m = sacUpdate(trainer, seqs)
|
||||
check isFinite(m.criticLoss)
|
||||
check isFinite(m.actorLoss)
|
||||
check isFinite(m.alphaLoss)
|
||||
|
||||
test "alpha stays positive after update":
|
||||
var t2 = trainer
|
||||
discard sacUpdate(t2, seqs)
|
||||
check t2.alpha() > 0.0'f32
|
||||
|
||||
test "critic1 weights change after update":
|
||||
let w_before = trainer.critic1.fc3.w.clone()
|
||||
discard sacUpdate(trainer, seqs)
|
||||
let w_after = trainer.critic1.fc3.w
|
||||
var changed = false
|
||||
for i in 0 ..< w_before.size:
|
||||
if abs(w_before.unsafe_raw_offset[i] - w_after.unsafe_raw_offset[i]) > 1e-10'f32:
|
||||
changed = true
|
||||
break
|
||||
check changed
|
||||
|
||||
test "critic2 weights change after update":
|
||||
let w_before = trainer.critic2.fc3.w.clone()
|
||||
discard sacUpdate(trainer, seqs)
|
||||
let w_after = trainer.critic2.fc3.w
|
||||
var changed = false
|
||||
for i in 0 ..< w_before.size:
|
||||
if abs(w_before.unsafe_raw_offset[i] - w_after.unsafe_raw_offset[i]) > 1e-10'f32:
|
||||
changed = true
|
||||
break
|
||||
check changed
|
||||
|
||||
test "actor weights change after update":
|
||||
let w_before = trainer.actor.fc1.w.clone()
|
||||
discard sacUpdate(trainer, seqs)
|
||||
let w_after = trainer.actor.fc1.w
|
||||
var changed = false
|
||||
for i in 0 ..< w_before.size:
|
||||
if abs(w_before.unsafe_raw_offset[i] - w_after.unsafe_raw_offset[i]) > 1e-10'f32:
|
||||
changed = true
|
||||
break
|
||||
check changed
|
||||
|
||||
test "soft target update: target moves toward critic":
|
||||
## After update, target fc3.w should be closer to critic1.fc3.w than before.
|
||||
let targetBefore = trainer.targetCritic1.fc3.w.clone()
|
||||
let criticW = trainer.critic1.fc3.w.clone()
|
||||
discard sacUpdate(trainer, seqs)
|
||||
let targetAfter = trainer.targetCritic1.fc3.w
|
||||
|
||||
# Distance before: ||targetBefore - criticW||
|
||||
var distBefore = 0.0'f32
|
||||
for i in 0 ..< targetBefore.size:
|
||||
let d = targetBefore.unsafe_raw_offset[i] - criticW.unsafe_raw_offset[i]
|
||||
distBefore += d * d
|
||||
|
||||
# Distance after: ||targetAfter - criticW_new|| (critic changed too, use original for ref)
|
||||
var distAfter = 0.0'f32
|
||||
for i in 0 ..< targetAfter.size:
|
||||
let d = targetAfter.unsafe_raw_offset[i] - criticW.unsafe_raw_offset[i]
|
||||
distAfter += d * d
|
||||
|
||||
# Target moved toward critic (distance decreased).
|
||||
# With tau=0.005, it moves a tiny bit — just check direction.
|
||||
check distAfter <= distBefore + 1e-3'f32 # soft bound; critic also moves
|
||||
|
||||
test "empty sequences is a no-op":
|
||||
let m = sacUpdate(trainer, @[])
|
||||
check m.criticLoss == 0.0'f32
|
||||
check m.actorLoss == 0.0'f32
|
||||
check m.alpha == 0.0'f32
|
||||
|
||||
suite "SACTrainer — done=true terminal transitions":
|
||||
|
||||
test "update with done=true transitions produces finite losses":
|
||||
randomize(7)
|
||||
putEnv("SACLSTM_HIDDEN_SIZE", "16")
|
||||
var trainer = makeTrainer()
|
||||
var buf = makeBuffer()
|
||||
# Fill with short episodes: done every 12 steps (burnIn=4, trainWindow=8 → seqLen=12)
|
||||
fillBuffer(buf, 800, donePeriod = 12)
|
||||
let seqs = buf.sampleSequences(2)
|
||||
if seqs.len > 0:
|
||||
let m = sacUpdate(trainer, seqs)
|
||||
check isFinite(m.criticLoss)
|
||||
check isFinite(m.actorLoss)
|
||||
check isFinite(m.alphaLoss)
|
||||
check m.alpha > 0.0'f32
|
||||
|
||||
suite "SACTrainer — multiple updates":
|
||||
|
||||
test "three sequential updates all stay finite":
|
||||
randomize(99)
|
||||
putEnv("SACLSTM_HIDDEN_SIZE", "16")
|
||||
var trainer = makeTrainer()
|
||||
var buf = makeBuffer()
|
||||
fillBuffer(buf, 1000)
|
||||
for _ in 1..3:
|
||||
let seqs = buf.sampleSequences(4)
|
||||
if seqs.len > 0:
|
||||
let m = sacUpdate(trainer, seqs)
|
||||
check isFinite(m.criticLoss)
|
||||
check isFinite(m.actorLoss)
|
||||
check m.alpha > 0.0'f32
|
||||
@@ -0,0 +1,141 @@
|
||||
import unittest
|
||||
import arraymancer
|
||||
import zip/zipfiles
|
||||
import std/[os, math, strutils]
|
||||
import SAC_LSTM_Bot/network
|
||||
import SAC_LSTM_Bot/weights
|
||||
|
||||
const
|
||||
STATE_DIM = 20
|
||||
ACTION_DIM = 4
|
||||
|
||||
proc tensorsEqual(a, b: Tensor[float32]; tol: float32 = 1e-6'f32): bool =
|
||||
if a.shape != b.shape: return false
|
||||
for i in 0 ..< a.size:
|
||||
if abs(a.unsafe_raw_offset[i] - b.unsafe_raw_offset[i]) > tol: return false
|
||||
true
|
||||
|
||||
suite "saveWeights / loadCheckpoint":
|
||||
|
||||
setup:
|
||||
let actor = initActorNet(STATE_DIM)
|
||||
let c1 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let c2 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let tc1 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let tc2 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let alpha = 0.2'f32
|
||||
let zipPath = getTempDir() / "test_weights_latest.zip"
|
||||
|
||||
teardown:
|
||||
if fileExists(zipPath): removeFile(zipPath)
|
||||
|
||||
test "creates a valid zip file":
|
||||
saveWeights(zipPath, actor, c1, c2, tc1, tc2, alpha)
|
||||
check fileExists(zipPath)
|
||||
var z: ZipArchive
|
||||
check z.open(zipPath, fmRead)
|
||||
var count = 0
|
||||
for f in z.walkFiles: inc count
|
||||
z.close()
|
||||
check count > 0
|
||||
|
||||
test "all entries are .npy files (+ alpha.npy)":
|
||||
saveWeights(zipPath, actor, c1, c2, tc1, tc2, alpha)
|
||||
var z: ZipArchive
|
||||
discard z.open(zipPath, fmRead)
|
||||
var allNpy = true
|
||||
for f in z.walkFiles:
|
||||
if not f.endsWith(".npy"): allNpy = false
|
||||
z.close()
|
||||
check allNpy
|
||||
|
||||
test "round-trip: actor weights preserved":
|
||||
saveWeights(zipPath, actor, c1, c2, tc1, tc2, alpha)
|
||||
let ck = loadCheckpoint(zipPath)
|
||||
check tensorsEqual(actor.fc1.w, ck.actor.fc1.w)
|
||||
check tensorsEqual(actor.fc1.b, ck.actor.fc1.b)
|
||||
check tensorsEqual(actor.lstm.wCombined, ck.actor.lstm.wCombined)
|
||||
check tensorsEqual(actor.lstm.bCombined, ck.actor.lstm.bCombined)
|
||||
check tensorsEqual(actor.fc2.w, ck.actor.fc2.w)
|
||||
check tensorsEqual(actor.muHead.w, ck.actor.muHead.w)
|
||||
check tensorsEqual(actor.logStdHead.w, ck.actor.logStdHead.w)
|
||||
|
||||
test "round-trip: critic weights preserved":
|
||||
saveWeights(zipPath, actor, c1, c2, tc1, tc2, alpha)
|
||||
let ck = loadCheckpoint(zipPath)
|
||||
check tensorsEqual(c1.fc1.w, ck.critic1.fc1.w)
|
||||
check tensorsEqual(c1.lstm.wCombined, ck.critic1.lstm.wCombined)
|
||||
check tensorsEqual(c1.fc3.w, ck.critic1.fc3.w)
|
||||
check tensorsEqual(tc1.fc1.w, ck.targetCritic1.fc1.w)
|
||||
check tensorsEqual(tc2.fc1.w, ck.targetCritic2.fc1.w)
|
||||
|
||||
test "round-trip: alpha preserved":
|
||||
saveWeights(zipPath, actor, c1, c2, tc1, tc2, alpha)
|
||||
let ck = loadCheckpoint(zipPath)
|
||||
check abs(ck.alpha - alpha) < 1e-6'f32
|
||||
|
||||
test "round-trip: hiddenDim reconstructed":
|
||||
saveWeights(zipPath, actor, c1, c2, tc1, tc2, alpha)
|
||||
let ck = loadCheckpoint(zipPath)
|
||||
check ck.actor.hiddenDim == actor.hiddenDim
|
||||
check ck.critic1.hiddenDim == c1.hiddenDim
|
||||
|
||||
test "adam not present → initialized=false":
|
||||
saveWeights(zipPath, actor, c1, c2, tc1, tc2, alpha)
|
||||
let ck = loadCheckpoint(zipPath)
|
||||
check not ck.adam.initialized
|
||||
|
||||
suite "saveCheckpoint (with Adam)":
|
||||
|
||||
setup:
|
||||
let actor = initActorNet(STATE_DIM)
|
||||
let c1 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let c2 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let tc1 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let tc2 = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
let alpha = 0.1'f32
|
||||
var adam = initSACAdamStates(actor, c1, c2)
|
||||
# put some non-zero values in Adam state
|
||||
adam.actor.fc1.w.m[0, 0] = 0.5'f32
|
||||
adam.actor.fc1.w.t = 42
|
||||
adam.critic1.fc1.w.t = 7
|
||||
adam.alpha.t = 99
|
||||
let zipPath = getTempDir() / "test_checkpoint.zip"
|
||||
|
||||
teardown:
|
||||
if fileExists(zipPath): removeFile(zipPath)
|
||||
|
||||
test "round-trip: Adam m tensor":
|
||||
saveCheckpoint(zipPath, actor, c1, c2, tc1, tc2, alpha, adam)
|
||||
let ck = loadCheckpoint(zipPath)
|
||||
check ck.adam.initialized
|
||||
check abs(ck.adam.actor.fc1.w.m[0, 0] - 0.5'f32) < 1e-6'f32
|
||||
|
||||
test "round-trip: Adam t counters":
|
||||
saveCheckpoint(zipPath, actor, c1, c2, tc1, tc2, alpha, adam)
|
||||
let ck = loadCheckpoint(zipPath)
|
||||
check ck.adam.actor.fc1.w.t == 42
|
||||
check ck.adam.critic1.fc1.w.t == 7
|
||||
check ck.adam.alpha.t == 99
|
||||
|
||||
suite "Atomic save":
|
||||
|
||||
test "temp file is cleaned up after successful save":
|
||||
let zipPath = getTempDir() / "test_atomic.zip"
|
||||
let tmpZip = zipPath & ".tmp"
|
||||
let actor = initActorNet(STATE_DIM)
|
||||
let c = initCriticNet(STATE_DIM, ACTION_DIM)
|
||||
saveWeights(zipPath, actor, c, c, c, c, 0.2'f32)
|
||||
check fileExists(zipPath)
|
||||
check not fileExists(tmpZip)
|
||||
removeFile(zipPath)
|
||||
|
||||
suite "Error handling":
|
||||
|
||||
test "loadCheckpoint missing file → IOError":
|
||||
var raised = false
|
||||
try:
|
||||
discard loadCheckpoint(getTempDir() / "nonexistent_xxxxxx.zip")
|
||||
except IOError:
|
||||
raised = true
|
||||
check raised
|
||||
@@ -0,0 +1,712 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the single self-updating SAC training dashboard.
|
||||
|
||||
Pure-stdlib SVG output (matplotlib not available on this box).
|
||||
Generates ONE file:
|
||||
docs/campaign_dashboard.svg - four panels, current (v2) run only:
|
||||
1. test-match win % vs opponents (campaign_v4_stdout.log eval lines)
|
||||
2. critic_loss / |actor_loss| / alpha (training_metrics.jsonl; losses log
|
||||
left axis, alpha linear right axis)
|
||||
3. throughput, games/hour buckets (training_metrics.jsonl 'epoch' deltas;
|
||||
1 metrics row == one 10-game chunk,
|
||||
counts match stdout chunk markers)
|
||||
4. max score per eval cycle (campaign_v4_stdout.log eval blocks;
|
||||
eval_log.jsonl only ever holds the
|
||||
LATEST cycle, so history comes from
|
||||
the stdout log)
|
||||
plus an embedded JS snippet that reloads the page every 60 s when the SVG is
|
||||
opened as a top-level document in Chrome.
|
||||
|
||||
Usage:
|
||||
python3 tools/plot_progress.py [campaign_log] [metrics_jsonl] [outdir]
|
||||
All args optional; defaults relative to the SAC_LSTM_Bot/ root (parent of tools/).
|
||||
python3 tools/plot_progress.py --selftest # tiny built-in sanity check
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LOG_FILE = ROOT / "tools" / "plot_progress.log"
|
||||
|
||||
|
||||
def log(msg):
|
||||
"""Timestamped line to stderr AND tools/plot_progress.log (survives reboots;
|
||||
the watcher has no terminal to read errors from)."""
|
||||
line = f"{datetime.now():%F %T} {msg}"
|
||||
print(line)
|
||||
try:
|
||||
with open(LOG_FILE, "a") as fh:
|
||||
fh.write(line + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
EVAL_RE = re.compile(r">>> \[eval\] win rate: (\d+)/(\d+) \(([\d.]+)%\) vs (\S+)")
|
||||
EVAL_BLOCK_RE = re.compile(r">>> \[eval\] \d+ deterministic rounds vs (\S+)")
|
||||
ROUND_RE = re.compile(r"Round \d+/\d+\D+ticks:\d+ score:(\d+) win:")
|
||||
TREND_WINDOW = 10 # rolling mean shown as the thick trend line (panel 1)
|
||||
RATE_BUCKET = 20 # metric intervals per throughput bucket (~200 games)
|
||||
GAMES_PER_ROW = 10 # one training_metrics.jsonl row per 10-round chunk
|
||||
COLORS = {"Corners": "#d62728", "Crazy": "#1f77b4", "Target": "#2ca02c",
|
||||
"RamFire": "#ff7f0e", "SacTwin": "#9467bd"}
|
||||
CRITIC_C, ACTOR_C = "#1f77b4", "#ff7f0e"
|
||||
ALPHA_C = "#9467bd" # alpha line on the losses panel
|
||||
RELOAD_JS = ('<script type="text/javascript"><![CDATA[ '
|
||||
'setTimeout(function(){ location.reload(); }, 60000); ]]></script>')
|
||||
W, H = 1400, 1850
|
||||
HEALTH_H = 205 # bottom "Reading the signs" cheat-sheet band
|
||||
# rows: (header_y, panel_top_y, panel_bottom_y, x_left, x_right)
|
||||
# panel_top sits low enough to leave room under header+sub for the
|
||||
# per-panel how-to-read guide (3 italic lines, see panel_guide)
|
||||
C1_L, C1_R = 70, 697
|
||||
C2_L, C2_R = 747, 1375
|
||||
ROWS = {
|
||||
1: (100, 184, 784, C1_L, C1_R),
|
||||
2: (100, 184, 784, C2_L, C2_R),
|
||||
3: (940, 1024, 1494, C1_L, C1_R),
|
||||
4: (940, 1024, 1494, C2_L, C2_R),
|
||||
}
|
||||
PANEL_TITLES = [
|
||||
"Test matches - win % vs opponents",
|
||||
"Training losses (log) & alpha (linear)",
|
||||
"Throughput - games per hour",
|
||||
"Max score per eval cycle",
|
||||
]
|
||||
# per-panel how-to-read notes: (what it shows, axes, which direction is better)
|
||||
GUIDES = {
|
||||
1: ("How often the bot wins against each opponent in test battles.",
|
||||
"X = training progress (match number); Y = win rate, 0-100%.",
|
||||
"higher is better."),
|
||||
2: ("How well the brain is learning: losses should fall; alpha sets explore/exploit.",
|
||||
"X = training progress; left Y (log) = losses; right Y (linear) = alpha.",
|
||||
"lower losses = better; alpha falls over time as the bot gets confident."),
|
||||
3: ("How many games per hour the bot trains (its learning speed).",
|
||||
"X = training progress (10-game chunks); Y = games per hour.",
|
||||
"higher = faster learning; a steady line beats a spiky one."),
|
||||
4: ("Best single-round score the bot managed in each test cycle.",
|
||||
"X = eval cycle number; Y = best score achieved.",
|
||||
"higher is better; a rising trend means the bot is improving."),
|
||||
}
|
||||
# bottom health-check cheat-sheet: one column per subsection; each bullet is
|
||||
# a tuple of pre-wrapped text lines (first line gets the bullet marker)
|
||||
SIGNS_COLUMNS = [
|
||||
("Healthy patterns ✅", [
|
||||
("Both critic and actor losses trending down over time",),
|
||||
("Alpha decaying slowly from ~1.0,", "then plateauing — this is normal"),
|
||||
("Win rates appearing and increasing in the eval panel",),
|
||||
("Max scores rising in the max-score panel",),
|
||||
("Alpha plateauing is NOT a problem — it means",
|
||||
"exploration level is stable"),
|
||||
]),
|
||||
("Warning signs ⚠️", [
|
||||
("Losses exploding (suddenly jumping to", "millions or billions)"),
|
||||
("Win rates staying at 0% for a long time",
|
||||
"after the first ~20 eval cycles"),
|
||||
("Alpha reaching 0 — bot stops exploring entirely", "(gets stuck)"),
|
||||
("Max scores flatlining (no improvement",
|
||||
"over many eval cycles)"),
|
||||
("Any single loss value above 1e6",),
|
||||
]),
|
||||
("What each metric means (brief)", [
|
||||
("Critic loss: how wrong the bot's value",
|
||||
"estimates are — should go down"),
|
||||
("Actor loss: how well the bot's action policy",
|
||||
"is doing — should go down overall", "(some bumps are normal)"),
|
||||
("Alpha: exploration-exploitation tradeoff — starts",
|
||||
"high, settles at a positive value (NOT zero)"),
|
||||
("Max score: best score achieved per eval cycle —",
|
||||
"rising trend = learning"),
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
# ---------- parsing ----------
|
||||
|
||||
def parse_eval_series(path):
|
||||
"""Return {opponent: [win% per eval, in file order]}."""
|
||||
series = {}
|
||||
if not path.is_file():
|
||||
print(f"[skip] eval log not found: {path}")
|
||||
return series
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
m = EVAL_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
pct = float(m.group(3))
|
||||
except ValueError:
|
||||
continue
|
||||
series.setdefault(m.group(4), []).append(pct)
|
||||
return series
|
||||
|
||||
|
||||
def parse_max_scores(path):
|
||||
"""Return {opponent: [best single-round score per eval cycle, in file order]}.
|
||||
|
||||
eval_log.jsonl is atomically overwritten every cycle (sac_train.sh mv), so
|
||||
per-cycle history only exists in the stdout log: each eval prints a
|
||||
'>>> [eval] N deterministic rounds vs X' header, then Round/score lines,
|
||||
closed by the '[eval] win rate' (or crashed / no results) line. Training
|
||||
rounds share the Round-line format, so they are ignored unless inside a block.
|
||||
"""
|
||||
out, cur, best = {}, None, None
|
||||
if not path.is_file():
|
||||
print(f"[skip] campaign log not found: {path}")
|
||||
return out
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
m = EVAL_BLOCK_RE.search(line)
|
||||
if m:
|
||||
cur, best = m.group(1), None
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
if "[eval]" in line: # win-rate / crashed / no-results closes the block
|
||||
if best is not None:
|
||||
out.setdefault(cur, []).append(best)
|
||||
cur, best = None, None
|
||||
continue
|
||||
m = ROUND_RE.search(line)
|
||||
if m:
|
||||
v = int(m.group(1))
|
||||
best = v if best is None else max(best, v)
|
||||
return out
|
||||
|
||||
|
||||
def parse_metrics(path):
|
||||
"""Return list of metric dicts, skipping malformed lines."""
|
||||
rows = []
|
||||
if not path.is_file():
|
||||
print(f"[skip] metrics file not found: {path}")
|
||||
return rows
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def metric_col(rows, key, positive=False):
|
||||
"""[(index, value)] for float-parseable rows; abs() applied; optional >0 filter."""
|
||||
out = []
|
||||
for i, r in enumerate(rows):
|
||||
try:
|
||||
v = abs(float(r[key]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if positive and v <= 0:
|
||||
continue
|
||||
out.append((i, v))
|
||||
return out
|
||||
|
||||
|
||||
def rolling(vals, w=TREND_WINDOW):
|
||||
out, s = [], 0.0
|
||||
for i, v in enumerate(vals):
|
||||
s += v
|
||||
if i >= w:
|
||||
s -= vals[i - w]
|
||||
out.append(s / min(i + 1, w))
|
||||
return out
|
||||
|
||||
|
||||
def bucket_means(vals, n):
|
||||
"""Split vals into <=n contiguous buckets of near-equal size; per-bucket mean."""
|
||||
if not vals:
|
||||
return []
|
||||
n = min(n, len(vals))
|
||||
k, rem = divmod(len(vals), n)
|
||||
out, i = [], 0
|
||||
for b in range(n):
|
||||
size = k + (1 if b < rem else 0)
|
||||
out.append(sum(vals[i:i + size]) / size)
|
||||
i += size
|
||||
return out
|
||||
|
||||
|
||||
# ---------- tiny SVG helpers ----------
|
||||
|
||||
def esc(s):
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def write_svg(path, text):
|
||||
"""Validate the finished SVG, then atomically swap it into place.
|
||||
|
||||
Readers never see partial output; an invalid render aborts without
|
||||
touching the previous good file."""
|
||||
try:
|
||||
ET.fromstring(text)
|
||||
except ET.ParseError as e:
|
||||
print(f"[error] {path.name}: generated SVG invalid, keeping old file ({e})")
|
||||
return False
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") # unique: a manual
|
||||
tmp.write_text(text) # run + watcher can race
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
|
||||
|
||||
def polyline(pts, color, width=1.5, dash=None, opacity=1.0):
|
||||
if len(pts) < 2:
|
||||
return ""
|
||||
d = f' stroke-dasharray="{dash}"' if dash else ""
|
||||
p = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
|
||||
return (f'<polyline points="{p}" fill="none" stroke="{color}" '
|
||||
f'stroke-width="{width}" opacity="{opacity}"{d}/>\n')
|
||||
|
||||
|
||||
def dots(pts, color, r=2, opacity=0.25):
|
||||
return "".join(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r}" '
|
||||
f'fill="{color}" opacity="{opacity}"/>\n' for x, y in pts)
|
||||
|
||||
|
||||
def hgrid(x0, x1, ys, color="#dddddd"):
|
||||
return "".join(f'<line x1="{x0}" y1="{y:.1f}" x2="{x1}" y2="{y:.1f}" '
|
||||
f'stroke="{color}"/>\n' for y in ys)
|
||||
|
||||
|
||||
def axis(x0, y0, x1, y1, xt, yt, xlabel, ylabel, ylog=False):
|
||||
"""Draw axes + ticks + labels. xt/yt are (value, px) tick lists."""
|
||||
s = (f'<line x1="{x0}" y1="{y0}" x2="{x1}" y2="{y0}" stroke="black"/>\n'
|
||||
f'<line x1="{x0}" y1="{y0}" x2="{x0}" y2="{y1}" stroke="black"/>\n')
|
||||
for v, px in xt:
|
||||
s += (f'<line x1="{px:.1f}" y1="{y0}" x2="{px:.1f}" y2="{y0 + 4}" stroke="black"/>\n'
|
||||
f'<text x="{px:.1f}" y="{y0 + 17}" text-anchor="middle" font-size="11">'
|
||||
f"{esc(v)}</text>\n")
|
||||
for v, py in yt:
|
||||
s += (f'<line x1="{x0 - 4}" y1="{py:.1f}" x2="{x0}" y2="{py:.1f}" stroke="black"/>\n'
|
||||
f'<text x="{x0 - 7}" y="{py + 4:.1f}" text-anchor="end" font-size="11">'
|
||||
f"{esc(v)}</text>\n")
|
||||
s += (f'<text x="{(x0 + x1) // 2}" y="{y0 + 33}" text-anchor="middle" font-size="12">'
|
||||
f"{esc(xlabel)}</text>\n"
|
||||
f'<text x="16" y="{(y0 + y1) // 2}" text-anchor="middle" font-size="12" '
|
||||
f'transform="rotate(-90 16 {(y0 + y1) // 2})">{esc(ylabel)}</text>\n')
|
||||
return s
|
||||
|
||||
|
||||
def ticks_linear(vmin, vmax, p0, p1, n=5, fmt="{:.0f}"):
|
||||
return [(fmt.format(vmin + (vmax - vmin) * i / (n - 1)),
|
||||
p0 + (p1 - p0) * i / (n - 1)) for i in range(n)]
|
||||
|
||||
|
||||
def ticks_log(vmin, vmax, p0, p1, n=5):
|
||||
vals = [10 ** (vmin + (vmax - vmin) * i / (n - 1)) for i in range(n)]
|
||||
return [("{:.3g}".format(v), p0 + (p1 - p0) * i / (n - 1)) for i, v in enumerate(vals)]
|
||||
|
||||
|
||||
def legend(items, x, y):
|
||||
"""items: [(color, label)]"""
|
||||
s = ""
|
||||
for i, (color, label) in enumerate(items):
|
||||
yy = y + i * 18
|
||||
s += (f'<line x1="{x}" y1="{yy}" x2="{x + 28}" y2="{yy}" stroke="{color}" '
|
||||
f'stroke-width="3"/>\n'
|
||||
f'<text x="{x + 34}" y="{yy + 4}" font-size="12">{esc(label)}</text>\n')
|
||||
return s
|
||||
|
||||
|
||||
def map_fn(p0, p1, vmin, vmax, log=False):
|
||||
def f(v):
|
||||
span = (vmax - vmin) or 1 # single-point series -> degenerate range
|
||||
t = (math.log10(v) - vmin) / span if log else (v - vmin) / span
|
||||
return p0 + max(0.0, min(1.0, t)) * (p1 - p0)
|
||||
return f
|
||||
|
||||
|
||||
def signs_block():
|
||||
"""Health-check cheat-sheet band along the bottom of the canvas."""
|
||||
y0 = H - HEALTH_H
|
||||
s = f'<rect x="0" y="{y0}" width="{W}" height="{HEALTH_H}" fill="#f2f2f2"/>\n'
|
||||
s += (f'<text x="16" y="{y0 + 21}" font-size="15" font-weight="bold">'
|
||||
"Reading the signs</text>\n"
|
||||
f'<text x="{W - 16}" y="{y0 + 20}" text-anchor="end" font-size="11" '
|
||||
'fill="#666">Regenerate anytime: python3 tools/plot_progress.py</text>\n')
|
||||
for (head, bullets), x in zip(SIGNS_COLUMNS, (16, 500, 985)):
|
||||
s += (f'<text x="{x}" y="{y0 + 43}" font-size="13" font-weight="bold">'
|
||||
f"{esc(head)}</text>\n")
|
||||
y = y0 + 61
|
||||
for lines in bullets:
|
||||
for j, ln in enumerate(lines):
|
||||
s += (f'<text x="{x}" y="{y}" font-size="12">'
|
||||
f"{esc(('• ' if j == 0 else ' ') + ln)}</text>\n")
|
||||
y += 14
|
||||
return s
|
||||
|
||||
|
||||
def header(x, y, title, sub=None):
|
||||
s = (f'<text x="{x}" y="{y}" font-size="15" font-weight="bold">'
|
||||
f"{esc(title)}</text>\n")
|
||||
if sub:
|
||||
s += (f'<text x="{x}" y="{y + 15}" font-size="11" fill="#555">'
|
||||
f"{esc(sub)}</text>\n")
|
||||
return s
|
||||
|
||||
|
||||
def panel_guide(x0, y, guide):
|
||||
"""Italic how-to-read note between a panel's header and its plot area."""
|
||||
what, axes, better = guide
|
||||
s = ""
|
||||
for i, txt in enumerate((f"What: {what}", f"Axes: {axes}", f"Better: {better}")):
|
||||
s += (f'<text x="{x0}" y="{y + i * 16}" font-size="12" font-style="italic" '
|
||||
f'fill="#555">{esc(txt)}</text>\n')
|
||||
return s
|
||||
|
||||
|
||||
# ---------- panels ----------
|
||||
|
||||
def panel_test_matches(series, geo):
|
||||
_, pt, pb, x0, x1 = geo
|
||||
s = ""
|
||||
if not series:
|
||||
s += f'<text x="{x0 + 10}" y="{pt + 40}" font-size="12" fill="#a00">' \
|
||||
"no eval lines found</text>\n"
|
||||
return s
|
||||
xmax = max(max(len(v) for v in series.values()), 2)
|
||||
xm, ym = map_fn(x0, x1, 1, xmax), map_fn(pb, pt, 0, 100)
|
||||
s += hgrid(x0, x1, [ym(v) for v in range(0, 101, 20)])
|
||||
step = max(1, (xmax // 8 // 10) * 10)
|
||||
xt = [(str(v), xm(v)) for v in range(step, xmax + 1, step)] or [("1", xm(1))]
|
||||
s += axis(x0, pb, x1, pt, xt, ticks_linear(0, 100, pb, pt, n=6),
|
||||
"test match number (each opponent)", "win rate (%)")
|
||||
items = []
|
||||
for name in ("Corners", "Crazy", "Target"):
|
||||
vals = series.get(name, [])
|
||||
if not vals:
|
||||
continue
|
||||
c = COLORS[name]
|
||||
s += dots([(xm(i + 1), ym(v)) for i, v in enumerate(vals)], c)
|
||||
s += polyline([(xm(i + 1), ym(v))
|
||||
for i, v in enumerate(rolling(vals))], c, 3.5)
|
||||
items.append((c, f"{name} - {len(vals)} evals"))
|
||||
s += legend(items, x0 + 12, pb + 52)
|
||||
return s
|
||||
|
||||
def panel_max_score(maxes, geo):
|
||||
_, pt, pb, x0, x1 = geo
|
||||
s = ""
|
||||
if not maxes:
|
||||
s += f'<text x="{x0 + 10}" y="{pt + 40}" font-size="12" fill="#a00">' \
|
||||
"no eval lines found</text>\n"
|
||||
return s
|
||||
ncyc = max(len(v) for v in maxes.values())
|
||||
xmax = max(ncyc, 2)
|
||||
hi = max(1, max(v for vals in maxes.values() for v in vals)) * 1.1
|
||||
xm, ym = map_fn(x0, x1, 1, xmax), map_fn(pb, pt, 0, hi)
|
||||
s += hgrid(x0, x1, [ym(hi * k / 4) for k in range(5)])
|
||||
step = max(1, xmax // 8)
|
||||
xt = [(str(v), xm(v)) for v in range(step, xmax + 1, step)] or [("1", xm(1))]
|
||||
s += axis(x0, pb, x1, pt, xt, ticks_linear(0, hi, pb, pt, n=5),
|
||||
"eval cycle number", "best single-round score")
|
||||
items, per_opp = [], {}
|
||||
for name in ("Corners", "Crazy", "Target"):
|
||||
vals = maxes.get(name, [])
|
||||
if not vals:
|
||||
continue
|
||||
c = COLORS[name]
|
||||
pts = [(xm(i + 1), ym(v)) for i, v in enumerate(vals)]
|
||||
per_opp[name] = vals
|
||||
s += dots(pts, c)
|
||||
s += polyline(pts, c, 2.5)
|
||||
items.append((c, f"{name} - {len(vals)} evals"))
|
||||
if len(items) >= 2: # combined best across opponents, cycle-aligned
|
||||
comb = [max(vals[i] for vals in per_opp.values() if i < len(vals))
|
||||
for i in range(ncyc)]
|
||||
s += polyline([(xm(i + 1), ym(v)) for i, v in enumerate(comb)],
|
||||
"#555555", 2.5, dash="6 4")
|
||||
items.append(("#555555", "combined max"))
|
||||
s += legend(items, x0 + 12, pb + 52)
|
||||
return s
|
||||
|
||||
def panel_losses(rows, geo):
|
||||
_, pt, pb, x0, x1 = geo
|
||||
s = ""
|
||||
critic = metric_col(rows, "critic_loss", positive=True)
|
||||
actor = metric_col(rows, "actor_loss") # abs() applied; sign dropped
|
||||
actor = [(i, v) for i, v in actor if v > 0]
|
||||
alpha = metric_col(rows, "alpha")
|
||||
if not (critic or actor or alpha):
|
||||
s += f'<text x="{x0 + 10}" y="{pt + 40}" font-size="12" fill="#a00">' \
|
||||
"no valid loss points</text>\n"
|
||||
return s
|
||||
x1 -= 46 # room on the right for the twin alpha axis labels
|
||||
n = len(rows)
|
||||
xm = lambda i: x0 + (x1 - x0) * i / max(n - 1, 1)
|
||||
# log-domain source (losses in practice); drop non-positive values so a
|
||||
# run of alpha==0 rows can't raise math domain error and kill the build
|
||||
base = [(i, v) for i, v in critic + actor if v > 0] or \
|
||||
[(i, v) for i, v in alpha if v > 0]
|
||||
if not base:
|
||||
s += f'<text x="{x0 + 10}" y="{pt + 40}" font-size="12" fill="#a00">' \
|
||||
"no valid loss points</text>\n"
|
||||
return s
|
||||
lo = math.floor(math.log10(min(v for _, v in base)))
|
||||
hi = math.ceil(math.log10(max(v for _, v in base)))
|
||||
if lo == hi:
|
||||
hi = lo + 1
|
||||
ym = map_fn(pb, pt, lo, hi, log=True)
|
||||
s += hgrid(x0, x1, [ym(10 ** e) for e in range(lo, hi + 1)])
|
||||
s += axis(x0, pb, x1, pt, ticks_linear(1, n, x0, x1, n=5),
|
||||
ticks_log(lo, hi, pb, pt), "metric line number", "loss (log)")
|
||||
s += polyline([(xm(i), ym(v)) for i, v in critic], CRITIC_C, 1.8)
|
||||
s += polyline([(xm(i), ym(v)) for i, v in actor], ACTOR_C, 1.8)
|
||||
if alpha: # twin axis: alpha on its own linear scale, purple like the line
|
||||
ahi = max(1.0, max(v for _, v in alpha))
|
||||
yma = map_fn(pb, pt, 0, ahi)
|
||||
s += hgrid(x0, x1, [yma(ahi * k / 4) for k in range(5)], "#e9dcf5")
|
||||
mid = (pt + pb) // 2
|
||||
s += f'<line x1="{x1}" y1="{pb}" x2="{x1}" y2="{pt}" stroke="{ALPHA_C}"/>\n'
|
||||
for v, py in ticks_linear(0, ahi, pb, pt, n=5, fmt="{:.3g}"):
|
||||
s += (f'<line x1="{x1}" y1="{py:.1f}" x2="{x1 + 4}" y2="{py:.1f}" '
|
||||
f'stroke="{ALPHA_C}"/>\n'
|
||||
f'<text x="{x1 + 7}" y="{py + 4:.1f}" font-size="11" '
|
||||
f'fill="{ALPHA_C}">{esc(v)}</text>\n')
|
||||
s += (f'<text x="{x1 + 17}" y="{mid}" text-anchor="middle" font-size="12" '
|
||||
f'fill="{ALPHA_C}" transform="rotate(90 {x1 + 17} {mid})">alpha</text>\n')
|
||||
s += polyline([(xm(i), yma(v)) for i, v in alpha], ALPHA_C, 1.8)
|
||||
items = [(CRITIC_C, "critic_loss"), (ACTOR_C, "|actor_loss|")]
|
||||
if alpha:
|
||||
items.append((ALPHA_C, "alpha"))
|
||||
s += legend(items, x0 + 12, pb + 52)
|
||||
return s
|
||||
|
||||
def panel_throughput(rows, geo):
|
||||
_, pt, pb, x0, x1 = geo
|
||||
s = ""
|
||||
eps = []
|
||||
for r in rows:
|
||||
try:
|
||||
eps.append(float(r["epoch"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
rates = [] # games/hour per inter-row interval
|
||||
for a, b in zip(eps, eps[1:]):
|
||||
dt = b - a
|
||||
if dt > 0:
|
||||
rates.append(3600.0 * GAMES_PER_ROW / dt)
|
||||
if not rates:
|
||||
s += f'<text x="{x0 + 10}" y="{pt + 40}" font-size="12" fill="#a00">' \
|
||||
"no usable epoch timestamps</text>\n"
|
||||
return s
|
||||
bm = bucket_means(rates, RATE_BUCKET)
|
||||
xm = map_fn(x0, x1, 1, len(rates))
|
||||
ymax = max(max(rates), max(bm)) * 1.1
|
||||
ym = map_fn(pb, pt, 0, ymax)
|
||||
s += hgrid(x0, x1, [ym(ymax * k / 4) for k in range(5)])
|
||||
step = max(1, len(rates) // 10)
|
||||
xt = [(str(v), xm(v)) for v in range(step, len(rates) + 1, step)]
|
||||
s += axis(x0, pb, x1, pt, xt, ticks_linear(0, ymax, pb, pt, n=5, fmt="{:.0f}"),
|
||||
f"chunk interval ({GAMES_PER_ROW}-game chunks)", "games / hour")
|
||||
s += dots([(xm(i + 1), ym(v)) for i, v in enumerate(rates)], "#7f7f7f", r=1.6)
|
||||
if len(bm) >= 2:
|
||||
ctr = [xm(round((i + 0.5) * len(rates) / len(bm))) for i in range(len(bm))]
|
||||
s += polyline(list(zip(ctr, map(ym, bm))), "#2ca02c", 3.5)
|
||||
return s
|
||||
|
||||
|
||||
# ---------- assembly ----------
|
||||
|
||||
def build_dashboard(campaign, metrics_f, out):
|
||||
series = parse_eval_series(campaign)
|
||||
print("[info] evals parsed: " +
|
||||
(", ".join(f"{k}={len(v)}" for k, v in sorted(series.items())) or "(none)"))
|
||||
maxes = parse_max_scores(campaign)
|
||||
print("[info] eval max-score cycles parsed: " +
|
||||
(", ".join(f"{k}={len(v)}" for k, v in sorted(maxes.items())) or "(none)"))
|
||||
rows = parse_metrics(metrics_f)
|
||||
print(f"[info] metric rows parsed: {len(rows)}")
|
||||
|
||||
s = (f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
|
||||
f'viewBox="0 0 {W} {H}" font-family="sans-serif">\n'
|
||||
f'<rect width="{W}" height="{H}" fill="white"/>\n'
|
||||
f'<text x="{W // 2}" y="32" text-anchor="middle" font-size="21" '
|
||||
f'font-weight="bold">SAC-LSTM campaign dashboard - live run (current only)</text>\n'
|
||||
f'<text x="{W // 2}" y="56" text-anchor="middle" font-size="12" fill="#555">'
|
||||
f'generated {datetime.now():%Y-%m-%d %H:%M:%S} - auto-reloads every 60 s '
|
||||
f'(open this file in Chrome)</text>\n')
|
||||
|
||||
drawers = [
|
||||
(ROWS[1], PANEL_TITLES[0], GUIDES[1],
|
||||
"raw dots = single test matches, thick = rolling-mean-%d" % TREND_WINDOW,
|
||||
lambda: panel_test_matches(series, ROWS[1])),
|
||||
(ROWS[2], PANEL_TITLES[1], GUIDES[2],
|
||||
"training_metrics.jsonl - big early spikes are normal",
|
||||
lambda: panel_losses(rows, ROWS[2])),
|
||||
(ROWS[3], PANEL_TITLES[2], GUIDES[3],
|
||||
"method: training_metrics.jsonl 'epoch' deltas; 1 row = one 10-game chunk",
|
||||
lambda: panel_throughput(rows, ROWS[3])),
|
||||
(ROWS[4], PANEL_TITLES[3], GUIDES[4],
|
||||
"campaign_v4_stdout.log - max of the 10 deterministic round scores per eval",
|
||||
lambda: panel_max_score(maxes, ROWS[4])),
|
||||
]
|
||||
for geo, title, guide, sub, drawer in drawers:
|
||||
s += header(geo[3], geo[0], title, sub)
|
||||
s += panel_guide(geo[3], geo[0] + 33, guide)
|
||||
try:
|
||||
body = drawer()
|
||||
except Exception as e: # one bad panel must not kill the whole page
|
||||
log(f"[warn] panel '{title}' failed, rendering placeholder: {e}")
|
||||
body = (f'<text x="{geo[3] + 10}" y="{geo[1] + 40}" font-size="12" '
|
||||
f'fill="#a00">panel error: {esc(e)}</text>')
|
||||
s += body or "" # panels bare-return None on their no-data path
|
||||
|
||||
s += signs_block()
|
||||
s += RELOAD_JS + "\n"
|
||||
s += "</svg>\n"
|
||||
return write_svg(out, s)
|
||||
|
||||
|
||||
# ---------- selftest ----------
|
||||
|
||||
def selftest():
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
td = Path(td)
|
||||
(td / "log").write_text(
|
||||
">>> [eval] 10 deterministic rounds vs Corners\n"
|
||||
"Round 1/10 - ticks:100 score:3 win:true\n"
|
||||
"garbage line\n"
|
||||
"Round 2/10 - ticks:100 score:7 win:false\n"
|
||||
">>> [eval] win rate: 3/10 (30%) vs Corners\n"
|
||||
">>> [eval] 10 deterministic rounds vs Crazy\n"
|
||||
"Round 1/10 - ticks:100 score:70 win:false\n"
|
||||
">>> [eval] win rate: 7/10 (70%) vs Crazy\n"
|
||||
">>> [eval] broken\n"
|
||||
"Round 9/9 - ticks:1 score:999 win:false\n"
|
||||
">>> [eval] 10 deterministic rounds vs Corners\n"
|
||||
"Round 1/10 - ticks:100 score:50 win:true\n"
|
||||
">>> [eval] win rate: 5/10 (50%) vs Corners\n"
|
||||
">>> [eval] 10 deterministic rounds vs Crazy\n"
|
||||
"Round 1/10 - ticks:100 score:40 win:true\n"
|
||||
">>> [eval] win rate: 4/10 (40%) vs Crazy\n")
|
||||
ser = parse_eval_series(td / "log")
|
||||
assert ser == {"Corners": [30.0, 50.0], "Crazy": [70.0, 40.0]}, ser
|
||||
mxs = parse_max_scores(td / "log")
|
||||
# stray Round 999 after the unclosed '[eval] broken' line is ignored;
|
||||
# per-cycle max of the Round scores above
|
||||
assert mxs == {"Corners": [7, 50], "Crazy": [70, 40]}, mxs
|
||||
assert rolling([10] * 25, 20)[-1] == 10.0
|
||||
assert rolling([1, 2, 3], 20) == [1.0, 1.5, 2.0]
|
||||
assert len(bucket_means(list(range(1287)), RATE_BUCKET)) == RATE_BUCKET
|
||||
bm = bucket_means([0, 10], RATE_BUCKET)
|
||||
assert bm == [0.0, 10.0], bm # fewer points than buckets -> no empty buckets
|
||||
(td / "m.jsonl").write_text(
|
||||
'{"epoch": 1000.0, "critic_loss": 10, "actor_loss": -2, "alpha": 0.5}\n'
|
||||
"not json\n"
|
||||
'{"epoch": 1060.0, "critic_loss": 100, "actor_loss": -4, "alpha": 0.25}\n'
|
||||
'{"epoch": 1090.0, "critic_loss": 50, "actor_loss": 3, "alpha": 0.2}\n')
|
||||
rows = parse_metrics(td / "m.jsonl")
|
||||
assert len(rows) == 3 and rows[1]["critic_loss"] == 100
|
||||
assert metric_col(rows, "actor_loss") == [(0, 2.0), (1, 4.0), (2, 3.0)]
|
||||
|
||||
dash = td / "dash.svg"
|
||||
assert build_dashboard(td / "log", td / "m.jsonl", dash)
|
||||
text = dash.read_text()
|
||||
ET.fromstring(text) # whole doc must parse -> closing tag present
|
||||
assert RELOAD_JS in text, "auto-reload script missing"
|
||||
for t in PANEL_TITLES:
|
||||
assert esc(t) in text, f"panel title missing: {t}"
|
||||
assert text.count(PANEL_TITLES[0]) == 1
|
||||
assert "Reading the signs" in text, "health-check section missing"
|
||||
for head, bullets in SIGNS_COLUMNS:
|
||||
assert esc(head) in text, f"health-check column missing: {head}"
|
||||
for lines in bullets:
|
||||
assert esc("• " + lines[0]) in text, f"bullet missing: {lines[0]}"
|
||||
assert text.count("• ") == sum(len(b) for _, b in SIGNS_COLUMNS)
|
||||
assert 'width="1400"' in text and 'height="1850"' in text
|
||||
# every panel carries its own What/Axes/Better how-to-read note
|
||||
assert text.count("What:") == len(PANEL_TITLES), text.count("What:")
|
||||
for g in GUIDES.values():
|
||||
assert esc(g[0]) in text, f"panel guide missing: {g[0]}"
|
||||
# circles: 4 eval dots (panel 1) + 2 throughput rate dots (panel 3)
|
||||
# + 2 cycles x 2 opponents max-score dots (panel 4)
|
||||
assert text.count("<circle") == 10, text.count("<circle")
|
||||
# 2 trends + 3 losses-panel polylines (critic, actor, alpha)
|
||||
# + 1 throughput
|
||||
# + 3 max-score panel (Corners, Crazy, combined; Target absent in fixture)
|
||||
assert text.count("<polyline") == 9, text.count("<polyline")
|
||||
# losses panel twin axis: "alpha" text = legend + right ylabel; purple
|
||||
# fills = 5 right-axis tick labels + rotated ylabel
|
||||
assert text.count(">alpha<") == 2, text.count(">alpha<")
|
||||
assert text.count('fill="#9467bd"') == 6, text.count('fill="#9467bd"')
|
||||
assert "combined max" in text, "max-score combined line missing"
|
||||
|
||||
# orientation guard: a known rising series (10% -> 90%) rendered through
|
||||
# the FULL build path must plot upward (smaller SVG y) and forward in
|
||||
# time (larger x). Fails loudly if axis mapping is ever inverted again.
|
||||
ori_log = td / "ori.log"
|
||||
ori_log.write_text(
|
||||
">>> [eval] win rate: 1/10 (10%) vs Corners\n"
|
||||
">>> [eval] win rate: 9/10 (90%) vs Corners\n")
|
||||
ori_dash = td / "ori.svg"
|
||||
assert build_dashboard(ori_log, td / "m.jsonl", ori_dash)
|
||||
m = re.search(r'<polyline points="([^"]+)"', ori_dash.read_text())
|
||||
pts = [tuple(map(float, p.split(","))) for p in m.group(1).split()]
|
||||
assert len(pts) == 2, pts
|
||||
(xa, ya), (xb, yb) = pts
|
||||
assert yb < ya, f"y-axis inverted: win % rose 10->90 but ink moved down ({ya} -> {yb})"
|
||||
assert xb > xa, f"x-axis reversed: newer eval plotted left ({xa} -> {xb})"
|
||||
|
||||
# resilience: missing/empty inputs render placeholders, never crash
|
||||
empty_dash = td / "empty.svg"
|
||||
assert build_dashboard(td / "nope.log", td / "nope.jsonl", empty_dash)
|
||||
etext = empty_dash.read_text()
|
||||
assert etext.count("no eval lines found") == 2, etext.count("no eval lines found")
|
||||
assert "no valid loss points" in etext
|
||||
assert "no usable epoch timestamps" in etext
|
||||
|
||||
# all-zero alpha rows (post-crash trainer state) must not raise in the
|
||||
# losses panel's log-domain math -> placeholder instead of dead build
|
||||
(td / "zero.jsonl").write_text(
|
||||
'{"epoch": 1000.0, "alpha": 0}\n{"epoch": 1060.0, "alpha": 0}\n')
|
||||
zero_dash = td / "zero.svg"
|
||||
assert build_dashboard(td / "log", td / "zero.jsonl", zero_dash)
|
||||
assert "no valid loss points" in zero_dash.read_text()
|
||||
|
||||
# single throughput interval (fresh 2-row metrics file right after a
|
||||
# restart) used to divide by zero in map_fn and kill the whole build
|
||||
(td / "one.jsonl").write_text(
|
||||
'{"epoch": 1000.0, "critic_loss": 10, "alpha": 0.5}\n'
|
||||
'{"epoch": 1060.0, "critic_loss": 5, "alpha": 0.4}\n')
|
||||
one_dash = td / "one.svg"
|
||||
assert build_dashboard(td / "log", td / "one.jsonl", one_dash)
|
||||
assert "panel error" not in one_dash.read_text()
|
||||
assert "<polyline" in one_dash.read_text()
|
||||
print("selftest OK")
|
||||
|
||||
|
||||
def main():
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
return
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
||||
campaign = Path(args[0]) if len(args) > 0 else ROOT / "campaign_v4_stdout.log"
|
||||
metrics = Path(args[1]) if len(args) > 1 else ROOT / "training_metrics.jsonl"
|
||||
outdir = Path(args[2]) if len(args) > 2 else ROOT / "docs"
|
||||
|
||||
try:
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
for p in (campaign, metrics):
|
||||
if not p.is_file():
|
||||
# loudest symptom of a watcher launched from a stale checkout
|
||||
log(f"[warn] input missing: {p} - is this the live checkout?")
|
||||
ok = build_dashboard(campaign, metrics, outdir / "campaign_dashboard.svg")
|
||||
except Exception:
|
||||
log(f"[error] dashboard build failed:\n{traceback.format_exc().rstrip()}")
|
||||
ok = False
|
||||
if ok:
|
||||
print(f"[done] dashboard written to {outdir / 'campaign_dashboard.svg'}")
|
||||
else:
|
||||
print("[error] dashboard NOT updated - see " + str(LOG_FILE))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Keep docs/campaign_dashboard.svg fresh: regenerate every 60 s.
|
||||
# Failures are appended to tools/plot_progress.log (by this wrapper and by
|
||||
# plot_progress.py itself) - check that file when the dashboard looks stale.
|
||||
# The startup banner records WHICH tree this instance watches, so a copy
|
||||
# launched from a stale checkout (the post-reboot failure mode) is visible.
|
||||
set -u
|
||||
dir=$(cd "$(dirname "$0")" && pwd)
|
||||
log="$dir/plot_progress.log"
|
||||
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
exec 9>"$dir/.watch_dashboard.lock"
|
||||
flock -n 9 || { echo "[watch_dashboard] another instance already running, exiting" >&2; exit 0; }
|
||||
fi
|
||||
|
||||
echo "[watch_dashboard] $(date '+%F %T') started, watching root=$(cd "$dir/.." && pwd)" >> "$log" 2>/dev/null || true
|
||||
while :; do
|
||||
if ! python3 "$dir/plot_progress.py"; then
|
||||
echo "[watch_dashboard] $(date '+%F %T') regeneration failed (see $log)" >&2
|
||||
echo "[watch_dashboard] $(date '+%F %T') regeneration failed" >> "$log" 2>/dev/null || true
|
||||
fi
|
||||
sleep 60
|
||||
done
|
||||
@@ -0,0 +1,6 @@
|
||||
{"epoch":1787565804.965847,"steps":1,"buffer_size":24,"drained":1,"grad_steps":1,"critic_loss":6.929736,"actor_loss":13.433716,"alpha_loss":-0.0044489726,"alpha":0.9753562}
|
||||
{"epoch":1787565814.1075847,"steps":1,"buffer_size":24,"drained":1,"grad_steps":1,"critic_loss":0.05124796,"actor_loss":12.885783,"alpha_loss":0.016930042,"alpha":0.9762861}
|
||||
{"epoch":1787565961.245973,"steps":1,"buffer_size":24,"drained":1,"grad_steps":1,"critic_loss":0.4263701,"actor_loss":14.863115,"alpha_loss":0.03347406,"alpha":0.97734594}
|
||||
{"epoch":1787565971.2967772,"steps":1,"buffer_size":24,"drained":1,"grad_steps":1,"critic_loss":0.30882663,"actor_loss":15.45638,"alpha_loss":0.047085714,"alpha":0.97881573}
|
||||
{"epoch":1787566172.4240468,"steps":1,"buffer_size":24,"drained":1,"grad_steps":1,"critic_loss":3.1835616,"actor_loss":22.56434,"alpha_loss":0.117146604,"alpha":0.9814878}
|
||||
{"epoch":1787566184.1664138,"steps":1,"buffer_size":24,"drained":1,"grad_steps":1,"critic_loss":2.150265,"actor_loss":22.027954,"alpha_loss":0.07355834,"alpha":0.98294044}
|
||||
@@ -0,0 +1 @@
|
||||
33.3333
|
||||
@@ -0,0 +1 @@
|
||||
0 10 0 0 0
|
||||
@@ -0,0 +1 @@
|
||||
10 100 0 0 100
|
||||
@@ -0,0 +1 @@
|
||||
0 0 0 10 0
|
||||
@@ -0,0 +1 @@
|
||||
2395
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
139
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
100
|
||||
@@ -0,0 +1 @@
|
||||
38912
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user