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,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.
|
smartsolver/__init__.py
ADDED
|
@@ -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
|