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