SamSankar commited on
Commit
b4120ae
·
verified ·
1 Parent(s): 89afadf

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -584,6 +584,187 @@ python inference.py --episodes 3 --steps 5
584
 
585
  ---
586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  ## 🔗 Links
588
 
589
  | | |
 
584
 
585
  ---
586
 
587
+ ## 🔌 Integration Examples
588
+
589
+ ### OpenAI SDK Integration
590
+
591
+ ```python
592
+ # examples/openai_integration.py
593
+ from openai import OpenAI
594
+ import requests
595
+
596
+ client = OpenAI()
597
+ ENV_URL = "https://samsankar-hallucination-guard-env.hf.space"
598
+
599
+ def evaluate_with_gpt4(question: str, context: str) -> dict:
600
+ # Get answer from GPT-4
601
+ response = client.chat.completions.create(
602
+ model="gpt-4o-mini",
603
+ messages=[{
604
+ "role": "user",
605
+ "content": f"Answer ONLY from context.\n\nContext: {context}\n\nQuestion: {question}\n\n"
606
+ f"Return JSON: {{'answer': '...', 'confidence': 0.XX, 'source_quote': '...'}}"
607
+ }],
608
+ temperature=0.1
609
+ )
610
+
611
+ # Parse and submit to environment
612
+ import json
613
+ result = json.loads(response.choices[0].message.content)
614
+
615
+ step = requests.post(f"{ENV_URL}/step", json={
616
+ "answer": result["answer"],
617
+ "confidence": result["confidence"],
618
+ "source_quote": result["source_quote"]
619
+ })
620
+
621
+ return step.json()
622
+
623
+ # See examples/openai_integration.py for full implementation
624
+ ```
625
+
626
+ ### Anthropic Claude Integration
627
+
628
+ ```python
629
+ # examples/anthropic_integration.py
630
+ from anthropic import Anthropic
631
+ import requests
632
+
633
+ client = Anthropic()
634
+ ENV_URL = "https://samsankar-hallucination-guard-env.hf.space"
635
+
636
+ def evaluate_with_claude(question: str, context: str) -> dict:
637
+ response = client.messages.create(
638
+ model="claude-sonnet-4-20250514",
639
+ max_tokens=500,
640
+ messages=[{
641
+ "role": "user",
642
+ "content": f"Answer using ONLY the provided context.\n\nContext: {context}\n\nQuestion: {question}"
643
+ }]
644
+ )
645
+
646
+ # Submit to environment
647
+ step = requests.post(f"{ENV_URL}/step", json={
648
+ "answer": response.content[0].text,
649
+ "confidence": 0.8,
650
+ "source_quote": ""
651
+ })
652
+
653
+ return step.json()
654
+
655
+ # See examples/anthropic_integration.py for full implementation
656
+ ```
657
+
658
+ ### Batch Evaluation
659
+
660
+ ```bash
661
+ # Run batch evaluation across all tasks
662
+ python examples/batch_evaluation.py --episodes 5 --output results.json
663
+ ```
664
+
665
+ ---
666
+
667
+ ## 🚀 Production Deployment
668
+
669
+ ### Docker Compose (Multi-Service)
670
+
671
+ ```yaml
672
+ # docker-compose.yml
673
+ version: '3.8'
674
+
675
+ services:
676
+ hallucination-guard:
677
+ build: .
678
+ ports:
679
+ - "7860:7860"
680
+ environment:
681
+ - PYTHONUNBUFFERED=1
682
+ healthcheck:
683
+ test: ["CMD", "curl", "-f", "http://localhost:7860/health"]
684
+ interval: 30s
685
+ timeout: 15s
686
+ retries: 3
687
+ start_period: 300s
688
+ deploy:
689
+ resources:
690
+ limits:
691
+ memory: 4G
692
+ reservations:
693
+ memory: 2G
694
+
695
+ # Optional: Redis for session caching
696
+ redis:
697
+ image: redis:alpine
698
+ ports:
699
+ - "6379:6379"
700
+ ```
701
+
702
+ ### Kubernetes Deployment
703
+
704
+ ```yaml
705
+ # k8s/deployment.yaml
706
+ apiVersion: apps/v1
707
+ kind: Deployment
708
+ metadata:
709
+ name: hallucination-guard-env
710
+ spec:
711
+ replicas: 2
712
+ selector:
713
+ matchLabels:
714
+ app: hallucination-guard
715
+ template:
716
+ metadata:
717
+ labels:
718
+ app: hallucination-guard
719
+ spec:
720
+ containers:
721
+ - name: server
722
+ image: hallucination-guard:latest
723
+ ports:
724
+ - containerPort: 7860
725
+ resources:
726
+ limits:
727
+ memory: "4Gi"
728
+ cpu: "2"
729
+ requests:
730
+ memory: "2Gi"
731
+ cpu: "1"
732
+ livenessProbe:
733
+ httpGet:
734
+ path: /health
735
+ port: 7860
736
+ initialDelaySeconds: 300
737
+ periodSeconds: 30
738
+ readinessProbe:
739
+ httpGet:
740
+ path: /health
741
+ port: 7860
742
+ initialDelaySeconds: 60
743
+ periodSeconds: 10
744
+ ---
745
+ apiVersion: v1
746
+ kind: Service
747
+ metadata:
748
+ name: hallucination-guard-service
749
+ spec:
750
+ selector:
751
+ app: hallucination-guard
752
+ ports:
753
+ - port: 80
754
+ targetPort: 7860
755
+ type: LoadBalancer
756
+ ```
757
+
758
+ ### Environment Configuration
759
+
760
+ | Variable | Description | Default |
761
+ |----------|-------------|---------|
762
+ | `USE_LARGE_NLI` | Use large NLI model (more accurate, more memory) | `false` |
763
+ | `MAX_QUESTIONS` | Maximum questions per episode | `10` |
764
+ | `LOG_LEVEL` | Logging level | `INFO` |
765
+
766
+ ---
767
+
768
  ## 🔗 Links
769
 
770
  | | |
