amer224 commited on
Commit
d36c2de
·
verified ·
1 Parent(s): df8ea5d

Add brains/sovereign-v3/league.py

Browse files
Files changed (1) hide show
  1. brains/sovereign-v3/league.py +157 -0
brains/sovereign-v3/league.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ league.py — league-lite self-play manager (fictitious self-play).
3
+
4
+ The last-survivor objective punishes myopic bot-exploit policies, so the
5
+ pipeline keeps a pool of FROZEN PAST SELVES and lets the current agent
6
+ train against them (league-lite: a cheap league a 2xT4 budget can afford).
7
+
8
+ Usage (pipeline side):
9
+
10
+ from league import League
11
+ lg = League("weights/league")
12
+ lg.add("main") # the current agent
13
+ # after each eval round:
14
+ lg.update_elo([("main", "main-20250101-0000", 0.75), ...])
15
+ # if the current agent beats the pool:
16
+ if lg.maybe_promote("main", win_rate=0.62):
17
+ torch.save(model.state_dict(), lg.snapshot_path("main"))
18
+
19
+ # rollouts: fill the sim's bot slots with frozen selves
20
+ opponents = lg.pick_opponents(2) # 2 past selves + 7 bots
21
+
22
+ Elo is a rough ranking of checkpoint strength over time — not a science;
23
+ the promotion threshold is the real gate. Pure python + json, no deps.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import time
30
+ from pathlib import Path
31
+ from typing import List, Optional
32
+
33
+
34
+ class LeagueMember:
35
+ def __init__(self, name: str, path: Optional[str] = None,
36
+ elo: float = 1000.0, kind: str = "past"):
37
+ self.name = name
38
+ self.path = path
39
+ self.elo = float(elo)
40
+ self.kind = kind
41
+
42
+
43
+ class League:
44
+ def __init__(self, directory: str, main_name: str = "main"):
45
+ self.dir = Path(directory)
46
+ self.dir.mkdir(parents=True, exist_ok=True)
47
+ self.main_name = main_name
48
+ self.members: List[LeagueMember] = []
49
+ self.history: List[tuple] = []
50
+ self._load()
51
+
52
+ # -- persistence --------------------------------------------------------
53
+
54
+ def _load(self) -> None:
55
+ p = self.dir / "league.json"
56
+ if p.exists():
57
+ try:
58
+ data = json.loads(p.read_text())
59
+ self.members = [LeagueMember(**m)
60
+ for m in data.get("members", [])]
61
+ self.history = [tuple(h) for h in data.get("history", [])]
62
+ except (json.JSONDecodeError, TypeError):
63
+ pass # start fresh on a corrupt file
64
+
65
+ def save(self) -> None:
66
+ data = {"members": [vars(m) for m in self.members],
67
+ "history": [list(h) for h in self.history]}
68
+ (self.dir / "league.json").write_text(json.dumps(data, indent=2))
69
+
70
+ # -- membership ---------------------------------------------------------
71
+
72
+ def add(self, name: str, path: Optional[str] = None,
73
+ elo: float = 1000.0, kind: str = "past") -> LeagueMember:
74
+ for m in self.members:
75
+ if m.name == name:
76
+ return m
77
+ m = LeagueMember(name, str(path) if path else None, elo, kind)
78
+ self.members.append(m)
79
+ self.save()
80
+ return m
81
+
82
+ def snapshot_path(self, name: str) -> Path:
83
+ return self.dir / f"{name}_snapshot.pt"
84
+
85
+ # -- opponent selection -------------------------------------------------
86
+
87
+ def pick_opponents(self, k: int, exclude: Optional[str] = None
88
+ ) -> List[LeagueMember]:
89
+ """k frozen opponents: alternating weakest/strongest for curriculum
90
+ pressure (always train against SOMEONE beatable and SOMEONE tough)."""
91
+ exclude = exclude or self.main_name
92
+ pool = [m for m in self.members if m.name != exclude]
93
+ if not pool:
94
+ return []
95
+ pool.sort(key=lambda m: m.elo)
96
+ picks: List[LeagueMember] = []
97
+ for i in range(min(k, len(pool))):
98
+ if i % 2 == 0:
99
+ picks.append(pool[min(i // 2, len(pool) - 1)])
100
+ else:
101
+ picks.append(pool[max(len(pool) - 1 - i // 2, 0)])
102
+ seen, out = set(), []
103
+ for p in picks:
104
+ if p.name not in seen:
105
+ out.append(p)
106
+ seen.add(p.name)
107
+ return out
108
+
109
+ # -- ratings ------------------------------------------------------------
110
+
111
+ def update_elo(self, results) -> None:
112
+ """results: iterable of (name_a, name_b, score_a) with score_a in
113
+ [0,1] = fraction of the match won by a (0.5 = draw)."""
114
+ K = 32.0
115
+ for a_name, b_name, score_a in results:
116
+ a = next((m for m in self.members if m.name == a_name), None)
117
+ b = next((m for m in self.members if m.name == b_name), None)
118
+ if a is None or b is None:
119
+ continue
120
+ ea = 1.0 / (1.0 + 10 ** ((b.elo - a.elo) / 400.0))
121
+ delta = K * (float(score_a) - ea)
122
+ a.elo += delta
123
+ b.elo -= delta
124
+ self.history.extend(results)
125
+ self.save()
126
+
127
+ def maybe_promote(self, name: str, win_rate: float,
128
+ threshold: float = 0.55) -> bool:
129
+ """Freeze a snapshot of `name` into the pool when it beats it."""
130
+ if win_rate >= threshold:
131
+ stamp = time.strftime("%Y%m%d-%H%M%S")
132
+ self.add(f"{name}-{stamp}",
133
+ path=str(self.snapshot_path(name)),
134
+ elo=1200.0, kind="past")
135
+ return True
136
+ return False
137
+
138
+ def summary(self) -> str:
139
+ lines = [f"{m.name:32s} elo={m.elo:7.1f} {m.kind}"
140
+ for m in sorted(self.members, key=lambda m: -m.elo)]
141
+ return "\n".join(lines) if lines else "(empty league)"
142
+
143
+
144
+ if __name__ == "__main__":
145
+ # smoke: create a temp league, add members, rate matches, promote
146
+ import tempfile
147
+ with tempfile.TemporaryDirectory() as td:
148
+ lg = League(td)
149
+ lg.add("main", elo=1000)
150
+ lg.add("past-1", elo=980)
151
+ lg.add("past-2", elo=1050)
152
+ lg.update_elo([("main", "past-1", 1.0), ("main", "past-2", 0.0)])
153
+ ops = lg.pick_opponents(2)
154
+ print("opponents:", [o.name for o in ops])
155
+ print("promoted:", lg.maybe_promote("main", 0.60))
156
+ print(lg.summary())
157
+ print("league.py smoke OK")