1#![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
24pub struct StubSelfPlayHandler {
30 name: &'static str,
31}
32
33impl StubSelfPlayHandler {
34 #[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#[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 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
78pub type SharedSelfPlayLoop = Arc<Mutex<Option<SelfPlayLoop>>>;
82
83#[must_use]
85pub fn new_shared_loop() -> SharedSelfPlayLoop {
86 Arc::new(Mutex::new(None))
87}
88
89pub struct SelfPlayRunTool {
96 store: Arc<MemoryStore>,
97 loop_state: SharedSelfPlayLoop,
98 stats: ToolStats,
99 effects: EffectRow,
100}
101
102impl SelfPlayRunTool {
103 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 let context = if memory_context.is_empty() {
141 self.gather_memory_context()
142 } else {
143 memory_context.to_string()
144 };
145
146 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 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 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
225pub struct SelfPlayStatusTool {
229 loop_state: SharedSelfPlayLoop,
230 stats: ToolStats,
231 effects: EffectRow,
232}
233
234impl SelfPlayStatusTool {
235 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
293pub struct SelfPlayExportTool {
297 loop_state: SharedSelfPlayLoop,
298 stats: ToolStats,
299 effects: EffectRow,
300}
301
302impl SelfPlayExportTool {
303 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
370pub 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}