examples/anthropic_integration.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anthropic Claude SDK Integration Example for HallucinationGuard-Env.
3
+
4
+ This example demonstrates how to evaluate Claude models
5
+ (Claude 3.5 Sonnet, Claude 3 Opus) using the HallucinationGuard environment.
6
+
7
+ Requirements:
8
+ pip install anthropic requests
9
+ """
10
+
11
+ import os
12
+ from typing import Optional
13
+ import requests
14
+
15
+ # Anthropic SDK
16
+ try:
17
+ from anthropic import Anthropic
18
+ except ImportError:
19
+ print("Install Anthropic SDK: pip install anthropic")
20
+ raise
21
+
22
+
23
+ class ClaudeHallucinationEvaluator:
24
+ """
25
+ Evaluate Claude models for hallucination resistance.
26
+
27
+ Features:
28
+ - Supports Claude 3.5 Sonnet, Claude 3 Opus, Claude 3 Haiku
29
+ - Uses structured prompts for consistent responses
30
+ - Tracks calibration and grounding scores
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ env_base_url: str = "https://samsankar-hallucination-guard-env.hf.space",
36
+ anthropic_api_key: Optional[str] = None,
37
+ model: str = "claude-sonnet-4-20250514"
38
+ ):
39
+ """
40
+ Initialize evaluator.
41
+
42
+ Args:
43
+ env_base_url: HallucinationGuard-Env server URL
44
+ anthropic_api_key: Anthropic API key (or set ANTHROPIC_API_KEY env var)
45
+ model: Claude model name
46
+ """
47
+ self.env_base_url = env_base_url.rstrip('/')
48
+ self.model = model
49
+ self.client = Anthropic(api_key=anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY"))
50
+
51
+ # Session tracking
52
+ self.episode_id = None
53
+
54
+ def reset_environment(self, difficulty: str = "intermediate") -> dict:
55
+ """Start a new evaluation episode."""
56
+ response = requests.post(
57
+ f"{self.env_base_url}/reset",
58
+ json={"difficulty": difficulty}
59
+ )
60
+ response.raise_for_status()
61
+ data = response.json()
62
+ self.episode_id = data.get("episode_id")
63
+ return data
64
+
65
+ def generate_answer(self, question: str, context: str) -> dict:
66
+ """
67
+ Generate an answer using Claude model.
68
+
69
+ Claude is instructed to:
70
+ 1. Answer ONLY from the context
71
+ 2. Provide calibrated confidence
72
+ 3. Cite verbatim source quotes
73
+ """
74
+ prompt = f"""I need you to answer a question using ONLY the provided context.
75
+
76
+ CRITICAL INSTRUCTIONS:
77
+ 1. Answer ONLY using information from the context below
78
+ 2. If the answer is not in the context, respond: "I cannot determine the answer from the provided context."
79
+ 3. Provide a confidence score (0.0 to 1.0) for your answer
80
+ 4. Include a direct quote from the context that supports your answer
81
+
82
+ CONTEXT:
83
+ {context}
84
+
85
+ QUESTION:
86
+ {question}
87
+
88
+ Respond in this exact JSON format:
89
+ {{
90
+ "answer": "your answer based solely on the context",
91
+ "confidence": 0.XX,
92
+ "source_quote": "exact verbatim quote from the context"
93
+ }}
94
+
95
+ Remember: Only use information from the context. Do not use outside knowledge."""
96
+
97
+ try:
98
+ response = self.client.messages.create(
99
+ model=self.model,
100
+ max_tokens=500,
101
+ temperature=0.1,
102
+ messages=[
103
+ {"role": "user", "content": prompt}
104
+ ]
105
+ )
106
+
107
+ content = response.content[0].text
108
+
109
+ # Parse JSON response
110
+ import json
111
+ import re
112
+
113
+ # Extract JSON from response
114
+ json_match = re.search(r'\{[^{}]*\}', content, re.DOTALL)
115
+ if json_match:
116
+ result = json.loads(json_match.group())
117
+ else:
118
+ # Fallback if no JSON found
119
+ result = {
120
+ "answer": content.split('"answer"')[1].split('"')[1] if '"answer"' in content else content[:200],
121
+ "confidence": 0.5,
122
+ "source_quote": ""
123
+ }
124
+
125
+ return {
126
+ "answer": result.get("answer", ""),
127
+ "confidence": float(result.get("confidence", 0.5)),
128
+ "source_quote": result.get("source_quote", "")
129
+ }
130
+
131
+ except Exception as e:
132
+ print(f"Error generating answer: {e}")
133
+ return {
134
+ "answer": "I cannot determine the answer from the provided context.",
135
+ "confidence": 0.3,
136
+ "source_quote": ""
137
+ }
138
+
139
+ def step(self, answer: str, confidence: float, source_quote: str = "") -> dict:
140
+ """Submit an answer to the environment."""
141
+ response = requests.post(
142
+ f"{self.env_base_url}/step",
143
+ json={
144
+ "answer": answer,
145
+ "confidence": confidence,
146
+ "source_quote": source_quote
147
+ }
148
+ )
149
+ response.raise_for_status()
150
+ return response.json()
151
+
152
+ def evaluate_episode(
153
+ self,
154
+ num_questions: int = 10,
155
+ difficulty: str = "intermediate",
156
+ verbose: bool = True
157
+ ) -> dict:
158
+ """
159
+ Run a complete evaluation episode.
160
+
161
+ Args:
162
+ num_questions: Number of questions to evaluate
163
+ difficulty: Starting difficulty level
164
+ verbose: Print progress
165
+
166
+ Returns:
167
+ Episode statistics
168
+ """
169
+ obs = self.reset_environment(difficulty=difficulty)
170
+
171
+ total_reward = 0.0
172
+ hallucinations = 0
173
+ correct = 0
174
+ calibration_errors = []
175
+
176
+ for step_num in range(num_questions):
177
+ question = obs.get("question", "")
178
+ context = obs.get("context", "")
179
+
180
+ if verbose:
181
+ print(f"\n--- Question {step_num + 1}/{num_questions} ---")
182
+ print(f"Q: {question[:100]}...")
183
+
184
+ # Generate answer with Claude
185
+ answer_data = self.generate_answer(question, context)
186
+
187
+ if verbose:
188
+ print(f"A: {answer_data['answer'][:100]}...")
189
+ print(f"Confidence: {answer_data['confidence']:.2f}")
190
+
191
+ # Submit to environment
192
+ obs = self.step(
193
+ answer=answer_data["answer"],
194
+ confidence=answer_data["confidence"],
195
+ source_quote=answer_data["source_quote"]
196
+ )
197
+
198
+ # Track statistics
199
+ reward = obs.get("reward", 0.0)
200
+ total_reward += reward
201
+
202
+ if obs.get("is_hallucination", False):
203
+ hallucinations += 1
204
+
205
+ if obs.get("grounding_score", 0) > 0.7:
206
+ correct += 1
207
+
208
+ # Track calibration
209
+ correctness = obs.get("metadata", {}).get("correctness", 0.5)
210
+ calibration_error = abs(answer_data["confidence"] - correctness)
211
+ calibration_errors.append(calibration_error)
212
+
213
+ if verbose:
214
+ print(f"Reward: {reward:.3f}")
215
+ print(f"Hallucination: {obs.get('is_hallucination', False)}")
216
+
217
+ if obs.get("done", False):
218
+ break
219
+
220
+ # Calculate statistics
221
+ avg_reward = total_reward / max(1, step_num + 1)
222
+ hallucination_rate = hallucinations / max(1, step_num + 1)
223
+ accuracy = correct / max(1, step_num + 1)
224
+ avg_calibration = sum(calibration_errors) / max(1, len(calibration_errors))
225
+
226
+ results = {
227
+ "model": self.model,
228
+ "avg_reward": avg_reward,
229
+ "hallucination_rate": hallucination_rate,
230
+ "accuracy": accuracy,
231
+ "avg_calibration_error": avg_calibration,
232
+ "total_steps": step_num + 1,
233
+ "difficulty": difficulty
234
+ }
235
+
236
+ if verbose:
237
+ print(f"\n=== Episode Complete ===")
238
+ print(f"Model: {self.model}")
239
+ print(f"Average Reward: {avg_reward:.3f}")
240
+ print(f"Hallucination Rate: {hallucination_rate:.1%}")
241
+ print(f"Accuracy: {accuracy:.1%}")
242
+ print(f"Avg Calibration Error: {avg_calibration:.3f}")
243
+
244
+ return results
245
+
246
+
247
+ def compare_models(models: list, num_questions: int = 5, difficulty: str = "intermediate") -> dict:
248
+ """
249
+ Compare multiple Claude models.
250
+
251
+ Args:
252
+ models: List of model names to compare
253
+ num_questions: Questions per model
254
+ difficulty: Difficulty level
255
+
256
+ Returns:
257
+ Comparison results
258
+ """
259
+ results = {}
260
+
261
+ for model in models:
262
+ print(f"\n{'='*50}")
263
+ print(f"Evaluating: {model}")
264
+ print(f"{'='*50}")
265
+
266
+ evaluator = ClaudeHallucinationEvaluator(model=model)
267
+ model_results = evaluator.evaluate_episode(
268
+ num_questions=num_questions,
269
+ difficulty=difficulty
270
+ )
271
+ results[model] = model_results
272
+
273
+ # Print comparison
274
+ print(f"\n{'='*60}")
275
+ print("MODEL COMPARISON")
276
+ print(f"{'='*60}")
277
+ print(f"{'Model':<30} {'Reward':>10} {'Halluc%':>12} {'Accuracy':>10}")
278
+ print("-" * 60)
279
+ for model, res in results.items():
280
+ print(f"{model:<30} {res['avg_reward']:>10.3f} {res['hallucination_rate']*100:>11.1f}% {res['accuracy']*100:>9.1f}%")
281
+
282
+ return results
283
+
284
+
285
+ def main():
286
+ """Run evaluation demo."""
287
+ import argparse
288
+
289
+ parser = argparse.ArgumentParser(description="Evaluate Claude models for hallucination resistance")
290
+ parser.add_argument("--model", default="claude-sonnet-4-20250514",
291
+ help="Claude model name")
292
+ parser.add_argument("--difficulty", default="intermediate", help="Difficulty level")
293
+ parser.add_argument("--num-questions", type=int, default=5, help="Number of questions")
294
+ parser.add_argument("--env-url", default="https://samsankar-hallucination-guard-env.hf.space",
295
+ help="Environment server URL")
296
+ parser.add_argument("--compare", action="store_true",
297
+ help="Compare multiple models")
298
+
299
+ args = parser.parse_args()
300
+
301
+ # Check for API key
302
+ if not os.environ.get("ANTHROPIC_API_KEY"):
303
+ print("Error: Set ANTHROPIC_API_KEY environment variable")
304
+ return
305
+
306
+ if args.compare:
307
+ # Compare multiple models
308
+ models = [
309
+ "claude-sonnet-4-20250514",
310
+ "claude-3-5-sonnet-20241022",
311
+ "claude-3-haiku-20240307"
312
+ ]
313
+ compare_models(models, num_questions=args.num_questions, difficulty=args.difficulty)
314
+ else:
315
+ # Single model evaluation
316
+ evaluator = ClaudeHallucinationEvaluator(
317
+ env_base_url=args.env_url,
318
+ model=args.model
319
+ )
320
+
321
+ results = evaluator.evaluate_episode(
322
+ num_questions=args.num_questions,
323
+ difficulty=args.difficulty
324
+ )
325
+
326
+ print(f"\nFinal Results: {results}")
327
+
328
+
329
+ if __name__ == "__main__":
330
+ main()
examples/batch_evaluation.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch Evaluation Script for HallucinationGuard-Env.
3
+
4
+ This script demonstrates how to run batch evaluations across multiple
5
+ tasks and difficulties, generating comprehensive benchmark reports.
6
+
7
+ Requirements:
8
+ pip install requests matplotlib pandas
9
+ """
10
+
11
+ import json
12
+ import time
13
+ from typing import List, Dict, Any, Optional
14
+ from datetime import datetime
15
+ import requests
16
+
17
+
18
+ class BatchEvaluator:
19
+ """
20
+ Run batch evaluations across tasks and difficulties.
21
+
22
+ Features:
23
+ - Multi-task evaluation (Factual Grounding, Multi-hop, Adversarial)
24
+ - Multiple difficulty levels
25
+ - Performance metrics and calibration analysis
26
+ - JSON report generation
27
+ """
28
+
29
+ TASKS = [
30
+ "task_1_factual_grounding",
31
+ "task_2_multi_hop_synthesis",
32
+ "task_3_adversarial_resistance"
33
+ ]
34
+
35
+ DIFFICULTIES = ["beginner", "intermediate", "advanced"]
36
+
37
+ def __init__(self, env_base_url: str = "https://samsankar-hallucination-guard-env.hf.space"):
38
+ """Initialize evaluator with environment URL."""
39
+ self.env_base_url = env_base_url.rstrip('/')
40
+ self.session = requests.Session()
41
+
42
+ def get_tasks(self) -> List[Dict]:
43
+ """Get available tasks from environment."""
44
+ response = self.session.get(f"{self.env_base_url}/tasks")
45
+ response.raise_for_status()
46
+ return response.json().get("tasks", [])
47
+
48
+ def evaluate_baseline(
49
+ self,
50
+ task_id: str,
51
+ num_episodes: int = 3,
52
+ difficulty: str = "intermediate"
53
+ ) -> Dict[str, Any]:
54
+ """
55
+ Run baseline evaluation for a specific task.
56
+
57
+ Uses a simple heuristic baseline:
58
+ - Extract key entities from context
59
+ - Match entities to question
60
+ - Provide confidence based on match quality
61
+
62
+ Args:
63
+ task_id: Task identifier
64
+ num_episodes: Number of episodes to run
65
+ difficulty: Difficulty level
66
+
67
+ Returns:
68
+ Evaluation results
69
+ """
70
+ results = {
71
+ "task_id": task_id,
72
+ "difficulty": difficulty,
73
+ "episodes": [],
74
+ "summary": {}
75
+ }
76
+
77
+ all_rewards = []
78
+ all_hallucinations = []
79
+ all_correct = []
80
+
81
+ for episode_num in range(num_episodes):
82
+ # Reset environment
83
+ reset_data = self._reset(task_id=task_id, difficulty=difficulty)
84
+
85
+ episode_rewards = []
86
+ episode_hallucinations = 0
87
+ episode_correct = 0
88
+
89
+ steps = 0
90
+ max_steps = 10
91
+
92
+ while steps < max_steps:
93
+ # Get current observation
94
+ question = reset_data.get("question", "")
95
+ context = reset_data.get("context", "")
96
+
97
+ # Generate baseline answer
98
+ answer_data = self._generate_baseline_answer(question, context)
99
+
100
+ # Step environment
101
+ step_data = self._step(**answer_data)
102
+
103
+ # Track metrics
104
+ reward = step_data.get("reward", 0.0)
105
+ episode_rewards.append(reward)
106
+
107
+ if step_data.get("is_hallucination", False):
108
+ episode_hallucinations += 1
109
+
110
+ if step_data.get("grounding_score", 0) > 0.7:
111
+ episode_correct += 1
112
+
113
+ steps += 1
114
+
115
+ if step_data.get("done", False):
116
+ break
117
+
118
+ # Get next question
119
+ reset_data = step_data
120
+
121
+ # Episode statistics
122
+ episode_avg_reward = sum(episode_rewards) / max(1, len(episode_rewards))
123
+ all_rewards.append(episode_avg_reward)
124
+ all_hallucinations.append(episode_hallucinations / max(1, steps))
125
+ all_correct.append(episode_correct / max(1, steps))
126
+
127
+ results["episodes"].append({
128
+ "episode_num": episode_num + 1,
129
+ "avg_reward": episode_avg_reward,
130
+ "hallucination_rate": episode_hallucinations / max(1, steps),
131
+ "accuracy": episode_correct / max(1, steps),
132
+ "total_steps": steps
133
+ })
134
+
135
+ print(f"Episode {episode_num + 1}: Reward={episode_avg_reward:.3f}, "
136
+ f"Hallucinations={episode_hallucinations}/{steps}")
137
+
138
+ # Aggregate results
139
+ results["summary"] = {
140
+ "avg_reward": sum(all_rewards) / len(all_rewards),
141
+ "avg_hallucination_rate": sum(all_hallucinations) / len(all_hallucinations),
142
+ "avg_accuracy": sum(all_correct) / len(all_correct),
143
+ "total_episodes": num_episodes,
144
+ "timestamp": datetime.now().isoformat()
145
+ }
146
+
147
+ return results
148
+
149
+ def _reset(self, task_id: str = None, difficulty: str = "intermediate") -> dict:
150
+ """Reset environment."""
151
+ payload = {"difficulty": difficulty}
152
+ if task_id:
153
+ payload["task_id"] = task_id
154
+
155
+ response = self.session.post(f"{self.env_base_url}/reset", json=payload)
156
+ response.raise_for_status()
157
+ return response.json()
158
+
159
+ def _step(self, answer: str, confidence: float, source_quote: str = "") -> dict:
160
+ """Submit step."""
161
+ response = self.session.post(
162
+ f"{self.env_base_url}/step",
163
+ json={
164
+ "answer": answer,
165
+ "confidence": confidence,
166
+ "source_quote": source_quote
167
+ }
168
+ )
169
+ response.raise_for_status()
170
+ return response.json()
171
+
172
+ def _generate_baseline_answer(self, question: str, context: str) -> dict:
173
+ """
174
+ Generate a simple baseline answer.
175
+
176
+ Strategy:
177
+ 1. Extract sentences from context
178
+ 2. Find sentence most similar to question
179
+ 3. Use that as answer with moderate confidence
180
+ 4. Use sentence as source quote
181
+ """
182
+ import re
183
+
184
+ # Split context into sentences
185
+ sentences = re.split(r'[.!?]+', context)
186
+ sentences = [s.strip() for s in sentences if len(s.strip()) > 10]
187
+
188
+ if not sentences:
189
+ return {
190
+ "answer": "I cannot find the answer in the provided context.",
191
+ "confidence": 0.3,
192
+ "source_quote": ""
193
+ }
194
+
195
+ # Find most relevant sentence (simple keyword matching)
196
+ question_words = set(question.lower().split())
197
+
198
+ best_sentence = sentences[0]
199
+ best_overlap = 0
200
+
201
+ for sentence in sentences:
202
+ sentence_words = set(sentence.lower().split())
203
+ overlap = len(question_words & sentence_words)
204
+ if overlap > best_overlap:
205
+ best_overlap = overlap
206
+ best_sentence = sentence
207
+
208
+ # Check if answer is likely in context
209
+ if best_overlap < 2:
210
+ return {
211
+ "answer": "The answer does not appear to be in the provided context.",
212
+ "confidence": 0.4,
213
+ "source_quote": ""
214
+ }
215
+
216
+ # Extract key part of sentence as answer
217
+ answer = best_sentence[:200] if len(best_sentence) > 200 else best_sentence
218
+
219
+ return {
220
+ "answer": answer,
221
+ "confidence": 0.5 + (best_overlap / 20), # Higher confidence with more overlap
222
+ "source_quote": best_sentence[:150]
223
+ }
224
+
225
+ def run_full_evaluation(
226
+ self,
227
+ episodes_per_task: int = 3,
228
+ difficulties: List[str] = None
229
+ ) -> Dict[str, Any]:
230
+ """
231
+ Run full evaluation across all tasks and difficulties.
232
+
233
+ Args:
234
+ episodes_per_task: Episodes per task configuration
235
+ difficulties: List of difficulties to test
236
+
237
+ Returns:
238
+ Complete evaluation report
239
+ """
240
+ difficulties = difficulties or ["beginner", "intermediate", "advanced"]
241
+
242
+ report = {
243
+ "evaluation_date": datetime.now().isoformat(),
244
+ "environment_url": self.env_base_url,
245
+ "configuration": {
246
+ "episodes_per_task": episodes_per_task,
247
+ "difficulties": difficulties
248
+ },
249
+ "results": {}
250
+ }
251
+
252
+ print("Starting Full Evaluation")
253
+ print("=" * 60)
254
+
255
+ for task_id in self.TASKS:
256
+ print(f"\nEvaluating: {task_id}")
257
+ print("-" * 40)
258
+
259
+ report["results"][task_id] = {}
260
+
261
+ for difficulty in difficulties:
262
+ print(f" Difficulty: {difficulty}")
263
+
264
+ task_results = self.evaluate_baseline(
265
+ task_id=task_id,
266
+ num_episodes=episodes_per_task,
267
+ difficulty=difficulty
268
+ )
269
+
270
+ report["results"][task_id][difficulty] = task_results
271
+
272
+ # Brief pause between evaluations
273
+ time.sleep(1)
274
+
275
+ # Generate summary
276
+ report["summary"] = self._generate_summary(report)
277
+
278
+ return report
279
+
280
+ def _generate_summary(self, report: dict) -> dict:
281
+ """Generate cross-task summary."""
282
+ summary = {
283
+ "overall_avg_reward": 0.0,
284
+ "overall_avg_hallucination_rate": 0.0,
285
+ "overall_avg_accuracy": 0.0,
286
+ "best_task": "",
287
+ "best_difficulty": ""
288
+ }
289
+
290
+ all_rewards = []
291
+ all_hallucinations = []
292
+ all_accuracies = []
293
+ task_performances = {}
294
+
295
+ for task_id, difficulties in report.get("results", {}).items():
296
+ task_rewards = []
297
+ for difficulty, results in difficulties.items():
298
+ task_summary = results.get("summary", {})
299
+ all_rewards.append(task_summary.get("avg_reward", 0))
300
+ all_hallucinations.append(task_summary.get("avg_hallucination_rate", 0))
301
+ all_accuracies.append(task_summary.get("avg_accuracy", 0))
302
+ task_rewards.append(task_summary.get("avg_reward", 0))
303
+
304
+ task_performances[task_id] = sum(task_rewards) / len(task_rewards)
305
+
306
+ if all_rewards:
307
+ summary["overall_avg_reward"] = sum(all_rewards) / len(all_rewards)
308
+ if all_hallucinations:
309
+ summary["overall_avg_hallucination_rate"] = sum(all_hallucinations) / len(all_hallucinations)
310
+ if all_accuracies:
311
+ summary["overall_avg_accuracy"] = sum(all_accuracies) / len(all_accuracies)
312
+
313
+ if task_performances:
314
+ summary["best_task"] = max(task_performances, key=task_performances.get)
315
+
316
+ return summary
317
+
318
+ def save_report(self, report: dict, filename: str = None) -> str:
319
+ """Save report to JSON file."""
320
+ if filename is None:
321
+ filename = f"hallucination_eval_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
322
+
323
+ with open(filename, 'w') as f:
324
+ json.dump(report, f, indent=2)
325
+
326
+ print(f"Report saved to: {filename}")
327
+ return filename
328
+
329
+
330
+ def main():
331
+ """Run batch evaluation."""
332
+ import argparse
333
+
334
+ parser = argparse.ArgumentParser(description="Run batch hallucination evaluation")
335
+ parser.add_argument("--env-url", default="https://samsankar-hallucination-guard-env.hf.space",
336
+ help="Environment server URL")
337
+ parser.add_argument("--episodes", type=int, default=3, help="Episodes per task")
338
+ parser.add_argument("--output", default=None, help="Output file name")
339
+
340
+ args = parser.parse_args()
341
+
342
+ evaluator = BatchEvaluator(env_base_url=args.env_url)
343
+
344
+ # Run full evaluation
345
+ report = evaluator.run_full_evaluation(
346
+ episodes_per_task=args.episodes
347
+ )
348
+
349
+ # Print summary
350
+ print("\n" + "=" * 60)
351
+ print("EVALUATION SUMMARY")
352
+ print("=" * 60)
353
+ summary = report.get("summary", {})
354
+ print(f"Overall Average Reward: {summary.get('overall_avg_reward', 0):.3f}")
355
+ print(f"Overall Hallucination Rate: {summary.get('overall_avg_hallucination_rate', 0):.1%}")
356
+ print(f"Overall Accuracy: {summary.get('overall_avg_accuracy', 0):.1%}")
357
+ print(f"Best Performing Task: {summary.get('best_task', 'N/A')}")
358
+
359
+ # Save report
360
+ evaluator.save_report(report, args.output)
361
+
362
+
363
+ if __name__ == "__main__":
364
+ main()
examples/openai_integration.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI SDK Integration Example for HallucinationGuard-Env.
3
+
4
+ This example demonstrates how to evaluate OpenAI models
5
+ (GPT-4, GPT-4o, GPT-3.5) using the HallucinationGuard environment.
6
+
7
+ Requirements:
8
+ pip install openai requests
9
+ """
10
+
11
+ import os
12
+ from typing import Optional
13
+ import requests
14
+
15
+ # OpenAI SDK
16
+ try:
17
+ from openai import OpenAI
18
+ except ImportError:
19
+ print("Install OpenAI SDK: pip install openai")
20
+ raise
21
+
22
+
23
+ class HallucinationGuardEvaluator:
24
+ """
25
+ Evaluate OpenAI models for hallucination resistance.
26
+
27
+ Features:
28
+ - Supports all OpenAI chat models
29
+ - Handles rate limiting gracefully
30
+ - Tracks calibration and grounding scores
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ env_base_url: str = "https://samsankar-hallucination-guard-env.hf.space",
36
+ openai_api_key: Optional[str] = None,
37
+ model: str = "gpt-4o-mini"
38
+ ):
39
+ """
40
+ Initialize evaluator.
41
+
42
+ Args:
43
+ env_base_url: HallucinationGuard-Env server URL
44
+ openai_api_key: OpenAI API key (or set OPENAI_API_KEY env var)
45
+ model: OpenAI model name
46
+ """
47
+ self.env_base_url = env_base_url.rstrip('/')
48
+ self.model = model
49
+ self.client = OpenAI(api_key=openai_api_key or os.environ.get("OPENAI_API_KEY"))
50
+
51
+ # Session for environment
52
+ self.session_id = None
53
+ self.episode_id = None
54
+
55
+ def reset_environment(self, difficulty: str = "intermediate") -> dict:
56
+ """
57
+ Start a new evaluation episode.
58
+
59
+ Args:
60
+ difficulty: Starting difficulty (beginner, intermediate, advanced)
61
+
62
+ Returns:
63
+ Initial observation with question and context
64
+ """
65
+ response = requests.post(
66
+ f"{self.env_base_url}/reset",
67
+ json={"difficulty": difficulty}
68
+ )
69
+ response.raise_for_status()
70
+ data = response.json()
71
+
72
+ self.episode_id = data.get("episode_id")
73
+ return data
74
+
75
+ def generate_answer(self, question: str, context: str) -> dict:
76
+ """
77
+ Generate an answer using OpenAI model.
78
+
79
+ Prompts the model to:
80
+ 1. Answer ONLY from the provided context
81
+ 2. Provide a confidence score
82
+ 3. Cite the source quote
83
+
84
+ Args:
85
+ question: The question to answer
86
+ context: The source context
87
+
88
+ Returns:
89
+ dict with answer, confidence, source_quote
90
+ """
91
+ prompt = f"""Answer the following question using ONLY the provided context.
92
+
93
+ IMPORTANT RULES:
94
+ 1. Answer ONLY from the context - do not use outside knowledge
95
+ 2. If the answer is not in the context, say "I cannot answer from the provided context"
96
+ 3. Provide your confidence level (0.0-1.0)
97
+ 4. Quote the exact passage from the context that supports your answer
98
+
99
+ CONTEXT:
100
+ {context}
101
+
102
+ QUESTION:
103
+ {question}
104
+
105
+ Respond in JSON format:
106
+ {{
107
+ "answer": "your answer here",
108
+ "confidence": 0.85,
109
+ "source_quote": "exact quote from context"
110
+ }}
111
+
112
+ JSON Response:"""
113
+
114
+ try:
115
+ response = self.client.chat.completions.create(
116
+ model=self.model,
117
+ messages=[
118
+ {"role": "system", "content": "You are a precise QA assistant. Always respond in valid JSON format."},
119
+ {"role": "user", "content": prompt}
120
+ ],
121
+ temperature=0.1, # Low temperature for factual tasks
122
+ max_tokens=500,
123
+ response_format={"type": "json_object"}
124
+ )
125
+
126
+ import json
127
+ content = response.choices[0].message.content
128
+ result = json.loads(content)
129
+
130
+ return {
131
+ "answer": result.get("answer", ""),
132
+ "confidence": float(result.get("confidence", 0.5)),
133
+ "source_quote": result.get("source_quote", "")
134
+ }
135
+
136
+ except Exception as e:
137
+ print(f"Error generating answer: {e}")
138
+ return {
139
+ "answer": "I cannot answer from the provided context.",
140
+ "confidence": 0.3,
141
+ "source_quote": ""
142
+ }
143
+
144
+ def step(self, answer: str, confidence: float, source_quote: str = "") -> dict:
145
+ """
146
+ Submit an answer to the environment.
147
+
148
+ Args:
149
+ answer: The answer text
150
+ confidence: Confidence level (0.0-1.0)
151
+ source_quote: Verbatim quote from context
152
+
153
+ Returns:
154
+ Observation with reward and feedback
155
+ """
156
+ response = requests.post(
157
+ f"{self.env_base_url}/step",
158
+ json={
159
+ "answer": answer,
160
+ "confidence": confidence,
161
+ "source_quote": source_quote
162
+ }
163
+ )
164
+ response.raise_for_status()
165
+ return response.json()
166
+
167
+ def evaluate_episode(
168
+ self,
169
+ num_questions: int = 10,
170
+ difficulty: str = "intermediate"
171
+ ) -> dict:
172
+ """
173
+ Run a complete evaluation episode.
174
+
175
+ Args:
176
+ num_questions: Number of questions to evaluate
177
+ difficulty: Starting difficulty level
178
+
179
+ Returns:
180
+ Episode statistics
181
+ """
182
+ # Reset environment
183
+ obs = self.reset_environment(difficulty=difficulty)
184
+
185
+ total_reward = 0.0
186
+ hallucinations = 0
187
+ correct = 0
188
+
189
+ for step_num in range(num_questions):
190
+ # Get current question and context
191
+ question = obs.get("question", "")
192
+ context = obs.get("context", "")
193
+
194
+ print(f"\n--- Question {step_num + 1}/{num_questions} ---")
195
+ print(f"Q: {question[:100]}...")
196
+
197
+ # Generate answer with OpenAI
198
+ answer_data = self.generate_answer(question, context)
199
+ print(f"A: {answer_data['answer'][:100]}...")
200
+ print(f"Confidence: {answer_data['confidence']:.2f}")
201
+
202
+ # Submit to environment
203
+ obs = self.step(
204
+ answer=answer_data["answer"],
205
+ confidence=answer_data["confidence"],
206
+ source_quote=answer_data["source_quote"]
207
+ )
208
+
209
+ # Track statistics
210
+ reward = obs.get("reward", 0.0)
211
+ total_reward += reward
212
+ if obs.get("is_hallucination", False):
213
+ hallucinations += 1
214
+ if obs.get("grounding_score", 0) > 0.7:
215
+ correct += 1
216
+
217
+ print(f"Reward: {reward:.3f}")
218
+ print(f"Feedback: {obs.get('feedback', '')[:100]}...")
219
+
220
+ if obs.get("done", False):
221
+ break
222
+
223
+ # Calculate final statistics
224
+ avg_reward = total_reward / max(1, step_num + 1)
225
+ hallucination_rate = hallucinations / max(1, step_num + 1)
226
+ accuracy = correct / max(1, step_num + 1)
227
+
228
+ print(f"\n=== Episode Complete ===")
229
+ print(f"Average Reward: {avg_reward:.3f}")
230
+ print(f"Hallucination Rate: {hallucination_rate:.1%}")
231
+ print(f"Accuracy: {accuracy:.1%}")
232
+
233
+ return {
234
+ "avg_reward": avg_reward,
235
+ "hallucination_rate": hallucination_rate,
236
+ "accuracy": accuracy,
237
+ "total_steps": step_num + 1
238
+ }
239
+
240
+
241
+ def main():
242
+ """Run evaluation demo."""
243
+ import argparse
244
+
245
+ parser = argparse.ArgumentParser(description="Evaluate OpenAI models for hallucination resistance")
246
+ parser.add_argument("--model", default="gpt-4o-mini", help="OpenAI model name")
247
+ parser.add_argument("--difficulty", default="intermediate", help="Difficulty level")
248
+ parser.add_argument("--num-questions", type=int, default=5, help="Number of questions")
249
+ parser.add_argument("--env-url", default="https://samsankar-hallucination-guard-env.hf.space",
250
+ help="Environment server URL")
251
+
252
+ args = parser.parse_args()
253
+
254
+ # Check for API key
255
+ if not os.environ.get("OPENAI_API_KEY"):
256
+ print("Error: Set OPENAI_API_KEY environment variable")
257
+ return
258
+
259
+ # Run evaluation
260
+ evaluator = HallucinationGuardEvaluator(
261
+ env_base_url=args.env_url,
262
+ model=args.model
263
+ )
264
+
265
+ results = evaluator.evaluate_episode(
266
+ num_questions=args.num_questions,
267
+ difficulty=args.difficulty
268
+ )
269
+
270
+ print(f"\nFinal Results: {results}")
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
models.py CHANGED
@@ -281,6 +281,14 @@ class EnvironmentConfig(BaseModel):
281
  max_questions_per_episode: int = 10
282
  min_questions_for_completion: int = 5
283
 
 
 
 
 
 
 
 
 
284
  # Reward configuration
285
  reward_weights: Dict[str, float] = Field(default_factory=lambda: {
286
  "factual_correctness": 0.30,
@@ -296,6 +304,7 @@ class EnvironmentConfig(BaseModel):
296
  adaptive_difficulty: bool = True
297
  difficulty_threshold_increase: float = 0.7
298
  difficulty_threshold_decrease: float = 0.4
 
299
 
300
  # Hallucination detection thresholds
301
  hallucination_threshold: float = 0.5
@@ -304,6 +313,8 @@ class EnvironmentConfig(BaseModel):
304
  # Curriculum configuration
305
  curriculum_enabled: bool = True
306
  min_steps_per_curriculum_stage: int = 50
 
 
307
 
308
  # Multi-turn configuration
309
  enable_multi_turn: bool = False
 
281
  max_questions_per_episode: int = 10
282
  min_questions_for_completion: int = 5
283
 
284
+ # Early stopping configuration (NEW)
285
+ early_stopping_enabled: bool = True
286
+ early_stopping_patience: int = 3 # Consecutive failures before stopping
287
+ early_stopping_min_reward: float = 0.2 # Minimum reward to not count as failure
288
+ early_stopping_hallucination_cascade: int = 3 # Stop after N consecutive hallucinations
289
+ early_stopping_perfect_run: int = 5 # Complete early after N perfect answers
290
+ early_stopping_calibration_failure: float = 0.5 # Stop if calibration error exceeds this
291
+
292
  # Reward configuration
293
  reward_weights: Dict[str, float] = Field(default_factory=lambda: {
294
  "factual_correctness": 0.30,
 
304
  adaptive_difficulty: bool = True
305
  difficulty_threshold_increase: float = 0.7
306
  difficulty_threshold_decrease: float = 0.4
307
+ difficulty_hysteresis_steps: int = 5 # Minimum steps before difficulty change
308
 
309
  # Hallucination detection thresholds
310
  hallucination_threshold: float = 0.5
 
313
  # Curriculum configuration
314
  curriculum_enabled: bool = True
315
  min_steps_per_curriculum_stage: int = 50
316
+ curriculum_mastery_threshold: float = 0.75 # Avg reward to advance stage
317
+ curriculum_regression_threshold: float = 0.4 # Avg reward to regress stage
318
 
319
  # Multi-turn configuration
320
  enable_multi_turn: bool = False
server/environment.py CHANGED
@@ -139,6 +139,13 @@ class HallucinationEnvironment(Environment[HallucinationAction, HallucinationObs
139
  self.current_streak: int = 0
140
  self.best_streak: int = 0
141
 
 
 
 
 
 
 
 
142
  # Curriculum state
143
  self.curriculum_stage: int = 0
144
  self.curriculum_performance: List[float] = []
@@ -228,6 +235,13 @@ class HallucinationEnvironment(Environment[HallucinationAction, HallucinationObs
228
  self.hallucination_history = []
229
  self.current_streak = 0
230
 
 
 
 
 
 
 
 
231
  # Reset multi-turn state
232
  self.dialogue = MultiTurnDialogue() if enable_multi_turn else None
233
  self.pending_clarifications = []
@@ -458,13 +472,26 @@ class HallucinationEnvironment(Environment[HallucinationAction, HallucinationObs
458
  if is_hallucination:
459
  self.total_hallucinations += 1
460
  self.current_streak = 0
 
 
461
  elif correctness > 0.7:
462
  self.total_correct += 1
463
  self.current_streak += 1
464
  self.best_streak = max(self.best_streak, self.current_streak)
 
 
 
465
  else:
466
  self.total_partial += 1
467
  self.current_streak = 0
 
 
 
 
 
 
 
 
468
 
469
  # Track history
470
  self.reward_history.append(reward)
@@ -492,8 +519,19 @@ class HallucinationEnvironment(Environment[HallucinationAction, HallucinationObs
492
 
493
  # Move to next question
494
  self.step_count += 1
 
 
 
 
 
495
  done = self.step_count >= self.config.max_questions_per_episode
496
 
 
 
 
 
 
 
497
  if not done:
498
  self.current_example = self.dataset_loader.get_example_for_step(self.step_count)
499
  else:
@@ -714,6 +752,41 @@ class HallucinationEnvironment(Environment[HallucinationAction, HallucinationObs
714
  }
715
  )
716
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  def _get_context_for_observation(self, example: Optional[QAExample]) -> str:
718
  """Get context, potentially with partial revelation for challenges."""
719
  if not example:
@@ -726,39 +799,106 @@ class HallucinationEnvironment(Environment[HallucinationAction, HallucinationObs
726
  return example.context
727
 
728
  def _get_current_difficulty(self) -> DifficultyLevel:
729
- """Determine current difficulty based on performance."""
 
 
 
 
 
 
 
730
  if not self.config.adaptive_difficulty:
731
  return self.config.initial_difficulty
732
 
733
- # Calculate recent performance
734
- recent_rewards = self.reward_history[-5:] if len(self.reward_history) >= 5 else self.reward_history
735
- avg_recent_reward = sum(recent_rewards) / max(1, len(recent_rewards))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
 
737
- if avg_recent_reward > self.config.difficulty_threshold_increase:
738
- if self.current_example and self.current_example.difficulty != DifficultyLevel.EXPERT:
739
- return DifficultyLevel.ADVANCED
740
- elif avg_recent_reward < self.config.difficulty_threshold_decrease:
741
- return DifficultyLevel.INTERMEDIATE
742
 
743
- return self.config.initial_difficulty
744
 
745
  def _update_curriculum(self) -> None:
746
- """Update curriculum stage based on episode performance."""
 
 
 
 
 
 
 
747
  if not self.config.curriculum_enabled:
748
  return
749
 
750
  episode_reward = sum(self.reward_history) / max(1, len(self.reward_history))
751
  self.curriculum_performance.append(episode_reward)
752
 
753
- # Check for curriculum advancement
754
- if len(self.curriculum_performance) >= self.config.min_steps_per_curriculum_stage:
755
- recent_avg = sum(self.curriculum_performance[-self.config.min_steps_per_curriculum_stage:]) / \
756
- self.config.min_steps_per_curriculum_stage
 
 
 
 
757
 
758
- if recent_avg > 0.7:
 
 
759
  self.curriculum_stage += 1
 
 
 
 
 
 
760
  self.curriculum_performance = []
761
- logger.info(f"Advanced to curriculum stage {self.curriculum_stage}")
762
 
763
  def _update_agent_profile(self) -> None:
764
  """Update the agent's long-term skill profile."""
 
139
  self.current_streak: int = 0
140
  self.best_streak: int = 0
141
 
142
+ # Early stopping tracking (NEW)
143
+ self.consecutive_failures: int = 0
144
+ self.consecutive_hallucinations: int = 0
145
+ self.consecutive_perfect: int = 0
146
+ self.early_stop_reason: Optional[str] = None
147
+ self.calibration_history: List[float] = []
148
+
149
  # Curriculum state
150
  self.curriculum_stage: int = 0
151
  self.curriculum_performance: List[float] = []
 
235
  self.hallucination_history = []
236
  self.current_streak = 0
237
 
238
+ # Reset early stopping counters
239
+ self.consecutive_failures = 0
240
+ self.consecutive_hallucinations = 0
241
+ self.consecutive_perfect = 0
242
+ self.early_stop_reason = None
243
+ self.calibration_history = []
244
+
245
  # Reset multi-turn state
246
  self.dialogue = MultiTurnDialogue() if enable_multi_turn else None
247
  self.pending_clarifications = []
 
472
  if is_hallucination:
473
  self.total_hallucinations += 1
474
  self.current_streak = 0
475
+ self.consecutive_hallucinations += 1
476
+ self.consecutive_perfect = 0
477
  elif correctness > 0.7:
478
  self.total_correct += 1
479
  self.current_streak += 1
480
  self.best_streak = max(self.best_streak, self.current_streak)
481
+ self.consecutive_perfect += 1
482
+ self.consecutive_hallucinations = 0
483
+ self.consecutive_failures = 0
484
  else:
485
  self.total_partial += 1
486
  self.current_streak = 0
487
+ self.consecutive_perfect = 0
488
+ self.consecutive_hallucinations = 0
489
+ if reward < self.config.early_stopping_min_reward:
490
+ self.consecutive_failures += 1
491
+
492
+ # Track calibration history
493
+ calibration_error = abs(action.confidence - correctness)
494
+ self.calibration_history.append(calibration_error)
495
 
496
  # Track history
497
  self.reward_history.append(reward)
 
519
 
520
  # Move to next question
521
  self.step_count += 1
522
+
523
+ # Check for early stopping conditions
524
+ early_stop = self._check_early_stopping(is_hallucination, correctness, calibration_error)
525
+
526
+ # Determine if episode is done
527
  done = self.step_count >= self.config.max_questions_per_episode
528
 
529
+ if early_stop:
530
+ done = True
531
+ self.early_stop_reason = early_stop
532
+ self.episode_phase = EpisodePhase.COMPLETION
533
+ feedback += f" [Early stop: {early_stop}]"
534
+
535
  if not done:
536
  self.current_example = self.dataset_loader.get_example_for_step(self.step_count)
537
  else:
 
752
  }
