Skip to content

Commit 9d61083

Browse files
cheese-cakeeDavide Cifarelliwozcode
authored
fix(harness): consolidate duplicated math scoring into shared module (#383)
* fix(harness): consolidate duplicated math scoring into shared module _extract_boxed, _normalize_math, and _math_equiv were copy-pasted across three files with no shared import. generation_benchmark's copy was missing currency/unit normalization, \frac whitespace handling, and mixed-number matching — causing benchmark scores to disagree on identical model outputs. Move the superset implementation (from bench_llm.py) into harness/math_scoring.py. Import from the three consumers and delete the local copies. * fix(harness): normalize tfrac/dfrac to frac and anchor fraction equivalence Identified by cubic: two issues in math scoring. 1. \\tfrac and \\dfrac were stripped destructively instead of normalizing to \\frac, breaking equivalence for valid fractional answers (e.g. \\tfrac{1}{2} == 0.5 returned False). 2. Fraction equivalence used unanchored substring matching, allowing composite expressions to be incorrectly graded equivalent to a scalar (e.g. \\frac{1}{2} + \\frac{3}{4} == 0.5 returned True). * fix(harness): drop unused _normalize_math import (ruff F401) client_test_runner.py and generation_benchmark.py imported _normalize_math but only use it transitively via _math_equiv, so ruff F401 (in CI's select=[F,I,UP,B]) failed the lint gate. Drop the unused name from both imports. bench_llm.py keeps it (re-exported to bench_server.py) and is outside ruff's include list. Co-Authored-By: WOZCODE <contact@withwoz.com> --------- Co-authored-by: Davide Cifarelli <davide@cifarelli.tech> Co-authored-by: WOZCODE <contact@withwoz.com>
1 parent befbf03 commit 9d61083

4 files changed

Lines changed: 131 additions & 264 deletions

File tree

harness/benchmarks/generation_benchmark.py

Lines changed: 4 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
from pathlib import Path
2121
from typing import Any
2222

23+
# Shared math-scoring helpers (canonical copy in harness/).
24+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
25+
from math_scoring import _extract_boxed, _math_equiv
26+
2327

2428
def load_cases(path: Path) -> list[dict[str, Any]]:
2529
cases: list[dict[str, Any]] = []
@@ -52,73 +56,6 @@ def approx_token_count(text: str) -> int:
5256
return max(1, len(re.findall(r"\S+", text)))
5357

5458

55-
def _extract_boxed(text: str) -> str | None:
56-
"""Extract the last \\boxed{...} from a string, handling nested braces."""
57-
results = []
58-
i = 0
59-
while i < len(text):
60-
idx = text.find("\\boxed{", i)
61-
if idx == -1:
62-
break
63-
start = idx + len("\\boxed{")
64-
depth = 1
65-
j = start
66-
while j < len(text) and depth > 0:
67-
if text[j] == "{":
68-
depth += 1
69-
elif text[j] == "}":
70-
depth -= 1
71-
j += 1
72-
if depth == 0:
73-
results.append(text[start:j-1].strip())
74-
i = j
75-
return results[-1] if results else None
76-
77-
78-
def _normalize_math(s: str) -> str:
79-
"""Normalize a math answer string for comparison."""
80-
if s is None:
81-
return ""
82-
s = s.strip()
83-
if s.startswith("$") and s.endswith("$"):
84-
s = s[1:-1].strip()
85-
s = re.sub(r"\\text\s*\{([^}]*)\}", r"\1", s)
86-
s = re.sub(r"\\mathrm\s*\{([^}]*)\}", r"\1", s)
87-
for cmd in [r"\left", r"\right", r"\displaystyle", r"\tfrac", r"\dfrac"]:
88-
s = s.replace(cmd, "")
89-
s = re.sub(r"\s+", " ", s).strip()
90-
s = s.rstrip(".,")
91-
return s
92-
93-
94-
def _math_equiv(pred: str, gold: str) -> bool:
95-
"""Check if two math answers are equivalent."""
96-
if pred is None or gold is None:
97-
return False
98-
p = _normalize_math(pred)
99-
g = _normalize_math(gold)
100-
if p == g:
101-
return True
102-
try:
103-
pf = float(p.replace(",", ""))
104-
gf = float(g.replace(",", ""))
105-
return abs(pf - gf) < 1e-6
106-
except (ValueError, TypeError):
107-
pass
108-
frac_pat = re.compile(r"\\?frac\s*\{([^}]+)\}\s*\{([^}]+)\}")
109-
for s, other in [(p, g), (g, p)]:
110-
m = frac_pat.search(s)
111-
if m:
112-
try:
113-
val = float(m.group(1)) / float(m.group(2))
114-
oval = float(other.replace(",", ""))
115-
if abs(val - oval) < 1e-6:
116-
return True
117-
except (ValueError, ZeroDivisionError):
118-
pass
119-
return False
120-
121-
12259
def _extract_numeric_answer(text: str) -> str | None:
12360
"""Extract a numeric answer from model output for GSM-style problems."""
12461
think_end = text.rfind("</think>")

