MukulRay commited on
Commit
cd9075d
·
1 Parent(s): 7622730

Phase 1.1: archive patch_contradiction.py — research integrity fix

Browse files
eval/archived/README.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Archived Eval Files
2
+
3
+ ## patch_contradiction.py
4
+ Moved here on 2026-04-22 as part of Phase 1 integrity fix.
5
+
6
+ This file implements an eval-time contradiction scorer using debate-signal
7
+ keyword heuristics + LLM judge. It includes a `position_acknowledges_debate`
8
+ boost that can override an LLM "not contested" verdict when keywords like
9
+ "debate", "camp", "contested" appear in the synthesized position.
10
+
11
+ STATUS: ARCHIVED — DO NOT USE FOR REPORTED METRICS
12
+
13
+ The honest contradiction catch rate for RECON v1 is 0%. This file must not
14
+ be used to generate any numbers reported in a paper. It is preserved here
15
+ for reference only.
16
+
17
+ The root cause of the 0% contradiction rate is Bug 1 (STALE fires before
18
+ CONTRADICTED in critic_node), which is being fixed in Phase 1.2.
eval/archived/patch_contradiction.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ eval/patch_contradiction.py
3
+ ----------------------------
4
+ One-time patch for the 0% contradiction catch rate issue.
5
+
6
+ WHY THIS EXISTS
7
+ ---------------
8
+ The production critic checks STALE before CONTRADICTED, so contested questions
9
+ (Category C) almost always exit at STALE — the contradiction check never runs.
10
+ This is correct production behaviour (conservative critic) but breaks eval.
11
+
12
+ This script re-scores ONLY Category C rows using a dedicated eval-time
13
+ contradiction scorer that:
14
+ 1. Has no year-gap filter (contested topics can be same-year papers)
15
+ 2. Uses a less strict prompt (methodological disagreement counts)
16
+ 3. Runs independently of the critic pipeline
17
+
18
+ The existing full overnight CSVs are patched in-place.
19
+ Run takes ~10-15 mins (30 Cat C rows × 5 architectures = 150 judge calls).
20
+
21
+ Run from repo root:
22
+ python eval/patch_contradiction.py
23
+
24
+ Then re-run summary:
25
+ python eval/patch_contradiction.py --summary-only
26
+ """
27
+
28
+ import sys
29
+ import os
30
+ import csv
31
+ import json
32
+ import time
33
+ import re
34
+ import argparse
35
+
36
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
37
+
38
+ from dotenv import load_dotenv
39
+ load_dotenv()
40
+
41
+ from langchain_groq import ChatGroq
42
+ from langchain_core.messages import SystemMessage, HumanMessage
43
+
44
+ from src.retriever_utils import search_semantic_scholar
45
+
46
+ # ── Config ───────────────────────────────────────────────────────────────────
47
+ EVAL_DIR = os.path.dirname(os.path.abspath(__file__))
48
+ RESULTS_DIR = os.path.join(EVAL_DIR, "results")
49
+ GT_F = os.path.join(EVAL_DIR, "ground_truth.json")
50
+
51
+ ARCH_FILES = {
52
+ "single_rag": os.path.join(RESULTS_DIR, "single_rag.csv"),
53
+ "naive_multi": os.path.join(RESULTS_DIR, "naive_multi.csv"),
54
+ "recon_none": os.path.join(RESULTS_DIR, "recon_none.csv"),
55
+ "recon_linear": os.path.join(RESULTS_DIR, "recon_linear.csv"),
56
+ "recon_log": os.path.join(RESULTS_DIR, "recon_log.csv"),
57
+ }
58
+
59
+ # ── LLM setup ────────────────────────────────────────────────────────────────
60
+ _llm: ChatGroq | None = None
61
+
62
+ def get_llm() -> ChatGroq:
63
+ global _llm
64
+ if _llm is None:
65
+ _llm = ChatGroq(model="llama-3.3-70b-versatile", temperature=0.0)
66
+ return _llm
67
+
68
+
69
+ # ── Backoff (same pattern as run_eval.py) ────────────────────────────────────
70
+ _MAX_WAIT = 600
71
+
72
+ def _call_with_backoff(messages: list) -> str:
73
+ wait = 5
74
+ for attempt in range(6):
75
+ try:
76
+ return get_llm().invoke(messages).content.strip()
77
+ except Exception as e:
78
+ err = str(e)
79
+ if "429" not in err and "rate_limit" not in err.lower():
80
+ raise
81
+ m = re.search(r"try again in ([\d.]+)s", err)
82
+ retry_after = float(m.group(1)) if m else wait
83
+ if retry_after > _MAX_WAIT:
84
+ print(f"\n⛔ Daily token limit. Re-run tomorrow. Exiting cleanly.")
85
+ raise SystemExit(0)
86
+ actual = min(retry_after + 2, _MAX_WAIT)
87
+ print(f"\n⏳ Rate limit (attempt {attempt+1}/6). Waiting {actual:.0f}s...")
88
+ time.sleep(actual)
89
+ wait = min(wait * 2, 120)
90
+ raise RuntimeError("LLM call failed after 6 retries.")
91
+
92
+
93
+ # ── Eval-time contradiction scorer ───────────────────────────────────────────
94
+ # Less strict than the production critic:
95
+ # - No year-gap filter
96
+ # - Methodological disagreement counts as contested
97
+ # - Question is: do the papers represent BOTH sides of the debate?
98
+
99
+ EVAL_CONTRADICTION_SYSTEM = """You are evaluating whether retrieved ML research papers collectively represent a genuinely contested debate.
100
+
101
+ A topic is CONTESTED when:
102
+ - Papers propose competing methods with conflicting empirical claims
103
+ - Researchers disagree on which approach works better
104
+ - Papers reach different conclusions on the same question
105
+ - One paper explicitly identifies limitations or challenges of another's approach
106
+
107
+ A topic is NOT CONTESTED when:
108
+ - Papers propose different methods that solve different problems
109
+ - Papers are complementary rather than competing
110
+ - Disagreement is only about minor implementation details
111
+
112
+ Given a contested research question and retrieved paper abstracts, determine:
113
+ Does this paper set collectively represent BOTH sides of the debate, confirming the topic is genuinely contested?
114
+
115
+ Output ONLY a JSON object:
116
+ {"contested": true/false, "reason": "one sentence — name the two camps if true"}
117
+
118
+ Be reasonable — methodological preference disagreements count as contested."""
119
+
120
+
121
+ def eval_contradiction_scorer(
122
+ question: str,
123
+ camps_ground_truth: str,
124
+ synthesized_position: str,
125
+ ) -> tuple[int, str]:
126
+ """
127
+ Eval-time contradiction scorer for Category C questions.
128
+ Returns (1, reason) if contested debate detected, (0, reason) otherwise.
129
+
130
+ Two-step check:
131
+ 1. Does the synthesized POSITION acknowledge the debate exists?
132
+ 2. Do the retrieved papers confirm the topic is genuinely contested?
133
+
134
+ Step 1 uses only the position text (fast, no extra API call needed).
135
+ Step 2 is the LLM judge call.
136
+ """
137
+ # Step 1 — fast heuristic: does the position mention disagreement?
138
+ position_lower = (synthesized_position or "").lower()
139
+ debate_signals = [
140
+ "debate", "disagree", "controversy", "contested", "conflict",
141
+ "camp", "argue", "while others", "however", "challenge",
142
+ "alternative", "competing", "tradeoff", "trade-off",
143
+ "on the other hand", "in contrast", "proponents", "critics"
144
+ ]
145
+ position_acknowledges_debate = any(s in position_lower for s in debate_signals)
146
+
147
+ # Step 2 — LLM judge: does the synthesis accurately represent both camps?
148
+ prompt = f"""Contested research question: {question}
149
+
150
+ Known debate (ground truth camps):
151
+ {camps_ground_truth}
152
+
153
+ Synthesized position:
154
+ {synthesized_position[:1000] if synthesized_position else "No position generated."}
155
+
156
+ Does the synthesized position acknowledge that this topic is genuinely contested
157
+ and represent both camps of the debate?"""
158
+
159
+ try:
160
+ time.sleep(1)
161
+ raw = _call_with_backoff([
162
+ SystemMessage(content=EVAL_CONTRADICTION_SYSTEM),
163
+ HumanMessage(content=prompt),
164
+ ])
165
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
166
+ if m:
167
+ data = json.loads(m.group())
168
+ contested = bool(data.get("contested", False))
169
+ reason = str(data.get("reason", ""))
170
+
171
+ # Boost: if position already shows debate awareness, be slightly
172
+ # more lenient — partial credit for acknowledging disagreement
173
+ if not contested and position_acknowledges_debate:
174
+ # Re-check with context that position shows awareness
175
+ contested = True
176
+ reason = f"Position acknowledges debate ({reason})"
177
+
178
+ return (1 if contested else 0), reason
179
+
180
+ except SystemExit:
181
+ raise
182
+ except Exception as e:
183
+ return 0, f"scorer error: {e}"
184
+
185
+ return 0, "no result"
186
+
187
+
188
+ # ── CSV patch logic ───────────────────────────────────────────────────────────
189
+
190
+ def patch_csv(path: str, arch_name: str, gt_map: dict) -> dict:
191
+ """
192
+ Read existing CSV, re-score Category C contradiction_caught column,
193
+ write patched CSV back. Returns counts for reporting.
194
+ """
195
+ if not os.path.exists(path):
196
+ print(f" ⚠ {arch_name}: file not found, skipping.")
197
+ return {}
198
+
199
+ with open(path, encoding="utf-8") as f:
200
+ rows = list(csv.DictReader(f))
201
+
202
+ if not rows:
203
+ print(f" ⚠ {arch_name}: empty file, skipping.")
204
+ return {}
205
+
206
+ cat_c_rows = [(i, r) for i, r in enumerate(rows) if r.get("category") == "C"]
207
+ print(f"\n {arch_name}: patching {len(cat_c_rows)} Category C rows...")
208
+
209
+ caught = 0
210
+ total = len(cat_c_rows)
211
+
212
+ for j, (i, row) in enumerate(cat_c_rows, 1):
213
+ qid = row["question_id"]
214
+ question = row["question"]
215
+ position = row["synthesized_position"]
216
+
217
+ gt_entry = gt_map.get(qid, {})
218
+ camps_gt = gt_entry.get("camps", "")
219
+
220
+ print(f" [{j:02d}/{total}] {question[:60]}...")
221
+
222
+ try:
223
+ score, reason = eval_contradiction_scorer(
224
+ question=question,
225
+ camps_ground_truth=camps_gt,
226
+ synthesized_position=position,
227
+ )
228
+ except SystemExit:
229
+ raise
230
+ except Exception as e:
231
+ score, reason = 0, str(e)
232
+
233
+ rows[i]["contradiction_caught"] = score
234
+ rows[i]["judge_reason"] = (
235
+ rows[i].get("judge_reason", "") + f" | contradiction: {reason[:100]}"
236
+ ).strip(" |")
237
+
238
+ if score:
239
+ caught += 1
240
+ print(f" ✓ CONTESTED — {reason[:70]}")
241
+ else:
242
+ print(f" ✗ not caught — {reason[:70]}")
243
+
244
+ # Write patched CSV back (same columns, same order)
245
+ fieldnames = list(rows[0].keys()) if rows else []
246
+ with open(path, "w", newline="", encoding="utf-8") as f:
247
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
248
+ writer.writeheader()
249
+ writer.writerows(rows)
250
+
251
+ rate = caught / total if total else 0
252
+ print(f" ✓ {arch_name}: contradiction catch rate = {caught}/{total} = {rate:.1%}")
253
+
254
+ return {"arch": arch_name, "caught": caught, "total": total, "rate": rate}
255
+
256
+
257
+ # ── Summary recompute ─────────────────────────────────────────────────────────
258
+
259
+ def recompute_summary() -> None:
260
+ """Re-run summary aggregation from patched CSVs."""
261
+ summary_rows = []
262
+
263
+ for arch_name, path in ARCH_FILES.items():
264
+ if not os.path.exists(path):
265
+ continue
266
+
267
+ with open(path, encoding="utf-8") as f:
268
+ rows = list(csv.DictReader(f))
269
+
270
+ if not rows:
271
+ continue
272
+
273
+ total = len(rows)
274
+
275
+ acc_counts = {"MATCH": 0, "PARTIAL": 0, "MISMATCH": 0, "ERROR": 0, "SKIPPED": 0}
276
+ for r in rows:
277
+ key = r.get("position_accuracy", "SKIPPED")
278
+ acc_counts[key if key in acc_counts else "SKIPPED"] += 1
279
+
280
+ match_rate = acc_counts["MATCH"] / total if total else 0
281
+
282
+ cat_b = [r for r in rows if r.get("category") == "B"]
283
+ staleness_rate = (
284
+ sum(int(r["staleness_caught"]) for r in cat_b
285
+ if r.get("staleness_caught") not in ("", None))
286
+ / len(cat_b)
287
+ ) if cat_b else 0
288
+
289
+ cat_c = [r for r in rows if r.get("category") == "C"]
290
+ contradiction_rate = (
291
+ sum(int(r["contradiction_caught"]) for r in cat_c
292
+ if r.get("contradiction_caught") not in ("", None))
293
+ / len(cat_c)
294
+ ) if cat_c else 0
295
+
296
+ latencies = [float(r["latency_ms"]) for r in rows
297
+ if r.get("latency_ms") and r["latency_ms"] not in ("", "0.0", "0")]
298
+ avg_latency = sum(latencies) / len(latencies) if latencies else 0
299
+
300
+ retries = [int(r.get("retry_count", 0)) for r in rows]
301
+ retry_rate = sum(1 for x in retries if x > 0) / total if total else 0
302
+
303
+ error_rate = sum(1 for r in rows if r.get("error")) / total if total else 0
304
+
305
+ summary_rows.append({
306
+ "architecture": arch_name,
307
+ "total_questions": total,
308
+ "position_match_rate": round(match_rate, 4),
309
+ "staleness_catch_rate": round(staleness_rate, 4),
310
+ "contradiction_catch_rate": round(contradiction_rate, 4),
311
+ "avg_latency_ms": round(avg_latency, 1),
312
+ "retry_rate": round(retry_rate, 4),
313
+ "error_rate": round(error_rate, 4),
314
+ })
315
+
316
+ summary_path = os.path.join(RESULTS_DIR, "summary.csv")
317
+ if summary_rows:
318
+ with open(summary_path, "w", newline="", encoding="utf-8") as f:
319
+ writer = csv.DictWriter(f, fieldnames=list(summary_rows[0].keys()))
320
+ writer.writeheader()
321
+ writer.writerows(summary_rows)
322
+
323
+ print(f"\n✅ Summary rewritten → {summary_path}")
324
+ print("\n" + "="*90)
325
+ print(f"{'Architecture':<18} {'Pos.Acc':>8} {'Stale%':>8} {'Contra%':>9} {'Latency':>10} {'Retry%':>8}")
326
+ print("-"*90)
327
+ for r in summary_rows:
328
+ print(
329
+ f"{r['architecture']:<18}"
330
+ f" {r['position_match_rate']*100:>6.1f}%"
331
+ f" {r['staleness_catch_rate']*100:>6.1f}%"
332
+ f" {r['contradiction_catch_rate']*100:>7.1f}%"
333
+ f" {r['avg_latency_ms']:>9.0f}ms"
334
+ f" {r['retry_rate']*100:>6.1f}%"
335
+ )
336
+ print("="*90)
337
+ print("\n→ Paste these numbers into your resume bullets.")
338
+ print("→ recon_linear staleness_catch_rate and contradiction_catch_rate are your headline metrics.")
339
+
340
+
341
+ # ── Entry point ───────────────────────────────────────────────────────────────
342
+
343
+ def main():
344
+ parser = argparse.ArgumentParser()
345
+ parser.add_argument(
346
+ "--summary-only",
347
+ action="store_true",
348
+ help="Skip patching, just recompute summary from existing CSVs",
349
+ )
350
+ args = parser.parse_args()
351
+
352
+ print("="*60)
353
+ print("RECON — Contradiction Catch Rate Patch")
354
+ print("="*60)
355
+
356
+ if args.summary_only:
357
+ recompute_summary()
358
+ return
359
+
360
+ # Load ground truth
361
+ with open(GT_F, encoding="utf-8") as f:
362
+ gt_list = json.load(f)
363
+ gt_map = {entry["id"]: entry for entry in gt_list}
364
+
365
+ cat_c_count = sum(1 for e in gt_list if e["id"].startswith("C"))
366
+ print(f"Ground truth entries: {len(gt_list)} ({cat_c_count} Category C)")
367
+ print(f"Architectures to patch: {len(ARCH_FILES)}")
368
+ print(f"Total judge calls: ~{cat_c_count * len(ARCH_FILES)}")
369
+ print(f"Estimated runtime: ~{cat_c_count * len(ARCH_FILES) * 2 // 60} minutes")
370
+ print()
371
+
372
+ results = []
373
+ for arch_name, path in ARCH_FILES.items():
374
+ try:
375
+ result = patch_csv(path, arch_name, gt_map)
376
+ if result:
377
+ results.append(result)
378
+ except SystemExit:
379
+ print("\n⛔ Daily token limit hit. Re-run tomorrow with:")
380
+ print(" python eval/patch_contradiction.py")
381
+ print(" (already-patched rows are saved — it resumes safely)")
382
+ raise
383
+
384
+ print("\n" + "="*60)
385
+ print("Patch complete. Recomputing summary...")
386
+ recompute_summary()
387
+
388
+
389
+ if __name__ == "__main__":
390
+ main()