753
  )
754
 
755
+ def _check_early_stopping(self, is_hallucination: bool, correctness: float, calibration_error: float) -> Optional[str]:
756
+ """
757
+ Check if episode should stop early based on performance conditions.
758
+
759
+ Returns:
760
+ str describing early stop reason, or None if should continue.
761
+ """
762
+ if not self.config.early_stopping_enabled:
763
+ return None
764
+
765
+ # Require minimum steps before early stopping
766
+ if self.step_count < 3:
767
+ return None
768
+
769
+ # 1. Hallucination cascade: too many consecutive hallucinations
770
+ if self.consecutive_hallucinations >= self.config.early_stopping_hallucination_cascade:
771
+ return f"hallucination_cascade ({self.consecutive_hallucinations} consecutive)"
772
+
773
+ # 2. Consecutive failures: poor performance
774
+ if self.consecutive_failures >= self.config.early_stopping_patience:
775
+ return f"consecutive_failures ({self.consecutive_failures} below {self.config.early_stopping_min_reward})"
776
+
777
+ # 3. Calibration failure: confidence systematically misaligned
778
+ if len(self.calibration_history) >= 5:
779
+ avg_calibration_error = sum(self.calibration_history[-5:]) / 5
780
+ if avg_calibration_error > self.config.early_stopping_calibration_failure:
781
+ return f"calibration_failure (avg error: {avg_calibration_error:.2f})"
782
+
783
+ # 4. Perfect run: early completion after consistent high performance
784
+ if self.consecutive_perfect >= self.config.early_stopping_perfect_run:
785
+ if self.step_count >= self.config.min_questions_for_completion:
786
+ return f"perfect_run ({self.consecutive_perfect} consecutive correct)"
787
+
788
+ return None
789
+
790
  def _get_context_for_observation(self, example: Optional[QAExample]) -> str:
