Aman045 commited on
Commit
421f883
·
1 Parent(s): 0ce9b9c

refactor: update server to use searcharena package

Browse files
Files changed (2) hide show
  1. server/__init__.py +9 -12
  2. server/environment.py +30 -499
server/__init__.py CHANGED
@@ -1,20 +1,17 @@
1
- """Search RL Environment Server Components."""
 
2
 
 
 
 
 
 
3
  from .environment import SearchEnvironment, create_sample_corpus, create_sample_tasks
4
- from .retrieval import BM25Index, DocumentCorpus
5
- from .rewards import BetaScheduler, RewardCalculator, RewardMetrics, TrajectoryTracker
6
 
7
  __all__ = [
8
- # Environment
 
9
  "SearchEnvironment",
10
  "create_sample_corpus",
11
  "create_sample_tasks",
12
- # Retrieval
13
- "BM25Index",
14
- "DocumentCorpus",
15
- # Rewards
16
- "RewardCalculator",
17
- "RewardMetrics",
18
- "TrajectoryTracker",
19
- "BetaScheduler",
20
  ]
 
1
+ """
2
+ Server package - FastAPI wrapper for SearchArena.
3
 
4
+ This package contains only the server/API layer.
5
+ All core logic lives in the searcharena package.
6
+ """
7
+
8
+ from .app import app, create_environment
9
  from .environment import SearchEnvironment, create_sample_corpus, create_sample_tasks
 
 
10
 
11
  __all__ = [
12
+ "app",
13
+ "create_environment",
14
  "SearchEnvironment",
15
  "create_sample_corpus",
16
  "create_sample_tasks",
 
 
 
 
 
 
 
 
17
  ]
server/environment.py CHANGED
@@ -1,58 +1,30 @@
1
  """
2
- Search RL Environment Implementation.
3
 
4
- A reinforcement learning environment for training agents to perform
5
- multi-hop document retrieval tasks with explicit context management.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
  from typing import Any
11
- from uuid import uuid4
12
- from openenv.core.env_server.interfaces import Environment
13
- from openenv.core.env_server.types import State
14
- from .retrieval import DocumentCorpus
15
- from .rewards import BetaScheduler, RewardCalculator, RewardMetrics, TrajectoryTracker
16
 
17
- try:
18
- from .tasks import get_all_tasks, get_documents, get_task_statistics
19
- except ImportError:
20
- from server.tasks import get_all_tasks, get_documents, get_task_statistics
21
 
22
- try:
23
- from ..models import (
24
- ActionType,
25
- Chunk,
26
- ChunkSummary,
27
- SearchAction,
28
- SearchEnvConfig,
29
- SearchObservation,
30
- SearchTask,
31
- )
32
- except ImportError:
33
- from models import (
34
- ActionType,
35
- Chunk,
36
- ChunkSummary,
37
- SearchAction,
38
- SearchEnvConfig,
39
- SearchObservation,
40
- SearchTask,
41
- )
42
 
43
 
44
- class SearchEnvironment(Environment):
45
  """
46
- Search RL Environment for training agentic search models.
47
-
48
- The agent must:
49
- 1. Issue search queries to find relevant documents
50
- 2. Read documents to add them to context
51
- 3. Prune irrelevant documents to manage token budget
52
- 4. Submit a final answer based on retrieved evidence
53
 
