Spaces:
Running
Running
Download game/expr.py from FineEnvs/ScottWuMath: direct link, hf CLI and curl.
- Browser
- Download file 6.92 kB
-
https://huggingface.co/spaces/FineEnvs/ScottWuMath/resolve/main/game/expr.py
- Command line
-
hf download hf://spaces/FineEnvs/ScottWuMath/game/expr.py
-
curl -L -o expr.py https://huggingface.co/spaces/FineEnvs/ScottWuMath/resolve/main/game/expr.py
6.92 kB
| """Parse, evaluate and check an arithmetic answer. | |
| An answer is an ordinary expression over the card values with + - * / and parentheses, | |
| for example ``(13 - 12 + 11 - 10 + 7) * 9 * 5 * 5``. Every card is used exactly once. | |
| Division is exact (rational), so intermediate fractions are allowed, as in the 24 game. | |
| Unary minus, powers, decimals and joining digits ("1" and "3" into "13") are not allowed. | |
| Expressions are trees: an ``int`` leaf or a ``(op, left, right)`` tuple. | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import re | |
| from collections import Counter | |
| from dataclasses import dataclass, field | |
| from fractions import Fraction | |
| from typing import Union | |
| Tree = Union[int, tuple] | |
| OPS = ("+", "-", "*", "/") | |
| _PRECEDENCE = {"+": 1, "-": 1, "*": 2, "/": 2} | |
| _AST_OPS = {ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/"} | |
| _LETTERS = {"A": "1", "J": "11", "Q": "12", "K": "13"} | |
| MAX_LENGTH = 400 | |
| # What people and models type for the four operators. | |
| _SYMBOLS = str.maketrans( | |
| { | |
| "×": "*", | |
| "x": "*", | |
| "X": "*", | |
| "·": "*", | |
| "∙": "*", | |
| "÷": "/", | |
| "−": "-", | |
| "–": "-", | |
| "—": "-", | |
| "[": "(", | |
| "]": ")", | |
| "{": "(", | |
| "}": ")", | |
| } | |
| ) | |
| class ExpressionError(ValueError): | |
| pass | |
| def apply(op: str, a: Fraction, b: Fraction) -> Fraction: | |
| if op == "+": | |
| return a + b | |
| if op == "-": | |
| return a - b | |
| if op == "*": | |
| return a * b | |
| if b == 0: | |
| raise ZeroDivisionError | |
| return a / b | |
| def evaluate(tree: Tree) -> Fraction: | |
| if isinstance(tree, int): | |
| return Fraction(tree) | |
| op, left, right = tree | |
| return apply(op, evaluate(left), evaluate(right)) | |
| def leaves(tree: Tree) -> list[int]: | |
| if isinstance(tree, int): | |
| return [tree] | |
| return leaves(tree[1]) + leaves(tree[2]) | |
| def to_text(tree: Tree, pretty: bool = False) -> str: | |
| """Write a tree with the fewest parentheses; pretty=True uses × ÷ − like a person would.""" | |
| symbols = {"+": "+", "-": "−", "*": "×", "/": "÷"} if pretty else {o: o for o in OPS} | |
| def walk(node: Tree) -> tuple[str, int]: | |
| if isinstance(node, int): | |
| return str(node), 3 | |
| op, left, right = node | |
| prec = _PRECEDENCE[op] | |
| ltext, lprec = walk(left) | |
| rtext, rprec = walk(right) | |
| if lprec < prec: | |
| ltext = f"({ltext})" | |
| if rprec < prec or (rprec == prec and op in "-/"): | |
| rtext = f"({rtext})" | |
| return f"{ltext} {symbols[op]} {rtext}", prec | |
| return walk(tree)[0] | |
| def steps(tree: Tree) -> list[str]: | |
| """The solution as spoken, one combination per line: "13 − 12 = 1", "1 + 1 = 2", ...""" | |
| lines: list[str] = [] | |
| def walk(node: Tree) -> Fraction: | |
| if isinstance(node, int): | |
| return Fraction(node) | |
| op, left, right = node | |
| a, b = walk(left), walk(right) | |
| value = apply(op, a, b) | |
| sym = {"+": "+", "-": "−", "*": "×", "/": "÷"}[op] | |
| lines.append(f"{fmt(a)} {sym} {fmt(b)} = {fmt(value)}") | |
| return value | |
| walk(tree) | |
| return lines | |
| def fmt(value: Fraction) -> str: | |
| return str(value.numerator) if value.denominator == 1 else f"{value.numerator}/{value.denominator}" | |
| def normalize(text: str) -> str: | |
| text = text.strip().translate(_SYMBOLS).replace("**", "^") | |
| # "... = 2025": judge the left-hand side; the value is checked against the target anyway. | |
| if "=" in text: | |
| text = text.split("=", 1)[0] | |
| # Card letters as values: A, J, Q, K (only as whole tokens). | |
| text = re.sub(r"\b([AJQK])\b", lambda m: _LETTERS[m.group(1)], text) | |
| return text.strip() | |
| def parse(text: str) -> Tree: | |
| """Parse an answer into a tree, rejecting anything but integers, + - * / and parentheses.""" | |
| if not isinstance(text, str) or not text.strip(): | |
| raise ExpressionError("empty expression") | |
| if len(text) > MAX_LENGTH: | |
| raise ExpressionError(f"expression longer than {MAX_LENGTH} characters") | |
| source = normalize(text) | |
| if "^" in source: | |
| raise ExpressionError("powers are not allowed") | |
| try: | |
| node = ast.parse(source, mode="eval").body | |
| except SyntaxError as exc: | |
| raise ExpressionError(f"not an arithmetic expression: {source!r}") from exc | |
| def build(n: ast.AST) -> Tree: | |
| if isinstance(n, ast.Constant) and type(n.value) is int and n.value >= 0: | |
| return n.value | |
| if isinstance(n, ast.BinOp) and type(n.op) in _AST_OPS: | |
| return (_AST_OPS[type(n.op)], build(n.left), build(n.right)) | |
| if isinstance(n, ast.UnaryOp): | |
| raise ExpressionError("unary minus or plus is not allowed; every card must be combined with a binary operator") | |
| if isinstance(n, ast.Constant): | |
| raise ExpressionError(f"only whole card values are allowed, not {n.value!r}") | |
| raise ExpressionError(f"only + - * / and parentheses are allowed (found {type(n).__name__})") | |
| return build(node) | |
| class Check: | |
| """Everything the grader knows about one answer.""" | |
| expression: str | |
| parsed: bool = False | |
| uses_all_cards: bool = False | |
| value: Fraction | None = None | |
| exact: bool = False | |
| error: str | None = None | |
| missing: list[int] = field(default_factory=list) | |
| extra: list[int] = field(default_factory=list) | |
| canonical: str | None = None | |
| def to_dict(self) -> dict: | |
| return { | |
| "expression": self.expression, | |
| "parsed": self.parsed, | |
| "uses_all_cards": self.uses_all_cards, | |
| "value": None if self.value is None else fmt(self.value), | |
| "exact": self.exact, | |
| "error": self.error, | |
| "missing": self.missing, | |
| "extra": self.extra, | |
| "canonical": self.canonical, | |
| } | |
| def check(expression: str, card_values: list[int], target: int) -> Check: | |
| result = Check(expression=expression) | |
| try: | |
| tree = parse(expression) | |
| except ExpressionError as exc: | |
| result.error = str(exc) | |
| return result | |
| result.parsed = True | |
| result.canonical = to_text(tree) | |
| used, have = Counter(leaves(tree)), Counter(card_values) | |
| result.missing = sorted((have - used).elements()) | |
| result.extra = sorted((used - have).elements()) | |
| result.uses_all_cards = not result.missing and not result.extra | |
| try: | |
| result.value = evaluate(tree) | |
| except ZeroDivisionError: | |
| result.error = "division by zero" | |
| return result | |
| if not result.uses_all_cards: | |
| parts = [] | |
| if result.missing: | |
| parts.append("unused cards " + ", ".join(map(str, result.missing))) | |
| if result.extra: | |
| parts.append("numbers that are not cards on the table " + ", ".join(map(str, result.extra))) | |
| result.error = "; ".join(parts) | |
| return result | |
| result.exact = result.value == target | |
| return result | |