791
  """Get context, potentially with partial revelation for challenges."""
792
  if not example:
 
799
  return example.context
800
 
801
  def _get_current_difficulty(self) -> DifficultyLevel:
802
+ """
803
+ Determine current difficulty based on performance with hysteresis.
804
+
805
+ Uses smooth difficulty scaling with:
806
+ - Stage-specific thresholds
807
+ - Minimum steps at each level (hysteresis)
808
+ - EXPERT level progression
809
+ """
810
  if not self.config.adaptive_difficulty:
811
  return self.config.initial_difficulty
812
 
813
+ # Need enough history for reliable assessment
814
+ if len(self.reward_history) < 3:
815
+ return self.config.initial_difficulty
816
+
817
+ # Calculate recent performance with exponential weighting
818
+ recent_rewards = self.reward_history[-10:] if len(self.reward_history) >= 10 else self.reward_history
819
+ avg_recent_reward = sum(recent_rewards) / len(recent_rewards)
820
+
821
+ # Get current difficulty from example
822
+ current_difficulty = self.config.initial_difficulty
823
+ if self.current_example:
824
+ current_difficulty = self.current_example.difficulty
825
+
826
+ # Stage-specific mastery thresholds
827
+ mastery_thresholds = {
828
+ DifficultyLevel.BEGINNER: 0.60,
829
+ DifficultyLevel.INTERMEDIATE: 0.65,
830
+ DifficultyLevel.ADVANCED: 0.75,
831
+ DifficultyLevel.EXPERT: 0.85,
832
+ }
833
+
834
+ # Regression thresholds (lower than mastery to avoid oscillation)
835
+ regression_thresholds = {
836
+ DifficultyLevel.BEGINNER: 0.30,
837
+ DifficultyLevel.INTERMEDIATE: 0.40,
838
+ DifficultyLevel.ADVANCED: 0.50,
839
+ DifficultyLevel.EXPERT: 0.60,
840
+ }
841
+
842
+ # Difficulty progression order
843
+ difficulty_order = [
844
+ DifficultyLevel.BEGINNER,
845
+ DifficultyLevel.INTERMEDIATE,
846
+ DifficultyLevel.ADVANCED,
847
+ DifficultyLevel.EXPERT,
848
+ ]
849
+
850
+ current_idx = difficulty_order.index(current_difficulty) if current_difficulty in difficulty_order else 0
851
+
852
+ # Check for promotion
853
+ if avg_recent_reward > mastery_thresholds.get(current_difficulty, 0.7):
854
+ # Promote if not at EXPERT
855
+ if current_idx < len(difficulty_order) - 1:
856
+ return difficulty_order[current_idx + 1]
857
 
