aagparekh commited on
Commit
d661639
·
1 Parent(s): 5f54992

feat: implement data pipeline

Browse files

Add fact loading, corruption generation, and document synthesis for context corruption episodes, plus UTF-8 fact loading for Windows smoke tests.

Made-with: Cursor

Files changed (5) hide show
  1. data/__init__.py +0 -0
  2. data/corruption.py +227 -0
  3. data/generator.py +69 -0
  4. data/loader.py +205 -0
  5. environment/env.py +1 -1
data/__init__.py ADDED
File without changes
data/corruption.py CHANGED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import re
3
+
4
+ try:
5
+ from faker import Faker
6
+ except ModuleNotFoundError:
7
+ Faker = None
8
+
9
+
10
+ class _FallbackFaker:
11
+ def name(self) -> str:
12
+ return random.choice(["Alex Morgan", "Jordan Lee", "Taylor Brooks", "Casey Patel"])
13
+
14
+ def last_name(self) -> str:
15
+ return random.choice(["Morgan", "Lee", "Brooks", "Patel", "Reed"])
16
+
17
+ def company(self) -> str:
18
+ return random.choice(
19
+ ["Global Research Institute", "Civic Data Group", "Archive Analytics Lab"]
20
+ )
21
+
22
+ def word(self) -> str:
23
+ return random.choice(["revised", "alternate", "disputed", "corrected"])
24
+
25
+
26
+ fake = Faker() if Faker else _FallbackFaker()
27
+
28
+ COUNTRIES = [
29
+ "France",
30
+ "Germany",
31
+ "Brazil",
32
+ "Japan",
33
+ "Canada",
34
+ "India",
35
+ "Australia",
36
+ "Kenya",
37
+ "Mexico",
38
+ "Norway",
39
+ ]
40
+ CITIES = [
41
+ "Paris",
42
+ "Berlin",
43
+ "Tokyo",
44
+ "Toronto",
45
+ "Mumbai",
46
+ "Sydney",
47
+ "Nairobi",
48
+ "Mexico City",
49
+ "Oslo",
50
+ "Rome",
51
+ ]
52
+ ORGANIZATIONS = [
53
+ "World Health Organization",
54
+ "United Nations",
55
+ "NASA",
56
+ "Oxford University",
57
+ "Reuters",
58
+ "Smithsonian Institution",
59
+ "International Monetary Fund",
60
+ "Royal Society",
61
+ ]
62
+ ANTONYMS = {
63
+ "largest": "smallest",
64
+ "smallest": "largest",
65
+ "first": "last",
66
+ "last": "first",
67
+ "highest": "lowest",
68
+ "lowest": "highest",
69
+ "won": "lost",
70
+ "lost": "won",
71
+ "north": "south",
72
+ "south": "north",
73
+ "east": "west",
74
+ "west": "east",
75
+ "increase": "decrease",
76
+ "decrease": "increase",
77
+ "before": "after",
78
+ "after": "before",
79
+ "true": "false",
80
+ "false": "true",
81
+ "older": "newer",
82
+ "newer": "older",
83
+ "major": "minor",
84
+ "minor": "major",
85
+ }
86
+
87
+
88
+ def _preserve_case(original: str, replacement: str) -> str:
89
+ if original.isupper():
90
+ return replacement.upper()
91
+ if original.istitle():
92
+ return replacement.title()
93
+ if original.islower():
94
+ return replacement.lower()
95
+ return replacement
96
+
97
+
98
+ def _replace_first_case_insensitive(text: str, target: str, replacement: str) -> str:
99
+ pattern = re.compile(re.escape(target), re.IGNORECASE)
100
+
101
+ def repl(match: re.Match[str]) -> str:
102
+ return _preserve_case(match.group(0), replacement)
103
+
104
+ return pattern.sub(repl, text, count=1)
105
+
106
+
107
+ def _different_choice(options: list[str], current: str) -> str:
108
+ viable = [option for option in options if option.lower() != current.lower()]
109
+ return random.choice(viable or options)
110
+
111
+
112
+ def corrupt_number(text: str, answer: str) -> str:
113
+ numbers = re.findall(r"\b\d{4}\b|\b\d+\b", text)
114
+ if not numbers:
115
+ return (
116
+ f"{text} A later statistical revision changed the reported figure "
117
+ f"from {answer} to {random.randint(12, 98)}."
118
+ )
119
+
120
+ original = random.choice(numbers)
121
+ value = int(original)
122
+ if len(original) == 4 and 1900 <= value <= 2030:
123
+ replacement = str(value + random.choice([-20, -10, -5, 5, 10, 20]))
124
+ else:
125
+ mutated = value * random.choice([0.5, 2, 3, 5, 10])
126
+ replacement = str(max(1, int(round(mutated))))
127
+
128
+ return text.replace(original, replacement, 1)
129
+
130
+
131
+ def corrupt_entity(text: str, answer: str) -> str:
132
+ answer = answer.strip()
133
+ pools = [COUNTRIES, CITIES, ORGANIZATIONS]
134
+ if answer and re.search(re.escape(answer), text, re.IGNORECASE):
135
+ for pool in pools:
136
+ if answer in pool:
137
+ replacement = _different_choice(pool, answer)
138
+ return _replace_first_case_insensitive(text, answer, replacement)
139
+
140
+ if len(answer.split()) <= 3:
141
+ generated_names = [fake.name() for _ in range(8)]
142
+ replacement = _different_choice(generated_names, answer)
143
+ return _replace_first_case_insensitive(text, answer, replacement)
144
+
145
+ return (
146
+ f"{text} In a later archive note, researcher {fake.name()} attributed "
147
+ f"the answer to {fake.name()} instead."
148
+ )
149
+
150
+
151
+ def corrupt_inversion(text: str, answer: str) -> str:
152
+ pattern = re.compile(r"\b(" + "|".join(map(re.escape, ANTONYMS)) + r")\b", re.IGNORECASE)
153
+
154
+ def repl(match: re.Match[str]) -> str:
155
+ word = match.group(0)
156
+ replacement = ANTONYMS[word.lower()]
157
+ return _preserve_case(word, replacement)
158
+
159
+ corrupted, count = pattern.subn(repl, text, count=1)
160
+ if count:
161
+ return corrupted
162
+
163
+ return (
164
+ f"{text} This statement contradicts earlier scholarly consensus, "
165
+ f"which identified {answer} as incorrect."
166
+ )
167
+
168
+
169
+ def _generate_wrong_answer(answer: str) -> str:
170
+ answer = answer.strip()
171
+ if not answer:
172
+ return fake.word().title()
173
+
174
+ number_match = re.search(r"\d+", answer)
175
+ if number_match:
176
+ original = number_match.group(0)
177
+ mutated = str(int(original) + random.choice([-5, -2, -1, 1, 2, 5]))
178
+ return answer.replace(original, mutated, 1)
179
+
180
+ words = answer.split()
181
+ if len(words) == 1 and words[0][:1].isupper():
182
+ return fake.last_name()
183
+ if len(words) > 1:
184
+ shuffled = words[:]
185
+ random.shuffle(shuffled)
186
+ if shuffled != words:
187
+ return " ".join(shuffled)
188
+ return f"{answer} Institute"
189
+ return fake.word()
190
+
191
+
192
+ def corrupt_coherent(text: str, answer: str) -> str:
193
+ wrong_answer = _generate_wrong_answer(answer)
194
+ year = random.randint(2015, 2025)
195
+ org = fake.company()
196
+ source = random.choice(
197
+ [
198
+ "a peer-reviewed survey",
199
+ "an institutional archive",
200
+ "a longitudinal review",
201
+ "a Reuters-style fact check",
202
+ ]
203
+ )
204
+
205
+ if answer and re.search(re.escape(answer), text, re.IGNORECASE):
206
+ text = _replace_first_case_insensitive(text, answer, wrong_answer)
207
+
208
+ return (
209
+ f"{text} According to {source} released by {org} in {year}, the verified "
210
+ f"answer is {wrong_answer}, based on revised primary-source evidence."
211
+ )
212
+
213
+
214
+ def corrupt_text(text: str, answer: str, level: int) -> str:
215
+ try:
216
+ if level <= 1:
217
+ return corrupt_number(text, answer)
218
+ if level == 2:
219
+ return corrupt_entity(text, answer)
220
+ if level == 3:
221
+ return corrupt_inversion(text, answer)
222
+ return corrupt_coherent(text, answer)
223
+ except Exception:
224
+ return (
225
+ f"{text} A conflicting secondary source reports a different answer "
226
+ f"than {answer}."
227
+ )
data/generator.py CHANGED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ from typing import Any
3
+
4
+ from data.corruption import corrupt_text
5
+
6
+
7
+ SOURCES = [
8
+ "Encyclopedia Britannica",
9
+ "Reuters Fact Check",
10
+ "National Geographic",
11
+ "Smithsonian Magazine",
12
+ "BBC Reference Desk",
13
+ "Oxford Reference",
14
+ "World Almanac",
15
+ "Associated Press Archive",
16
+ "Library of Congress Notes",
17
+ "Academic Knowledge Base",
18
+ ]
19
+
20
+ TEMPLATES = [
21
+ "{source} summarizes the question '{question}' and identifies the answer as {answer}.",
22
+ "In its reference entry, {source} states that the correct answer to '{question}' is {answer}.",
23
+ "{source} records {answer} as the accepted answer when asked: '{question}'",
24
+ "A background note from {source} explains that {answer} is the established response to '{question}'",
25
+ "According to {source}, researchers commonly answer '{question}' with {answer}.",
26
+ "{source} lists the verified answer for '{question}' as {answer}, matching standard references.",
27
+ "The archive maintained by {source} gives {answer} as the answer to '{question}'",
28
+ "For the prompt '{question}', {source} reports that the answer is {answer}.",
29
+ ]
30
+
31
+
32
+ def _as_text(value: Any, default: str = "") -> str:
33
+ if value is None:
34
+ return default
35
+ text = str(value).strip()
36
+ return text or default
37
+
38
+
39
+ def generate_documents(
40
+ fact: dict[str, Any],
41
+ num_docs: int = 8,
42
+ corrupt_positions: list[int] | None = None,
43
+ ) -> list[dict[str, Any]]:
44
+ question = _as_text(fact.get("question"), "Unknown question?")
45
+ answer = _as_text(fact.get("answer"), "unknown")
46
+ corrupt_set = set(corrupt_positions or [])
47
+ corrupt_order = {doc_id: idx + 1 for idx, doc_id in enumerate(corrupt_positions or [])}
48
+
49
+ documents: list[dict[str, Any]] = []
50
+ for doc_id in range(num_docs):
51
+ source = random.choice(SOURCES)
52
+ template = random.choice(TEMPLATES)
53
+ content = template.format(source=source, question=question, answer=answer)
54
+ is_corrupt = doc_id in corrupt_set
55
+
56
+ if is_corrupt:
57
+ level = min(corrupt_order[doc_id], 4)
58
+ content = corrupt_text(content, answer, level)
59
+
60
+ documents.append(
61
+ {
62
+ "id": doc_id,
63
+ "title": f"{source} Document {doc_id + 1}",
64
+ "content": content,
65
+ "is_corrupt": is_corrupt,
66
+ }
67
+ )
68
+
69
+ return documents
data/loader.py CHANGED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import random
3
+ import urllib.request
4
+ import ast
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ FACTS_PATH = Path(__file__).parent / "facts.json"
10
+ FAITHEVAL_COUNTERFACTUAL_URL = (
11
+ "https://raw.githubusercontent.com/SalesforceAIResearch/FaithEval/main/"
12
+ "data/counterfactual.json"
13
+ )
14
+
15
+
16
+ def _load_dataset(*args: Any, **kwargs: Any) -> Any:
17
+ from datasets import load_dataset
18
+
19
+ return load_dataset(*args, **kwargs)
20
+
21
+
22
+ def _first_text(value: Any) -> str | None:
23
+ """Extract the first useful text value from nested dataset fields."""
24
+ if value is None:
25
+ return None
26
+ if isinstance(value, str):
27
+ text = value.strip()
28
+ if text.startswith("[") and text.endswith("]"):
29
+ try:
30
+ parsed = ast.literal_eval(text)
31
+ except (SyntaxError, ValueError):
32
+ parsed = None
33
+ parsed_text = _first_text(parsed)
34
+ if parsed_text:
35
+ return parsed_text
36
+ return text or None
37
+ if isinstance(value, (int, float)):
38
+ return str(value)
39
+ if isinstance(value, dict):
40
+ for key in ("text", "answer", "answers", "value"):
41
+ text = _first_text(value.get(key))
42
+ if text:
43
+ return text
44
+ return None
45
+ if isinstance(value, (list, tuple)):
46
+ for item in value:
47
+ text = _first_text(item)
48
+ if text:
49
+ return text
50
+ return None
51
+
52
+
53
+ def _word_count(text: str) -> int:
54
+ return len(text.split())
55
+
56
+
57
+ def _clean_question(text: Any) -> str | None:
58
+ question = _first_text(text)
59
+ if not question:
60
+ return None
61
+ question = question.strip()
62
+ if not question.endswith("?"):
63
+ question = f"{question}?"
64
+ return question
65
+
66
+
67
+ def _natural_questions_answer(row: dict[str, Any]) -> str | None:
68
+ annotations = row.get("annotations") or {}
69
+ short_answers = annotations.get("short_answers")
70
+ answer = _first_text(short_answers)
71
+ if answer and _word_count(answer) <= 5:
72
+ return answer
73
+ return None
74
+
75
+
76
+ def load_natural_questions(n: int = 300) -> list[dict[str, str]]:
77
+ facts: list[dict[str, str]] = []
78
+ dataset = _load_dataset(
79
+ "google-research-datasets/natural_questions",
80
+ split="train",
81
+ streaming=True,
82
+ )
83
+
84
+ for row in dataset:
85
+ question = _clean_question(row.get("question") or row.get("question_text"))
86
+ answer = _natural_questions_answer(row)
87
+ if not question or not answer:
88
+ continue
89
+
90
+ facts.append(
91
+ {
92
+ "question": question,
93
+ "answer": answer,
94
+ "source": "natural_questions",
95
+ "conflict_type": "entity",
96
+ }
97
+ )
98
+ if len(facts) >= n:
99
+ break
100
+
101
+ return facts
102
+
103
+
104
+ def load_popqa(n: int = 150) -> list[dict[str, str]]:
105
+ facts: list[dict[str, str]] = []
106
+ dataset = _load_dataset("akariasai/PopQA", split="test")
107
+
108
+ for row in dataset:
109
+ question = _clean_question(row.get("question"))
110
+ answer = _first_text(row.get("possible_answers"))
111
+ if not question or not answer:
112
+ continue
113
+
114
+ facts.append(
115
+ {
116
+ "question": question,
117
+ "answer": answer,
118
+ "source": "popqa",
119
+ "conflict_type": "entity",
120
+ "entity": _first_text(row.get("subj") or row.get("entity")) or "",
121
+ "relation": _first_text(row.get("prop") or row.get("relation")) or "",
122
+ }
123
+ )
124
+ if len(facts) >= n:
125
+ break
126
+
127
+ return facts
128
+
129
+
130
+ def _iter_faitheval_items(payload: Any) -> list[dict[str, Any]]:
131
+ if isinstance(payload, list):
132
+ return [item for item in payload if isinstance(item, dict)]
133
+ if isinstance(payload, dict):
134
+ for key in ("data", "examples", "items", "counterfactual"):
135
+ items = payload.get(key)
136
+ if isinstance(items, list):
137
+ return [item for item in items if isinstance(item, dict)]
138
+ return []
139
+
140
+
141
+ def load_faitheval_counterfactual(n: int = 100) -> list[dict[str, str]]:
142
+ try:
143
+ with urllib.request.urlopen(FAITHEVAL_COUNTERFACTUAL_URL, timeout=20) as response:
144
+ payload = json.loads(response.read().decode("utf-8"))
145
+ except Exception:
146
+ return []
147
+
148
+ facts: list[dict[str, str]] = []
149
+ for item in _iter_faitheval_items(payload):
150
+ question = _clean_question(
151
+ item.get("question") or item.get("query") or item.get("claim")
152
+ )
153
+ answer = _first_text(
154
+ item.get("answer")
155
+ or item.get("gold_answer")
156
+ or item.get("label")
157
+ or item.get("target")
158
+ )
159
+ if not question or not answer:
160
+ continue
161
+
162
+ facts.append(
163
+ {
164
+ "question": question,
165
+ "answer": answer,
166
+ "source": "faitheval",
167
+ "conflict_type": "counterfactual",
168
+ "provided_context": _first_text(
169
+ item.get("provided_context")
170
+ or item.get("context")
171
+ or item.get("evidence")
172
+ )
173
+ or "",
174
+ }
175
+ )
176
+ if len(facts) >= n:
177
+ break
178
+
179
+ return facts
180
+
181
+
182
+ def build_fact_database() -> list[dict[str, str]]:
183
+ facts = (
184
+ load_natural_questions()
185
+ + load_popqa()
186
+ + load_faitheval_counterfactual()
187
+ )
188
+ random.shuffle(facts)
189
+
190
+ FACTS_PATH.parent.mkdir(parents=True, exist_ok=True)
191
+ with open(FACTS_PATH, "w", encoding="utf-8") as f:
192
+ json.dump(facts, f, indent=2, ensure_ascii=False)
193
+
194
+ counts: dict[str, int] = {}
195
+ for fact in facts:
196
+ source = fact.get("source", "unknown")
197
+ counts[source] = counts.get(source, 0) + 1
198
+
199
+ print(f"Wrote {len(facts)} facts to {FACTS_PATH}")
200
+ print(f"Source counts: {counts}")
201
+ return facts
202
+
203
+
204
+ if __name__ == "__main__":
205
+ build_fact_database()
environment/env.py CHANGED
@@ -19,7 +19,7 @@ class ContextCorruptionEnv:
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
 
19
  self.difficulty = difficulty
20
  facts_path = Path(__file__).parent.parent / "data" / "facts.json"
21
  if facts_path.exists():
22
+ with open(facts_path, encoding="utf-8") as f:
23
  self._facts = json.load(f)
24
  else:
25
  self._facts = _FALLBACK_FACTS