ed-master-smartsolver 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ Metadata-Version: 2.4
2
+ Name: ed-master-smartsolver
3
+ Version: 0.1.0
4
+ Summary: Step-by-step mathematics solver built on SymPy — equations, calculus, and annotated rules.
5
+ Project-URL: Homepage, https://www.ed-master.co.za
6
+ Project-URL: Repository, https://github.com/mngadilinda/SmartSolver
7
+ Project-URL: Documentation, https://github.com/mngadilinda/SmartSolver#readme
8
+ Author-email: Ed-Master <support@ed-master.co.za>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: algebra,calculus,education,math,solver,step-by-step,sympy
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Education
22
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: sympy>=1.13
25
+ Provides-Extra: dev
26
+ Requires-Dist: build; extra == 'dev'
27
+ Requires-Dist: pytest; extra == 'dev'
28
+ Requires-Dist: twine; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # SmartSolver
32
+
33
+ **SmartSolver** is a step-by-step mathematics engine built on [SymPy](https://www.sympy.org/). It powers worked solutions inside [Ed-Master](https://www.ed-master.co.za) — showing not just the final answer, but the rules and identities applied along the way.
34
+
35
+ The implementation lives in `math_step_tracker.py` (class `SmartSolver`).
36
+
37
+ ---
38
+
39
+ ## Features
40
+
41
+ | Area | What SmartSolver shows |
42
+ |------|------------------------|
43
+ | **Equations** | Linear & quadratic solving, discriminant steps |
44
+ | **Trigonometry** | Quotient, Pythagorean, double-angle identities; tan reduction |
45
+ | **Logarithms & exponentials** | Log product/power rules, `log(x) = c → x = e^c`, `a^x = b` |
46
+ | **Differentiation** | Sum, product, power, chain rule labels |
47
+ | **Integration** | Partial fractions, LIATE-based integration by parts, direct rules |
48
+ | **Limits** | Setup, direct substitution where valid, final limit |
49
+ | **Partial fractions** | Factor denominator, decompose, integrate term-by-term |
50
+
51
+ Each step includes a **description**, **expression**, **LaTeX**, and **rule applied**.
52
+
53
+ ---
54
+
55
+ ## Requirements
56
+
57
+ - **Python 3.10+** (`requires-python` in `pyproject.toml`)
58
+ - **SymPy ≥ 1.13**
59
+
60
+ ### Supported Python versions
61
+
62
+ | Status | Versions |
63
+ |--------|----------|
64
+ | Minimum | Python **3.10** |
65
+ | Tested | Python **3.10**, **3.11**, **3.12**, **3.13** |
66
+
67
+ Only list versions you actually test. Do **not** claim support for unreleased major versions (e.g. Python 4.x or fictional 7.x).
68
+
69
+ ```bash
70
+ pip install ed-master-smartsolver
71
+ ```
72
+
73
+ Import in Python (package module name is `smartsolver`):
74
+
75
+ ```python
76
+ from smartsolver import SmartSolver
77
+ ```
78
+
79
+ Or install SymPy only when using from source:
80
+
81
+ ```bash
82
+ pip install sympy
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Quick start
88
+
89
+ ```python
90
+ from smartsolver import SmartSolver, StepRenderer, serialize_solver_result
91
+
92
+ solver = SmartSolver()
93
+
94
+ # Solve a quadratic
95
+ result = solver.solve_equation("x^2 + 5x + 6 = 0", "x")
96
+ print(StepRenderer.to_text(result["steps"]))
97
+ print("Solutions:", result["solutions"])
98
+
99
+ # JSON-friendly payload (for APIs)
100
+ payload = serialize_solver_result(result)
101
+ print(payload["steps_latex"])
102
+ ```
103
+
104
+ When running from a git checkout (editable install):
105
+
106
+ ```bash
107
+ pip install -e .
108
+ ```
109
+
110
+ ```python
111
+ from smartsolver import SmartSolver
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Input notation
117
+
118
+ SmartSolver accepts student-style strings:
119
+
120
+ | You type | Parsed as |
121
+ |----------|-----------|
122
+ | `x^2` or `x**2` | x squared |
123
+ | `5x` | `5*x` |
124
+ | `2x - 4` | `2x - 4 = 0` (equation assumed zero) |
125
+ | `sin(x)`, `cos(x)`, `log(x)` | SymPy trig / natural log |
126
+ | `2**x` | exponential |
127
+
128
+ ---
129
+
130
+ ## Operations
131
+
132
+ ### `solve_equation(equation, variable='x')`
133
+
134
+ Solve an equation with step-by-step working.
135
+
136
+ ```python
137
+ solver.solve_equation("sin(x) - cos(x) = 0", "x")
138
+ solver.solve_equation("2**x - 8 = 0", "x")
139
+ solver.solve_equation("log(x) - 3 = 0", "x")
140
+ ```
141
+
142
+ **Returns:** `steps`, `solutions`, `solution_latex`
143
+
144
+ ---
145
+
146
+ ### `differentiate(expression, variable='x', order=1)`
147
+
148
+ Differentiate with rule labels (sum, power, chain, etc.).
149
+
150
+ ```python
151
+ solver.differentiate("x**2 + 3*x", "x")
152
+ solver.differentiate("sin(x)**2", "x", order=1)
153
+ ```
154
+
155
+ **Returns:** `steps`, `derivative`, `derivative_latex`
156
+
157
+ ---
158
+
159
+ ### `integrate(expression, variable='x', lower=None, upper=None)`
160
+
161
+ Integrate with method tracking.
162
+
163
+ ```python
164
+ solver.integrate("x*exp(x)", "x") # integration by parts (LIATE)
165
+ solver.integrate("1/(x**2 - 1)", "x") # partial fractions
166
+ solver.integrate("x**2", "x", lower="0", upper="1") # definite
167
+ ```
168
+
169
+ **Returns:** `steps`, `integral`, `integral_latex`, `methods` (e.g. `['Integration by parts']`)
170
+
171
+ ---
172
+
173
+ ### `partial_fractions(expression, variable='x')`
174
+
175
+ Decompose a rational expression without integrating.
176
+
177
+ ```python
178
+ solver.partial_fractions("(2*x + 3)/((x - 1)*(x + 2))")
179
+ ```
180
+
181
+ **Returns:** `steps`, `decomposition`, `decomposition_latex`
182
+
183
+ ---
184
+
185
+ ### `limit(expression, variable='x', point='0', direction='+')`
186
+
187
+ Evaluate a limit with setup steps.
188
+
189
+ ```python
190
+ solver.limit("sin(x)/x", "x", point="0", direction="+")
191
+ ```
192
+
193
+ **Returns:** `steps`, `limit`, `limit_latex`
194
+
195
+ ---
196
+
197
+ ## Helper utilities
198
+
199
+ ```python
200
+ from smartsolver import (
201
+ StepRenderer,
202
+ serialize_solver_result,
203
+ normalize_math_input,
204
+ parse_math,
205
+ step_to_dict,
206
+ )
207
+
208
+ # Human-readable steps
209
+ StepRenderer.to_text(steps)
210
+ StepRenderer.to_latex(steps)
211
+ StepRenderer.to_html(steps)
212
+
213
+ # API / JSON serialization
214
+ serialize_solver_result(result)
215
+ ```
216
+
217
+ ### Optional helpers (Ed-Master Math Lab)
218
+
219
+ If you also ship `edmathlab.py` alongside this package, it provides stdout-friendly wrappers. They are not included in the PyPI wheel by default.
220
+
221
+ ---
222
+
223
+ ## REST API (Ed-Master platform)
224
+
225
+ When the Django backend is running, authenticated users can call:
226
+
227
+ ```
228
+ POST /api/math/steps/
229
+ Content-Type: application/json
230
+ ```
231
+
232
+ **Body:**
233
+
234
+ ```json
235
+ {
236
+ "operation": "solve",
237
+ "expression": "x^2 + 5x + 6 = 0",
238
+ "variable": "x"
239
+ }
240
+ ```
241
+
242
+ **Operations:** `solve`, `differentiate`, `integrate`, `limit`, `partial_fractions`
243
+
244
+ **Response fields:** `steps`, `steps_text`, `steps_html`, `steps_latex`, `result`, `result_latex`, `operation`
245
+
246
+ Requires a signed-in Ed-Master session (JWT cookie).
247
+
248
+ ---
249
+
250
+ ## Result structure
251
+
252
+ Each step is a `Step` dataclass:
253
+
254
+ ```python
255
+ @dataclass
256
+ class Step:
257
+ description: str # e.g. "Apply the quotient identity"
258
+ expression: str # SymPy string at this step
259
+ latex: str # LaTeX rendering
260
+ rule_applied: str # e.g. "Quotient identity", "LIATE: choose u"
261
+ substeps: list # nested steps (reserved)
262
+ ```
263
+
264
+ `serialize_solver_result()` flattens this for JSON APIs and adds rendered `steps_text`, `steps_html`, and `steps_latex`.
265
+
266
+ ---
267
+
268
+ ## Example session
269
+
270
+ ```python
271
+ from smartsolver import SmartSolver, StepRenderer
272
+
273
+ s = SmartSolver()
274
+
275
+ print("=== Quadratic ===")
276
+ r = s.solve_equation("x^2 + 5x + 6 = 0")
277
+ print(StepRenderer.to_text(r["steps"]))
278
+
279
+ print("=== Trig ===")
280
+ r = s.solve_equation("sin(x) - cos(x) = 0")
281
+ print(StepRenderer.to_text(r["steps"]))
282
+
283
+ print("=== Integration by parts ===")
284
+ r = s.integrate("x*exp(x)")
285
+ print(StepRenderer.to_text(r["steps"]))
286
+ print("Answer:", r["integral"])
287
+ print("Methods:", r["methods"])
288
+ ```
289
+
290
+ ---
291
+
292
+ ## Scope & limitations
293
+
294
+ SmartSolver is designed for **education**, not as a replacement for a full computer algebra system.
295
+
296
+ - Trig: covers common identities and reduction; general periodic solution sets are simplified.
297
+ - Logs: strongest on combinable logs and `log(f(x)) = constant` forms.
298
+ - Partial fractions: requires a proper rational form; improper rationals use polynomial division first.
299
+ - Integration by parts: one LIATE-guided pass; does not recurse automatically.
300
+ - u-substitution: basic patterns only.
301
+
302
+ SymPy still performs the underlying symbolic work; SmartSolver adds **annotated steps** around it.
303
+
304
+ ---
305
+
306
+ ## Running tests
307
+
308
+ ```bash
309
+ pip install -e ".[dev]"
310
+ python -m unittest discover -s tests -v
311
+ ```
312
+
313
+ Or a quick smoke test:
314
+
315
+ ```bash
316
+ python -c "from smartsolver import SmartSolver; print(SmartSolver().solve_equation('x-2=0')['solutions'])"
317
+ ```
318
+
319
+ ---
320
+
321
+ ## Project layout
322
+
323
+ ```
324
+ . # repository root (this folder)
325
+ ├── __init__.py # public exports
326
+ ├── math_step_tracker.py # SmartSolver core
327
+ ├── README.md
328
+ ├── LICENSE
329
+ ├── pyproject.toml # PEP 517 build (no setup.py)
330
+ └── tests/
331
+ └── test_smartsolver.py
332
+ ```
333
+
334
+ ---
335
+
336
+ ## Roadmap
337
+
338
+ - [x] PyPI packaging scaffold (`ed-master-smartsolver`)
339
+ - [ ] Recursive integration by parts
340
+ - [ ] Richer trig general solutions
341
+ - [ ] Public “Try SmartSolver” demo page on Ed-Master
342
+
343
+ ---
344
+
345
+ ## Links
346
+
347
+ - **Platform:** [ed-master.co.za](https://www.ed-master.co.za)
348
+ - **Try Math Lab:** [ed-master.co.za/try/math-lab](https://www.ed-master.co.za/try/math-lab)
349
+ - **Research projects:** [ed-master.co.za/projects](https://www.ed-master.co.za/projects)
350
+
351
+ ---
352
+
353
+ ## License
354
+
355
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ smartsolver/__init__.py,sha256=_JSxirZjUGljK4imBAre4-5WWZc_W_y43i_5phBrLXU,652
2
+ smartsolver/math_step_tracker.py,sha256=ZslRnmpojq20mm2P_7NQcNDw8CYjBH3BsvCdUg7gEAY,32840
3
+ ed_master_smartsolver-0.1.0.dist-info/METADATA,sha256=WA8Y1ZSTBRkmXFW1ph7YFMMzTHGksLc3kjdFlSkDwZY,9094
4
+ ed_master_smartsolver-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ ed_master_smartsolver-0.1.0.dist-info/licenses/LICENSE,sha256=DyqvLS7D_ZROZc4ARayoQ_CepH0Bh3vA7ad6QeCK8fM,1066
6
+ ed_master_smartsolver-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ed-Master
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,34 @@
1
+ """Ed-Master SmartSolver — step-by-step mathematics on SymPy."""
2
+
3
+ from .math_step_tracker import (
4
+ SmartSolver,
5
+ Step,
6
+ StepRenderer,
7
+ StepTracker,
8
+ contains_log_or_exp,
9
+ contains_trig,
10
+ liate_rank,
11
+ normalize_math_input,
12
+ parse_math,
13
+ serialize_solver_result,
14
+ solution_to_latex,
15
+ step_to_dict,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "SmartSolver",
22
+ "Step",
23
+ "StepRenderer",
24
+ "StepTracker",
25
+ "contains_log_or_exp",
26
+ "contains_trig",
27
+ "liate_rank",
28
+ "normalize_math_input",
29
+ "parse_math",
30
+ "serialize_solver_result",
31
+ "solution_to_latex",
32
+ "step_to_dict",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,946 @@
1
+ import re
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ import sympy as sp
6
+ from sympy import Eq, symbols
7
+ from sympy.parsing.sympy_parser import (
8
+ implicit_multiplication_application,
9
+ parse_expr,
10
+ standard_transformations,
11
+ )
12
+
13
+ PARSE_TRANSFORMATIONS = standard_transformations + (implicit_multiplication_application,)
14
+
15
+ TRIG_FUNCTIONS = (sp.sin, sp.cos, sp.tan, sp.sec, sp.csc, sp.cot)
16
+ INVERSE_TRIG_FUNCTIONS = (sp.asin, sp.acos, sp.atan, sp.acot, sp.asec, sp.acsc)
17
+ LIATE_LABELS = (
18
+ 'Logarithmic',
19
+ 'Inverse trig',
20
+ 'Algebraic',
21
+ 'Trigonometric',
22
+ 'Exponential',
23
+ )
24
+
25
+
26
+ def contains_trig(expr: sp.Expr) -> bool:
27
+ return any(expr.has(func) for func in TRIG_FUNCTIONS)
28
+
29
+
30
+ def contains_log_or_exp(expr: sp.Expr, var: sp.Symbol) -> bool:
31
+ if expr.has(sp.log):
32
+ return True
33
+ if expr.has(sp.exp):
34
+ return True
35
+ for atom in expr.atoms(sp.Pow):
36
+ if atom.has(var) and atom.base.is_number and atom.base != 1:
37
+ return True
38
+ return False
39
+
40
+
41
+ def liate_rank(expr: sp.Expr, var: sp.Symbol) -> int:
42
+ """Lower rank = higher priority to choose as u in integration by parts (LIATE)."""
43
+ if expr.has(sp.log):
44
+ return 0
45
+ if any(expr.has(func) for func in INVERSE_TRIG_FUNCTIONS):
46
+ return 1
47
+ if expr.is_polynomial(var) or (expr.is_Mul and all(a.is_polynomial(var) for a in expr.args)):
48
+ return 2
49
+ if contains_trig(expr):
50
+ return 3
51
+ if expr.has(sp.exp) or (expr.is_Pow and expr.base == sp.E):
52
+ return 4
53
+ if expr.is_polynomial(var):
54
+ return 2
55
+ return 2
56
+
57
+
58
+ @dataclass
59
+ class Step:
60
+ """Represents a single step in the solution."""
61
+
62
+ description: str
63
+ expression: str
64
+ latex: str
65
+ rule_applied: str
66
+ substeps: List['Step'] = field(default_factory=list)
67
+
68
+
69
+ def step_to_dict(step: Step) -> Dict[str, Any]:
70
+ return {
71
+ 'description': step.description,
72
+ 'expression': step.expression,
73
+ 'latex': step.latex,
74
+ 'rule_applied': step.rule_applied,
75
+ 'substeps': [step_to_dict(s) for s in step.substeps],
76
+ }
77
+
78
+
79
+ class StepTracker:
80
+ """Tracks all steps during computation."""
81
+
82
+ def __init__(self):
83
+ self.steps: List[Step] = []
84
+ self.current_expression = None
85
+ self.original_expression = None
86
+
87
+ def reset(self):
88
+ self.steps = []
89
+ self.current_expression = None
90
+ self.original_expression = None
91
+
92
+ def add_step(self, description: str, expr: sp.Expr, rule: str = ''):
93
+ step = Step(
94
+ description=description,
95
+ expression=str(expr),
96
+ latex=sp.latex(expr),
97
+ rule_applied=rule,
98
+ )
99
+ self.steps.append(step)
100
+ self.current_expression = expr
101
+ return step
102
+
103
+ def get_solution(self) -> List[Step]:
104
+ return self.steps
105
+
106
+
107
+ def normalize_math_input(text: str) -> str:
108
+ """Normalize student-style math notation for SymPy parsing."""
109
+ if not text:
110
+ return ''
111
+ text = text.strip()
112
+ text = text.replace('^', '**')
113
+ text = re.sub(r'(\d)([a-zA-Z])', r'\1*\2', text)
114
+ return text
115
+
116
+
117
+ def parse_math(text: str) -> sp.Expr:
118
+ return parse_expr(normalize_math_input(text), transformations=PARSE_TRANSFORMATIONS)
119
+
120
+
121
+ def solution_to_latex(solutions) -> str:
122
+ if not solutions:
123
+ return 'No solution found'
124
+ if isinstance(solutions, list):
125
+ if len(solutions) == 1:
126
+ return sp.latex(solutions[0])
127
+ return sp.latex(solutions)
128
+ return sp.latex(solutions)
129
+
130
+
131
+ class SmartSolver:
132
+ """Main solver class with step tracking."""
133
+
134
+ def __init__(self, show_steps: bool = True):
135
+ self.step_tracker = StepTracker()
136
+ self.show_steps = show_steps
137
+
138
+ def _begin_problem(self):
139
+ self.step_tracker.reset()
140
+
141
+ def _track_step(self, expr, description, rule=''):
142
+ if self.show_steps:
143
+ self.step_tracker.add_step(description, expr, rule)
144
+ return expr
145
+
146
+ def _parse_equation(self, equation: str) -> sp.Expr:
147
+ equation = equation.strip()
148
+ if '=' not in equation:
149
+ equation = f'{equation} = 0'
150
+ left, right = equation.split('=', 1)
151
+ return parse_math(left) - parse_math(right)
152
+
153
+ # ============ ALGEBRA SOLVING ============
154
+
155
+ def solve_equation(self, equation: str, variable: str = 'x') -> Dict:
156
+ """
157
+ Solve an equation with step-by-step solution.
158
+
159
+ Example:
160
+ >>> solver = SmartSolver()
161
+ >>> result = solver.solve_equation("x^2 + 5x + 6 = 0", "x")
162
+ """
163
+ self._begin_problem()
164
+ expr = self._parse_equation(equation)
165
+ var = symbols(variable)
166
+
167
+ self._track_step(expr, f'Original equation: {equation}', 'Setup')
168
+
169
+ if expr.is_polynomial(var):
170
+ solution = self._solve_polynomial(expr, var)
171
+ elif contains_log_or_exp(expr, var):
172
+ solution = self._solve_log_exp(expr, var)
173
+ elif contains_trig(expr):
174
+ solution = self._solve_trig(expr, var)
175
+ else:
176
+ solution = self._solve_general(expr, var)
177
+
178
+ return {
179
+ 'steps': self.step_tracker.get_solution(),
180
+ 'solutions': solution,
181
+ 'solution_latex': solution_to_latex(solution),
182
+ }
183
+
184
+ def _solve_polynomial(self, expr: sp.Expr, var: sp.Symbol) -> List:
185
+ degree = sp.degree(expr, var)
186
+
187
+ if degree == 0:
188
+ return []
189
+ if degree == 1:
190
+ a = expr.coeff(var, 1)
191
+ b = expr.coeff(var, 0)
192
+ self._track_step(
193
+ expr,
194
+ f'Linear equation: {a}{var} + {b} = 0',
195
+ 'Identify linear equation',
196
+ )
197
+ isolated = sp.simplify(-b / a)
198
+ self._track_step(
199
+ isolated,
200
+ f'Divide both sides by {a}: {var} = {-b}/{a}',
201
+ 'Isolate variable',
202
+ )
203
+ return [isolated]
204
+
205
+ if degree == 2:
206
+ a = expr.coeff(var, 2)
207
+ b = expr.coeff(var, 1)
208
+ c = expr.coeff(var, 0)
209
+
210
+ self._track_step(
211
+ expr,
212
+ f'Quadratic equation: {a}{var}^2 + {b}{var} + {c} = 0',
213
+ 'Identify quadratic equation',
214
+ )
215
+
216
+ discriminant = sp.simplify(b**2 - 4 * a * c)
217
+ self._track_step(
218
+ discriminant,
219
+ f'Calculate discriminant: Δ = {b}^2 - 4({a})({c}) = {discriminant}',
220
+ 'Discriminant',
221
+ )
222
+
223
+ sqrt_d = sp.sqrt(discriminant)
224
+ solutions = [
225
+ sp.simplify((-b - sqrt_d) / (2 * a)),
226
+ sp.simplify((-b + sqrt_d) / (2 * a)),
227
+ ]
228
+
229
+ if discriminant < 0:
230
+ self._track_step(
231
+ solutions[0],
232
+ f'{var} has complex roots using the quadratic formula',
233
+ 'Quadratic formula',
234
+ )
235
+ else:
236
+ self._track_step(
237
+ solutions[0],
238
+ f'{var}_1 = (-{b} - sqrt({discriminant})) / (2*{a}) = {solutions[0]}',
239
+ 'Quadratic formula',
240
+ )
241
+ self._track_step(
242
+ solutions[1],
243
+ f'{var}_2 = (-{b} + sqrt({discriminant})) / (2*{a}) = {solutions[1]}',
244
+ 'Quadratic formula',
245
+ )
246
+
247
+ return solutions
248
+
249
+ solutions = sp.solve(expr, var)
250
+ if solutions:
251
+ self._track_step(
252
+ Eq(var, solutions[0]),
253
+ f'Solved using SymPy: {var} = {solutions[0]}',
254
+ 'Polynomial solving',
255
+ )
256
+ return solutions
257
+
258
+ def _solve_trig(self, expr: sp.Expr, var: sp.Symbol) -> List:
259
+ """Solve trigonometric equations with explicit identity steps."""
260
+ working = expr
261
+
262
+ if working.has(sp.tan):
263
+ rewritten = sp.trigsimp(sp.rewrite(working, sp.sin))
264
+ if rewritten != working:
265
+ self._track_step(
266
+ rewritten,
267
+ 'Rewrite tangent using the quotient identity: tan(θ) = sin(θ)/cos(θ)',
268
+ 'Quotient identity',
269
+ )
270
+ working = rewritten
271
+
272
+ if working.has(sp.sec):
273
+ rewritten = sp.trigsimp(sp.rewrite(working, sp.sin))
274
+ if rewritten != working:
275
+ self._track_step(
276
+ rewritten,
277
+ 'Rewrite secant using 1/cos(θ)',
278
+ 'Reciprocal identity',
279
+ )
280
+ working = rewritten
281
+
282
+ pyth_sub = self._apply_pythagorean_identity(working, var)
283
+ if pyth_sub is not None and pyth_sub != working:
284
+ self._track_step(
285
+ pyth_sub,
286
+ 'Apply the Pythagorean identity: sin²(θ) + cos²(θ) = 1',
287
+ 'Pythagorean identity',
288
+ )
289
+ working = sp.trigsimp(pyth_sub)
290
+
291
+ double_angle = self._apply_double_angle_identity(working, var)
292
+ if double_angle is not None and double_angle != working:
293
+ self._track_step(
294
+ double_angle,
295
+ 'Apply a double-angle identity to simplify the expression',
296
+ 'Double-angle identity',
297
+ )
298
+ working = sp.trigsimp(double_angle)
299
+
300
+ quotient_step = self._try_quotient_reduction(working, var)
301
+ if quotient_step is not None:
302
+ reduced, description = quotient_step
303
+ self._track_step(reduced, description, 'Quotient identity')
304
+ working = sp.trigsimp(reduced)
305
+
306
+ simplified = sp.trigsimp(working)
307
+ if simplified != working:
308
+ self._track_step(simplified, 'Simplify using standard trigonometric identities', 'Trig simplify')
309
+ working = simplified
310
+
311
+ try:
312
+ solutions = sp.solve(working, var)
313
+ except Exception:
314
+ solutions = []
315
+
316
+ if solutions:
317
+ self._track_step(
318
+ Eq(var, solutions[0]),
319
+ f'Apply inverse trigonometric functions: {var} = {solutions[0]}',
320
+ 'Inverse trig',
321
+ )
322
+ if len(solutions) > 1:
323
+ for sol in solutions[1:]:
324
+ self._track_step(
325
+ Eq(var, sol),
326
+ f'Additional solution from periodicity: {var} = {sol}',
327
+ 'General solution',
328
+ )
329
+ return solutions
330
+
331
+ def _apply_pythagorean_identity(self, expr: sp.Expr, var: sp.Symbol) -> Optional[sp.Expr]:
332
+ sin_var = sp.sin(var)
333
+ cos_var = sp.cos(var)
334
+ replacement = 1 - sin_var**2
335
+ if expr.has(cos_var**2):
336
+ return expr.replace(cos_var**2, replacement)
337
+ replacement = 1 - cos_var**2
338
+ if expr.has(sin_var**2):
339
+ return expr.replace(sin_var**2, replacement)
340
+ return None
341
+
342
+ def _apply_double_angle_identity(self, expr: sp.Expr, var: sp.Symbol) -> Optional[sp.Expr]:
343
+ sin_2var = sp.sin(2 * var)
344
+ if expr.has(sin_2var):
345
+ return expr.replace(sin_2var, 2 * sp.sin(var) * sp.cos(var))
346
+ cos_2var = sp.cos(2 * var)
347
+ if expr.has(cos_2var):
348
+ return expr.replace(cos_2var, sp.cos(var) ** 2 - sp.sin(var) ** 2)
349
+ return None
350
+
351
+ def _try_quotient_reduction(
352
+ self, expr: sp.Expr, var: sp.Symbol
353
+ ) -> Optional[tuple[sp.Expr, str]]:
354
+ """Reduce sin/cos mixtures to tan when both appear in a sum."""
355
+ sin_var = sp.sin(var)
356
+ cos_var = sp.cos(var)
357
+ if not (expr.has(sin_var) and expr.has(cos_var)) or not expr.is_Add:
358
+ return None
359
+
360
+ sin_term = None
361
+ cos_coeff = None
362
+ for term in expr.args:
363
+ if term.has(sin_var) and not term.has(cos_var):
364
+ sin_term = term
365
+ elif term.has(cos_var) and not term.has(sin_var):
366
+ cos_coeff = term.coeff(cos_var)
367
+
368
+ if sin_term is None or cos_coeff is None:
369
+ return None
370
+
371
+ tan_value = sp.simplify(-cos_coeff / sin_term.coeff(sin_var))
372
+ tan_eq = sp.tan(var) - tan_value
373
+ description = (
374
+ f'Divide both sides by cos({var}) (cos({var}) ≠ 0): '
375
+ f'sin({var})/cos({var}) = {tan_value} → tan({var}) = {tan_value}'
376
+ )
377
+ return tan_eq, description
378
+
379
+ def _solve_log_exp(self, expr: sp.Expr, var: sp.Symbol) -> List:
380
+ """Solve logarithmic and exponential equations with explicit log rules."""
381
+ working = expr
382
+
383
+ combined = sp.logcombine(working, force=True)
384
+ if combined != working:
385
+ self._track_step(
386
+ combined,
387
+ 'Combine logarithms: log(a) + log(b) = log(ab) and n·log(a) = log(aⁿ)',
388
+ 'Log product/power rule',
389
+ )
390
+ working = combined
391
+
392
+ expanded = sp.expand_log(working, force=True)
393
+ if expanded != working:
394
+ self._track_step(
395
+ expanded,
396
+ 'Expand logarithms using log(a/b) = log(a) − log(b) or log(aⁿ) = n·log(a)',
397
+ 'Log expansion',
398
+ )
399
+ working = expanded
400
+
401
+ exp_solution = self._try_solve_exponential(working, var)
402
+ if exp_solution is not None:
403
+ return exp_solution
404
+
405
+ log_solution = self._try_solve_logarithm(working, var)
406
+ if log_solution is not None:
407
+ return log_solution
408
+
409
+ try:
410
+ solutions = sp.solve(working, var)
411
+ if solutions:
412
+ self._track_step(
413
+ Eq(var, solutions[0]),
414
+ f'Solve using inverse logarithm/exponential: {var} = {solutions[0]}',
415
+ 'Log/exp solving',
416
+ )
417
+ return solutions
418
+ except Exception:
419
+ return []
420
+
421
+ def _try_solve_exponential(self, expr: sp.Expr, var: sp.Symbol) -> Optional[List]:
422
+ """Handle a**x = b style equations."""
423
+ if not expr.has(var):
424
+ return None
425
+
426
+ for atom in expr.atoms(sp.Pow):
427
+ if atom.has(var) and atom.base.is_number and atom.base > 0 and expr.is_Add:
428
+ base = atom.base
429
+ other = sp.simplify(expr - atom)
430
+ if not other.has(var):
431
+ rhs_val = sp.simplify(-other)
432
+ self._track_step(
433
+ atom,
434
+ f'Isolate exponential term: {base}^({var}) = {rhs_val}',
435
+ 'Isolate exponential',
436
+ )
437
+ if rhs_val <= 0:
438
+ return []
439
+ log_step = sp.log(rhs_val) / sp.log(base)
440
+ self._track_step(
441
+ log_step,
442
+ f'Take log base {base} of both sides: {var} = log({rhs_val})/log({base})',
443
+ 'Log both sides',
444
+ )
445
+ solved = sp.simplify(log_step)
446
+ self._track_step(
447
+ solved,
448
+ f'Simplify: {var} = {solved}',
449
+ 'Exponential rule',
450
+ )
451
+ return [solved]
452
+ return None
453
+
454
+ def _try_solve_logarithm(self, expr: sp.Expr, var: sp.Symbol) -> Optional[List]:
455
+ """Handle log(f(x)) = c → f(x) = e^c."""
456
+ if not expr.has(sp.log):
457
+ return None
458
+
459
+ log_atoms = list(expr.atoms(sp.log))
460
+ if len(log_atoms) == 1 and expr.is_Add:
461
+ log_term = log_atoms[0]
462
+ other = sp.simplify(expr - log_term)
463
+ if not other.has(sp.log) and not other.has(var):
464
+ rhs = sp.simplify(-other)
465
+ self._track_step(
466
+ log_term,
467
+ f'Isolate logarithm: log(...) = {rhs}',
468
+ 'Isolate logarithm',
469
+ )
470
+ inner = log_term.args[0]
471
+ exponentiated = sp.Eq(inner, sp.exp(rhs))
472
+ self._track_step(
473
+ exponentiated,
474
+ f'Convert to exponential form: if log(f({var})) = {rhs}, then f({var}) = e^({rhs})',
475
+ 'Definition of natural log',
476
+ )
477
+ try:
478
+ solutions = sp.solve(exponentiated.lhs - exponentiated.rhs, var)
479
+ if solutions:
480
+ self._track_step(
481
+ Eq(var, solutions[0]),
482
+ f'Solve the resulting equation: {var} = {solutions[0]}',
483
+ 'Algebraic solve',
484
+ )
485
+ return solutions
486
+ except Exception:
487
+ return None
488
+ return None
489
+
490
+ def partial_fractions(self, expression: str, variable: str = 'x') -> Dict:
491
+ """Decompose a rational expression with partial-fraction steps."""
492
+ self._begin_problem()
493
+ expr = parse_math(expression)
494
+ var = symbols(variable)
495
+
496
+ self._track_step(expr, f'Rational expression: {expression}', 'Setup')
497
+
498
+ together = sp.together(expr)
499
+ if together != expr:
500
+ self._track_step(together, 'Write as a single rational fraction', 'Common denominator')
501
+
502
+ numer, denom = sp.fraction(sp.together(expr))
503
+ self._track_step(
504
+ sp.Mul(numer, 1 / denom, evaluate=False),
505
+ f'Numerator: {numer}, denominator: {denom}',
506
+ 'Identify rational form',
507
+ )
508
+
509
+ if sp.degree(numer, var) >= sp.degree(denom, var):
510
+ poly, remainder = sp.div(numer, denom, var)
511
+ if poly != 0:
512
+ self._track_step(
513
+ poly + remainder / denom,
514
+ 'Polynomial division: separate polynomial part before partial fractions',
515
+ 'Polynomial division',
516
+ )
517
+ expr = remainder / denom
518
+
519
+ factored_denom = sp.factor(sp.fraction(sp.together(expr))[1])
520
+ self._track_step(
521
+ factored_denom,
522
+ f'Factor the denominator: {factored_denom}',
523
+ 'Factor denominator',
524
+ )
525
+
526
+ decomposed = sp.apart(sp.together(expr), var)
527
+ self._track_step(
528
+ decomposed,
529
+ 'Decompose into partial fractions with undetermined coefficients',
530
+ 'Partial fraction decomposition',
531
+ )
532
+
533
+ final_form = sp.together(decomposed)
534
+ if final_form != decomposed:
535
+ self._track_step(final_form, 'Combine into a single expression over common factors', 'Simplify')
536
+
537
+ return {
538
+ 'steps': self.step_tracker.get_solution(),
539
+ 'decomposition': decomposed,
540
+ 'decomposition_latex': sp.latex(decomposed),
541
+ }
542
+
543
+ def _solve_general(self, expr: sp.Expr, var: sp.Symbol) -> List:
544
+ try:
545
+ solutions = sp.solve(expr, var)
546
+ if solutions:
547
+ self._track_step(
548
+ Eq(var, solutions[0]),
549
+ f'Solved: {var} = {solutions[0]}',
550
+ 'General solving',
551
+ )
552
+ return solutions
553
+ except Exception:
554
+ return []
555
+
556
+ # ============ DIFFERENTIATION ============
557
+
558
+ def differentiate(self, expression: str, variable: str = 'x', order: int = 1) -> Dict:
559
+ self._begin_problem()
560
+ expr = parse_math(expression)
561
+ var = symbols(variable)
562
+
563
+ self._track_step(expr, f'Original function: f({variable}) = {expression}', 'Setup')
564
+
565
+ result = expr
566
+ for i in range(order):
567
+ result = self._differentiate_with_steps(result, var, step_number=i + 1)
568
+
569
+ return {
570
+ 'steps': self.step_tracker.get_solution(),
571
+ 'derivative': result,
572
+ 'derivative_latex': sp.latex(result),
573
+ }
574
+
575
+ def _differentiate_with_steps(self, expr: sp.Expr, var: sp.Symbol, step_number: int) -> sp.Expr:
576
+ if expr.is_Add:
577
+ self._track_step(expr, f'Step {step_number}: Apply sum rule to each term', 'Sum rule')
578
+ derivatives = []
579
+ for term in expr.args:
580
+ term_derivative = sp.diff(term, var)
581
+ rule_used = self._identify_derivative_rule(term, var)
582
+ self._track_step(
583
+ term_derivative,
584
+ f'd/d{var}({term}) using {rule_used}',
585
+ rule_used,
586
+ )
587
+ derivatives.append(term_derivative)
588
+ combined = sp.simplify(sp.Add(*derivatives))
589
+ self._track_step(combined, f'Combine terms: f^({step_number})({var}) = {combined}', 'Simplify')
590
+ return combined
591
+
592
+ derivative = sp.diff(expr, var)
593
+ rule_used = self._identify_derivative_rule(expr, var)
594
+ self._track_step(
595
+ derivative,
596
+ f'Step {step_number}: Apply {rule_used}',
597
+ rule_used,
598
+ )
599
+ return derivative
600
+
601
+ def _identify_derivative_rule(self, expr: sp.Expr, var: sp.Symbol) -> str:
602
+ if expr.is_Pow:
603
+ if expr.base == var:
604
+ return 'Power rule'
605
+ return 'Chain rule'
606
+ if expr.is_Mul:
607
+ return 'Product rule'
608
+ if expr.is_Add:
609
+ return 'Sum rule'
610
+ if isinstance(expr, sp.Function):
611
+ return 'Chain rule'
612
+ return 'Differentiation'
613
+
614
+ # ============ INTEGRATION ============
615
+
616
+ def integrate(
617
+ self,
618
+ expression: str,
619
+ variable: str = 'x',
620
+ lower: Optional[str] = None,
621
+ upper: Optional[str] = None,
622
+ ) -> Dict:
623
+ self._begin_problem()
624
+ expr = parse_math(expression)
625
+ var = symbols(variable)
626
+
627
+ self._track_step(expr, f'Original function: f({variable}) = {expression}', 'Setup')
628
+
629
+ result = None
630
+ methods_used: List[str] = []
631
+
632
+ if self._is_proper_rational(expr, var):
633
+ result = self._integrate_rational_with_partials(expr, var)
634
+ if result is not None:
635
+ methods_used.append('Partial fractions')
636
+
637
+ if result is None and len(self._integration_factors(expr)) >= 2:
638
+ parts_result = self._integrate_by_parts(expr, var)
639
+ if parts_result is not None:
640
+ result = parts_result
641
+ methods_used.append('Integration by parts')
642
+
643
+ if result is None:
644
+ try:
645
+ candidate = sp.integrate(expr, var)
646
+ if candidate != expr:
647
+ result = candidate
648
+ self._track_step(result, 'Apply standard integration rules', 'Direct integration')
649
+ methods_used.append('Direct integration')
650
+ except Exception:
651
+ result = None
652
+
653
+ if result is None:
654
+ substitution_result = self._integrate_by_substitution(expr, var)
655
+ if substitution_result is not None:
656
+ result = substitution_result
657
+ methods_used.append('Substitution')
658
+
659
+ if result is None:
660
+ parts_result = self._integrate_by_parts(expr, var)
661
+ if parts_result is not None:
662
+ result = parts_result
663
+ methods_used.append('Integration by parts')
664
+
665
+ if lower is not None and upper is not None:
666
+ lower_val = float(lower)
667
+ upper_val = float(upper)
668
+ definite = sp.integrate(expr, (var, lower_val, upper_val))
669
+ self._track_step(
670
+ definite,
671
+ f'Evaluate from {lower} to {upper}',
672
+ 'Definite integration',
673
+ )
674
+ result = definite
675
+
676
+ return {
677
+ 'steps': self.step_tracker.get_solution(),
678
+ 'integral': result,
679
+ 'methods': methods_used,
680
+ 'integral_latex': sp.latex(result) if result is not None else 'Unable to integrate',
681
+ }
682
+
683
+ def _integration_factors(self, expr: sp.Expr) -> List[sp.Expr]:
684
+ if expr.is_Mul:
685
+ return list(expr.args)
686
+ return [expr]
687
+
688
+ def _is_proper_rational(self, expr: sp.Expr, var: sp.Symbol) -> bool:
689
+ try:
690
+ numer, denom = sp.fraction(sp.together(expr))
691
+ return (
692
+ numer.is_polynomial(var)
693
+ and denom.is_polynomial(var)
694
+ and sp.degree(numer, var) < sp.degree(denom, var)
695
+ and sp.degree(denom, var) > 0
696
+ )
697
+ except Exception:
698
+ return False
699
+
700
+ def _integrate_rational_with_partials(self, expr: sp.Expr, var: sp.Symbol) -> Optional[sp.Expr]:
701
+ together = sp.together(expr)
702
+ numer, denom = sp.fraction(together)
703
+ self._track_step(
704
+ together,
705
+ f'Proper rational function: ({numer}) / ({denom})',
706
+ 'Rational form',
707
+ )
708
+
709
+ factored_denom = sp.factor(denom)
710
+ self._track_step(
711
+ factored_denom,
712
+ f'Factor the denominator: {factored_denom}',
713
+ 'Factor denominator',
714
+ )
715
+
716
+ decomposed = sp.apart(together, var)
717
+ self._track_step(
718
+ decomposed,
719
+ 'Decompose into partial fractions before integrating',
720
+ 'Partial fraction decomposition',
721
+ )
722
+
723
+ integrated = sp.integrate(decomposed, var)
724
+ self._track_step(
725
+ integrated,
726
+ 'Integrate each partial fraction term separately',
727
+ 'Integrate terms',
728
+ )
729
+ return sp.simplify(integrated)
730
+
731
+ def _integrate_by_substitution(self, expr: sp.Expr, var: sp.Symbol) -> Optional[sp.Expr]:
732
+ """Simple u-substitution when the integrand is g(f(x))*f'(x) up to a constant."""
733
+ if not expr.has(var):
734
+ return None
735
+
736
+ inner = None
737
+ if expr.is_Mul:
738
+ for factor in expr.args:
739
+ if factor.is_Pow and factor.exp == -1 and factor.base.is_Add:
740
+ inner = factor.base
741
+ break
742
+
743
+ if inner is None:
744
+ return None
745
+
746
+ derivative = sp.diff(inner, var)
747
+ if not expr.has(derivative):
748
+ return None
749
+
750
+ u = sp.Symbol('u')
751
+ self._track_step(
752
+ inner,
753
+ f'Substitution: let u = {inner}, so du = ({derivative}) d{var}',
754
+ 'u-substitution',
755
+ )
756
+ substituted = sp.simplify(expr / derivative)
757
+ self._track_step(
758
+ substituted,
759
+ f'Rewrite the integral in terms of u: ∫ {substituted} du',
760
+ 'u-substitution',
761
+ )
762
+ integrated_u = sp.integrate(substituted, u)
763
+ result = sp.simplify(integrated_u.subs(u, inner))
764
+ self._track_step(
765
+ result,
766
+ f'Substitute back u = {inner}: result = {result}',
767
+ 'u-substitution',
768
+ )
769
+ return result
770
+
771
+ def _integrate_by_parts(self, expr: sp.Expr, var: sp.Symbol) -> Optional[sp.Expr]:
772
+ factors = self._integration_factors(expr)
773
+ if len(factors) < 2:
774
+ return None
775
+
776
+ ranked = sorted((liate_rank(f, var), f) for f in factors)
777
+ u = ranked[0][1]
778
+ dv_factors = [f for f in factors if f != u]
779
+ dv = sp.Mul(*dv_factors) if len(dv_factors) > 1 else dv_factors[0]
780
+ rank = ranked[0][0]
781
+ liate_label = LIATE_LABELS[rank] if 0 <= rank < len(LIATE_LABELS) else 'Algebraic'
782
+
783
+ self._track_step(
784
+ u,
785
+ f'Choose u = {u} ({liate_label} term has LIATE priority for u)',
786
+ 'LIATE: choose u',
787
+ )
788
+ self._track_step(
789
+ dv,
790
+ f'Choose dv = {dv} d{var} (remaining factor)',
791
+ 'Integration by parts setup',
792
+ )
793
+
794
+ du = sp.diff(u, var)
795
+ self._track_step(
796
+ du,
797
+ f'Differentiate u: du = {du} d{var}',
798
+ 'Differentiate u',
799
+ )
800
+
801
+ v = sp.integrate(dv, var)
802
+ if v == 0 and dv != 0:
803
+ return None
804
+ self._track_step(
805
+ v,
806
+ f'Integrate dv: v = ∫ {dv} d{var} = {v}',
807
+ 'Integrate dv',
808
+ )
809
+
810
+ uv = sp.expand(u * v)
811
+ self._track_step(
812
+ uv,
813
+ f'Apply formula ∫ u dv = u·v − ∫ v du; first term u·v = {uv}',
814
+ 'Integration by parts',
815
+ )
816
+
817
+ remaining_integrand = sp.expand(v * du)
818
+ self._track_step(
819
+ remaining_integrand,
820
+ f'Remaining integral: ∫ v du = ∫ {remaining_integrand} d{var}',
821
+ 'Integration by parts',
822
+ )
823
+
824
+ remaining = sp.integrate(remaining_integrand, var)
825
+ self._track_step(
826
+ remaining,
827
+ f'Evaluate ∫ v du = {remaining}',
828
+ 'Integrate remainder',
829
+ )
830
+
831
+ result = sp.simplify(uv - remaining)
832
+ self._track_step(
833
+ result,
834
+ f'Final result: u·v − ∫ v du = {result}',
835
+ 'Integration by parts result',
836
+ )
837
+ return result
838
+
839
+ # ============ LIMITS ============
840
+
841
+ def limit(
842
+ self,
843
+ expression: str,
844
+ variable: str = 'x',
845
+ point: str = '0',
846
+ direction: str = '+',
847
+ ) -> Dict:
848
+ self._begin_problem()
849
+ expr = parse_math(expression)
850
+ var = symbols(variable)
851
+ point_val = parse_math(point)
852
+
853
+ self._track_step(expr, f'Original expression: {expression}', 'Setup')
854
+ self._track_step(
855
+ expr,
856
+ f'Find limit as {variable} -> {point} (direction: {direction})',
857
+ 'Limit setup',
858
+ )
859
+
860
+ direct = expr.subs(var, point_val)
861
+ if direct.is_finite and direct not in (sp.zoo, sp.nan):
862
+ self._track_step(direct, f'Direct substitution: {direct}', 'Direct substitution')
863
+
864
+ result = sp.limit(expr, var, point_val, dir=direction)
865
+ self._track_step(result, f'Limit = {result}', 'Final result')
866
+
867
+ return {
868
+ 'steps': self.step_tracker.get_solution(),
869
+ 'limit': result,
870
+ 'limit_latex': sp.latex(result),
871
+ }
872
+
873
+
874
+ class StepRenderer:
875
+ """Render steps in various formats."""
876
+
877
+ @staticmethod
878
+ def to_text(steps: List[Step]) -> str:
879
+ output = []
880
+ for i, step in enumerate(steps, 1):
881
+ output.append(f'Step {i}: {step.description}')
882
+ output.append(f' -> {step.expression}')
883
+ if step.rule_applied:
884
+ output.append(f' Rule: {step.rule_applied}')
885
+ output.append('')
886
+ return '\n'.join(output)
887
+
888
+ @staticmethod
889
+ def to_latex(steps: List[Step]) -> str:
890
+ latex_parts = []
891
+ for i, step in enumerate(steps, 1):
892
+ latex_parts.append(f'\\text{{Step {i}: }} {step.latex}')
893
+ if step.rule_applied:
894
+ latex_parts.append(f'\\quad \\text{{({step.rule_applied})}}')
895
+ return ' \\\\ '.join(latex_parts)
896
+
897
+ @staticmethod
898
+ def to_html(steps: List[Step]) -> str:
899
+ html = ["<div class='solution-steps'>"]
900
+ for i, step in enumerate(steps, 1):
901
+ html.append("<div class='step'>")
902
+ html.append(f"<div class='step-number'>Step {i}</div>")
903
+ html.append(f"<div class='step-desc'>{step.description}</div>")
904
+ html.append(f"<div class='step-expression'>{step.expression}</div>")
905
+ if step.rule_applied:
906
+ html.append(f"<div class='step-rule'>Rule: {step.rule_applied}</div>")
907
+ html.append('</div>')
908
+ html.append('</div>')
909
+ return '\n'.join(html)
910
+
911
+
912
+ def serialize_solver_result(result: Dict[str, Any]) -> Dict[str, Any]:
913
+ """Convert a SmartSolver result into JSON-serializable data."""
914
+ steps = result.get('steps', [])
915
+ payload: Dict[str, Any] = {}
916
+
917
+ for key, value in result.items():
918
+ if key == 'steps':
919
+ payload['steps'] = [step_to_dict(step) for step in value]
920
+ elif key in ('derivative', 'integral', 'limit', 'decomposition'):
921
+ payload[key] = str(value) if value is not None else None
922
+ elif key == 'solutions':
923
+ payload['solutions'] = [str(s) for s in value]
924
+ else:
925
+ payload[key] = value
926
+
927
+ if 'derivative' in result:
928
+ payload['result'] = str(result['derivative'])
929
+ payload['result_latex'] = result.get('derivative_latex')
930
+ elif 'integral' in result:
931
+ payload['result'] = str(result['integral']) if result['integral'] is not None else None
932
+ payload['result_latex'] = result.get('integral_latex')
933
+ elif 'decomposition' in result:
934
+ payload['result'] = str(result['decomposition'])
935
+ payload['result_latex'] = result.get('decomposition_latex')
936
+ elif 'limit' in result:
937
+ payload['result'] = str(result['limit'])
938
+ payload['result_latex'] = result.get('limit_latex')
939
+ elif 'solutions' in result:
940
+ payload['result'] = [str(s) for s in result['solutions']]
941
+ payload['result_latex'] = result.get('solution_latex')
942
+
943
+ payload['steps_text'] = StepRenderer.to_text(steps)
944
+ payload['steps_html'] = StepRenderer.to_html(steps)
945
+ payload['steps_latex'] = StepRenderer.to_latex(steps)
946
+ return payload