File size: 6,922 Bytes
50f2856
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""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)


@dataclass
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