# MATH grader v2 (Oct 27 2026). Superset of math_grade.py: anything v1 accepts, v2 accepts. # Adds conservative equivalences for the 16 E17 false negatives: base subscripts, option letters, # units, decimal vs fraction (incl. a/b), \frac9{19}, leading 'x \in', ordinals, unordered lists and +-, # and element-wise pmatrix comparison. import re from math_grade import last_boxed, norm as norm1, num as num1, correct as correct_v1 def norm(s): s = norm1(s) if s is None: return None s = re.sub(r'\\(?:mbox|textbf|mathrm)\{([^{}]*)\}', r'\1', s) s = re.sub(r'\\text\{([^{}]*)\}', r'\1', s) s = re.sub(r'\\frac(\d)\{', r'\\frac{\1}{', s) # \frac9{19} s = re.sub(r'\\frac\{([^{}]+)\}(\d)(?!\d)', r'\\frac{\1}{\2}', s) s = re.sub(r'^[a-zA-Z]\\in', '', s) # x \in [..] s = re.sub(r'_\{(\d+)\}$', r'_\1', s) # 4210_{5} -> 4210_5 m = re.fullmatch(r'\(?([A-E])\)?', s) # (C) / C if m: s = m.group(1) s = re.sub(r'^(-?\d+)(?:\^\{?(?:\\mathrm\{)?(?:st|nd|rd|th)\}?\}?|st|nd|rd|th)$', r'\1', s) # ordinals m = re.fullmatch(r'(-?[\d.,]+|\\frac\{\d+\}\{\d+\})((?:[a-zA-Z]+)(?:\^\{?\d\}?)?)', s) # trailing units if m and m.group(2) not in ('i', 'e', 'pi'): s = m.group(1) return s def num(s): v = num1(s) if v is not None: return v m = re.fullmatch(r'(-?\d+)/(\d+)', s) if m and int(m.group(2)): return int(m.group(1)) / int(m.group(2)) m = re.fullmatch(r'-\\frac\{(\d+)\}\{(\d+)\}', s) if m: return -int(m.group(1)) / int(m.group(2)) return None def eq(a, b): if a == b: return True x, y = num(a), num(b) return x is not None and y is not None and abs(x - y) < 1e-9 def split_top(s): out, d, cur = [], 0, '' for ch in s: if ch in '([{': d += 1 elif ch in ')]}': d -= 1 if ch == ',' and d == 0: out.append(cur); cur = '' else: cur += ch out.append(cur) return out def expand_pm(parts): out = [] for p in parts: m = re.fullmatch(r'(.+?)\\pm(.+)', p) out += [m.group(1) + '+' + m.group(2), m.group(1) + '-' + m.group(2)] if m else [p] return out def pmat(s): m = re.fullmatch(r'\\begin\{[pb]matrix\}(.*)\\end\{[pb]matrix\}', s) return [e for e in re.split(r'\\\\|&', m.group(1))] if m else None def correct(text, gold): if correct_v1(text, gold): return True p, g = norm(last_boxed(text)), norm(gold) if p is None: return False if eq(p, g): return True # base subscript present on only one side bp, bg = re.fullmatch(r'(\w+)_(\d+)', p), re.fullmatch(r'(\w+)_(\d+)', g) if bg and not bp and p == bg.group(1): return True if bp and not bg and g == bp.group(1): return True # matrices element-wise mp, mg = pmat(re.sub(r'\s+', '', last_boxed(text))), pmat(re.sub(r'\s+', '', gold)) if mp is not None and mg is not None: # raw (pre-norm) so \\\\ row breaks survive return len(mp) == len(mg) and all(eq(norm(a), norm(b)) for a, b in zip(mp, mg)) # unordered lists of roots (gold not an interval/tuple), with +- expansion if not g.startswith(('(', '[')) and not p.startswith(('(', '[')): A, B = expand_pm(split_top(p)), expand_pm(split_top(g)) if len(A) == len(B) and len(A) > 1: A, B = sorted(norm(a) for a in A), sorted(norm(b) for b in B) used = [False] * len(B) for a in A: k = next((j for j, b in enumerate(B) if not used[j] and eq(a, b)), None) if k is None: return False used[k] = True return True return False