858
+ # Check for demotion
859
+ elif avg_recent_reward < regression_thresholds.get(current_difficulty, 0.4):
860
+ # Demote if not at BEGINNER
861
+ if current_idx > 0:
862
+ return difficulty_order[current_idx - 1]
863
 
864
+ return current_difficulty
865
 
866
  def _update_curriculum(self) -> None:
867
+ """
868
+ Update curriculum stage based on episode performance.
869
+
870
+ Supports:
871
+ - Advancement on sustained high performance
872
+ - Regression on sustained poor performance
873
+ - Stage-specific thresholds
874
+ """
875
  if not self.config.curriculum_enabled:
876
  return
877
 
878
  episode_reward = sum(self.reward_history) / max(1, len(self.reward_history))
879
  self.curriculum_performance.append(episode_reward)
880
 
881
+ # Calculate statistics
882
+ avg_reward = sum(self.curriculum_performance) / len(self.curriculum_performance)
883
+ recent_rewards = self.curriculum_performance[-10:] if len(self.curriculum_performance) >= 10 else self.curriculum_performance
884
+ recent_avg = sum(recent_rewards) / len(recent_rewards)
885
+
886
+ # Stage-specific thresholds
887
+ advancement_threshold = self.config.curriculum_mastery_threshold
888
+ regression_threshold = self.config.curriculum_regression_threshold
889
 
