Skip to main content

swarm_engine_eval/environments/
deep_search.rs

1//! DeepSearchEnvironment - 疑似Deep Search環境
2//!
3//! 情報検索・評価・統合をシミュレートする環境。
4//! 実際のWeb検索ではなく、疑似ドキュメント集合を使用。
5//!
6//! # SearchEnvironment との違い
7//!
8//! - SearchEnvironment: 単純なファイル検索(1つの正解を探す)
9//! - DeepSearchEnvironment: 情報の信頼性評価、複数ソースからの事実統合
10//!
11//! # アクション
12//!
13//! - `Search`: クエリで検索、関連ドキュメントのリストを返す
14//! - `ReadDocument`: ドキュメントの内容を読む
15//! - `EvaluateSource`: ソースの信頼度を評価
16//! - `ExtractFact`: ドキュメントから事実を抽出
17//! - `SubmitAnswer`: 回答を提出(完了判定)
18//!
19//! # 目標
20//!
21//! 信頼できる情報源から正確な回答を構築する。
22//!
23//! # DependencyGraph
24//!
25//! ```text
26//! Search → ReadDocument
27//! ReadDocument → EvaluateSource | ExtractFact
28//! EvaluateSource → ExtractFact
29//! ExtractFact → SubmitAnswer (terminal)
30//! ```
31
32use std::collections::HashSet;
33use std::sync::RwLock;
34
35use swarm_engine_core::agent::WorkResult;
36use swarm_engine_core::environment::Environment;
37use swarm_engine_core::types::{Action, WorkerId};
38
39// ============================================================================
40// Document & Source Definitions
41// ============================================================================
42
43/// ドキュメントの信頼度
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub enum Reliability {
46    /// 高信頼(公式ソース、学術論文等)
47    High,
48    /// 中信頼(ニュースサイト、専門ブログ等)
49    Medium,
50    /// 低信頼(個人ブログ、SNS等)
51    Low,
52    /// 誤情報を含む可能性
53    Unreliable,
54}
55
56impl Reliability {
57    fn score(&self) -> f64 {
58        match self {
59            Reliability::High => 1.0,
60            Reliability::Medium => 0.7,
61            Reliability::Low => 0.4,
62            Reliability::Unreliable => 0.1,
63        }
64    }
65
66    fn description(&self) -> &str {
67        match self {
68            Reliability::High => "High reliability - Official or academic source",
69            Reliability::Medium => "Medium reliability - Reputable news or expert blog",
70            Reliability::Low => "Low reliability - Personal blog or forum",
71            Reliability::Unreliable => "Unreliable - May contain misinformation",
72        }
73    }
74}
75
76/// 疑似ドキュメント
77#[derive(Debug, Clone)]
78pub struct Document {
79    pub id: String,
80    pub title: String,
81    pub source: String,
82    pub content: String,
83    pub keywords: Vec<String>,
84    pub reliability: Reliability,
85    /// このドキュメントが含む事実(正解かどうかのフラグ付き)
86    pub facts: Vec<(String, bool)>,
87}
88
89impl Document {
90    fn new(id: impl Into<String>) -> Self {
91        Self {
92            id: id.into(),
93            title: String::new(),
94            source: String::new(),
95            content: String::new(),
96            keywords: Vec::new(),
97            reliability: Reliability::Medium,
98            facts: Vec::new(),
99        }
100    }
101
102    fn title(mut self, title: impl Into<String>) -> Self {
103        self.title = title.into();
104        self
105    }
106
107    fn source(mut self, source: impl Into<String>) -> Self {
108        self.source = source.into();
109        self
110    }
111
112    fn content(mut self, content: impl Into<String>) -> Self {
113        self.content = content.into();
114        self
115    }
116
117    fn keywords(mut self, keywords: Vec<&str>) -> Self {
118        self.keywords = keywords.into_iter().map(String::from).collect();
119        self
120    }
121
122    fn reliability(mut self, reliability: Reliability) -> Self {
123        self.reliability = reliability;
124        self
125    }
126
127    fn fact(mut self, fact: impl Into<String>, is_correct: bool) -> Self {
128        self.facts.push((fact.into(), is_correct));
129        self
130    }
131
132    /// クエリとの関連度を計算(0.0〜1.0)
133    fn relevance(&self, query: &str) -> f64 {
134        let query_lower = query.to_lowercase();
135        let query_words: HashSet<_> = query_lower.split_whitespace().collect();
136
137        let mut score: f64 = 0.0;
138
139        // タイトルマッチ
140        if self.title.to_lowercase().contains(&query_lower) {
141            score += 0.4;
142        }
143
144        // キーワードマッチ
145        for kw in &self.keywords {
146            if query_words.contains(kw.to_lowercase().as_str()) {
147                score += 0.2;
148            }
149        }
150
151        // コンテンツマッチ(部分一致)
152        let content_lower = self.content.to_lowercase();
153        for word in &query_words {
154            if content_lower.contains(*word) {
155                score += 0.1;
156            }
157        }
158
159        score.min(1.0)
160    }
161}
162
163// ============================================================================
164// DeepSearchEnvironment
165// ============================================================================
166
167/// 検索タスクの目標
168#[derive(Debug, Clone)]
169pub struct SearchGoal {
170    /// 検索クエリ(ユーザーの質問)
171    pub query: String,
172    /// 正解となる事実のキーワード
173    pub expected_facts: Vec<String>,
174}
175
176/// 環境の内部状態
177struct SearchState {
178    /// 疑似ドキュメント集合
179    documents: Vec<Document>,
180    /// 目標
181    goal: SearchGoal,
182    /// 検索履歴
183    search_history: Vec<String>,
184    /// 読んだドキュメント
185    read_documents: HashSet<String>,
186    /// 評価済みドキュメント
187    evaluated_documents: HashSet<String>,
188    /// 抽出した事実
189    extracted_facts: Vec<(String, String, bool)>, // (doc_id, fact, is_correct)
190    /// 完了フラグ
191    done: bool,
192}
193
194impl SearchState {
195    fn reset(&mut self, documents: Vec<Document>, goal: SearchGoal) {
196        self.documents = documents;
197        self.goal = goal;
198        self.search_history.clear();
199        self.read_documents.clear();
200        self.evaluated_documents.clear();
201        self.extracted_facts.clear();
202        self.done = false;
203    }
204}
205
206/// 疑似Deep Search環境
207pub struct DeepSearchEnvironment {
208    state: RwLock<SearchState>,
209    initial_documents: Vec<Document>,
210    initial_goal: SearchGoal,
211}
212
213impl DeepSearchEnvironment {
214    /// 新しい検索環境を作成
215    pub fn new(documents: Vec<Document>, goal: SearchGoal) -> Self {
216        let state = SearchState {
217            documents: documents.clone(),
218            goal: goal.clone(),
219            search_history: Vec::new(),
220            read_documents: HashSet::new(),
221            evaluated_documents: HashSet::new(),
222            extracted_facts: Vec::new(),
223            done: false,
224        };
225
226        Self {
227            state: RwLock::new(state),
228            initial_documents: documents,
229            initial_goal: goal,
230        }
231    }
232
233    /// プリセット: 技術質問シナリオ
234    pub fn tech_question_scenario() -> Self {
235        let documents = vec![
236            Document::new("doc1")
237                .title("Rust Memory Safety - Official Documentation")
238                .source("doc.rust-lang.org")
239                .content("Rust guarantees memory safety without garbage collection through its ownership system. The borrow checker enforces rules at compile time.")
240                .keywords(vec!["rust", "memory", "safety", "ownership", "borrow"])
241                .reliability(Reliability::High)
242                .fact("Rust uses ownership system for memory safety", true)
243                .fact("Rust has no garbage collector", true),
244
245            Document::new("doc2")
246                .title("Why Rust is Memory Safe - Tech Blog")
247                .source("techblog.example.com")
248                .content("Rust achieves memory safety through compile-time checks. The ownership model prevents data races and null pointer dereferences.")
249                .keywords(vec!["rust", "memory", "safe", "compile"])
250                .reliability(Reliability::Medium)
251                .fact("Rust prevents data races at compile time", true)
252                .fact("Rust has no runtime overhead for safety", true),
253
254            Document::new("doc3")
255                .title("Rust vs Go Memory Management")
256                .source("forum.dev")
257                .content("Some say Rust is slower because of all its safety checks. Go is better for most projects.")
258                .keywords(vec!["rust", "go", "memory", "performance"])
259                .reliability(Reliability::Low)
260                .fact("Rust safety checks cause runtime overhead", false),
261
262            Document::new("doc4")
263                .title("Memory Safety Myths Debunked")
264                .source("random-blog.net")
265                .content("Rust is just hype. All languages can be memory safe if you're careful. Rust's borrow checker is annoying and unnecessary.")
266                .keywords(vec!["rust", "memory", "hype"])
267                .reliability(Reliability::Unreliable)
268                .fact("Rust borrow checker is unnecessary", false),
269
270            Document::new("doc5")
271                .title("Understanding Ownership in Rust")
272                .source("rust-book.example.org")
273                .content("Each value in Rust has a variable that's called its owner. There can only be one owner at a time. When the owner goes out of scope, the value will be dropped.")
274                .keywords(vec!["rust", "ownership", "scope", "drop"])
275                .reliability(Reliability::High)
276                .fact("Each value has exactly one owner", true)
277                .fact("Values are dropped when owner goes out of scope", true),
278        ];
279
280        let goal = SearchGoal {
281            query: "How does Rust achieve memory safety?".to_string(),
282            expected_facts: vec![
283                "ownership".to_string(),
284                "borrow".to_string(),
285                "compile".to_string(),
286            ],
287        };
288
289        Self::new(documents, goal)
290    }
291
292    // ------------------------------------------------------------------------
293    // Helpers
294    // ------------------------------------------------------------------------
295
296    /// Resolve doc_id from target.
297    /// - If target is a valid doc_id (exists in documents), use it directly
298    /// - Otherwise, auto-select first unread document
299    fn resolve_doc_id_for_read(&self, target: &str) -> Option<String> {
300        let state = self.state.read().unwrap();
301
302        // Check if target is a valid doc_id
303        if state.documents.iter().any(|d| d.id == target) {
304            return Some(target.to_string());
305        }
306
307        // Auto-select first unread document
308        state
309            .documents
310            .iter()
311            .find(|d| !state.read_documents.contains(&d.id))
312            .map(|d| d.id.clone())
313    }
314
315    /// Resolve doc_id from target.
316    /// - If target is a valid doc_id (exists and has been read), use it directly
317    /// - Otherwise, auto-select first read but unevaluated document
318    fn resolve_doc_id_for_eval(&self, target: &str) -> Option<String> {
319        let state = self.state.read().unwrap();
320
321        // Check if target is a valid doc_id that has been read
322        if state.read_documents.contains(target) {
323            return Some(target.to_string());
324        }
325
326        // Auto-select first read but unevaluated document
327        state
328            .read_documents
329            .iter()
330            .find(|id| !state.evaluated_documents.contains(*id))
331            .cloned()
332    }
333
334    /// Resolve doc_id from target.
335    /// - If target is a valid doc_id (exists and has been read), use it directly
336    /// - Otherwise, auto-select first read document
337    fn resolve_doc_id_for_extract(&self, target: &str) -> Option<String> {
338        let state = self.state.read().unwrap();
339
340        // Check if target is a valid doc_id that has been read
341        if state.read_documents.contains(target) {
342            return Some(target.to_string());
343        }
344
345        // Auto-select first read document
346        state.read_documents.iter().next().cloned()
347    }
348
349    // ------------------------------------------------------------------------
350    // Action Handlers
351    // ------------------------------------------------------------------------
352
353    fn handle_search(&self, action: &Action) -> WorkResult {
354        let target = action.params.target.as_deref().unwrap_or("");
355
356        // If target is "node:X" format, use the goal query as the search query
357        let query = if target.starts_with("node:") || target.is_empty() {
358            let state = self.state.read().unwrap();
359            state.goal.query.clone()
360        } else {
361            target.to_string()
362        };
363
364        if query.is_empty() {
365            return WorkResult::env_failure("Search query is required");
366        }
367
368        let mut state = self.state.write().unwrap();
369        state.search_history.push(query.clone());
370
371        // 関連度でソートしてトップ5を返す
372        let mut results: Vec<_> = state
373            .documents
374            .iter()
375            .map(|doc| (doc, doc.relevance(&query)))
376            .filter(|(_, rel)| *rel > 0.1)
377            .collect();
378
379        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
380        results.truncate(5);
381
382        if results.is_empty() {
383            return WorkResult::env_success_with_data(
384                "No relevant documents found",
385                "Try different search terms".to_string(),
386            );
387        }
388
389        // 表示用出力
390        let output: Vec<String> = results
391            .iter()
392            .map(|(doc, rel)| {
393                format!(
394                    "- [{}] {} (source: {}, relevance: {:.0}%)",
395                    doc.id,
396                    doc.title,
397                    doc.source,
398                    rel * 100.0
399                )
400            })
401            .collect();
402
403        // 発見したドキュメントIDのリスト(ExploMap で展開される)
404        let discovered_targets: Vec<String> =
405            results.iter().map(|(doc, _)| doc.id.clone()).collect();
406
407        WorkResult::env_success_with_discoveries(
408            format!("Found {} relevant documents", results.len()),
409            output.join("\n"),
410            discovered_targets,
411        )
412    }
413
414    fn handle_read_document(&self, action: &Action) -> WorkResult {
415        let target = action.params.target.as_deref().unwrap_or("");
416
417        let doc_id = match self.resolve_doc_id_for_read(target) {
418            Some(id) => id,
419            None => return WorkResult::env_failure("No unread documents available"),
420        };
421
422        let mut state = self.state.write().unwrap();
423
424        let doc = match state.documents.iter().find(|d| d.id == doc_id) {
425            Some(d) => d.clone(),
426            None => return WorkResult::env_failure(format!("Document '{}' not found", doc_id)),
427        };
428
429        state.read_documents.insert(doc_id.to_string());
430
431        let output = format!(
432            "Title: {}\nSource: {}\n\nContent:\n{}",
433            doc.title, doc.source, doc.content
434        );
435
436        WorkResult::env_success_with_data(format!("Read document '{}'", doc_id), output)
437    }
438
439    fn handle_evaluate_source(&self, action: &Action) -> WorkResult {
440        let target = action.params.target.as_deref().unwrap_or("");
441        let doc_id = match self.resolve_doc_id_for_eval(target) {
442            Some(id) => id,
443            None => {
444                return WorkResult::env_failure(
445                    "No documents available for evaluation (read documents first)",
446                )
447            }
448        };
449
450        let mut state = self.state.write().unwrap();
451
452        // 先に読んでいないと評価できない
453        if !state.read_documents.contains(&doc_id) {
454            return WorkResult::env_failure(format!(
455                "Must read document '{}' before evaluating",
456                doc_id
457            ));
458        }
459
460        let doc = match state.documents.iter().find(|d| d.id == doc_id) {
461            Some(d) => d.clone(),
462            None => return WorkResult::env_failure(format!("Document '{}' not found", doc_id)),
463        };
464
465        state.evaluated_documents.insert(doc_id.to_string());
466
467        let output = format!(
468            "Source: {}\nReliability: {:?} ({:.0}%)\nAssessment: {}",
469            doc.source,
470            doc.reliability,
471            doc.reliability.score() * 100.0,
472            doc.reliability.description()
473        );
474
475        WorkResult::env_success_with_data(format!("Evaluated source '{}'", doc_id), output)
476    }
477
478    fn handle_extract_fact(&self, action: &Action) -> WorkResult {
479        let target = action.params.target.as_deref().unwrap_or("");
480        let doc_id = match self.resolve_doc_id_for_extract(target) {
481            Some(id) => id,
482            None => return WorkResult::env_failure("No read documents available for extraction"),
483        };
484
485        let claim = action
486            .params
487            .args
488            .get("claim")
489            .map(|s| s.as_str())
490            .unwrap_or("ownership"); // Default claim for auto-mode
491
492        let mut state = self.state.write().unwrap();
493
494        // 先に読んでいないと抽出できない
495        if !state.read_documents.contains(&doc_id) {
496            return WorkResult::env_failure(format!(
497                "Must read document '{}' before extracting facts",
498                doc_id
499            ));
500        }
501
502        let doc = match state.documents.iter().find(|d| d.id == doc_id) {
503            Some(d) => d.clone(),
504            None => return WorkResult::env_failure(format!("Document '{}' not found", doc_id)),
505        };
506
507        // claim がドキュメントの facts に含まれるかチェック
508        let claim_lower = claim.to_lowercase();
509        let matched_fact = doc.facts.iter().find(|(fact, _)| {
510            let fact_lower = fact.to_lowercase();
511            claim_lower
512                .split_whitespace()
513                .any(|w| fact_lower.contains(w))
514        });
515
516        let (extracted, is_correct) = match matched_fact {
517            Some((fact, correct)) => (fact.clone(), *correct),
518            None => {
519                return WorkResult::env_failure(format!(
520                    "Claim '{}' not found in document '{}'",
521                    claim, doc_id
522                ));
523            }
524        };
525
526        state
527            .extracted_facts
528            .push((doc_id.to_string(), extracted.clone(), is_correct));
529
530        let confidence = if is_correct {
531            doc.reliability.score()
532        } else {
533            0.0
534        };
535
536        let output = format!(
537            "Extracted: \"{}\"\nSource reliability: {:?}\nConfidence: {:.0}%",
538            extracted,
539            doc.reliability,
540            confidence * 100.0
541        );
542
543        WorkResult::env_success_with_data(format!("Extracted fact from '{}'", doc_id), output)
544    }
545
546    fn handle_submit_answer(&self, action: &Action) -> WorkResult {
547        let answer = action.params.target.as_deref().unwrap_or("");
548        if answer.is_empty() {
549            return WorkResult::env_failure("Answer is required");
550        }
551
552        let mut state = self.state.write().unwrap();
553
554        // 統計を先に計算
555        let correct_count = state
556            .extracted_facts
557            .iter()
558            .filter(|(_, _, correct)| *correct)
559            .count();
560
561        let incorrect_count = state
562            .extracted_facts
563            .iter()
564            .filter(|(_, _, correct)| !*correct)
565            .count();
566
567        // 期待されるキーワードがいくつ含まれているか
568        let answer_lower = answer.to_lowercase();
569        let matched_count = state
570            .goal
571            .expected_facts
572            .iter()
573            .filter(|kw| answer_lower.contains(&kw.to_lowercase()))
574            .count();
575
576        let total_expected = state.goal.expected_facts.len();
577        let keyword_coverage = matched_count as f64 / total_expected as f64;
578
579        // スコア計算
580        let correct_score = correct_count as f64 * 0.3;
581        let incorrect_penalty = incorrect_count as f64 * 0.2;
582        let coverage_score = keyword_coverage * 0.4;
583        let total_score = (correct_score + coverage_score - incorrect_penalty)
584            .max(0.0)
585            .min(1.0);
586
587        let success = total_score >= 0.6 && incorrect_count == 0;
588
589        state.done = true;
590
591        let summary = format!(
592            "Answer evaluation:\n\
593             - Correct facts extracted: {}\n\
594             - Incorrect facts extracted: {}\n\
595             - Keyword coverage: {:.0}%\n\
596             - Total score: {:.0}%\n\
597             - Result: {}",
598            correct_count,
599            incorrect_count,
600            keyword_coverage * 100.0,
601            total_score * 100.0,
602            if success {
603                "SUCCESS"
604            } else {
605                "NEEDS IMPROVEMENT"
606            }
607        );
608
609        if success {
610            WorkResult::done_success(summary)
611        } else {
612            WorkResult::done_failure(summary)
613        }
614    }
615}
616
617impl Environment for DeepSearchEnvironment {
618    fn step(&self, _worker_id: WorkerId, action: &Action) -> WorkResult {
619        match action.name.as_str() {
620            "Search" => self.handle_search(action),
621            "ReadDocument" => self.handle_read_document(action),
622            "EvaluateSource" => self.handle_evaluate_source(action),
623            "ExtractFact" => self.handle_extract_fact(action),
624            "SubmitAnswer" => self.handle_submit_answer(action),
625            _ => WorkResult::unsupported(&action.name),
626        }
627    }
628
629    fn reset(&self) {
630        let mut state = self.state.write().unwrap();
631        state.reset(self.initial_documents.clone(), self.initial_goal.clone());
632    }
633
634    fn name(&self) -> &str {
635        "DeepSearchEnvironment"
636    }
637}
638
639// ============================================================================
640// Tests
641// ============================================================================
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use std::collections::HashMap;
647
648    fn is_success(result: &WorkResult) -> bool {
649        match result {
650            WorkResult::Acted { action_result, .. } => action_result.success,
651            WorkResult::Done { success, .. } => *success,
652            _ => false,
653        }
654    }
655
656    fn is_done(result: &WorkResult) -> bool {
657        matches!(result, WorkResult::Done { .. })
658    }
659
660    fn get_output(result: &WorkResult) -> Option<String> {
661        match result {
662            WorkResult::Acted { action_result, .. } => action_result
663                .output
664                .as_ref()
665                .map(|o| o.as_text().to_string()),
666            WorkResult::Done { message, .. } => message.clone(),
667            _ => None,
668        }
669    }
670
671    fn get_error(result: &WorkResult) -> Option<String> {
672        match result {
673            WorkResult::Acted { action_result, .. } => action_result.error.clone(),
674            _ => None,
675        }
676    }
677
678    fn action(name: &str, target: Option<&str>) -> Action {
679        Action {
680            name: name.into(),
681            params: swarm_engine_core::types::ActionParams {
682                target: target.map(|s| s.into()),
683                args: HashMap::new(),
684                data: vec![],
685            },
686        }
687    }
688
689    fn action_with_args(name: &str, target: Option<&str>, args: Vec<(&str, &str)>) -> Action {
690        let mut a = action(name, target);
691        for (k, v) in args {
692            a.params.args.insert(k.to_string(), v.to_string());
693        }
694        a
695    }
696
697    #[test]
698    fn test_search_returns_relevant_documents() {
699        let env = DeepSearchEnvironment::tech_question_scenario();
700
701        let act = action("Search", Some("rust memory safety"));
702        let result = env.step(WorkerId(0), &act);
703
704        assert!(is_success(&result));
705        let output = get_output(&result).unwrap();
706        assert!(output.contains("doc1"));
707    }
708
709    #[test]
710    fn test_read_document() {
711        let env = DeepSearchEnvironment::tech_question_scenario();
712
713        let act = action("ReadDocument", Some("doc1"));
714        let result = env.step(WorkerId(0), &act);
715
716        assert!(is_success(&result));
717        let output = get_output(&result).unwrap();
718        assert!(output.contains("ownership"));
719    }
720
721    #[test]
722    fn test_evaluate_requires_read_first() {
723        let env = DeepSearchEnvironment::tech_question_scenario();
724
725        // Try to evaluate without reading first
726        let act = action("EvaluateSource", Some("doc1"));
727        let result = env.step(WorkerId(0), &act);
728
729        assert!(!is_success(&result));
730        // Error message: either "Must read" (explicit doc_id) or "No documents available" (auto-resolve)
731        let err = get_error(&result).unwrap();
732        assert!(
733            err.contains("Must read") || err.contains("No documents available"),
734            "Unexpected error: {}",
735            err
736        );
737    }
738
739    #[test]
740    fn test_extract_fact() {
741        let env = DeepSearchEnvironment::tech_question_scenario();
742
743        // Read first
744        let read = action("ReadDocument", Some("doc1"));
745        env.step(WorkerId(0), &read);
746
747        // Extract fact
748        let extract = action_with_args("ExtractFact", Some("doc1"), vec![("claim", "ownership")]);
749        let result = env.step(WorkerId(0), &extract);
750
751        assert!(is_success(&result));
752    }
753
754    #[test]
755    fn test_full_workflow() {
756        let env = DeepSearchEnvironment::tech_question_scenario();
757
758        // 1. Search
759        let search = action("Search", Some("rust memory"));
760        let result = env.step(WorkerId(0), &search);
761        assert!(is_success(&result));
762
763        // 2. Read high-reliability document
764        let read = action("ReadDocument", Some("doc1"));
765        let result = env.step(WorkerId(0), &read);
766        assert!(is_success(&result));
767
768        // 3. Evaluate source
769        let eval = action("EvaluateSource", Some("doc1"));
770        let result = env.step(WorkerId(0), &eval);
771        assert!(is_success(&result));
772
773        // 4. Extract fact
774        let extract = action_with_args("ExtractFact", Some("doc1"), vec![("claim", "ownership")]);
775        let result = env.step(WorkerId(0), &extract);
776        assert!(is_success(&result));
777
778        // 5. Submit answer
779        let submit = action(
780            "SubmitAnswer",
781            Some(
782                "Rust achieves memory safety through ownership and borrow checking at compile time",
783            ),
784        );
785        let result = env.step(WorkerId(0), &submit);
786        assert!(is_done(&result));
787    }
788
789    #[test]
790    fn test_node_target_format() {
791        let env = DeepSearchEnvironment::tech_question_scenario();
792
793        // Search with "node:1" target should use goal.query
794        let search = action("Search", Some("node:1"));
795        let result = env.step(WorkerId(0), &search);
796        assert!(
797            is_success(&result),
798            "Search with node:1 failed: {:?}",
799            get_error(&result)
800        );
801        let output = get_output(&result).unwrap();
802        assert!(output.contains("doc"), "Search output: {}", output);
803
804        // ReadDocument with "node:2" target should auto-select first unread doc
805        let read = action("ReadDocument", Some("node:2"));
806        let result = env.step(WorkerId(0), &read);
807        assert!(
808            is_success(&result),
809            "ReadDocument with node:2 failed: {:?}",
810            get_error(&result)
811        );
812    }
813}