File size: 5,551 Bytes
fe0c99f
9b70494
fe0c99f
de8ccff
fe0c99f
de8ccff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe0c99f
 
 
9b70494
 
fe0c99f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de8ccff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe0c99f
 
 
 
 
 
 
9b70494
 
fe0c99f
 
 
 
 
 
 
 
 
 
 
 
 
9b70494
 
fe0c99f
 
 
 
 
 
 
 
9b70494
 
 
 
 
 
fe0c99f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de8ccff
fe0c99f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de8ccff
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
"""Robust LaTeX โ†’ SymPy parser with multiple fallback strategies."""
import logging
import re

from sympy import (
    Abs,
    E,
    acos,
    asin,
    atan,
    cos,
    cot,
    csc,
    exp,
    ln,
    log,
    oo,
    pi,
    sec,
    sin,
    sqrt,
    symbols,
    sympify,
    tan,
)
from sympy.parsing.latex import parse_latex

logger = logging.getLogger(__name__)

_COMMON = {
    r"\sin": "sin", r"\cos": "cos", r"\tan": "tan",
    r"\sec": "sec", r"\csc": "csc", r"\cot": "cot",
    r"\arcsin": "asin", r"\arccos": "acos", r"\arctan": "atan",
    r"\ln": "log", r"\log": "log", r"\exp": "exp",
    r"\sqrt": "sqrt", r"\pi": "pi", r"\infty": "oo",
    r"\left": "", r"\right": "", r"\,": " ", r"\!": "",
    r"\cdot": "*", r"\times": "*",
}


class ExpressionParser:
    def __init__(self):
        self._x, self._y, self._z, self._t = symbols("x y z t")
        self._n, self._k = symbols("n k", integer=True)

    def parse(self, latex_str: str) -> dict:
        """Parse a LaTeX math expression into a SymPy object.

        Tries three strategies in order: SymPy's ``parse_latex``, a manual
        translation pass, and finally a raw ``sympify`` call.  The first
        strategy that succeeds is returned.

        Args:
            latex_str: A LaTeX string such as ``r"\\frac{d}{dx} x^2"`` or
                ``"x^2 + 3x - 1"``.

        Returns:
            On success: ``{"success": True, "sympy_expr": Expr, "latex": str,
            "variables": list[str], "raw": str}``.
            On failure: ``{"success": False, "error": str, "latex": str}``.
        """
        cleaned = self._preprocess(latex_str)
        expr = None
        error = None

        # Strategy 1: SymPy parse_latex
        try:
            expr = parse_latex(cleaned)
        except Exception as exc:  # noqa: BLE001 โ€” parse_latex wraps many ANTLR / SymPy errors; we fall through to the next strategy.
            logger.debug("parse_latex failed for %r: %s", cleaned, exc)

        # Strategy 2: manual translation to SymPy string
        if expr is None:
            try:
                py_str = self._latex_to_sympy_str(cleaned)
                expr = sympify(py_str, locals={
                    "x": self._x, "y": self._y, "z": self._z, "t": self._t,
                    "n": self._n, "k": self._k, "pi": pi, "e": E, "E": E,
                    "sin": sin, "cos": cos, "tan": tan, "sec": sec,
                    "csc": csc, "cot": cot, "asin": asin, "acos": acos,
                    "atan": atan, "log": log, "ln": ln, "exp": exp,
                    "sqrt": sqrt, "Abs": Abs, "oo": oo,
                })
            except Exception as exc:  # noqa: BLE001 โ€” sympify exposes many parse-class exceptions; fall through to Strategy 3.
                logger.debug("manual sympify failed for %r: %s", cleaned, exc)

        # Strategy 3: try raw sympify
        if expr is None:
            try:
                expr = sympify(cleaned.replace("^", "**"))
            except Exception as e:
                error = str(e)

        # ``sympify`` can return non-Basic singletons for inputs like
        # ``"..."`` (Python's Ellipsis) which lack ``free_symbols``. Reject
        # those alongside None so downstream callers always see a real
        # sympy expression on the success branch โ€” surfaced by the
        # hypothesis fuzz suite (``test_parser_never_raises_on_fuzzy_math_like_input``).
        if expr is not None and hasattr(expr, "free_symbols"):
            return {
                "success": True,
                "sympy_expr": expr,
                "latex": latex_str,
                "variables": sorted(str(s) for s in expr.free_symbols),
                "raw": str(expr),
            }
        return {"success": False, "error": error or "Unable to parse expression", "latex": latex_str}

    # โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    def _preprocess(self, latex: str) -> str:
        s = latex.strip()
        s = re.sub(r"\\left|\\right", "", s)
        s = s.replace(r"\,", " ").replace(r"\!", "")
        s = s.replace("ฯ€", "pi").replace("โˆž", "oo")
        s = s.replace("ร—", "*").replace("ยท", "*")
        s = s.replace("โˆ’", "-")
        s = re.sub(r"\\\s+", " ", s)
        return re.sub(r"\\operatorname\{(\w+)\}", r"\\\1", s)

    def _latex_to_sympy_str(self, latex: str) -> str:
        s = latex
        # handle \frac{a}{b} โ†’ ((a)/(b))
        while r"\frac" in s:
            s = re.sub(r"\\frac\{([^{}]*)\}\{([^{}]*)\}", r"((\1)/(\2))", s)
            s = re.sub(
                r"\\frac\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}",
                r"((\1)/(\2))", s,
            )
            if r"\frac" in s:
                break
        # handle \sqrt[n]{x} and \sqrt{x}
        s = re.sub(r"\\sqrt\[([^\]]+)\]\{([^{}]+)\}", r"((\2)**(1/(\1)))", s)
        s = re.sub(r"\\sqrt\{([^{}]+)\}", r"sqrt(\1)", s)
        for pat, rep in _COMMON.items():
            s = s.replace(pat, rep)
        s = s.replace("^", "**").replace("{", "(").replace("}", ")")
        # insert multiplication: 2x โ†’ 2*x, )x โ†’ )*x, x( โ†’ x*(
        s = re.sub(r"(\d)([a-zA-Z(])", r"\1*\2", s)
        s = re.sub(r"\)(\w)", r")*\1", s)
        s = re.sub(r"\)\(", r")*(", s)
        s = re.sub(r"(?<![a-zA-Z])([a-zA-Z])\(", r"\1*(", s)
        s = re.sub(r"([a-zA-Z0-9\)])\s+([a-zA-Z])", r"\1*\2", s)
        return re.sub(r"\s+", " ", s).strip()