Upload 8 files
Browse files- tests/__init__.py +0 -0
- tests/__pycache__/__init__.cpython-310.pyc +0 -0
- tests/__pycache__/test_endpoints.cpython-310-pytest-8.0.0.pyc +0 -0
- tests/__pycache__/test_grader.cpython-310-pytest-8.0.0.pyc +0 -0
- tests/__pycache__/test_tasks.cpython-310-pytest-8.0.0.pyc +0 -0
- tests/test_endpoints.py +97 -0
- tests/test_grader.py +247 -0
- tests/test_tasks.py +116 -0
tests/__init__.py
ADDED
|
File without changes
|
tests/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (162 Bytes). View file
|
|
|
tests/__pycache__/test_endpoints.cpython-310-pytest-8.0.0.pyc
ADDED
|
Binary file (7.16 kB). View file
|
|
|
tests/__pycache__/test_grader.cpython-310-pytest-8.0.0.pyc
ADDED
|
Binary file (16.8 kB). View file
|
|
|
tests/__pycache__/test_tasks.cpython-310-pytest-8.0.0.pyc
ADDED
|
Binary file (10.9 kB). View file
|
|
|
tests/test_endpoints.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the FastAPI server endpoints."""
|
| 2 |
+
|
| 3 |
+
import sys, os
|
| 4 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Skip entire module if environment can't be created (e.g. missing datasets)
|
| 11 |
+
# This allows CI to run without HF dataset access
|
| 12 |
+
pytestmark = pytest.mark.skipif(
|
| 13 |
+
os.getenv("SKIP_SERVER_TESTS", "1") == "1",
|
| 14 |
+
reason="Set SKIP_SERVER_TESTS=0 to run server tests (requires dataset access)"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TestHealthEndpoint:
|
| 19 |
+
def test_health_returns_200(self):
|
| 20 |
+
from server.app import app
|
| 21 |
+
client = TestClient(app)
|
| 22 |
+
resp = client.get("/health")
|
| 23 |
+
assert resp.status_code == 200
|
| 24 |
+
data = resp.json()
|
| 25 |
+
assert data["status"] == "healthy"
|
| 26 |
+
assert "version" in data
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TestTasksEndpoint:
|
| 30 |
+
def test_tasks_returns_three_tasks(self):
|
| 31 |
+
from server.app import app
|
| 32 |
+
client = TestClient(app)
|
| 33 |
+
resp = client.get("/tasks")
|
| 34 |
+
assert resp.status_code == 200
|
| 35 |
+
data = resp.json()
|
| 36 |
+
assert len(data["tasks"]) == 3
|
| 37 |
+
task_ids = [t["task_id"] for t in data["tasks"]]
|
| 38 |
+
assert "task_1_factual_grounding" in task_ids
|
| 39 |
+
assert "task_2_multi_hop_synthesis" in task_ids
|
| 40 |
+
assert "task_3_adversarial_resistance" in task_ids
|
| 41 |
+
|
| 42 |
+
def test_tasks_has_action_schema(self):
|
| 43 |
+
from server.app import app
|
| 44 |
+
client = TestClient(app)
|
| 45 |
+
resp = client.get("/tasks")
|
| 46 |
+
data = resp.json()
|
| 47 |
+
assert "action_schema" in data
|
| 48 |
+
assert "answer" in data["action_schema"]["properties"]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class TestGraderEndpoint:
|
| 52 |
+
def test_grader_requires_task_id(self):
|
| 53 |
+
from server.app import app
|
| 54 |
+
client = TestClient(app)
|
| 55 |
+
resp = client.post("/grader", json={"step_rewards": [0.5], "step_infos": [{}]})
|
| 56 |
+
assert resp.status_code == 422
|
| 57 |
+
|
| 58 |
+
def test_grader_invalid_task_id(self):
|
| 59 |
+
from server.app import app
|
| 60 |
+
client = TestClient(app)
|
| 61 |
+
resp = client.post("/grader", json={
|
| 62 |
+
"task_id": "nonexistent",
|
| 63 |
+
"step_rewards": [0.5],
|
| 64 |
+
"step_infos": [{}],
|
| 65 |
+
})
|
| 66 |
+
assert resp.status_code == 404
|
| 67 |
+
|
| 68 |
+
def test_grader_returns_score(self):
|
| 69 |
+
from server.app import app
|
| 70 |
+
client = TestClient(app)
|
| 71 |
+
resp = client.post("/grader", json={
|
| 72 |
+
"task_id": "task_1_factual_grounding",
|
| 73 |
+
"step_rewards": [0.7, 0.5, 0.3],
|
| 74 |
+
"step_infos": [
|
| 75 |
+
{"correctness": 0.7, "grounding": 0.6, "calibration": 0.8,
|
| 76 |
+
"hallucination_score": 0.1, "is_hallucination": False},
|
| 77 |
+
{"correctness": 0.5, "grounding": 0.4, "calibration": 0.7,
|
| 78 |
+
"hallucination_score": 0.2, "is_hallucination": False},
|
| 79 |
+
{"correctness": 0.3, "grounding": 0.3, "calibration": 0.6,
|
| 80 |
+
"hallucination_score": 0.5, "is_hallucination": True},
|
| 81 |
+
],
|
| 82 |
+
})
|
| 83 |
+
assert resp.status_code == 200
|
| 84 |
+
data = resp.json()
|
| 85 |
+
assert 0.0 <= data["score"] <= 1.0
|
| 86 |
+
assert "breakdown" in data
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class TestMetadataEndpoint:
|
| 90 |
+
def test_metadata(self):
|
| 91 |
+
from server.app import app
|
| 92 |
+
client = TestClient(app)
|
| 93 |
+
resp = client.get("/metadata")
|
| 94 |
+
assert resp.status_code == 200
|
| 95 |
+
data = resp.json()
|
| 96 |
+
assert data["name"] == "hallucination-guard-env"
|
| 97 |
+
assert "version" in data
|
tests/test_grader.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the 9-component reward system and hallucination detection."""
|
| 2 |
+
|
| 3 |
+
import sys, os
|
| 4 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from server.grader import (
|
| 8 |
+
calculate_reward,
|
| 9 |
+
detect_hallucination_advanced,
|
| 10 |
+
compute_calibration_error,
|
| 11 |
+
is_refusal_answer,
|
| 12 |
+
normalize_text,
|
| 13 |
+
check_quote_in_context_advanced,
|
| 14 |
+
check_factual_accuracy_advanced,
|
| 15 |
+
compute_rouge,
|
| 16 |
+
compute_bertscore,
|
| 17 |
+
HallucinationType,
|
| 18 |
+
HallucinationSeverity,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestRewardRange:
|
| 23 |
+
"""Rewards must always be in [0, 1]."""
|
| 24 |
+
|
| 25 |
+
@pytest.mark.parametrize("difficulty", ["beginner", "intermediate", "advanced", "expert"])
|
| 26 |
+
def test_reward_in_range_correct_answer(self, difficulty):
|
| 27 |
+
reward, info = calculate_reward(
|
| 28 |
+
answer="Paris is the capital of France.",
|
| 29 |
+
confidence=0.9,
|
| 30 |
+
source_quote="Paris is the capital of France.",
|
| 31 |
+
context="Paris is the capital of France. It is located in northern France.",
|
| 32 |
+
ground_truth="Paris",
|
| 33 |
+
difficulty_level=difficulty,
|
| 34 |
+
)
|
| 35 |
+
assert 0.0 <= reward <= 1.0, f"Reward {reward} out of range for {difficulty}"
|
| 36 |
+
|
| 37 |
+
def test_reward_in_range_wrong_answer(self):
|
| 38 |
+
reward, info = calculate_reward(
|
| 39 |
+
answer="London is the capital of France.",
|
| 40 |
+
confidence=0.9,
|
| 41 |
+
source_quote="London is the capital of France.",
|
| 42 |
+
context="Paris is the capital of France.",
|
| 43 |
+
ground_truth="Paris",
|
| 44 |
+
)
|
| 45 |
+
assert 0.0 <= reward <= 1.0
|
| 46 |
+
|
| 47 |
+
def test_reward_in_range_empty_answer(self):
|
| 48 |
+
reward, info = calculate_reward(
|
| 49 |
+
answer="",
|
| 50 |
+
confidence=0.5,
|
| 51 |
+
source_quote="",
|
| 52 |
+
context="Some context here.",
|
| 53 |
+
ground_truth="Some answer",
|
| 54 |
+
)
|
| 55 |
+
assert 0.0 <= reward <= 1.0
|
| 56 |
+
|
| 57 |
+
def test_reward_in_range_refusal(self):
|
| 58 |
+
reward, info = calculate_reward(
|
| 59 |
+
answer="I cannot answer from the provided context.",
|
| 60 |
+
confidence=0.3,
|
| 61 |
+
source_quote="",
|
| 62 |
+
context="Some unrelated context.",
|
| 63 |
+
ground_truth="not mentioned in context",
|
| 64 |
+
)
|
| 65 |
+
assert 0.0 <= reward <= 1.0
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class TestRefusalHandling:
|
| 69 |
+
"""Proper refusals on unanswerable questions should be rewarded."""
|
| 70 |
+
|
| 71 |
+
def test_proper_refusal_rewarded(self):
|
| 72 |
+
reward, info = calculate_reward(
|
| 73 |
+
answer="I cannot answer from the provided context.",
|
| 74 |
+
confidence=0.3,
|
| 75 |
+
source_quote="",
|
| 76 |
+
context="The sky is blue.",
|
| 77 |
+
ground_truth="not mentioned in context",
|
| 78 |
+
)
|
| 79 |
+
assert reward >= 0.5, f"Proper refusal should get reward >= 0.5, got {reward}"
|
| 80 |
+
assert info.get("is_refusal") is True
|
| 81 |
+
|
| 82 |
+
def test_underconfident_refusal_penalized(self):
|
| 83 |
+
"""Refusing when the answer IS in context should be penalized."""
|
| 84 |
+
reward, info = calculate_reward(
|
| 85 |
+
answer="I cannot determine the answer from the context.",
|
| 86 |
+
confidence=0.3,
|
| 87 |
+
source_quote="",
|
| 88 |
+
context="The capital of France is Paris.",
|
| 89 |
+
ground_truth="Paris",
|
| 90 |
+
)
|
| 91 |
+
assert reward <= 0.4, f"Underconfident refusal should be penalized, got {reward}"
|
| 92 |
+
|
| 93 |
+
def test_overconfident_refusal(self):
|
| 94 |
+
"""High confidence refusal on answerable question should be penalized."""
|
| 95 |
+
reward, info = calculate_reward(
|
| 96 |
+
answer="I don't know the answer.",
|
| 97 |
+
confidence=0.9,
|
| 98 |
+
source_quote="",
|
| 99 |
+
context="The capital of France is Paris.",
|
| 100 |
+
ground_truth="Paris",
|
| 101 |
+
)
|
| 102 |
+
assert reward <= 0.5
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class TestHallucinationDetection:
|
| 106 |
+
"""Hallucination detection should classify types correctly."""
|
| 107 |
+
|
| 108 |
+
def test_no_hallucination_for_grounded_answer(self):
|
| 109 |
+
score, htype, severity, analysis = detect_hallucination_advanced(
|
| 110 |
+
answer="Paris is the capital of France.",
|
| 111 |
+
context="Paris is the capital of France.",
|
| 112 |
+
ground_truth="Paris",
|
| 113 |
+
confidence=0.9,
|
| 114 |
+
)
|
| 115 |
+
assert score < 0.3, f"Grounded answer should have low hallucination score, got {score}"
|
| 116 |
+
|
| 117 |
+
def test_fabricated_fact_detected(self):
|
| 118 |
+
score, htype, severity, analysis = detect_hallucination_advanced(
|
| 119 |
+
answer="Berlin is the capital of France.",
|
| 120 |
+
context="Paris is the capital of France.",
|
| 121 |
+
ground_truth="Paris",
|
| 122 |
+
confidence=0.9,
|
| 123 |
+
)
|
| 124 |
+
assert score > 0.3, f"Fabricated fact should have high hallucination score, got {score}"
|
| 125 |
+
|
| 126 |
+
def test_numerical_fabrication_detected(self):
|
| 127 |
+
score, htype, severity, analysis = detect_hallucination_advanced(
|
| 128 |
+
answer="The population is 8.7 million.",
|
| 129 |
+
context="The population is 2.1 million people.",
|
| 130 |
+
ground_truth="2.1 million",
|
| 131 |
+
confidence=0.8,
|
| 132 |
+
)
|
| 133 |
+
assert analysis.get("numerical_fabrication", 0) > 0, \
|
| 134 |
+
f"Fabricated number 8.7 should be detected, got {analysis}"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class TestCitationAccuracy:
|
| 138 |
+
"""Source quote verification should work correctly."""
|
| 139 |
+
|
| 140 |
+
def test_exact_quote_match(self):
|
| 141 |
+
score, analysis = check_quote_in_context_advanced(
|
| 142 |
+
"Paris is the capital of France.",
|
| 143 |
+
"Paris is the capital of France. It is a beautiful city.",
|
| 144 |
+
)
|
| 145 |
+
assert score == 1.0, f"Exact quote should score 1.0, got {score}"
|
| 146 |
+
|
| 147 |
+
def test_no_quote(self):
|
| 148 |
+
score, analysis = check_quote_in_context_advanced(
|
| 149 |
+
"",
|
| 150 |
+
"Some context here.",
|
| 151 |
+
)
|
| 152 |
+
assert score == 0.0
|
| 153 |
+
|
| 154 |
+
def test_partial_quote(self):
|
| 155 |
+
score, analysis = check_quote_in_context_advanced(
|
| 156 |
+
"capital of France",
|
| 157 |
+
"Paris is the capital of France.",
|
| 158 |
+
)
|
| 159 |
+
assert score > 0.5, f"Partial quote should score > 0.5, got {score}"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class TestCalibrationError:
|
| 163 |
+
"""Calibration error should penalize overconfidence."""
|
| 164 |
+
|
| 165 |
+
def test_perfect_calibration(self):
|
| 166 |
+
error = compute_calibration_error(0.9, 0.9)
|
| 167 |
+
assert error == 0.0
|
| 168 |
+
|
| 169 |
+
def test_overconfidence_penalized(self):
|
| 170 |
+
error = compute_calibration_error(0.95, 0.3)
|
| 171 |
+
assert error > 0.5, f"Overconfidence should be heavily penalized, got {error}"
|
| 172 |
+
|
| 173 |
+
def test_underconfidence_safe(self):
|
| 174 |
+
error = compute_calibration_error(0.3, 0.9)
|
| 175 |
+
assert error < compute_calibration_error(0.95, 0.3), \
|
| 176 |
+
"Overconfidence should be penalized more than underconfidence"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class TestBERTScoreEdgeCases:
|
| 180 |
+
"""BERTScore should not crash on edge cases."""
|
| 181 |
+
|
| 182 |
+
def test_empty_strings(self):
|
| 183 |
+
result = compute_bertscore("", "")
|
| 184 |
+
assert result["f1"] == 0.0
|
| 185 |
+
|
| 186 |
+
def test_identical_strings(self):
|
| 187 |
+
result = compute_bertscore("The cat sat on the mat.", "The cat sat on the mat.")
|
| 188 |
+
assert result["f1"] > 0.8, f"Identical strings should have high BERTScore, got {result['f1']}"
|
| 189 |
+
|
| 190 |
+
def test_short_strings(self):
|
| 191 |
+
result = compute_bertscore("yes", "no")
|
| 192 |
+
assert "f1" in result # Should not crash
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class TestROUGE:
|
| 196 |
+
"""ROUGE scores should be computed correctly."""
|
| 197 |
+
|
| 198 |
+
def test_identical_strings(self):
|
| 199 |
+
result = compute_rouge("The cat sat on the mat.", "The cat sat on the mat.")
|
| 200 |
+
assert result["rougeL"] == 1.0
|
| 201 |
+
|
| 202 |
+
def test_completely_different(self):
|
| 203 |
+
result = compute_rouge("The cat sat on the mat.", "Dogs run in the park.")
|
| 204 |
+
assert result["rougeL"] < 0.5
|
| 205 |
+
|
| 206 |
+
def test_empty_strings(self):
|
| 207 |
+
result = compute_rouge("", "")
|
| 208 |
+
assert result["rouge1"] == 0.0
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class TestFactualAccuracy:
|
| 212 |
+
"""Factual accuracy should handle various answer types."""
|
| 213 |
+
|
| 214 |
+
def test_exact_match(self):
|
| 215 |
+
score, analysis = check_factual_accuracy_advanced(
|
| 216 |
+
"Paris", "Paris", "Paris is the capital of France."
|
| 217 |
+
)
|
| 218 |
+
assert score >= 0.9, f"Exact match should score high, got {score}"
|
| 219 |
+
|
| 220 |
+
def test_wrong_answer(self):
|
| 221 |
+
score, analysis = check_factual_accuracy_advanced(
|
| 222 |
+
"London", "Paris", "Paris is the capital of France."
|
| 223 |
+
)
|
| 224 |
+
assert score < 0.5, f"Wrong answer should score low, got {score}"
|
| 225 |
+
|
| 226 |
+
def test_contains_truth(self):
|
| 227 |
+
score, analysis = check_factual_accuracy_advanced(
|
| 228 |
+
"The capital is Paris, which is in northern France.",
|
| 229 |
+
"Paris",
|
| 230 |
+
"Paris is the capital of France.",
|
| 231 |
+
)
|
| 232 |
+
assert score >= 0.8, f"Answer containing truth should score high, got {score}"
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class TestNormalizeText:
|
| 236 |
+
"""Text normalization should handle edge cases."""
|
| 237 |
+
|
| 238 |
+
def test_empty_string(self):
|
| 239 |
+
assert normalize_text("") == ""
|
| 240 |
+
|
| 241 |
+
def test_whitespace_normalization(self):
|
| 242 |
+
result = normalize_text(" The cat sat ")
|
| 243 |
+
assert " " not in result
|
| 244 |
+
|
| 245 |
+
def test_case_normalization(self):
|
| 246 |
+
result = normalize_text("PARIS IS THE CAPITAL")
|
| 247 |
+
assert result == result.lower()
|
tests/test_tasks.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the OpenEnv task registry and grader."""
|
| 2 |
+
|
| 3 |
+
import sys, os
|
| 4 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from server.tasks import (
|
| 8 |
+
ALL_TASKS, TASK_1, TASK_2, TASK_3,
|
| 9 |
+
get_task, task_id_for_difficulty,
|
| 10 |
+
compute_task_score, ACTION_SCHEMA,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestTaskRegistry:
|
| 15 |
+
"""Task registry should contain all required tasks."""
|
| 16 |
+
|
| 17 |
+
def test_three_tasks_exist(self):
|
| 18 |
+
assert len(ALL_TASKS) == 3
|
| 19 |
+
assert "task_1_factual_grounding" in ALL_TASKS
|
| 20 |
+
assert "task_2_multi_hop_synthesis" in ALL_TASKS
|
| 21 |
+
assert "task_3_adversarial_resistance" in ALL_TASKS
|
| 22 |
+
|
| 23 |
+
def test_task_difficulties(self):
|
| 24 |
+
assert TASK_1.difficulty == "beginner"
|
| 25 |
+
assert TASK_2.difficulty == "intermediate"
|
| 26 |
+
assert TASK_3.difficulty == "advanced"
|
| 27 |
+
|
| 28 |
+
def test_get_task(self):
|
| 29 |
+
assert get_task("task_1_factual_grounding") is TASK_1
|
| 30 |
+
assert get_task("nonexistent") is None
|
| 31 |
+
|
| 32 |
+
def test_difficulty_mapping(self):
|
| 33 |
+
assert task_id_for_difficulty("beginner") == TASK_1.task_id
|
| 34 |
+
assert task_id_for_difficulty("intermediate") == TASK_2.task_id
|
| 35 |
+
assert task_id_for_difficulty("advanced") == TASK_3.task_id
|
| 36 |
+
assert task_id_for_difficulty("expert") == TASK_3.task_id
|
| 37 |
+
|
| 38 |
+
def test_action_schema_has_required_fields(self):
|
| 39 |
+
props = ACTION_SCHEMA["properties"]
|
| 40 |
+
assert "answer" in props
|
| 41 |
+
assert "confidence" in props
|
| 42 |
+
assert "source_quote" in props
|
| 43 |
+
assert ACTION_SCHEMA["required"] == ["answer"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TestTaskGrader:
|
| 47 |
+
"""Per-episode task grader should produce scores in [0, 1]."""
|
| 48 |
+
|
| 49 |
+
def test_empty_steps_score_zero(self):
|
| 50 |
+
result = compute_task_score(TASK_1, [], [])
|
| 51 |
+
assert result["score"] == 0.0
|
| 52 |
+
|
| 53 |
+
def test_perfect_scores(self):
|
| 54 |
+
step_rewards = [1.0, 1.0, 1.0, 1.0, 1.0]
|
| 55 |
+
step_infos = [
|
| 56 |
+
{"correctness": 1.0, "grounding": 1.0, "calibration": 1.0,
|
| 57 |
+
"hallucination_score": 0.0, "is_hallucination": False}
|
| 58 |
+
for _ in range(5)
|
| 59 |
+
]
|
| 60 |
+
result = compute_task_score(TASK_1, step_rewards, step_infos)
|
| 61 |
+
assert 0.9 <= result["score"] <= 1.05, f"Perfect answers should score ~1.0, got {result['score']}"
|
| 62 |
+
|
| 63 |
+
def test_zero_rewards_score_low(self):
|
| 64 |
+
step_rewards = [0.0, 0.0, 0.0]
|
| 65 |
+
step_infos = [
|
| 66 |
+
{"correctness": 0.0, "grounding": 0.0, "calibration": 0.0,
|
| 67 |
+
"hallucination_score": 1.0, "is_hallucination": True}
|
| 68 |
+
for _ in range(3)
|
| 69 |
+
]
|
| 70 |
+
result = compute_task_score(TASK_1, step_rewards, step_infos)
|
| 71 |
+
assert result["score"] <= 0.1, f"All-wrong should score ~0, got {result['score']}"
|
| 72 |
+
|
| 73 |
+
def test_task3_overconfidence_penalty(self):
|
| 74 |
+
"""Task 3 should penalize overconfident wrong answers."""
|
| 75 |
+
step_rewards = [0.3, 0.3, 0.3]
|
| 76 |
+
step_infos = [
|
| 77 |
+
{"correctness": 0.2, "grounding": 0.3, "calibration": 0.9,
|
| 78 |
+
"hallucination_score": 0.7, "is_hallucination": True}
|
| 79 |
+
for _ in range(3)
|
| 80 |
+
]
|
| 81 |
+
result_t3 = compute_task_score(TASK_3, step_rewards, step_infos)
|
| 82 |
+
# Same data on task 1 should score higher than task 3
|
| 83 |
+
result_t1 = compute_task_score(TASK_1, step_rewards, step_infos)
|
| 84 |
+
assert result_t3["score"] <= result_t1["score"], \
|
| 85 |
+
f"Task 3 should penalize overconfidence more than Task 1"
|
| 86 |
+
|
| 87 |
+
def test_completion_bonus(self):
|
| 88 |
+
"""5+ steps should get a completion bonus."""
|
| 89 |
+
short_infos = [{"correctness": 0.5, "grounding": 0.5, "calibration": 0.5,
|
| 90 |
+
"hallucination_score": 0.0, "is_hallucination": False}]
|
| 91 |
+
long_infos = [{"correctness": 0.5, "grounding": 0.5, "calibration": 0.5,
|
| 92 |
+
"hallucination_score": 0.0, "is_hallucination": False}
|
| 93 |
+
for _ in range(6)]
|
| 94 |
+
short_result = compute_task_score(TASK_1, [0.5], short_infos)
|
| 95 |
+
long_result = compute_task_score(TASK_1, [0.5] * 6, long_infos)
|
| 96 |
+
assert long_result["score"] > short_result["score"], \
|
| 97 |
+
"Longer episodes should get completion bonus"
|
| 98 |
+
|
| 99 |
+
def test_score_always_in_range(self):
|
| 100 |
+
"""Any combination of rewards/infos should produce score in [0, 1]."""
|
| 101 |
+
import random
|
| 102 |
+
random.seed(42)
|
| 103 |
+
for _ in range(100):
|
| 104 |
+
n = random.randint(1, 10)
|
| 105 |
+
rewards = [random.uniform(0, 1) for _ in range(n)]
|
| 106 |
+
infos = [{
|
| 107 |
+
"correctness": random.uniform(0, 1),
|
| 108 |
+
"grounding": random.uniform(0, 1),
|
| 109 |
+
"calibration": random.uniform(0, 1),
|
| 110 |
+
"hallucination_score": random.uniform(0, 1),
|
| 111 |
+
"is_hallucination": random.random() > 0.5,
|
| 112 |
+
} for _ in range(n)]
|
| 113 |
+
for task in [TASK_1, TASK_2, TASK_3]:
|
| 114 |
+
result = compute_task_score(task, rewards, infos)
|
| 115 |
+
assert 0.0 <= result["score"] <= 1.0, \
|
| 116 |
+
f"Score {result['score']} out of range for {task.task_id}"
|