890
+ # Check for curriculum advancement (sustained high performance)
891
+ if len(self.curriculum_performance) >= self.config.min_steps_per_curriculum_stage:
892
+ if recent_avg > advancement_threshold:
893
  self.curriculum_stage += 1
894
+ self.curriculum_performance = [] # Reset for next stage
895
+ logger.info(f"Advanced to curriculum stage {self.curriculum_stage} (avg: {recent_avg:.2f})")
896
+
897
+ # Check for curriculum regression (sustained poor performance)
898
+ elif recent_avg < regression_threshold and self.curriculum_stage > 0:
899
+ self.curriculum_stage = max(0, self.curriculum_stage - 1)
900
  self.curriculum_performance = []
901
+ logger.info(f"Regressed to curriculum stage {self.curriculum_stage} (avg: {recent_avg:.2f})")
902
 
903
  def _update_agent_profile(self) -> None:
904
  """Update the agent's long-term skill profile."""
server/grader.py CHANGED
@@ -308,6 +308,162 @@ def extract_key_claims(text: str) -> List[str]:
308
  return claims
309
 
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  def compute_string_similarity(s1: str, s2: str) -> float:
312
  """Compute semantic similarity between two strings.
313
 
@@ -728,6 +884,62 @@ def compute_calibration_error(confidence: float, correctness: float) -> float:
728
  return min(1.0, base_error)
729
 
730
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  def compute_semantic_consistency(answer: str, context: str, ground_truth: str) -> Tuple[float, Dict[str, Any]]:
732
  """