54
- Rewards are based on F-beta score, trajectory recall, answer retrieval,
55
- and efficiency/degeneracy penalties.
56
  """
57
 
58
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
@@ -60,186 +32,19 @@ class SearchEnvironment(Environment):
60
  def __init__(
61
  self,
62
  config: SearchEnvConfig | None = None,
63
- corpus: DocumentCorpus | None = None,
64
- tasks: list[SearchTask] | None = None,
65
  ):
66
- """
67
- Initialize the Search RL Environment.
68
-
69
- Args:
70
- config: Environment configuration
71
- corpus: Pre-loaded document corpus (or will create empty one)
72
- tasks: List of tasks to sample from
73
- """
74
  super().__init__()
75
- self.config = config or SearchEnvConfig()
76
- self.corpus = corpus or DocumentCorpus(config=self.config.model_dump())
77
- self.tasks = tasks or []
78
- self._task_index = 0
79
-
80
- # Reward calculator
81
- self.reward_calculator = RewardCalculator(
82
- beta=self.config.beta,
83
- f_beta_weight=self.config.f_beta_weight,
84
- answer_reward_weight=self.config.answer_reward_weight,
85
- trajectory_reward_weight=self.config.trajectory_reward_weight,
86
- successful_trajectory_floor=self.config.successful_trajectory_floor,
87
- use_trajectory_reward=self.config.use_trajectory_reward,
88
- )
89
- self.beta_scheduler = (
90
- BetaScheduler(
91
- start_beta=self.config.beta_schedule_start,
92
- end_beta=self.config.beta_schedule_end,
93
- warmup_steps=self.config.beta_schedule_warmup_steps,
94
- decay_steps=self.config.beta_schedule_decay_steps,
95
- )
96
- if self.config.use_beta_schedule
97
- else None
98
- )
99
-
100
- # Episode state
101
- self._state = State(episode_id=str(uuid4()), step_count=0)
102
- self._current_task: SearchTask | None = None
103
- self._tracker = TrajectoryTracker()
104
- self._context_chunks: dict[str, Chunk] = {}
105
- self._context_token_count: int = 0
106
- self._chunks_seen: set[str] = set()
107
- self._seen_texts: list[str] = []
108
- self._done: bool = False
109
- self._last_metrics: RewardMetrics | None = None
110
-
111
- def set_corpus(self, corpus: DocumentCorpus) -> None:
112
- """Set the document corpus."""
113
- self.corpus = corpus
114
-
115
- def set_tasks(self, tasks: list[SearchTask]) -> None:
116
- """Set the task list."""
117
- self.tasks = tasks
118
- self._task_index = 0
119
-
120
- def add_task(self, task: SearchTask) -> None:
121
- """Add a task to the task list."""
122
- self.tasks.append(task)
123
-
124
- def _get_next_task(self) -> SearchTask | None:
125
- """Get the next task in sequence (cycles through tasks)."""
126
- if not self.tasks:
127
- return None
128
- task = self.tasks[self._task_index % len(self.tasks)]
129
- self._task_index += 1
130
- return task
131
-
132
- @property
133
- def _budget_usage(self) -> float:
134
- if self.config.max_context_tokens <= 0:
135
- return 0.0
136
- return self._context_token_count / self.config.max_context_tokens
137
-
138
- def _get_budget_warning(self) -> str | None:
139
- if self.config.max_context_tokens <= 0:
140
- return None
141
- usage = self._budget_usage
142
-
143
- if usage >= self.config.hard_budget_threshold:
144
- return (
145
- f"HARD LIMIT: Context at {usage:.0%} capacity. "
146
- "Only prune or answer actions allowed."
147
- )
148
- elif usage >= self.config.soft_budget_threshold:
149
- return (
150
- f"WARNING: Context at {usage:.0%} capacity. "
151
- "Consider pruning irrelevant chunks or submitting answer."
152
- )
153
- return None
154
-
155
- def _create_observation(
156
- self,
157
- action_result: dict[str, Any] | None = None,
158
- action_type: str | None = None,
159
- reward: float = 0.0,
160
- ) -> SearchObservation:
161
- """Create observation from current state."""
162
- # Create chunk summaries for context
163
- context_summaries = []
164
- for chunk in self._context_chunks.values():
165
- summary = ChunkSummary(
166
- chunk_id=chunk.chunk_id,
167
- document_id=chunk.document_id,
168
- title=chunk.metadata.get("title", chunk.document_id),
169
- snippet=chunk.content[: self.config.snippet_length] + "..."
170
- if len(chunk.content) > self.config.snippet_length
171
- else chunk.content,
172
- score=chunk.retrieval_score or 0.0,
173
- token_count=chunk.token_count,
174
- )
175
- context_summaries.append(summary)
176
-
177
- budget_usage = self._budget_usage
178
-
179
- return SearchObservation(
180
- question=self._current_task.question if self._current_task else "",
181
- context_chunks=context_summaries,
182
- context_token_count=self._context_token_count,
183
- context_token_budget=self.config.max_context_tokens,
184
- budget_usage_percent=budget_usage * 100,
185
- budget_warning=self._get_budget_warning(),
186
- action_result=action_result,
187
- action_type=action_type,
188
- step_count=self._state.step_count,
189
- max_steps=self.config.max_steps,
190
- queries_issued=list(self._tracker.queries),
191
- chunks_seen_count=len(self._chunks_seen),
192
- done=self._done,
193
- reward=reward,
194
- )
195
 
196
  def reset(
197
  self,
198
  seed: int | None = None,
199
  episode_id: str | None = None,
200
- task: SearchTask | None = None,
201
  **kwargs: Any,
202
  ) -> SearchObservation:
203
- """
204
- Reset the environment for a new episode.
205
-
206
- Args:
207
- seed: Random seed for reproducibility
208
- episode_id: Optional episode identifier
209
- task: Specific task to use (if None, samples from task list)
210
-
211
- Returns:
212
- Initial observation with question and empty context
213
- """
214
- # Reset state
215
- self._state = State(
216
- episode_id=episode_id or str(uuid4()),
217
- step_count=0,
218
- )
219
- self._tracker.reset()
220
- self._context_chunks.clear()
221
- self._context_token_count = 0
222
- self._chunks_seen.clear()
223
- self._seen_texts.clear()
224
- self._done = False
225
- self._last_metrics = None
226
- self._configure_reward_beta(**kwargs)
227
-
228
- # Get task
229
- if task is not None:
230
- self._current_task = task
231
- else:
232
- self._current_task = self._get_next_task()
233
-
234
- if self._current_task is None:
235
- # No tasks available - create a dummy observation
236
- return SearchObservation(
237
- question="No tasks available. Please add tasks to the environment.",
238
- done=True,
239
- reward=0.0,
240
- )
241
-
242
- return self._create_observation()
243
 
244
  def step(
245
  self,
@@ -247,296 +52,22 @@ class SearchEnvironment(Environment):
247
  timeout_s: float | None = None,
248
  **kwargs: Any,
249
  ) -> SearchObservation:
250
- """
251
- Execute an action in the environment.
252
-
253
- Args:
254
- action: SearchAction with action_type and payload
255
-
256
- Returns:
257
- SearchObservation with action result and updated state
258
- """
259
- if self._done:
260
- return self._create_observation(
261
- action_result={"error": "Episode already finished"},
262
- action_type=action.action_type.value,
263
- reward=0.0,
264
- )
265
-
266
- self._state.step_count += 1
267
- reward = 0.0
268
- action_result: dict[str, Any] = {}
269
-
270
- # Check step limit
271
- if self._state.step_count >= self.config.max_steps:
272
- self._done = True
273
- # Force answer with empty response
274
- action_result = self._handle_answer("", [])
275
- reward = self._last_metrics.total_reward if self._last_metrics else 0.0
276
- return self._create_observation(
277
- action_result=action_result,
278
- action_type="timeout",
279
- reward=reward,
280
- )
281
-
282
- if (
283
- self._budget_usage >= self.config.hard_budget_threshold
284
- and action.action_type not in [ActionType.PRUNE, ActionType.ANSWER]
285
- ):
286
- return self._create_observation(
287
- action_result={
288
- "error": "Token budget exceeded. Only prune or answer allowed."
289
- },
290
- action_type=action.action_type.value,
291
- reward=-0.1, # Small penalty for invalid action
292
- )
293
-
294
- if action.action_type == ActionType.SEARCH:
295
- if action.search is None:
296
- action_result = {"error": "Missing search payload"}
297
- else:
298
- action_result = self._handle_search(
299
- action.search.query, action.search.top_k
300
- )
301
-
302
- elif action.action_type == ActionType.READ:
303
- if action.read is None:
304
- action_result = {"error": "Missing read payload"}
305
- else:
306
- action_result = self._handle_read(action.read.chunk_ids)
307
-
308
- elif action.action_type == ActionType.PRUNE:
309
- if action.prune is None:
310
- action_result = {"error": "Missing prune payload"}
311
- else:
312
- action_result = self._handle_prune(action.prune.chunk_ids)
313
-
314
- elif action.action_type == ActionType.ANSWER:
315
- if action.answer is None:
316
- action_result = {"error": "Missing answer payload"}
317
- else:
318
- action_result = self._handle_answer(
319
- action.answer.answer, action.answer.supporting_chunk_ids
320
- )
321
- reward = self._last_metrics.total_reward if self._last_metrics else 0.0
322
-
323
- return self._create_observation(
324
- action_result=action_result,
325
- action_type=action.action_type.value,
326
- reward=reward,
327
- )
328
-
329
- def _handle_search(self, query: str, top_k: int) -> dict[str, Any]:
330
- """Handle search action."""
331
- # Determine chunks to exclude (for deduplication)
332
- exclude_ids = self._chunks_seen if self.config.deduplicate_searches else None
333
-
334
- try:
335
- results = self.corpus.search(
336
- query=query,
337
- top_k=top_k,
338
- exclude_ids=exclude_ids,
339
- snippet_length=self.config.snippet_length,
340
- )
341
- except Exception as exc:
342
- return {"error": str(exc)}
343
-
344
- # Track results
345
- chunk_ids = [r.chunk_id for r in results]
346
- self._tracker.record_search(query, chunk_ids)
347
- self._chunks_seen.update(chunk_ids)
348
-
349
- # Track snippets for content-based matching fallback in reward calculation
350
- for r in results:
351
- if r.snippet:
352
- self._seen_texts.append(r.snippet)
353
-
354
- return {
355
- "query": query,
356
- "results": [r.model_dump() for r in results],
357
- "total_found": len(results),
358
- }
359
-
360
- def _handle_read(self, chunk_ids: list[str]) -> dict[str, Any]:
361
- """Handle read action."""
362
- chunks_added: list[Chunk] = []
363
- tokens_added = 0
364
- budget_exceeded = False
365
- chunks_truncated = 0
366
-
367
- remaining_budget = self.config.max_context_tokens - self._context_token_count
368
-
369
- for chunk_id in chunk_ids:
370
- # Skip if already in context
371
- if chunk_id in self._context_chunks:
372
- continue
373
-
374
- try:
375
- chunk = self.corpus.get_chunk(chunk_id)
376
- except Exception as exc:
377
- return {"error": str(exc)}
378
- if chunk is None:
379
- continue
380
-
381
- # Check if chunk fits in budget
382
- if tokens_added + chunk.token_count > remaining_budget:
383
- budget_exceeded = True
384
- chunks_truncated += 1
385
- continue
386
-
387
- # Add to context
388
- self._context_chunks[chunk_id] = chunk
389
- self._context_token_count += chunk.token_count
390
- tokens_added += chunk.token_count
391
- chunks_added.append(chunk)
392
-
393
-
394
- self._tracker.record_read([c.chunk_id for c in chunks_added])
395
- self._chunks_seen.update(chunk_ids)
396
-
397
- return {
398
- "chunks": [c.model_dump() for c in chunks_added],
399
- "tokens_added": tokens_added,
400
- "budget_exceeded": budget_exceeded,
401
- "chunks_truncated": chunks_truncated,
402
- }
403
-
404
- def _handle_prune(self, chunk_ids: list[str]) -> dict[str, Any]:
405
- """Handle prune action."""
406
- chunks_removed = 0
407
- tokens_freed = 0
408
- invalid_ids: list[str] = []
409
-
410
- for chunk_id in chunk_ids:
411
- if chunk_id in self._context_chunks:
412
- chunk = self._context_chunks.pop(chunk_id)
413
- self._context_token_count -= chunk.token_count
414
- tokens_freed += chunk.token_count
415
- chunks_removed += 1
416
- else:
417
- invalid_ids.append(chunk_id)
418
-
419
-
420
- self._tracker.record_prune(chunk_ids)
421
-
422
- return {
423
- "chunks_removed": chunks_removed,
424
- "tokens_freed": tokens_freed,
425
- "invalid_ids": invalid_ids,
426
- }
427
-
428
- def _handle_answer(
429
- self, answer: str, supporting_chunk_ids: list[str]
430
- ) -> dict[str, Any]:
431
- """Handle answer action - ends the episode."""
432
- self._done = True
433
-
434
- if self._current_task is None:
435
- return {"answer_submitted": answer, "final_reward": 0.0}
436
-
437
- gold_chunks = set(self._current_task.gold_chunk_ids)
438
-
439
- metrics = self.reward_calculator.calculate_reward(
440
- tracker=self._tracker,
441
- gold_chunks=gold_chunks,
442
- gold_answer=self._current_task.gold_answer,
443
- predicted_answer=answer,
444
- context_texts=[chunk.content for chunk in self._context_chunks.values()],
445
- steps_used=self._state.step_count,
446
- max_steps=self.config.max_steps,
447
- tokens_used=self._context_token_count,
448
- max_tokens=self.config.max_context_tokens,
449
- all_seen_texts=self._seen_texts if self._seen_texts else None,
450
- )
451
-
452
- self._last_metrics = metrics
453
-
454
- return {
455
- "answer_submitted": answer,
456
- "final_reward": metrics.total_reward,
457
- "trajectory_recall": metrics.trajectory_recall,
458
- "output_recall": metrics.output_recall,
459
- "output_precision": metrics.output_precision,
460
- "f_beta": metrics.f_beta,
461
- "beta_used": metrics.beta,
462
- "answer_correct": metrics.answer_correct,
463
- "answer_found_in_context": metrics.answer_found_in_context,
464
- "answer_similarity": metrics.answer_similarity,
465
- "f_beta_reward": metrics.f_beta_reward,
466
- "trajectory_reward": metrics.trajectory_reward,
467
- "answer_reward": metrics.answer_reward,
468
- "turn_penalty": metrics.turn_penalty,
469
- "prune_penalty": metrics.prune_penalty,
470
- "pre_penalty_reward": metrics.pre_penalty_reward,
471
- "reward_floor": metrics.reward_floor,
472
- }
473
 
474
  @property
475
  def state(self) -> State:
476
- """Get the current environment state."""
477
- return self._state
478
-
479
- def get_metrics(self) -> RewardMetrics | None:
480
- """Get the last computed reward metrics."""
481
- return self._last_metrics
482
-
483
- def _configure_reward_beta(self, **kwargs: Any) -> None:
484
- """Set the reward beta for the next episode."""
485
- reward_beta = kwargs.get("reward_beta")
486
- training_step = kwargs.get("training_step")
487
-
488
- if reward_beta is not None:
489
- self.reward_calculator.beta = float(reward_beta)
490
- return
491
-
492
- if self.beta_scheduler is not None and training_step is not None:
493
- self.reward_calculator.beta = self.beta_scheduler.get_beta(
494
- int(training_step)
495
- )
496
- return
497
 
498
- self.reward_calculator.beta = self.config.beta
499
-
500
-
501
- def create_sample_corpus(
502
- config: SearchEnvConfig | None = None,
503
- ) -> DocumentCorpus:
504
- """
505
- Create a sample corpus for testing.
506
-
507
- Documents are loaded from the tasks module for better organization.
508
- """
509
- config_dict = config.model_dump() if config is not None else None
510
- corpus = DocumentCorpus(config=config_dict)
511
-
512
- # Load documents from tasks module
513
- documents = get_documents()
514
-
515
- for doc in documents:
516
- corpus.add_document(
517
- doc_id=doc["doc_id"],
518
- content=doc["content"],
519
- metadata=doc["metadata"],
520
- chunk_size=500,
521
- chunk_overlap=50,
522
  )
523
 
524
- return corpus
525
-
526
-
527
- def create_sample_tasks() -> list[SearchTask]:
528
- """
529
- Create sample tasks for testing.
530
-
531
- Tasks are loaded from the tasks module which organizes them by difficulty.
532
-
533
- Tasks follow the Context-1 paper style:
534
- - Obfuscated clues (don't mention entities directly)
535
- - Short, verifiable answers (exist verbatim in documents)
536
- - Multi-constraint questions requiring decomposition
537
-
538
- Includes easy, medium, and hard difficulties across multiple domains.
539
- """
540
- return get_all_tasks()
541
-
542
 
 
 
 
 
 
 
1
  """
2
+ Server environment wrapper.
3
 
4
+ This is a thin wrapper - all logic lives in searcharena.engine.
5
+ Follows OpsArena pattern: server/ only contains the OpenEnv interface.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
  from typing import Any
 
 
 
 
 
11
 
12
+ from openenv.core.env_server.interfaces import Environment, EnvironmentMetadata
13
+ from openenv.core.env_server.types import State
 
 
14
 
15
+ from searcharena.engine import (
16
+ SearchEnvironment as _SearchEnvironment,
17
+ create_sample_corpus,
18
+ create_sample_tasks,
19
+ )
20
+ from searcharena.models import SearchAction, SearchEnvConfig, SearchObservation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
+ class SearchEnvironment(Environment[SearchAction, SearchObservation, State]):
24
  """
25
+ OpenEnv-compatible wrapper for SearchArena environment.
 
 
 
 
 
 
26
 
27
+ This thin wrapper delegates all logic to searcharena.engine.SearchEnvironment.
 
28
  """
29
 
30
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
 
32
  def __init__(
33
  self,
34
  config: SearchEnvConfig | None = None,
35
+ corpus: Any | None = None,
36
+ tasks: list | None = None,
37
  ):
 
 
 
 
 
 
 
 
38
  super().__init__()
39
+ self._env = _SearchEnvironment(config=config, corpus=corpus, tasks=tasks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  def reset(
42
  self,
43
  seed: int | None = None,
44
  episode_id: str | None = None,
 
45
  **kwargs: Any,
46
  ) -> SearchObservation:
47
+ return self._env.reset(seed=seed, episode_id=episode_id, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  def step(
50
  self,
 
52
  timeout_s: float | None = None,
53
  **kwargs: Any,
54
  ) -> SearchObservation:
55
+ return self._env.step(action, timeout_s=timeout_s, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  @property
58
  def state(self) -> State:
59
+ return self._env.state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
+ def get_metadata(self) -> EnvironmentMetadata:
62
+ return EnvironmentMetadata(
63
+ name="SearchArena",
64
+ description="Multi-hop document retrieval environment for training search agents.",
65
+ version="0.1.0",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  )
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ __all__ = [
70
+ "SearchEnvironment",
71
+ "create_sample_corpus",
72
+ "create_sample_tasks",
73
+ ]