harness/client_test_runner.py

Lines changed: 18 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
import itertools
1919
import json
2020
import os
21+
import re
2122
import shutil
2223
import signal
2324
import socket
2425
import subprocess
26+
import sys
2527
import time
2628
import urllib.error
2729
import urllib.request
@@ -30,6 +32,10 @@
3032
from pathlib import Path
3133
from typing import Any
3234

35+
# Shared math-scoring helpers (canonical copy in harness/).
36+
sys.path.insert(0, str(Path(__file__).resolve().parent))
37+
from math_scoring import _extract_boxed, _math_equiv
38+
3339
ROOT = Path(__file__).resolve().parent.parent
3440
DEFAULT_WORK_DIR = ROOT / ".harness-work"
3541
MODEL = "luce-dflash"
@@ -1357,101 +1363,6 @@ def cmd_report(args: argparse.Namespace) -> int:
13571363
return 0 if payload["ok"] else 1
13581364

13591365

1360-
# ── Math scoring helpers (ported from bench_llm.py) ─────────────────────────
1361-
1362-
import re as _re
1363-
1364-
1365-
def _extract_boxed(text: str) -> str | None:
1366-
"""Extract the last \\boxed{...} from a string, handling nested braces."""
1367-
results = []
1368-
i = 0
1369-
while i < len(text):
1370-
idx = text.find("\\boxed{", i)
1371-
if idx == -1:
1372-
break
1373-
start = idx + len("\\boxed{")
1374-
depth = 1
1375-
j = start
1376-
while j < len(text) and depth > 0:
1377-
if text[j] == "{":
1378-
depth += 1
1379-
elif text[j] == "}":
1380-
depth -= 1
1381-
j += 1
1382-
if depth == 0:
1383-
results.append(text[start:j-1].strip())
1384-
i = j
1385-
return results[-1] if results else None
1386-
1387-
1388-
def _normalize_math(s: str | None) -> str:
1389-
"""Normalize a math answer string for comparison."""
1390-
if s is None:
1391-
return ""
1392-
s = s.strip()
1393-
if s.startswith("$") and s.endswith("$"):
1394-
s = s[1:-1].strip()
1395-
# Strip currency $ (e.g. "$18" → "18")
1396-
if _re.match(r'^\$\d', s):
1397-
s = s[1:]
1398-
s = _re.sub(r"\\text\s*\{([^}]*)\}", r"\1", s)
1399-
s = _re.sub(r"\\mathrm\s*\{([^}]*)\}", r"\1", s)
1400-
for cmd in [r"\left", r"\right", r"\displaystyle", r"\tfrac", r"\dfrac"]:
1401-
s = s.replace(cmd, "")
1402-
for unit in [" cm", " m", " km", " kg", " g", " s", " ms",
1403-
" degrees", " degree", "\u00b0", " inches", " feet",
1404-
" square units", " units", " dollars"]:
1405-
if s.lower().rstrip(".").endswith(unit):
1406-
s = s[:len(s) - len(unit) - (1 if s.endswith(".") else 0)]
1407-
s = _re.sub(r"\s+", " ", s).strip()
1408-
s = s.rstrip(".,")
1409-
return s
1410-
1411-
1412-
def _math_equiv(pred: str | None, gold: str | None) -> bool:
1413-
"""Check if two math answers are equivalent."""
1414-
if pred is None or gold is None:
1415-
return False
1416-
p = _normalize_math(pred)
1417-
g = _normalize_math(gold)
1418-
if p == g:
1419-
return True
1420-
p_c = _re.sub(r"\s*\\frac", r"\\frac", p)
1421-
g_c = _re.sub(r"\s*\\frac", r"\\frac", g)
1422-
if p_c == g_c:
1423-
return True
1424-
try:
1425-
pf = float(p.replace(",", ""))
1426-
gf = float(g.replace(",", ""))
1427-
return abs(pf - gf) < 1e-6
1428-
except (ValueError, TypeError):
1429-
pass
1430-
mixed_pat = _re.compile(r"^(\d+)\s*\\frac\s*\{(\d+)\}\s*\{(\d+)\}$")
1431-
for s, other in [(p, g), (g, p)]:
1432-
m = mixed_pat.match(s)
1433-
if m:
1434-
try:
1435-
val = float(m.group(1)) + float(m.group(2)) / float(m.group(3))
1436-
oval = float(other.replace(",", ""))
1437-
if abs(val - oval) < 1e-6:
1438-
return True
1439-
except (ValueError, ZeroDivisionError):
1440-
pass
1441-
frac_pat = _re.compile(r"\\?frac\s*\{([^}]+)\}\s*\{([^}]+)\}")
1442-
for s, other in [(p, g), (g, p)]:
1443-
m = frac_pat.search(s)
1444-
if m:
1445-
try:
1446-
val = float(m.group(1)) / float(m.group(2))
1447-
oval = float(other.replace(",", ""))
1448-
if abs(val - oval) < 1e-6:
1449-
return True
1450-
except (ValueError, ZeroDivisionError):
1451-
pass
1452-
return False
1453-
1454-
14551366
def _score_math_response(text: str, gold_answer: str) -> tuple[bool, str]:
14561367
"""Score a Math500 response. Returns (correct, detail_str)."""
14571368
think_end = text.rfind("</think>")
@@ -1461,16 +1372,16 @@ def _score_math_response(text: str, gold_answer: str) -> tuple[bool, str]:
14611372