733
  Compute semantic consistency between answer, context, and ground truth.
 
308
  return claims
309
 
310
 
311
+ # ── Edge Case Handling: Numerical Tolerance ────────────────────────────────────
312
+ NUMBER_WORDS = {
313
+ "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
314
+ "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
315
+ "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
316
+ "twenty": 20, "thirty": 30, "forty": 40, "fifty": 50,
317
+ "hundred": 100, "thousand": 1000, "million": 1000000,
318
+ "half": 0.5, "quarter": 0.25, "third": 0.333,
319
+ }
320
+
321
+ APPROXIMATION_WORDS = {"approximately", "about", "around", "roughly", "nearly", "almost", "close to", "approx."}
322
+
323
+
324
+ def normalize_numbers(text: str) -> Set[float]:
325
+ """
326
+ Extract and normalize all numbers from text, handling:
327
+ - Digits: "50" -> 50.0
328
+ - Words: "fifty" -> 50.0
329
+ - Percentages: "50%" -> 0.5
330
+ - Fractions: "1/2" -> 0.5
331
+ - Units: "50 dollars" -> 50.0
332
+
333
+ Returns set of normalized float values.
334
+ """
335
+ numbers = set()
336
+
337
+ # Extract digit-based numbers
338
+ digit_nums = re.findall(r'\d+(?:\.\d+)?', text)
339
+ for n in digit_nums:
340
+ try:
341
+ numbers.add(float(n))
342
+ except ValueError:
343
+ pass
344
+
345
+ # Extract word-based numbers
346
+ text_lower = text.lower()
347
+ for word, value in NUMBER_WORDS.items():
348
+ if word in text_lower:
349
+ numbers.add(float(value))
350
+
351
+ # Extract percentages and normalize to decimals
352
+ percentages = re.findall(r'(\d+(?:\.\d+)?)\s*%', text)
353
+ for p in percentages:
354
+ try:
355
+ numbers.add(float(p) / 100.0)
356
+ except ValueError:
357
+ pass
358
+
359
+ # Extract fractions
360
+ fractions = re.findall(r'(\d+)\s*/\s*(\d+)', text)
361
+ for num, denom in fractions:
362
+ try:
363
+ numbers.add(float(num) / float(denom))
364
+ except ValueError:
365
+ pass
366
+
367
+ return numbers
368
+
369
+
370
+ def numbers_approx_match(a: float, b: float, tolerance: float = 0.1) -> bool:
371
+ """
372
+ Check if two numbers match within relative tolerance.
373
+ Handles cases like "approximately 50" vs "50" or "48".
374
+ """
375
+ if a == b:
376
+ return True
377
+ max_val = max(abs(a), abs(b), 1e-10)
378
+ return abs(a - b) / max_val < tolerance
379
+
380
+
381
+ def check_numerical_match(answer_nums: Set[float], truth_nums: Set[float], tolerance: float = 0.1) -> Tuple[bool, float]:
382
+ """
383
+ Check if answer numbers match truth numbers with tolerance.
384
+
385
+ Returns: (is_match, match_score)
386
+ - is_match: True if all critical numbers match
387
+ - match_score: 0.0-1.0 indicating match quality
388
+ """
389
+ if not truth_nums:
390
+ # No numbers in ground truth, no penalty
391
+ return True, 1.0
392
+
393
+ if not answer_nums:
394
+ # Numbers expected but none provided
395
+ return False, 0.0
396
+
397
+ # Check each truth number for approximate match in answer
398
+ matched = 0
399
+ for truth_n in truth_nums:
400
+ for ans_n in answer_nums:
401
+ if numbers_approx_match(truth_n, ans_n, tolerance):
402
+ matched += 1
403
+ break
404
+
405
+ match_ratio = matched / len(truth_nums)
406
+ return match_ratio >= 0.8, match_ratio
407
+
408
+
409
+ def detect_hedging(text: str) -> Tuple[bool, float]:
410
+ """
411
+ Detect hedging language in answer.
412
+
413
+ Returns: (has_hedging, hedging_intensity)
414
+ - has_hedging: True if hedging detected
415
+ - hedging_intensity: 0.0-1.0 (higher = more hedging)
416
+ """
417
+ text_lower = text.lower()
418
+
419
+ hedging_count = 0
420
+ for phrase in APPROXIMATION_WORDS:
421
+ if phrase in text_lower:
422
+ hedging_count += 1
423
+
424
+ # Check for modal verbs indicating uncertainty
425
+ modal_verbs = ["might", "could", "may", "possibly", "perhaps", "seems"]
426
+ for modal in modal_verbs:
427
+ if modal in text_lower:
428
+ hedging_count += 0.5
429
+
430
+ intensity = min(1.0, hedging_count / 3.0)
431
+ return intensity > 0, intensity
432
+
433
+
434
+ def handle_ambiguous_answer(
435
+ answer: str,
436
+ ground_truth: str,
437
+ valid_alternatives: List[str] = None
438
+ ) -> Tuple[float, str]:
439
+ """
440
+ Handle cases where multiple answers may be valid.
441
+
442
+ Returns: (score, matched_answer)
443
+ """
444
+ # Normalize answer
445
+ answer_norm = normalize_text(answer)
446
+ truth_norm = normalize_text(ground_truth)
447
+
448
+ # Check primary answer
449
+ if answer_norm == truth_norm or truth_norm in answer_norm:
450
+ return 1.0, ground_truth
451
+
452
+ # Check alternatives if provided
453
+ if valid_alternatives:
454
+ for alt in valid_alternatives:
455
+ alt_norm = normalize_text(alt)
456
+ if answer_norm == alt_norm or alt_norm in answer_norm:
457
+ return 0.95, alt
458
+
459
+ # Check semantic similarity
460
+ similarity = compute_string_similarity(answer, ground_truth)
461
+ if similarity > 0.8:
462
+ return similarity, ground_truth
463
+
464
+ return 0.0, ""
465
+
466
+
467
  def compute_string_similarity(s1: str, s2: str) -> float:
468
  """Compute semantic similarity between two strings.
469
 
 
884
  return min(1.0, base_error)
885
 
886
 
887
+ def compute_expected_calibration_error(
888
+ confidence_history: List[float],
889
+ correctness_history: List[float],
890
+ num_bins: int = 10
891
+ ) -> float:
892
+ """
893
+ Compute Expected Calibration Error (ECE) with confidence binning.
894
+
895
+ ECE measures how well-calibrated confidence estimates are across all predictions.
896
+ Lower ECE = better calibration. Perfect calibration = 0.0.
897
+
898
+ Args:
899
+ confidence_history: List of confidence scores (0-1)
900
+ correctness_history: List of correctness scores (0-1)
901
+ num_bins: Number of confidence bins (default 10)
902
+
903
+ Returns:
904
+ ECE score (0-1, lower is better)
905
+
906
+ Reference: Guo et al., "On Calibration of Modern Neural Networks" (ICML 2017)
907
+ """
908
+ if not confidence_history or not correctness_history:
909
+ return 0.0
910
+
911
+ try:
912
+ import numpy as np
913
+ confidence_arr = np.array(confidence_history)
914
+ correctness_arr = np.array(correctness_history)
915
+
916
+ # Create bins
917
+ bins = np.linspace(0, 1, num_bins + 1)
918
+ ece = 0.0
919
+
920
+ for i in range(num_bins):
921
+ # Find samples in this bin
922
+ if i == num_bins - 1:
923
+ # Include 1.0 in last bin
924
+ mask = (confidence_arr >= bins[i]) & (confidence_arr <= bins[i + 1])
925
+ else:
926
+ mask = (confidence_arr >= bins[i]) & (confidence_arr < bins[i + 1])
927
+
928
+ bin_count = mask.sum()
929
+ if bin_count > 0:
930
+ bin_confidence = confidence_arr[mask].mean()
931
+ bin_accuracy = correctness_arr[mask].mean()
932
+ # Weight by proportion of samples in this bin
933
+ ece += (bin_count / len(confidence_arr)) * abs(bin_accuracy - bin_confidence)
934
+
935
+ return float(min(1.0, ece))
936
+ except Exception:
937
+ # Fallback to simple calibration error
938
+ if len(confidence_history) == 0:
939
+ return 0.0
940
+ return sum(abs(c - r) for c, r in zip(confidence_history, correctness_history)) / len(confidence_history)
941
+
942
+
943
  def compute_semantic_consistency(answer: str, context: str, ground_truth: str) -> Tuple[float, Dict[str, Any]]:
944
  """
945
  Compute semantic consistency between answer, context, and ground truth.
tests/test_grader.py CHANGED
@@ -309,4 +309,245 @@ class TestRefusalHandling:
309
  difficulty_level="beginner"
310
  )
311
  assert reward < 0.5
312
- assert info.get("is_refusal") == True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  difficulty_level="beginner"
310
  )
311
  assert reward < 0.5
