Skip to main content

swarm_engine_eval/environments/
search.rs

1//! SearchEnvironment - ファイル検索環境
2//!
3//! 複数の検索結果から正解を見つけるシナリオ。
4//! SelectionLogic が複数の frontier から選択する動作を検証するための環境。
5//!
6//! # パターン
7//!
8//! ```text
9//! SearchFiles → [5 files] → ReadFile → (Success/Fail) → Analyze → Done
10//! ```
11//!
12//! # 設計
13//!
14//! - `SearchFiles`: 検索を実行し、複数のファイルパスを返す
15//! - `ReadFile(target)`: 指定されたファイルを読む(正解は1つだけ成功)
16//! - `Analyze`: 正解ファイルの内容を分析して完了
17//!
18//! これにより、SearchFiles 実行後に複数の frontier が生成され、
19//! SelectionLogic がスコアベースで選択する機会が生まれる。
20
21use std::collections::HashSet;
22use std::sync::RwLock;
23
24use swarm_engine_core::actions::ParamResolver;
25use swarm_engine_core::agent::WorkResult;
26use swarm_engine_core::environment::Environment;
27use swarm_engine_core::types::{Action, WorkerId};
28
29// ============================================================================
30// SearchEnvironment
31// ============================================================================
32
33/// ファイル検索環境
34///
35/// 検索結果から正解ファイルを見つけるシナリオ。
36pub struct SearchEnvironment {
37    /// 検索結果として返すファイル一覧
38    files: Vec<String>,
39    /// 正解ファイル(これだけが成功する)
40    target_file: String,
41    /// 正解ファイルの内容
42    target_content: String,
43    /// 内部状態
44    state: RwLock<SearchState>,
45}
46
47#[derive(Debug, Default)]
48struct SearchState {
49    /// 検索を実行したか
50    searched: bool,
51    /// 読んだファイル一覧
52    read_files: HashSet<String>,
53    /// 正解ファイルを読んだか
54    found_target: bool,
55    /// 完了した Worker
56    completed: Vec<WorkerId>,
57}
58
59impl SearchEnvironment {
60    /// 新しい環境を作成
61    pub fn new(
62        files: Vec<String>,
63        target_file: impl Into<String>,
64        target_content: impl Into<String>,
65    ) -> Self {
66        Self {
67            files,
68            target_file: target_file.into(),
69            target_content: target_content.into(),
70            state: RwLock::new(SearchState::default()),
71        }
72    }
73
74    /// 基本シナリオ: 5ファイルから1つの正解を探す
75    pub fn basic_scenario() -> Self {
76        let files = vec![
77            "src/config.rs".to_string(),
78            "src/utils.rs".to_string(),
79            "src/handler.rs".to_string(), // target
80            "src/types.rs".to_string(),
81            "src/error.rs".to_string(),
82        ];
83        Self::new(
84            files,
85            "src/handler.rs",
86            "fn handle_request() { /* implementation */ }",
87        )
88    }
89
90    /// 中規模シナリオ: 10ファイルから1つの正解を探す
91    pub fn medium_scenario() -> Self {
92        let files = vec![
93            "src/lib.rs".to_string(),
94            "src/config.rs".to_string(),
95            "src/utils.rs".to_string(),
96            "src/handler.rs".to_string(),
97            "src/types.rs".to_string(),
98            "src/error.rs".to_string(),
99            "src/api/mod.rs".to_string(),
100            "src/api/routes.rs".to_string(), // target
101            "src/api/middleware.rs".to_string(),
102            "src/db/connection.rs".to_string(),
103        ];
104        Self::new(
105            files,
106            "src/api/routes.rs",
107            "pub fn register_routes(app: &mut App) { /* routes */ }",
108        )
109    }
110
111    /// 大規模シナリオ: 20ファイルから1つの正解を探す
112    pub fn large_scenario() -> Self {
113        let files = vec![
114            "src/lib.rs".to_string(),
115            "src/main.rs".to_string(),
116            "src/config.rs".to_string(),
117            "src/utils.rs".to_string(),
118            "src/handler.rs".to_string(),
119            "src/types.rs".to_string(),
120            "src/error.rs".to_string(),
121            "src/api/mod.rs".to_string(),
122            "src/api/routes.rs".to_string(),
123            "src/api/middleware.rs".to_string(),
124            "src/db/mod.rs".to_string(),
125            "src/db/connection.rs".to_string(),
126            "src/db/queries.rs".to_string(),
127            "src/services/mod.rs".to_string(),
128            "src/services/auth.rs".to_string(), // target
129            "src/services/user.rs".to_string(),
130            "src/services/payment.rs".to_string(),
131            "src/models/mod.rs".to_string(),
132            "src/models/user.rs".to_string(),
133            "src/models/order.rs".to_string(),
134        ];
135        Self::new(
136            files,
137            "src/services/auth.rs",
138            "pub fn authenticate(token: &str) -> Result<User> { /* auth logic */ }",
139        )
140    }
141
142    /// カスタムシナリオ: ファイル数と正解位置を指定
143    pub fn custom_scenario(file_count: usize, target_index: usize, seed: u64) -> Self {
144        let file_count = file_count.clamp(2, 50);
145        let target_index = target_index.min(file_count - 1);
146
147        // 簡易乱数生成
148        let mut rng_state = seed;
149        let mut next_rand = || {
150            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
151            rng_state
152        };
153
154        let prefixes = ["src", "lib", "core", "api", "services", "models", "utils"];
155        let suffixes = [
156            "mod", "types", "error", "handler", "config", "utils", "impl",
157        ];
158
159        let files: Vec<String> = (0..file_count)
160            .map(|i| {
161                let prefix = prefixes[(next_rand() as usize) % prefixes.len()];
162                let suffix = suffixes[(next_rand() as usize) % suffixes.len()];
163                format!("{}/{}_{}.rs", prefix, suffix, i)
164            })
165            .collect();
166
167        let target_file = files[target_index].clone();
168
169        Self::new(
170            files,
171            target_file,
172            "// TARGET FILE CONTENT\nfn target_function() { /* found! */ }",
173        )
174    }
175
176    // ------------------------------------------------------------------------
177    // Action Handlers
178    // ------------------------------------------------------------------------
179
180    fn handle_search_files(&self, _worker_id: WorkerId, action: &Action) -> WorkResult {
181        let resolver = ParamResolver::new(action);
182        let query = resolver.get("query").unwrap_or("*.rs");
183
184        let mut state = self.state.write().unwrap();
185        state.searched = true;
186
187        // JSON 配列として返す(discovery として認識される)
188        let files_json: Vec<serde_json::Value> = self
189            .files
190            .iter()
191            .map(|f| serde_json::Value::String(f.clone()))
192            .collect();
193
194        let result = serde_json::json!({
195            "query": query,
196            "count": self.files.len(),
197            "files": files_json,
198            "message": "Use ReadFile to examine each file."
199        });
200
201        WorkResult::env_success_structured(result)
202    }
203
204    fn handle_read_file(&self, _worker_id: WorkerId, action: &Action) -> WorkResult {
205        let resolver = ParamResolver::new(action);
206        let file_path = match resolver.require("file") {
207            Ok(s) => s,
208            Err(e) => return WorkResult::env_failure(format!("ReadFile: {}", e)),
209        };
210
211        let mut state = self.state.write().unwrap();
212
213        // ファイルが検索結果に含まれているか確認
214        // Note: searched チェックを緩和し、initial_context からの直接 ReadFile を許可
215        if !self.files.contains(&file_path.to_string()) {
216            return WorkResult::env_failure(format!(
217                "File '{}' not found. Available files: {:?}",
218                file_path, self.files
219            ));
220        }
221
222        state.read_files.insert(file_path.to_string());
223
224        // 正解ファイルかどうか
225        if file_path == self.target_file {
226            state.found_target = true;
227            WorkResult::env_success(format!(
228                "=== {} ===\n{}\n\n[TARGET FOUND] This file contains the target content!",
229                file_path, self.target_content
230            ))
231        } else {
232            // 不正解ファイル - 失敗として記録
233            WorkResult::env_failure(format!(
234                "=== {} ===\n// Empty or irrelevant content\n// Not the target file",
235                file_path
236            ))
237        }
238    }
239
240    fn handle_analyze(&self, worker_id: WorkerId, action: &Action) -> WorkResult {
241        let resolver = ParamResolver::new(action);
242        let file_path = match resolver.require("file") {
243            Ok(s) => s,
244            Err(e) => return WorkResult::env_failure(format!("Analyze: {}", e)),
245        };
246
247        let mut state = self.state.write().unwrap();
248
249        // 正解ファイルを見つけていない場合はエラー
250        if !state.found_target {
251            return WorkResult::env_failure("Cannot analyze without finding the target file first. Read files to find the target.");
252        }
253
254        // 正解ファイルを分析しているか
255        if file_path != self.target_file {
256            return WorkResult::env_failure(format!(
257                "Cannot analyze '{}'. The target file is different.",
258                file_path
259            ));
260        }
261
262        // 成功!
263        if !state.completed.contains(&worker_id) {
264            state.completed.push(worker_id);
265        }
266
267        WorkResult::done_success(format!(
268            "=== Analysis Complete ===\nFile: {}\nContent analyzed successfully!\n\nTask completed!",
269            file_path
270        ))
271    }
272}
273
274impl Environment for SearchEnvironment {
275    fn step(&self, worker_id: WorkerId, action: &Action) -> WorkResult {
276        match action.name.to_lowercase().as_str() {
277            "searchfiles" | "search_files" | "search" | "list" | "listfiles" | "list_files" => {
278                self.handle_search_files(worker_id, action)
279            }
280            "readfile" | "read_file" | "read" | "cat" => self.handle_read_file(worker_id, action),
281            "analyze" | "process" | "complete" => self.handle_analyze(worker_id, action),
282            "continue" => WorkResult::env_success("Continuing..."),
283            _ => WorkResult::unsupported(&action.name),
284        }
285    }
286
287    fn reset(&self) {
288        let mut state = self.state.write().unwrap();
289        state.searched = false;
290        state.read_files.clear();
291        state.found_target = false;
292        state.completed.clear();
293    }
294
295    fn name(&self) -> &str {
296        "SearchEnvironment"
297    }
298}
299
300// ============================================================================
301// Tests
302// ============================================================================
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::collections::HashMap;
308
309    fn is_success(result: &WorkResult) -> bool {
310        match result {
311            WorkResult::Acted { action_result, .. } => action_result.success,
312            WorkResult::Done { success, .. } => *success,
313            _ => false,
314        }
315    }
316
317    fn is_done(result: &WorkResult) -> bool {
318        matches!(result, WorkResult::Done { .. })
319    }
320
321    fn action(name: &str, target: Option<&str>) -> Action {
322        Action {
323            name: name.into(),
324            params: swarm_engine_core::types::ActionParams {
325                target: target.map(|s| s.into()),
326                args: HashMap::new(),
327                data: vec![],
328            },
329        }
330    }
331
332    #[test]
333    fn test_search_files() {
334        let env = SearchEnvironment::basic_scenario();
335        let worker = WorkerId(0);
336
337        let result = env.step(worker, &action("SearchFiles", None));
338        assert!(is_success(&result));
339        assert!(!is_done(&result));
340    }
341
342    #[test]
343    fn test_read_file_without_search() {
344        let env = SearchEnvironment::basic_scenario();
345        let worker = WorkerId(0);
346
347        // 検索なしでも ReadFile は動作する(initial_context からの直接アクセス対応)
348        // ただし、正解ファイルのみ成功
349        let result = env.step(worker, &action("ReadFile", Some("src/handler.rs")));
350        assert!(is_success(&result)); // 正解ファイルは成功
351
352        // 不正解ファイルは失敗
353        let env2 = SearchEnvironment::basic_scenario();
354        let result2 = env2.step(worker, &action("ReadFile", Some("src/config.rs")));
355        assert!(!is_success(&result2));
356    }
357
358    #[test]
359    fn test_read_target_file_success() {
360        let env = SearchEnvironment::basic_scenario();
361        let worker = WorkerId(0);
362
363        // 検索
364        env.step(worker, &action("SearchFiles", None));
365
366        // 正解ファイルを読む
367        let result = env.step(worker, &action("ReadFile", Some("src/handler.rs")));
368        assert!(is_success(&result));
369    }
370
371    #[test]
372    fn test_read_wrong_file_failure() {
373        let env = SearchEnvironment::basic_scenario();
374        let worker = WorkerId(0);
375
376        // 検索
377        env.step(worker, &action("SearchFiles", None));
378
379        // 不正解ファイルを読む
380        let result = env.step(worker, &action("ReadFile", Some("src/config.rs")));
381        assert!(!is_success(&result)); // 失敗
382    }
383
384    #[test]
385    fn test_full_search_flow() {
386        let env = SearchEnvironment::basic_scenario();
387        let worker = WorkerId(0);
388
389        // 1. SearchFiles
390        let result = env.step(worker, &action("SearchFiles", None));
391        assert!(is_success(&result));
392        assert!(!is_done(&result));
393
394        // 2. ReadFile (wrong) - 失敗だがフローは続行可能
395        let result = env.step(worker, &action("ReadFile", Some("src/config.rs")));
396        assert!(!is_success(&result));
397        assert!(!is_done(&result));
398
399        // 3. ReadFile (target) - 成功
400        let result = env.step(worker, &action("ReadFile", Some("src/handler.rs")));
401        assert!(is_success(&result));
402        assert!(!is_done(&result));
403
404        // 4. Analyze - 完了!
405        let result = env.step(worker, &action("Analyze", Some("src/handler.rs")));
406        assert!(is_success(&result));
407        assert!(is_done(&result));
408    }
409
410    #[test]
411    fn test_analyze_requires_found_target() {
412        let env = SearchEnvironment::basic_scenario();
413        let worker = WorkerId(0);
414
415        // 検索
416        env.step(worker, &action("SearchFiles", None));
417
418        // 正解を見つけずにAnalyzeしようとするとエラー
419        let result = env.step(worker, &action("Analyze", Some("src/handler.rs")));
420        assert!(!is_success(&result));
421    }
422
423    #[test]
424    fn test_custom_scenario() {
425        let env = SearchEnvironment::custom_scenario(10, 3, 42);
426        let worker = WorkerId(0);
427
428        // 検索
429        let result = env.step(worker, &action("SearchFiles", None));
430        assert!(is_success(&result));
431    }
432}