14621373
# Fallback: "the answer is **X**" patterns
14631374
if pred is None:
1464-
bold_pattern = _re.compile(
1375+
bold_pattern = re.compile(
14651376
r'(?:answer\s+is|there\s+are|result\s+is|equals?|=)\s*\*\*(.+?)\*\*',
1466-
_re.IGNORECASE)
1377+
re.IGNORECASE)
14671378
m = bold_pattern.search(answer_text)
14681379
if m:
14691380
pred = m.group(1).strip().rstrip(".")
14701381

14711382
# Fallback: last $...$ expression
14721383
if pred is None:
1473-
matches = _re.findall(r'\$([^$]+)\$', answer_text)
1384+
matches = re.findall(r'\$([^$]+)\$', answer_text)
14741385
if matches:
14751386
pred = matches[-1].strip()
14761387

@@ -1497,32 +1408,32 @@ def _score_gsm_response(text: str, gold_answer: str) -> tuple[bool, str]:
14971408
boxed = _extract_boxed(answer_text)
14981409
if boxed:
14991410
cleaned = boxed.replace(",", "").replace("$", "").strip()
1500-
if _re.match(r'^[+-]?\d+\.?\d*$', cleaned):
1411+
if re.match(r'^[+-]?\d+\.?\d*$', cleaned):
15011412
pred = cleaned
15021413

15031414
# #### <number>
15041415
if pred is None:
1505-
m = _re.search(r'####\s*\$?([+-]?\d[\d,]*\.?\d*)', answer_text)
1416+
m = re.search(r'####\s*\$?([+-]?\d[\d,]*\.?\d*)', answer_text)
15061417
if m:
15071418
pred = m.group(1).replace(",", "")
15081419

15091420
# "the answer is **X**"
15101421
if pred is None:
1511-
m = _re.search(
1422+
m = re.search(
15121423
r'(?:answer\s+is|result\s+is|equals?|there\s+are|we\s+get)\s*\*?\*?\$?([+-]?\d[\d,]*\.?\d*)',
1513-
answer_text, _re.IGNORECASE)
1424+
answer_text, re.IGNORECASE)
15141425
if m:
15151426
pred = m.group(1).replace(",", "")
15161427

15171428
# **<number>** or **$<number>**
15181429
if pred is None:
1519-
m = _re.search(r'\*\*\$?([+-]?\d[\d,]*\.?\d*)\*\*', answer_text)
1430+
m = re.search(r'\*\*\$?([+-]?\d[\d,]*\.?\d*)\*\*', answer_text)
15201431
if m:
15211432
pred = m.group(1).replace(",", "")
15221433

15231434
# Last standalone number
15241435
if pred is None:
1525-
nums = _re.findall(r'(?<![.\d])([+-]?\d[\d,]*\.?\d*)(?![.\d])', answer_text)
1436+
nums = re.findall(r'(?<![.\d])([+-]?\d[\d,]*\.?\d*)(?![.\d])', answer_text)
15261437
if nums:
15271438
pred = nums[-1].replace(",", "")
15281439