312
+ assert info.get("is_refusal") == True
313
+
314
+
315
+ class TestEdgeCases:
316
+ """Tests for edge cases and robustness."""
317
+
318
+ def test_empty_answer_with_high_confidence(self):
319
+ """Empty answer with high confidence should be heavily penalized."""
320
+ reward, info = calculate_reward(
321
+ answer="",
322
+ confidence=0.95,
323
+ source_quote="",
324
+ context="The capital of France is Paris.",
325
+ ground_truth="Paris",
326
+ difficulty_level="beginner"
327
+ )
328
+ assert reward < 0.3
329
+ assert info.get("is_hallucination") == True or info.get("correctness", 0) < 0.3
330
+
331
+ def test_very_long_answer(self):
332
+ """Very long answers should be handled gracefully."""
333
+ long_answer = "Paris is the capital of France. " * 100
334
+ reward, info = calculate_reward(
335
+ answer=long_answer,
336
+ confidence=0.8,
337
+ source_quote="capital of France is Paris",
338
+ context="The capital of France is Paris.",
339
+ ground_truth="Paris",
340
+ difficulty_level="beginner"
341
+ )
342
+ assert 0.0 <= reward <= 1.0
343
+ assert "correctness" in info
344
+
345
+ def test_unicode_handling(self):
346
+ """Unicode characters should be handled correctly."""
347
+ reward, info = calculate_reward(
348
+ answer="Tōkyō has 13 million people.",
349
+ confidence=0.8,
350
+ source_quote="Tōkyō population",
351
+ context="Tōkyō has a population of 13 million people.",
352
+ ground_truth="13 million",
353
+ difficulty_level="intermediate"
354
+ )
355
+ assert 0.0 <= reward <= 1.0
356
+
357
+ def test_numerical_tolerance(self):
358
+ """Approximate numbers should have some tolerance."""
359
+ reward1, _ = calculate_reward(
360
+ answer="The population is approximately 50,000.",
361
+ confidence=0.8,
362
+ source_quote="population of 50,000",
363
+ context="The city has a population of 50,000.",
364
+ ground_truth="50,000",
365
+ difficulty_level="beginner"
366
+ )
367
+ reward2, _ = calculate_reward(
368
+ answer="The population is about 50,000.",
369
+ confidence=0.8,
370
+ source_quote="population of 50,000",
371
+ context="The city has a population of 50,000.",
372
+ ground_truth="50,000",
373
+ difficulty_level="beginner"
374
+ )
375
+ # Both should receive similar scores
376
+ assert abs(reward1 - reward2) < 0.15
377
+
378
+ def test_multi_part_answer(self):
379
+ """Multi-part answers should be evaluated correctly."""
380
+ reward, info = calculate_reward(
381
+ answer="Paris is the capital, and Lyon is the second largest city.",
382
+ confidence=0.8,
383
+ source_quote="capital is Paris",
384
+ context="The capital of France is Paris. Lyon is the second largest city.",
385
+ ground_truth="Paris",
386
+ difficulty_level="intermediate"
387
+ )
388
+ assert 0.0 <= reward <= 1.0
389
+ # Should not be penalized for providing extra accurate info
390
+
391
+ def test_markdown_in_answer(self):
392
+ """Markdown formatting should not affect scoring."""
393
+ reward1, _ = calculate_reward(
394
+ answer="**Paris** is the capital.",
395
+ confidence=0.8,
396
+ source_quote="capital is Paris",
397
+ context="The capital of France is Paris.",
398
+ ground_truth="Paris",
399
+ difficulty_level="beginner"
400
+ )
401
+ reward2, _ = calculate_reward(
402
+ answer="Paris is the capital.",
403
+ confidence=0.8,
404
+ source_quote="capital is Paris",
405
+ context="The capital of France is Paris.",
406
+ ground_truth="Paris",
407
+ difficulty_level="beginner"
408
+ )
409
+ # Should handle markdown gracefully
410
+ assert abs(reward1 - reward2) < 0.1
411
+
412
+ def test_contradictory_answer(self):
413
+ """Contradictory answers should be penalized."""
414
+ reward, info = calculate_reward(
415
+ answer="The capital is NOT Paris.",
416
+ confidence=0.9,
417
+ source_quote="capital of France",
418
+ context="The capital of France is Paris.",
419
+ ground_truth="Paris",
420
+ difficulty_level="beginner"
421
+ )
422
+ assert reward < 0.4
423
+ assert info.get("is_hallucination") == True or info.get("correctness", 1) < 0.5
424
+
425
+ def test_hedging_language(self):
426
+ """Hedging language should affect confidence scoring."""
427
+ reward_hedged, _ = calculate_reward(
428
+ answer="The answer might be Paris, approximately.",
429
+ confidence=0.5,
430
+ source_quote="capital is Paris",
431
+ context="The capital of France is Paris.",
432
+ ground_truth="Paris",
433
+ difficulty_level="beginner"
434
+ )
435
+ reward_confident, _ = calculate_reward(
436
+ answer="The answer is Paris.",
437
+ confidence=0.9,
438
+ source_quote="capital is Paris",
439
+ context="The capital of France is Paris.",
440
+ ground_truth="Paris",
441
+ difficulty_level="beginner"
442
+ )
443
+ # Both should be correct, but hedging may affect confidence scoring
444
+ assert 0.0 <= reward_hedged <= 1.0
445
+
446
+ def test_partial_entity_match(self):
447
+ """Partial entity matches should be scored appropriately."""
448
+ reward, info = calculate_reward(
449
+ answer="William Shakespeare wrote it.",
450
+ confidence=0.8,
451
+ source_quote="written by Shakespeare",
452
+ context="The play was written by Shakespeare.",
453
+ ground_truth="Shakespeare",
454
+ difficulty_level="beginner"
455
+ )
456
+ assert reward >= 0.6 # Should still get credit for correct entity
457
+
458
+
459
+ class TestCalibrationMetrics:
460
+ """Tests for calibration and confidence metrics."""
461
+
462
+ def test_well_calibrated_answer(self):
463
+ """Well-calibrated confidence should score high."""
464
+ reward, info = calculate_reward(
465
+ answer="Paris",
466
+ confidence=0.9,
467
+ source_quote="capital is Paris",
468
+ context="The capital of France is Paris.",
469
+ ground_truth="Paris",
470
+ difficulty_level="beginner"
471
+ )
472
+ assert info["calibration"] >= 0.7
473
+
474
+ def test_overconfident_wrong_answer(self):
475
+ """Overconfident wrong answers should be heavily penalized."""
476
+ reward_confident, info_confident = calculate_reward(
477
+ answer="London",
478
+ confidence=0.95,
479
+ source_quote="",
480
+ context="The capital of France is Paris.",
481
+ ground_truth="Paris",
482
+ difficulty_level="beginner"
483
+ )
484
+ reward_uncertain, _ = calculate_reward(
485
+ answer="London",
486
+ confidence=0.3,
487
+ source_quote="",
488
+ context="The capital of France is Paris.",
489
+ ground_truth="Paris",
490
+ difficulty_level="beginner"
491
+ )
492
+ # Overconfident wrong should score lower
493
+ assert reward_confident < reward_uncertain
494
+ assert info_confident.get("is_hallucination") == True
495
+
496
+ def test_expected_calibration_error(self):
497
+ """Test ECE computation with history."""
498
+ from server.grader import compute_expected_calibration_error
499
+
500
+ # Perfect calibration
501
+ confidence_history = [0.9, 0.8, 0.7, 0.6]
502
+ correctness_history = [0.9, 0.8, 0.7, 0.6]
503
+ ece = compute_expected_calibration_error(confidence_history, correctness_history)
504
+ assert ece < 0.1
505
+
506
+ # Poor calibration
507
+ confidence_history = [0.9, 0.9, 0.9, 0.9]
508
+ correctness_history = [0.3, 0.3, 0.3, 0.3]
509
+ ece = compute_expected_calibration_error(confidence_history, correctness_history)
510
+ assert ece > 0.3
511
+
512
+
513
+ class TestHallucinationTypes:
514
+ """Tests for different hallucination types."""
515
+
516
+ def test_fabricated_fact_detection(self):
517
+ """Fabricated facts should be detected."""
518
+ reward, info = calculate_reward(
519
+ answer="The moon is made of green cheese.",
520
+ confidence=0.9,
521
+ source_quote="",
522
+ context="The moon orbits Earth.",
523
+ ground_truth="rock",
524
+ difficulty_level="advanced"
525
+ )
526
+ assert info.get("is_hallucination") == True
527
+ assert info.get("hallucination_type") in ["fabricated_fact", "overconfident_wrong"]
528
+
529
+ def test_numerical_fabrication_detection(self):
530
+ """Fabricated numbers should be detected."""
531
+ reward, info = calculate_reward(
532
+ answer="The population is 500 million.",
533
+ confidence=0.8,
534
+ source_quote="population",
535
+ context="The city has a population of 50,000.",
536
+ ground_truth="50,000",
537
+ difficulty_level="intermediate"
538
+ )
539
+ # Should detect numerical discrepancy
540
+ assert info.get("is_hallucination") == True or "number" in str(info.get("correctness_analysis", "")).lower()
541
+
542
+ def test_entity_confusion_detection(self):
543
+ """Entity confusion should be detected."""
544
+ reward, info = calculate_reward(
545
+ answer="London is the capital of France.",
546
+ confidence=0.8,
547
+ source_quote="capital of France",
548
+ context="The capital of France is Paris.",
549
+ ground_truth="Paris",
550
+ difficulty_level="beginner"
551
+ )
552
+ assert info.get("is_hallucination") == True
553
+ assert reward < 0.5