Skip to main content

wm_tools/expansion/
self_play.rs

1//! Self-play training tools — Sutton's second scaling method (learning).
2//!
3//! Gana::Ox — "Self-play training, LoRA adapter management, learning"
4//!
5//! Tools:
6//! - `selfplay.run` — Run N self-play cycles (propose → solve → verify → collect)
7//! - `selfplay.status` — Get self-play loop statistics
8//! - `selfplay.export` — Export collected training data
9
10#![forbid(unsafe_code)]
11#![allow(clippy::significant_drop_tightening)]
12
13use async_trait::async_trait;
14
15use serde_json::{Value, json};
16use std::sync::{Arc, Mutex};
17use wm_bicameral::{
18    ExactMatchVerifier, LoRAAdapterManager, SelfPlayConfig, SelfPlayLoop, TaskProposer, TaskSolver,
19    TierHandler,
20};
21use wm_core::{Context, EffectRow, Gana, Tool, ToolStats};
22use wm_memory::MemoryStore;
23
24// ── Stub TierHandler for self-play (when no LLM is available) ──────────
25
26/// A simple stub handler that produces canned responses for self-play.
27/// In production, the proposer uses the right hemisphere and the solver
28/// uses the left hemisphere.
29pub struct StubSelfPlayHandler {
30    name: &'static str,
31}
32
33impl StubSelfPlayHandler {
34    /// Create a new stub handler with the given name.
35    #[must_use]
36    pub const fn new(name: &'static str) -> Self {
37        Self { name }
38    }
39}
40
41impl TierHandler for StubSelfPlayHandler {
42    fn handle(&self, _prompt: &str, _max_tokens: usize) -> Result<(String, f32), String> {
43        Ok((
44            r#"{"prompt": "What is 2+2?", "expected": "4", "difficulty": 0.1}"#.to_string(),
45            0.5,
46        ))
47    }
48
49    fn name(&self) -> &'static str {
50        self.name
51    }
52}
53
54/// Build a SelfPlayLoop from environment configuration.
55#[must_use]
56pub fn build_self_play_loop(store_path: &std::path::Path) -> SelfPlayLoop {
57    let adapter_dir = store_path.join("lora_adapters");
58
59    // In production, these would be real LLM handlers.
60    // For now, use stubs that produce reasonable test tasks.
61    let proposer_handler = Box::new(StubSelfPlayHandler::new("stub_proposer"));
62    let solver_handler = Box::new(StubSelfPlayHandler::new("stub_solver"));
63
64    let proposer = TaskProposer::ungrounded(proposer_handler);
65    let solver = TaskSolver::new(solver_handler);
66    let verifier = Box::new(ExactMatchVerifier::new());
67    let adapter = LoRAAdapterManager::with_config(adapter_dir, 1000, false);
68
69    SelfPlayLoop::new(
70        proposer,
71        solver,
72        verifier,
73        adapter,
74        SelfPlayConfig::default(),
75    )
76}
77
78// ── Shared self-play state ────────────────────────────────────────────
79
80/// Shared self-play loop state, protected by a mutex.
81pub type SharedSelfPlayLoop = Arc<Mutex<Option<SelfPlayLoop>>>;
82
83/// Create a new shared self-play loop state (initially empty).
84#[must_use]
85pub fn new_shared_loop() -> SharedSelfPlayLoop {
86    Arc::new(Mutex::new(None))
87}
88
89// ── selfplay.run ──────────────────────────────────────────────────────
90
91/// Run self-play cycles.
92///
93/// Executes the propose → solve → verify → collect loop N times.
94/// If a LoRA update threshold is reached, triggers an adapter update.
95pub struct SelfPlayRunTool {
96    store: Arc<MemoryStore>,
97    loop_state: SharedSelfPlayLoop,
98    stats: ToolStats,
99    effects: EffectRow,
100}
101
102impl SelfPlayRunTool {
103    /// Create a new self-play run tool.
104    pub fn new(store: Arc<MemoryStore>, loop_state: SharedSelfPlayLoop) -> Self {
105        Self {
106            store,
107            loop_state,
108            stats: ToolStats::default(),
109            effects: EffectRow::read_only(vec![wm_core::Resource::Galaxy("research".into())]),
110        }
111    }
112}
113
114#[async_trait]
115impl Tool for SelfPlayRunTool {
116    fn name(&self) -> &str {
117        "selfplay.run"
118    }
119    fn gana(&self) -> Gana {
120        Gana::Ox
121    }
122    fn effects(&self) -> &EffectRow {
123        &self.effects
124    }
125    fn description(&self) -> &str {
126        "[Experimental] Run self-play training cycles (propose → solve → verify → collect training data)"
127    }
128    fn stats(&self) -> &ToolStats {
129        &self.stats
130    }
131    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
132        let num_cycles = args.get("cycles").and_then(Value::as_u64).unwrap_or(1) as usize;
133
134        let memory_context = args
135            .get("memory_context")
136            .and_then(Value::as_str)
137            .unwrap_or("");
138
139        // Gather memory context if not provided
140        let context = if memory_context.is_empty() {
141            self.gather_memory_context()
142        } else {
143            memory_context.to_string()
144        };
145
146        // Get or create the self-play loop
147        let mut loop_guard = self
148            .loop_state
149            .lock()
150            .map_err(|e| wm_core::CoreError::Tool(format!("self-play loop lock: {e}")))?;
151        if loop_guard.is_none() {
152            // Build a new loop using the store path
153            let store_path = self
154                .store
155                .path()
156                .parent()
157                .unwrap_or_else(|| std::path::Path::new("."));
158            *loop_guard = Some(build_self_play_loop(store_path));
159        }
160
161        let loop_ = loop_guard.as_mut().unwrap();
162        loop_.config.max_cycles_per_run = num_cycles;
163
164        let results = loop_.run(&context);
165        let stats = loop_.stats().clone();
166
167        let cycle_results: Vec<Value> = results
168            .iter()
169            .map(|r| {
170                json!({
171                    "task_type": r.task.task_type.name(),
172                    "prompt": r.task.prompt,
173                    "difficulty": r.task.difficulty,
174                    "solution": r.solution.output,
175                    "confidence": r.solution.confidence,
176                    "verified_correct": r.verification.correct,
177                    "verification_score": r.verification.score,
178                    "verifier": r.verification.verifier,
179                    "collected": r.collected,
180                    "adapter_updated": r.adapter_updated,
181                    "duration_ms": r.duration_ms,
182                })
183            })
184            .collect();
185
186        Ok(json!({
187            "cycles_run": results.len(),
188            "results": cycle_results,
189            "stats": {
190                "total_cycles": stats.cycles,
191                "verified_correct": stats.verified_correct,
192                "verified_incorrect": stats.verified_incorrect,
193                "accuracy": stats.accuracy(),
194                "samples_collected": stats.samples_collected,
195                "adapter_updates": stats.adapter_updates,
196                "avg_difficulty": stats.avg_difficulty,
197                "adapter_version": loop_.adapter_version(),
198            },
199        }))
200    }
201}
202
203impl SelfPlayRunTool {
204    fn gather_memory_context(&self) -> String {
205        let mut parts = Vec::new();
206        for galaxy in wm_core::Galaxy::memory_galaxies() {
207            if let Ok(mems) = self.store.scan(galaxy, 10) {
208                for mem in mems.iter().take(3) {
209                    // model_exclude memories never enter task context.
210                    if mem.metadata.model_exclude {
211                        continue;
212                    }
213                    parts.push(format!("- {}", mem.content));
214                }
215            }
216        }
217        if parts.is_empty() {
218            String::new()
219        } else {
220            parts.join("\n")
221        }
222    }
223}
224
225// ── selfplay.status ───────────────────────────────────────────────────
226
227/// Get self-play loop statistics.
228pub struct SelfPlayStatusTool {
229    loop_state: SharedSelfPlayLoop,
230    stats: ToolStats,
231    effects: EffectRow,
232}
233
234impl SelfPlayStatusTool {
235    /// Create a new self-play status tool.
236    pub fn new(loop_state: SharedSelfPlayLoop) -> Self {
237        Self {
238            loop_state,
239            stats: ToolStats::default(),
240            effects: EffectRow::read_only(vec![]),
241        }
242    }
243}
244
245#[async_trait]
246impl Tool for SelfPlayStatusTool {
247    fn name(&self) -> &str {
248        "selfplay.status"
249    }
250    fn gana(&self) -> Gana {
251        Gana::Ox
252    }
253    fn effects(&self) -> &EffectRow {
254        &self.effects
255    }
256    fn description(&self) -> &str {
257        "[Experimental] Get self-play training loop statistics and status"
258    }
259    fn stats(&self) -> &ToolStats {
260        &self.stats
261    }
262    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
263        let loop_guard = self
264            .loop_state
265            .lock()
266            .map_err(|e| wm_core::CoreError::Tool(format!("self-play loop lock: {e}")))?;
267
268        if let Some(loop_) = loop_guard.as_ref() {
269            let stats = loop_.stats();
270            Ok(json!({
271                "initialized": true,
272                "total_cycles": stats.cycles,
273                "verified_correct": stats.verified_correct,
274                "verified_incorrect": stats.verified_incorrect,
275                "accuracy": stats.accuracy(),
276                "samples_collected": stats.samples_collected,
277                "adapter_updates": stats.adapter_updates,
278                "adapter_version": loop_.adapter_version(),
279                "sample_count": loop_.sample_count(),
280                "avg_difficulty": stats.avg_difficulty,
281                "accuracy_trend": stats.accuracy_trend,
282                "success_by_type": stats.success_by_type,
283            }))
284        } else {
285            Ok(json!({
286                "initialized": false,
287                "message": "Self-play loop not yet initialized. Run selfplay.run to start.",
288            }))
289        }
290    }
291}
292
293// ── selfplay.export ───────────────────────────────────────────────────
294
295/// Export collected training data from the self-play loop.
296pub struct SelfPlayExportTool {
297    loop_state: SharedSelfPlayLoop,
298    stats: ToolStats,
299    effects: EffectRow,
300}
301
302impl SelfPlayExportTool {
303    /// Create a new self-play export tool.
304    pub fn new(loop_state: SharedSelfPlayLoop) -> Self {
305        Self {
306            loop_state,
307            stats: ToolStats::default(),
308            effects: EffectRow::read_only(vec![]),
309        }
310    }
311}
312
313#[async_trait]
314impl Tool for SelfPlayExportTool {
315    fn name(&self) -> &str {
316        "selfplay.export"
317    }
318    fn gana(&self) -> Gana {
319        Gana::Ox
320    }
321    fn effects(&self) -> &EffectRow {
322        &self.effects
323    }
324    fn description(&self) -> &str {
325        "[Experimental] Export collected self-play training data (JSONL or llama.cpp format)"
326    }
327    fn stats(&self) -> &ToolStats {
328        &self.stats
329    }
330    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
331        let format = args
332            .get("format")
333            .and_then(Value::as_str)
334            .unwrap_or("jsonl");
335
336        let include_negative = args
337            .get("include_negative")
338            .and_then(Value::as_bool)
339            .unwrap_or(false);
340
341        let loop_guard = self
342            .loop_state
343            .lock()
344            .map_err(|e| wm_core::CoreError::Tool(format!("self-play loop lock: {e}")))?;
345
346        if let Some(loop_) = loop_guard.as_ref() {
347            let data = match format {
348                "llama_cpp" => loop_.export_llama_cpp(),
349                _ => loop_.export_training_data(include_negative),
350            };
351
352            let sample_count = data.lines().count();
353
354            Ok(json!({
355                "format": format,
356                "sample_count": sample_count,
357                "data": data,
358            }))
359        } else {
360            Ok(json!({
361                "format": format,
362                "sample_count": 0,
363                "data": "",
364                "message": "Self-play loop not yet initialized.",
365            }))
366        }
367    }
368}
369
370// ── Registration ──────────────────────────────────────────────────────
371
372/// Register all self-play tools into a registry.
373pub fn register_self_play(
374    registry: &wm_dispatch::ToolRegistry,
375    store: &Arc<MemoryStore>,
376    loop_state: SharedSelfPlayLoop,
377) -> wm_dispatch::ToolRegistry {
378    registry
379        .register(Arc::new(SelfPlayRunTool::new(
380            store.clone(),
381            loop_state.clone(),
382        )))
383        .register(Arc::new(SelfPlayStatusTool::new(loop_state.clone())))
384        .register(Arc::new(SelfPlayExportTool::new(loop_state)))
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
392        let tmp = tempfile::tempdir().unwrap();
393        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
394        (tmp, store)
395    }
396
397    #[tokio::test]
398    async fn selfplay_run_executes_requested_cycles_with_stub_handlers() {
399        let (_tmp, store) = open_store();
400        let state = new_shared_loop();
401
402        let result = SelfPlayRunTool::new(store, state.clone())
403            .call(&mut Context::default(), json!({"cycles": 1}))
404            .await
405            .unwrap();
406        assert_eq!(result["cycles_run"], 1);
407        assert_eq!(result["results"][0]["collected"], true);
408        assert_eq!(result["results"][0]["verified_correct"], true);
409        assert_eq!(result["stats"]["total_cycles"], 1);
410        assert_eq!(result["stats"]["samples_collected"], 1);
411        assert_eq!(result["stats"]["adapter_updates"], 0);
412    }
413
414    #[tokio::test]
415    async fn selfplay_status_discloses_uninitialized_then_initialized() {
416        let tmp = tempfile::tempdir().unwrap();
417        let state = new_shared_loop();
418
419        let uninit = SelfPlayStatusTool::new(state.clone())
420            .call(&mut Context::default(), json!({}))
421            .await
422            .unwrap();
423        assert_eq!(uninit["initialized"], false);
424        assert!(uninit["message"].as_str().unwrap().contains("not yet"));
425
426        {
427            let mut guard = state.lock().unwrap();
428            let mut loop_ = build_self_play_loop(tmp.path());
429            loop_.config.max_cycles_per_run = 1;
430            loop_.run("");
431            *guard = Some(loop_);
432        }
433
434        let init = SelfPlayStatusTool::new(state)
435            .call(&mut Context::default(), json!({}))
436            .await
437            .unwrap();
438        assert_eq!(init["initialized"], true);
439        assert_eq!(init["total_cycles"], 1);
440        assert_eq!(init["sample_count"], 1);
441    }
442
443    #[tokio::test]
444    async fn selfplay_export_jsonl_and_llama_cpp_formats_are_non_empty() {
445        let (_tmp, store) = open_store();
446        let state = new_shared_loop();
447
448        let empty = SelfPlayExportTool::new(state.clone())
449            .call(&mut Context::default(), json!({"format": "jsonl"}))
450            .await
451            .unwrap();
452        assert_eq!(empty["sample_count"], 0);
453        assert_eq!(empty["data"], "");
454
455        SelfPlayRunTool::new(store, state.clone())
456            .call(&mut Context::default(), json!({"cycles": 1}))
457            .await
458            .unwrap();
459
460        let jsonl = SelfPlayExportTool::new(state.clone())
461            .call(&mut Context::default(), json!({"format": "jsonl"}))
462            .await
463            .unwrap();
464        assert!(jsonl["sample_count"].as_u64().unwrap() >= 1);
465        assert!(jsonl["data"].as_str().unwrap().contains("2+2"));
466
467        let llama = SelfPlayExportTool::new(state)
468            .call(&mut Context::default(), json!({"format": "llama_cpp"}))
469            .await
470            .unwrap();
471        assert!(!llama["data"].as_str().unwrap().is_empty());
472    }
473}