@@ -1555,13 +1466,13 @@ def _score_he_response(text: str, entry_point: str, gold_test: str) -> tuple[boo
15551466

15561467
# Extract code block (```python ... ``` or ``` ... ```)
15571468
code = None
1558-
m = _re.search(r'```(?:python)?\s*\n(.*?)```', answer_text, _re.DOTALL)
1469+
m = re.search(r'```(?:python)?\s*\n(.*?)```', answer_text, re.DOTALL)
15591470
if m:
15601471
code = m.group(1)
15611472
else:
15621473
# Try to find the function definition directly
1563-
m = _re.search(r'((?:from\s|import\s).*?\n)?(\s*def\s+' + _re.escape(entry_point) + r'\b.*)',
1564-
answer_text, _re.DOTALL)
1474+
m = re.search(r'((?:from\s|import\s).*?\n)?(\s*def\s+' + re.escape(entry_point) + r'\b.*)',
1475+
answer_text, re.DOTALL)
15651476
if m:
15661477
prefix = m.group(1) or ""
15671478
code = prefix + m.group(2)

harness/math_scoring.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Shared math-scoring helpers for answer equivalence.
2+
3+
Canonical home for ``_extract_boxed``, ``_normalize_math``, and
4+
``_math_equiv``. Every harness script that scores MATH / GSM8K
5+
answers should import from here rather than maintaining a local copy.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
12+
13+
def _extract_boxed(text: str) -> str | None:
14+
"""Extract the last \\boxed{...} from a string, handling nested braces."""
15+
results = []
16+
i = 0
17+
while i < len(text):
18+
idx = text.find("\\boxed{", i)
19+
if idx == -1:
20+
break
21+
start = idx + len("\\boxed{")
22+
depth = 1
23+
j = start
24+
while j < len(text) and depth > 0:
25+
if text[j] == "{":
26+
depth += 1
27+
elif text[j] == "}":
28+
depth -= 1
29+
j += 1
30+
if depth == 0:
31+
results.append(text[start : j - 1].strip())
32+
i = j
33+
return results[-1] if results else None
34+
35+
36+
def _normalize_math(s: str | None) -> str:
37+
"""Normalize a math answer string for comparison."""
38+
if s is None:
39+
return ""
40+
s = s.strip()
41+
if s.startswith("$") and s.endswith("$"):
42+
s = s[1:-1].strip()
43+
# Strip currency $ (e.g. "$18" -> "18")
44+
if re.match(r"^\$\d", s):
45+
s = s[1:]
46+
s = re.sub(r"\\text\s*\{([^}]*)\}", r"\1", s)
47+
s = re.sub(r"\\mathrm\s*\{([^}]*)\}", r"\1", s)
48+
for cmd in [r"\left", r"\right", r"\displaystyle"]:
49+
s = s.replace(cmd, "")
50+
s = s.replace(r"\tfrac", r"\frac")
51+
s = s.replace(r"\dfrac", r"\frac")
52+
for unit in [
53+
" cm", " m", " km", " kg", " g", " s", " ms",
54+
" degrees", " degree", "\u00b0", " inches", " feet",
55+
" square units", " units", " dollars",
56+
]:
57+
if s.lower().rstrip(".").endswith(unit):
58+
s = s[: len(s) - len(unit) - (1 if s.endswith(".") else 0)]
59+
s = re.sub(r"\s+", " ", s).strip()
60+
s = s.rstrip(".,")
61+
return s
62+
63+
64+
def _math_equiv(pred: str | None, gold: str | None) -> bool:
65+
"""Check if two math answers are equivalent."""
66+
if pred is None or gold is None:
67+
return False
68+
p = _normalize_math(pred)
69+
g = _normalize_math(gold)
70+
if p == g:
71+
return True
72+
p_c = re.sub(r"\s*\\frac", r"\\frac", p)
73+
g_c = re.sub(r"\s*\\frac", r"\\frac", g)
74+
if p_c == g_c:
75+
return True
76+
try:
77+
pf = float(p.replace(",", ""))
78+
gf = float(g.replace(",", ""))
79+
return abs(pf - gf) < 1e-6
80+
except (ValueError, TypeError):
81+
pass
82+
mixed_pat = re.compile(r"^(\d+)\s*\\frac\s*\{(\d+)\}\s*\{(\d+)\}$")
83+
for s, other in [(p, g), (g, p)]:
84+
m = mixed_pat.match(s)
85+
if m:
86+
try:
87+
val = float(m.group(1)) + float(m.group(2)) / float(m.group(3))
88+
oval = float(other.replace(",", ""))
89+
if abs(val - oval) < 1e-6:
90+
return True
91+
except (ValueError, ZeroDivisionError):
92+
pass
93+
frac_pat = re.compile(r"^\\frac\s*\{([^}]+)\}\s*\{([^}]+)\}$")
94+
for s, other in [(p, g), (g, p)]:
95+
m = frac_pat.search(s)
96+
if m:
97+
try:
98+
val = float(m.group(1)) / float(m.group(2))
99+
oval = float(other.replace(",", ""))
100+
if abs(val - oval) < 1e-6:
101+
return True
102+
except (ValueError, ZeroDivisionError):
103+
pass
104+
return False

0 commit comments

Comments
 (0)