Siddh12334 commited on
Commit
6801c6a
·
1 Parent(s): aacbc52

feat: implement actions, reward, env, server

Browse files
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .env
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .DS_Store
10
+ data/facts.json
11
+ assets/*.png
environment/actions.py CHANGED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum
2
+ from typing import Optional
3
+ from pydantic import BaseModel, field_validator
4
+
5
+
6
+ class ActionType(str, Enum):
7
+ read_doc = "read_doc"
8
+ flag_suspicious = "flag_suspicious"
9
+ unflag_doc = "unflag_doc"
10
+ submit_answer = "submit_answer"
11
+
12
+
13
+ class ContextCorruptionAction(BaseModel):
14
+ action_type: ActionType
15
+ doc_id: Optional[int] = None
16
+ answer: Optional[str] = None
17
+ confidence: Optional[float] = None
18
+
19
+ @field_validator("confidence")
20
+ @classmethod
21
+ def confidence_range(cls, v):
22
+ if v is not None and not (0.0 <= v <= 1.0):
23
+ raise ValueError("confidence must be between 0.0 and 1.0")
24
+ return v
25
+
26
+
27
+ class Document(BaseModel):
28
+ id: int
29
+ title: str
30
+ content: str
31
+ is_flagged: bool = False
32
+
33
+
34
+ class EpisodeObservation(BaseModel):
35
+ question: str
36
+ documents: list[Document]
37
+ flagged_ids: list[int]
38
+ budget_remaining: int
39
+ turn: int
40
+ episode_done: bool = False
41
+ reward: Optional[float] = None
42
+ message: Optional[str] = None
environment/env.py CHANGED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import random
3
+ from pathlib import Path
4
+
5
+ from environment.actions import ActionType, ContextCorruptionAction, Document, EpisodeObservation
6
+ from environment.reward import compute_reward
7
+
8
+ _FALLBACK_FACTS = [
9
+ {"question": "What is the capital of France?", "answer": "Paris"}
10
+ ]
11
+
12
+
13
+ class ContextCorruptionEnv:
14
+ MAX_BUDGET = 12
15
+ NUM_DOCS = 8
16
+ DIFFICULTY_LEVELS = [1, 2, 3, 4]
17
+
18
+ def __init__(self, difficulty=None):
19
+ self.difficulty = difficulty
20
+ facts_path = Path(__file__).parent.parent / "data" / "facts.json"
21
+ if facts_path.exists():
22
+ with open(facts_path) as f:
23
+ self._facts = json.load(f)
24
+ else:
25
+ self._facts = _FALLBACK_FACTS
26
+ self._reset_state()
27
+
28
+ def _reset_state(self):
29
+ self._question = ""
30
+ self._ground_truth = ""
31
+ self._documents: list[dict] = []
32
+ self._corrupt_ids: list[int] = []
33
+ self._flagged_ids: list[int] = []
34
+ self._budget_used = 0
35
+ self._turn = 0
36
+ self._done = False
37
+ self._reward = None
38
+ self._breakdown = None
39
+
40
+ def reset(self) -> EpisodeObservation:
41
+ self._reset_state()
42
+ fact = random.choice(self._facts)
43
+ n_corrupt = self.difficulty if self.difficulty is not None else random.choice(self.DIFFICULTY_LEVELS)
44
+ self._corrupt_ids = random.sample(range(self.NUM_DOCS), n_corrupt)
45
+ self._question = fact["question"]
46
+ self._ground_truth = fact["answer"]
47
+
48
+ try:
49
+ from data.generator import generate_documents
50
+ raw_docs = generate_documents(fact, num_docs=self.NUM_DOCS, corrupt_positions=self._corrupt_ids)
51
+ except Exception:
52
+ raw_docs = [
53
+ {"id": i, "title": f"Document {i}", "content": fact["answer"], "is_corrupt": i in self._corrupt_ids}
54
+ for i in range(self.NUM_DOCS)
55
+ ]
56
+
57
+ self._documents = raw_docs
58
+ return self._build_observation()
59
+
60
+ def step(self, action: ContextCorruptionAction) -> EpisodeObservation:
61
+ if self._done:
62
+ return self._build_observation(message="Episode already done.")
63
+
64
+ self._turn += 1
65
+ self._budget_used += 1
66
+ reward = None
67
+
68
+ if action.action_type == ActionType.read_doc:
69
+ pass # budget cost is the point; content already in observation
70
+
71
+ elif action.action_type == ActionType.flag_suspicious:
72
+ if action.doc_id is not None and action.doc_id not in self._flagged_ids:
73
+ self._flagged_ids.append(action.doc_id)
74
+
75
+ elif action.action_type == ActionType.unflag_doc:
76
+ if action.doc_id in self._flagged_ids:
77
+ self._flagged_ids.remove(action.doc_id)
78
+
79
+ elif action.action_type == ActionType.submit_answer:
80
+ reward, self._breakdown = compute_reward(
81
+ submitted_answer=action.answer or "",
82
+ ground_truth_answer=self._ground_truth,
83
+ flagged_ids=self._flagged_ids,
84
+ corrupt_ids=self._corrupt_ids,
85
+ confidence=action.confidence or 0.0,
86
+ budget_used=self._budget_used,
87
+ max_budget=self.MAX_BUDGET,
88
+ )
89
+ self._reward = reward
90
+ self._done = True
91
+
92
+ # Force-submit on budget exhaustion
93
+ if self._budget_used >= self.MAX_BUDGET and not self._done:
94
+ reward, self._breakdown = compute_reward(
95
+ submitted_answer="",
96
+ ground_truth_answer=self._ground_truth,
97
+ flagged_ids=self._flagged_ids,
98
+ corrupt_ids=self._corrupt_ids,
99
+ confidence=0.0,
100
+ budget_used=self._budget_used,
101
+ max_budget=self.MAX_BUDGET,
102
+ )
103
+ self._reward = reward
104
+ self._done = True
105
+
106
+ return self._build_observation(reward=reward)
107
+
108
+ def state(self) -> dict:
109
+ return {
110
+ "question": self._question,
111
+ "ground_truth": self._ground_truth,
112
+ "corrupt_ids": self._corrupt_ids,
113
+ "flagged_ids": self._flagged_ids,
114
+ "budget_used": self._budget_used,
115
+ "turn": self._turn,
116
+ "done": self._done,
117
+ "reward": self._reward,
118
+ "breakdown": self._breakdown,
119
+ }
120
+
121
+ def _build_observation(self, reward=None, message=None) -> EpisodeObservation:
122
+ docs = [
123
+ Document(
124
+ id=d["id"],
125
+ title=d["title"],
126
+ content=d["content"],
127
+ is_flagged=d["id"] in self._flagged_ids,
128
+ )
129
+ for d in self._documents
130
+ ]
131
+ return EpisodeObservation(
132
+ question=self._question,
133
+ documents=docs,
134
+ flagged_ids=list(self._flagged_ids),
135
+ budget_remaining=self.MAX_BUDGET - self._budget_used,
136
+ turn=self._turn,
137
+ episode_done=self._done,
138
+ reward=reward if reward is not None else self._reward,
139
+ message=message,
140
+ )
environment/reward.py CHANGED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+
4
+ def _normalize(text: str) -> str:
5
+ text = text.lower()
6
+ text = re.sub(r"[^\w\s]", "", text)
7
+ text = re.sub(r"\s+", " ", text).strip()
8
+ return text
9
+
10
+
11
+ def compute_reward(
12
+ submitted_answer: str,
13
+ ground_truth_answer: str,
14
+ flagged_ids: list[int],
15
+ corrupt_ids: list[int],
16
+ confidence: float,
17
+ budget_used: int,
18
+ max_budget: int,
19
+ ) -> tuple[float, dict]:
20
+ # Answer correctness
21
+ correct = _normalize(submitted_answer) == _normalize(ground_truth_answer)
22
+ answer_score = 0.4 if correct else 0.0
23
+
24
+ # Flag recall
25
+ true_positives = [i for i in flagged_ids if i in corrupt_ids]
26
+ recall = len(true_positives) / len(corrupt_ids) if corrupt_ids else 0.0
27
+ recall_score = 0.3 * recall
28
+
29
+ # Precision (false positive penalty)
30
+ false_positives = [i for i in flagged_ids if i not in corrupt_ids]
31
+ precision_score = max(0.0, 0.2 - 0.1 * len(false_positives))
32
+
33
+ # Confidence calibration
34
+ confidence = confidence or 0.0
35
+ calibration_score = (0.1 * confidence) if correct else (-0.2 * confidence)
36
+
37
+ # Efficiency bonus
38
+ efficiency_score = 0.05 * (1 - budget_used / max_budget)
39
+
40
+ total = answer_score + recall_score + precision_score + calibration_score + efficiency_score
41
+
42
+ breakdown = {
43
+ "answer_correctness": round(answer_score, 4),
44
+ "flag_recall": round(recall_score, 4),
45
+ "false_positive_penalty": round(precision_score, 4),
46
+ "confidence_calibration": round(calibration_score, 4),
47
+ "efficiency": round(efficiency_score, 4),
48
+ "total": round(total, 4),
49
+ }
50
+ return round(total, 4), breakdown
environment/server.py CHANGED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from fastapi import FastAPI, HTTPException
3
+ import uvicorn
4
+
5
+ from environment.actions import ContextCorruptionAction, EpisodeObservation
6
+ from environment.env import ContextCorruptionEnv
7
+
8
+ app = FastAPI(title="ContextCorruption-Env")
9
+
10
+ # session_id -> env instance
11
+ _sessions: dict[str, ContextCorruptionEnv] = {}
12
+ _MAX_SESSIONS = 64
13
+
14
+
15
+ @app.post("/reset", response_model=EpisodeObservation)
16
+ def reset(session_id: str | None = None):
17
+ if session_id is None:
18
+ if len(_sessions) >= _MAX_SESSIONS:
19
+ raise HTTPException(status_code=503, detail="Max concurrent sessions reached")
20
+ session_id = str(uuid.uuid4())
21
+ if session_id not in _sessions:
22
+ _sessions[session_id] = ContextCorruptionEnv()
23
+ obs = _sessions[session_id].reset()
24
+ return obs
25
+
26
+
27
+ @app.post("/step/{session_id}", response_model=EpisodeObservation)
28
+ def step(session_id: str, action: ContextCorruptionAction):
29
+ if session_id not in _sessions:
30
+ raise HTTPException(status_code=404, detail="Session not found")
31
+ obs = _sessions[session_id].step(action)
32
+ if obs.episode_done:
33
+ del _sessions[session_id]
34
+ return obs
35
+
36
+
37
+ @app.get("/state/{session_id}")
38
+ def state(session_id: str):
39
+ if session_id not in _sessions:
40
+ raise HTTPException(status_code=404, detail="Session not found")
41
+ return _sessions[session_id].state()
42
+
43
+
44
+ @app.delete("/session/{session_id}")
45
+ def close_session(session_id: str):
46
+ _sessions.pop(session_id, None)
47
+ return {"status": "closed"}
48
+
49
+
50
+ @app.get("/health")
51
+ def health():
52
+ return {"status": "ok", "active_sessions": len(_sessions)}
53
+
54
+
55
+ if __name__ == "__main__":
56
+ uvicorn.run("environment.server:app", host="0.0.0.0", port=8000, reload=False)