nhamid289 commited on
Commit
c964813
·
verified ·
1 Parent(s): df7ca40

Deploy optimisation from main (6727a1a)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +18 -0
  2. README.md +54 -5
  3. backend/.python-version +1 -0
  4. backend/README 2.md +10 -0
  5. backend/README.md +10 -0
  6. backend/contract/gradient_descent_quadratic 2.json +11 -0
  7. backend/contract/gradient_descent_quadratic.json +11 -0
  8. backend/contract/invalid_function 2.json +6 -0
  9. backend/contract/invalid_function.json +6 -0
  10. backend/contract/newton_singular_hessian 2.json +20 -0
  11. backend/contract/newton_singular_hessian.json +20 -0
  12. backend/contract/past_the_end 2.json +6 -0
  13. backend/contract/past_the_end.json +6 -0
  14. backend/pyproject.toml +18 -0
  15. backend/src/algorithms 2.json +60 -0
  16. backend/src/algorithms.json +60 -0
  17. backend/src/logic.py +231 -0
  18. backend/src/manager 2.py +118 -0
  19. backend/src/manager.py +118 -0
  20. backend/tests/test_contract.py +53 -0
  21. backend/tests/test_logic 2.py +175 -0
  22. backend/tests/test_logic.py +175 -0
  23. backend/uv.lock +169 -0
  24. dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J 2.woff2 +0 -0
  25. dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  26. dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ 2.woff +0 -0
  27. dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  28. dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv 2.woff +0 -0
  29. dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  30. dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag 2.woff2 +0 -0
  31. dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  32. dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv 2.woff +0 -0
  33. dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  34. dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f 2.woff2 +0 -0
  35. dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  36. dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT 2.woff +0 -0
  37. dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  38. dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T 2.woff2 +0 -0
  39. dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  40. dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP 2.woff +0 -0
  41. dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  42. dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y 2.woff2 +0 -0
  43. dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  44. dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo 2.woff +0 -0
  45. dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  46. dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd 2.woff2 +0 -0
  47. dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  48. dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV 2.woff2 +0 -0
  49. dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  50. dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC 2.woff +0 -0
.dockerignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependency folders (The biggest space eaters)
2
+ node_modules/
3
+ __pycache__/
4
+ *.pyc
5
+ venv/
6
+ .env
7
+
8
+ # Git history (Not needed inside the container)
9
+ .git
10
+ .gitignore
11
+
12
+ # Build outputs from your local machine
13
+ dist/
14
+ build/
15
+
16
+ # OS trash
17
+ .DS_Store
18
+ Thumbs.db
README.md CHANGED
@@ -1,10 +1,59 @@
1
  ---
2
- title: Optimization Dev
3
- emoji: 🏆
4
- colorFrom: indigo
5
- colorTo: purple
6
  sdk: static
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Optimization
3
+ emoji: 🚀
4
+ colorFrom: purple
5
+ colorTo: indigo
6
  sdk: static
7
+ app_file: dist/index.html
8
  pinned: false
9
  ---
10
 
11
+ # Developer's Guide for React frontend
12
+
13
+ ## Prerequisites
14
+
15
+ Install **Node.js** if not done yet. The following commands should run if you have it installed.
16
+
17
+ ```bash
18
+ node -v
19
+ npm -v
20
+ ```
21
+
22
+ ## Local Development
23
+
24
+ First go to the react frontend folder:
25
+ ```bash
26
+ cd frontends/react
27
+ ```
28
+
29
+ Run the following commands to install required packages when you run the code the first time or after you make changes to `package.json`.
30
+ ```bash
31
+ npm install
32
+ ```
33
+
34
+ Now you should be able to start the local development server:
35
+ ```bash
36
+ npm run dev
37
+ ```
38
+ Open the link printed out on the terminal. The webpage automatically reloads when files are edited.
39
+
40
+ ## Deploy to Hugging Face
41
+
42
+ ### Build the production version:
43
+
44
+ ```bash
45
+ npm run build
46
+ ```
47
+
48
+ This generates the production build inside the `dist/` directory.
49
+
50
+ ### Commit and push:
51
+
52
+ ```bash
53
+ git add <files>
54
+ git commit
55
+ git push
56
+ ```
57
+
58
+ ⚠️ Make sure the `dist/` directory is included when adding to git.
59
+ ⚠️ Even if you only changed the backend, you must still run `npm run build` when deploying or it will not update.
backend/.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
backend/README 2.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Optimisation backend
2
+
3
+ Python run in the browser by Pyodide (see `frontends/react/src/pyodide.worker.ts`).
4
+
5
+ - `src/logic.py`: parsing the function, its derivatives, the optimisers and runs
6
+ - `src/manager.py`: the `Manager` methods the UI calls
7
+ - `src/algorithms.json`: each optimiser's settings, shared with the UI
8
+ - `contract/`: example calls, checked by `tests/test_contract.py` and the frontend's `contract.check.ts`
9
+
10
+ Run the tests with `uv run pytest`.
backend/README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Optimisation backend
2
+
3
+ Python run in the browser by Pyodide (see `frontends/react/src/pyodide.worker.ts`).
4
+
5
+ - `src/logic.py`: parsing the function, its derivatives, the optimisers and runs
6
+ - `src/manager.py`: the `Manager` methods the UI calls
7
+ - `src/algorithms.json`: each optimiser's settings, shared with the UI
8
+ - `contract/`: example calls, checked by `tests/test_contract.py` and the frontend's `contract.check.ts`
9
+
10
+ Run the tests with `uv run pytest`.
backend/contract/gradient_descent_quadratic 2.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Gradient descent on x^2 from x = 1 with learning rate 0.25 halves x each iteration",
3
+ "calls": {
4
+ "handle_set_function": { "args": { "variables": 1, "expression": "x^2" }, "result": null },
5
+ "handle_set_runs": { "args": { "runs": [{ "algorithm": "gradient_descent", "start": [1.0], "params": { "learning_rate": 0.25 } }] }, "result": null },
6
+ "handle_state": {
7
+ "args": { "iteration": 2 },
8
+ "result": { "runs": [{ "point": { "position": [0.25], "value": 0.0625, "gradient": [0.5], "hessian": [[2.0]] }, "stopped": null }] }
9
+ }
10
+ }
11
+ }
backend/contract/gradient_descent_quadratic.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Gradient descent on x^2 from x = 1 with learning rate 0.25 halves x each iteration",
3
+ "calls": {
4
+ "handle_set_function": { "args": { "variables": 1, "expression": "x^2" }, "result": null },
5
+ "handle_set_runs": { "args": { "runs": [{ "algorithm": "gradient_descent", "start": [1.0], "params": { "learning_rate": 0.25 } }] }, "result": null },
6
+ "handle_state": {
7
+ "args": { "iteration": 2 },
8
+ "result": { "runs": [{ "point": { "position": [0.25], "value": 0.0625, "gradient": [0.5], "hessian": [[2.0]] }, "stopped": null }] }
9
+ }
10
+ }
11
+ }
backend/contract/invalid_function 2.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "description": "A function of one variable can't use y",
3
+ "calls": {
4
+ "handle_set_function": { "args": { "variables": 1, "expression": "x^2 + y" }, "error": { "kind": "user", "message": "use only x" } }
5
+ }
6
+ }
backend/contract/invalid_function.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "description": "A function of one variable can't use y",
3
+ "calls": {
4
+ "handle_set_function": { "args": { "variables": 1, "expression": "x^2 + y" }, "error": { "kind": "user", "message": "use only x" } }
5
+ }
6
+ }
backend/contract/newton_singular_hessian 2.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Two runs stepped together on x^2 + 0y: Newton stops at once (singular Hessian), gradient descent (default learning rate 0.1) carries on",
3
+ "calls": {
4
+ "handle_set_function": { "args": { "variables": 2, "expression": "x^2 + 0y" }, "result": null },
5
+ "handle_set_runs": {
6
+ "args": { "runs": [
7
+ { "algorithm": "gradient_descent", "start": [1.0, 1.0], "params": {} },
8
+ { "algorithm": "newton", "start": [1.0, 1.0], "params": {} }
9
+ ] },
10
+ "result": null
11
+ },
12
+ "handle_state": {
13
+ "args": { "iteration": 1 },
14
+ "result": { "runs": [
15
+ { "point": { "position": [0.8, 1.0], "value": 0.64, "gradient": [1.6, 0.0], "hessian": [[2.0, 0.0], [0.0, 0.0]] }, "stopped": null },
16
+ { "point": null, "stopped": "The Hessian is singular here, so Newton's method can't take a step." }
17
+ ] }
18
+ }
19
+ }
20
+ }
backend/contract/newton_singular_hessian.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Two runs stepped together on x^2 + 0y: Newton stops at once (singular Hessian), gradient descent (default learning rate 0.1) carries on",
3
+ "calls": {
4
+ "handle_set_function": { "args": { "variables": 2, "expression": "x^2 + 0y" }, "result": null },
5
+ "handle_set_runs": {
6
+ "args": { "runs": [
7
+ { "algorithm": "gradient_descent", "start": [1.0, 1.0], "params": {} },
8
+ { "algorithm": "newton", "start": [1.0, 1.0], "params": {} }
9
+ ] },
10
+ "result": null
11
+ },
12
+ "handle_state": {
13
+ "args": { "iteration": 1 },
14
+ "result": { "runs": [
15
+ { "point": { "position": [0.8, 1.0], "value": 0.64, "gradient": [1.6, 0.0], "hessian": [[2.0, 0.0], [0.0, 0.0]] }, "stopped": null },
16
+ { "point": null, "stopped": "The Hessian is singular here, so Newton's method can't take a step." }
17
+ ] }
18
+ }
19
+ }
20
+ }
backend/contract/past_the_end 2.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Before any runs are set there is no state, so the stepper finds the end at iteration 0",
3
+ "calls": {
4
+ "handle_state": { "args": { "iteration": 0 }, "result": null }
5
+ }
6
+ }
backend/contract/past_the_end.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Before any runs are set there is no state, so the stepper finds the end at iteration 0",
3
+ "calls": {
4
+ "handle_state": { "args": { "iteration": 0 }, "result": null }
5
+ }
6
+ }
backend/pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "backend"
3
+ version = "0.1.0"
4
+ description = "Optimisation visualisation: objective functions and optimisers, run in the browser by Pyodide"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "numpy>=2.4.0",
8
+ "sympy>=1.14.0",
9
+ ]
10
+
11
+ [dependency-groups]
12
+ dev = [
13
+ "pytest>=9.2.0",
14
+ ]
15
+
16
+ [tool.pytest.ini_options]
17
+ pythonpath = ["src", "."]
18
+ testpaths = ["tests"]
backend/src/algorithms 2.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "algorithms": [
3
+ {
4
+ "id": "gradient_descent",
5
+ "label": "Gradient descent",
6
+ "params": [
7
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "How far each step moves along the negative gradient." },
8
+ { "name": "momentum", "label": "Momentum μ", "symbol": "μ", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.0, "description": "How much of the previous step carries over into the next one." }
9
+ ]
10
+ },
11
+ {
12
+ "id": "nesterov",
13
+ "label": "Nesterov momentum",
14
+ "params": [
15
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "How far each step moves along the negative gradient." },
16
+ { "name": "momentum", "label": "Momentum μ", "symbol": "μ", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.1, "description": "How much of the previous step carries over; the gradient is taken at the look-ahead point." }
17
+ ]
18
+ },
19
+ {
20
+ "id": "newton",
21
+ "label": "Newton's method",
22
+ "params": []
23
+ },
24
+ {
25
+ "id": "adam",
26
+ "label": "Adam",
27
+ "params": [
28
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "The largest step size, roughly." },
29
+ { "name": "beta1", "label": "Decay β₁", "symbol": "β₁", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.9, "description": "Decay rate of the moving average of gradients (first moment)." },
30
+ { "name": "beta2", "label": "Decay β₂", "symbol": "β₂", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.999, "description": "Decay rate of the moving average of squared gradients (second moment)." },
31
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 1e-8, "description": "Added to the denominator to avoid dividing by zero." }
32
+ ]
33
+ },
34
+ {
35
+ "id": "adagrad",
36
+ "label": "Adagrad",
37
+ "params": [
38
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "Divided by the root of the summed squared gradients." },
39
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 1e-8, "description": "Added to the denominator to avoid dividing by zero." }
40
+ ]
41
+ },
42
+ {
43
+ "id": "rmsprop",
44
+ "label": "RMSProp",
45
+ "params": [
46
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "Divided by the root of the moving average of squared gradients." },
47
+ { "name": "beta", "label": "Decay β", "symbol": "β", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.9, "description": "Decay rate of the moving average of squared gradients." },
48
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 1e-8, "description": "Added to the denominator to avoid dividing by zero." }
49
+ ]
50
+ },
51
+ {
52
+ "id": "adadelta",
53
+ "label": "Adadelta",
54
+ "params": [
55
+ { "name": "beta", "label": "Decay β", "symbol": "β", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.9, "description": "Decay rate of the moving averages of squared gradients and squared updates." },
56
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 0.001, "description": "Sets the first step size, and avoids dividing by zero." }
57
+ ]
58
+ }
59
+ ]
60
+ }
backend/src/algorithms.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "algorithms": [
3
+ {
4
+ "id": "gradient_descent",
5
+ "label": "Gradient descent",
6
+ "params": [
7
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "How far each step moves along the negative gradient." },
8
+ { "name": "momentum", "label": "Momentum μ", "symbol": "μ", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.0, "description": "How much of the previous step carries over into the next one." }
9
+ ]
10
+ },
11
+ {
12
+ "id": "nesterov",
13
+ "label": "Nesterov momentum",
14
+ "params": [
15
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "How far each step moves along the negative gradient." },
16
+ { "name": "momentum", "label": "Momentum μ", "symbol": "μ", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.1, "description": "How much of the previous step carries over; the gradient is taken at the look-ahead point." }
17
+ ]
18
+ },
19
+ {
20
+ "id": "newton",
21
+ "label": "Newton's method",
22
+ "params": []
23
+ },
24
+ {
25
+ "id": "adam",
26
+ "label": "Adam",
27
+ "params": [
28
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "The largest step size, roughly." },
29
+ { "name": "beta1", "label": "Decay β₁", "symbol": "β₁", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.9, "description": "Decay rate of the moving average of gradients (first moment)." },
30
+ { "name": "beta2", "label": "Decay β₂", "symbol": "β₂", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.999, "description": "Decay rate of the moving average of squared gradients (second moment)." },
31
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 1e-8, "description": "Added to the denominator to avoid dividing by zero." }
32
+ ]
33
+ },
34
+ {
35
+ "id": "adagrad",
36
+ "label": "Adagrad",
37
+ "params": [
38
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "Divided by the root of the summed squared gradients." },
39
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 1e-8, "description": "Added to the denominator to avoid dividing by zero." }
40
+ ]
41
+ },
42
+ {
43
+ "id": "rmsprop",
44
+ "label": "RMSProp",
45
+ "params": [
46
+ { "name": "learning_rate", "label": "Learning rate η", "symbol": "η", "kind": "number", "min": 0, "minExclusive": true, "default": 0.1, "description": "Divided by the root of the moving average of squared gradients." },
47
+ { "name": "beta", "label": "Decay β", "symbol": "β", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.9, "description": "Decay rate of the moving average of squared gradients." },
48
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 1e-8, "description": "Added to the denominator to avoid dividing by zero." }
49
+ ]
50
+ },
51
+ {
52
+ "id": "adadelta",
53
+ "label": "Adadelta",
54
+ "params": [
55
+ { "name": "beta", "label": "Decay β", "symbol": "β", "kind": "number", "min": 0, "max": 1, "maxExclusive": true, "default": 0.9, "description": "Decay rate of the moving averages of squared gradients and squared updates." },
56
+ { "name": "epsilon", "label": "Epsilon ε", "symbol": "ε", "kind": "number", "min": 0, "minExclusive": true, "default": 0.001, "description": "Sets the first step size, and avoids dividing by zero." }
57
+ ]
58
+ }
59
+ ]
60
+ }
backend/src/logic.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Objective functions and optimisers, in one or two variables.
2
+
3
+ Positions, gradients and Hessians are NumPy arrays of length 1 or 2, so each
4
+ optimiser is written once for both.
5
+ """
6
+
7
+ import numpy as np
8
+ from sympy import Abs, Expr, acos, asin, atan, cos, exp, lambdify, log, pi, sin, sqrt, symbols, tan
9
+ from sympy.parsing.sympy_parser import (
10
+ convert_xor,
11
+ implicit_multiplication_application,
12
+ parse_expr,
13
+ standard_transformations,
14
+ )
15
+
16
+ # Real, so that e.g. d/dx |x| is sign(x)
17
+ VARIABLES = symbols("x y", real=True)
18
+
19
+ ALLOWED_NAMES = {
20
+ "sin": sin, "cos": cos, "tan": tan, "asin": asin, "acos": acos, "atan": atan,
21
+ "log": log, "ln": log, "sqrt": sqrt, "abs": Abs, "exp": exp,
22
+ "e": exp(1), "pi": pi, "π": pi,
23
+ }
24
+
25
+
26
+ def parse_function(expression: str, n_variables: int) -> Expr:
27
+ """Parse e.g. "x^2 + 3y^2"; raises ValueError with a message for students."""
28
+ if not expression.strip():
29
+ raise ValueError("Enter a function.")
30
+ variables = VARIABLES[:n_variables]
31
+ names = {str(v): v for v in VARIABLES} | ALLOWED_NAMES
32
+ try:
33
+ expr = parse_expr(
34
+ expression,
35
+ local_dict=names,
36
+ transformations=standard_transformations + (implicit_multiplication_application, convert_xor),
37
+ )
38
+ except Exception:
39
+ raise ValueError("This isn't a function sympy can read. Check the brackets and operators.")
40
+ if not isinstance(expr, Expr):
41
+ raise ValueError("This isn't a function sympy can read. Check the brackets and operators.")
42
+ unknown = expr.free_symbols - set(variables)
43
+ if unknown:
44
+ allowed = " and ".join(str(v) for v in variables)
45
+ raise ValueError(f"Unknown name {', '.join(sorted(map(str, unknown)))}: use only {allowed}.")
46
+ return expr
47
+
48
+
49
+ class Objective:
50
+ """f with its gradient and Hessian, evaluated at a point or on a grid."""
51
+
52
+ def __init__(self, expr: Expr, n_variables: int):
53
+ variables = VARIABLES[:n_variables]
54
+ self.n = n_variables
55
+ self._f = lambdify(variables, expr, modules="numpy")
56
+ self._gradient = [lambdify(variables, expr.diff(v), modules="numpy") for v in variables]
57
+ self._hessian = [[lambdify(variables, expr.diff(a, b), modules="numpy") for b in variables] for a in variables]
58
+
59
+ def value(self, p: np.ndarray) -> float:
60
+ return float(self._f(*p))
61
+
62
+ def gradient(self, p: np.ndarray) -> np.ndarray:
63
+ return np.array([float(g(*p)) for g in self._gradient])
64
+
65
+ def hessian(self, p: np.ndarray) -> np.ndarray:
66
+ return np.array([[float(h(*p)) for h in row] for row in self._hessian])
67
+
68
+ def values_on(self, *grid: np.ndarray) -> np.ndarray:
69
+ # Constant expressions give a scalar; spread it over the grid
70
+ return np.broadcast_to(np.asarray(self._f(*grid), dtype=float), grid[0].shape)
71
+
72
+ def gradient_on(self, *grid: np.ndarray) -> list[np.ndarray]:
73
+ return [np.broadcast_to(np.asarray(g(*grid), dtype=float), grid[0].shape) for g in self._gradient]
74
+
75
+
76
+ class SingularHessian(Exception):
77
+ pass
78
+
79
+
80
+ # Each optimiser keeps its own state between steps; step(p) returns the next position.
81
+
82
+ class GradientDescent:
83
+ """Heavy-ball momentum: v ← μv − η∇f(x), x ← x + v."""
84
+
85
+ def __init__(self, objective: Objective, learning_rate: float, momentum: float):
86
+ self.f, self.lr, self.momentum = objective, learning_rate, momentum
87
+ self.v = 0.0
88
+
89
+ def step(self, p: np.ndarray) -> np.ndarray:
90
+ self.v = self.momentum * self.v - self.lr * self.f.gradient(p)
91
+ return p + self.v
92
+
93
+
94
+ class Nesterov:
95
+ """Gradient taken at the look-ahead point x + μv."""
96
+
97
+ def __init__(self, objective: Objective, learning_rate: float, momentum: float):
98
+ self.f, self.lr, self.momentum = objective, learning_rate, momentum
99
+ self.v = 0.0
100
+
101
+ def step(self, p: np.ndarray) -> np.ndarray:
102
+ self.v = self.momentum * self.v - self.lr * self.f.gradient(p + self.momentum * self.v)
103
+ return p + self.v
104
+
105
+
106
+ class Newton:
107
+ """x ← x − H⁻¹∇f(x)."""
108
+
109
+ def __init__(self, objective: Objective):
110
+ self.f = objective
111
+
112
+ def step(self, p: np.ndarray) -> np.ndarray:
113
+ h, g = self.f.hessian(p), self.f.gradient(p)
114
+ try:
115
+ delta = np.linalg.solve(h, g)
116
+ except np.linalg.LinAlgError:
117
+ raise SingularHessian()
118
+ # Pyodide's NumPy can't see floating-point errors, so solve returns inf or nan instead of raising
119
+ if np.all(np.isfinite(h)) and np.all(np.isfinite(g)) and not np.all(np.isfinite(delta)):
120
+ raise SingularHessian()
121
+ return p - delta
122
+
123
+
124
+ class Adagrad:
125
+ def __init__(self, objective: Objective, learning_rate: float, epsilon: float):
126
+ self.f, self.lr, self.eps = objective, learning_rate, epsilon
127
+ self.v = 0.0 # sum of squared gradients
128
+
129
+ def step(self, p: np.ndarray) -> np.ndarray:
130
+ g = self.f.gradient(p)
131
+ self.v = self.v + g**2
132
+ return p - self.lr / np.sqrt(self.v + self.eps) * g
133
+
134
+
135
+ class RMSProp:
136
+ def __init__(self, objective: Objective, learning_rate: float, beta: float, epsilon: float):
137
+ self.f, self.lr, self.beta, self.eps = objective, learning_rate, beta, epsilon
138
+ self.v = 0.0 # moving average of squared gradients
139
+
140
+ def step(self, p: np.ndarray) -> np.ndarray:
141
+ g = self.f.gradient(p)
142
+ self.v = self.beta * self.v + (1 - self.beta) * g**2
143
+ return p - self.lr / np.sqrt(self.v + self.eps) * g
144
+
145
+
146
+ class Adadelta:
147
+ def __init__(self, objective: Objective, beta: float, epsilon: float):
148
+ self.f, self.beta, self.eps = objective, beta, epsilon
149
+ self.v = 0.0 # moving average of squared gradients
150
+ self.s = 0.0 # moving average of squared updates
151
+
152
+ def step(self, p: np.ndarray) -> np.ndarray:
153
+ g = self.f.gradient(p)
154
+ self.v = self.beta * self.v + (1 - self.beta) * g**2
155
+ delta = -np.sqrt(self.s + self.eps) / np.sqrt(self.v + self.eps) * g
156
+ self.s = self.beta * self.s + (1 - self.beta) * delta**2
157
+ return p + delta
158
+
159
+
160
+ class Adam:
161
+ def __init__(self, objective: Objective, learning_rate: float, beta1: float, beta2: float, epsilon: float):
162
+ self.f, self.lr, self.beta1, self.beta2, self.eps = objective, learning_rate, beta1, beta2, epsilon
163
+ self.m = 0.0 # first moment
164
+ self.v = 0.0 # second moment
165
+ self.t = 0
166
+
167
+ def step(self, p: np.ndarray) -> np.ndarray:
168
+ g = self.f.gradient(p)
169
+ self.t += 1
170
+ self.m = self.beta1 * self.m + (1 - self.beta1) * g
171
+ self.v = self.beta2 * self.v + (1 - self.beta2) * g**2
172
+ m_hat = self.m / (1 - self.beta1**self.t)
173
+ v_hat = self.v / (1 - self.beta2**self.t)
174
+ return p - self.lr / (np.sqrt(v_hat) + self.eps) * m_hat
175
+
176
+
177
+ # Ids match backend/src/algorithms.json
178
+ OPTIMISERS = {
179
+ "gradient_descent": GradientDescent,
180
+ "nesterov": Nesterov,
181
+ "newton": Newton,
182
+ "adam": Adam,
183
+ "adagrad": Adagrad,
184
+ "rmsprop": RMSProp,
185
+ "adadelta": Adadelta,
186
+ }
187
+
188
+ # A run that gets further than this from the origin has diverged
189
+ DIVERGENCE_LIMIT = 1e6
190
+
191
+ DIVERGED = "The next point has no finite value or is beyond 1,000,000: the run diverged or left the function's domain."
192
+ SINGULAR = "The Hessian is singular here, so Newton's method can't take a step."
193
+
194
+
195
+ class Run:
196
+ """One optimiser's trajectory, computed as far as it has been asked for."""
197
+
198
+ def __init__(self, objective: Objective, optimiser, start: np.ndarray):
199
+ self.f = objective
200
+ self.optimiser = optimiser
201
+ self.points = [self._describe(start)]
202
+ self.position = start
203
+ # Why the run ended, once it has
204
+ self.stopped: str | None = None
205
+
206
+ def point(self, k: int) -> dict | None:
207
+ """The point after k iterations, or None if the run stopped before then."""
208
+ with np.errstate(all="ignore"):
209
+ while len(self.points) <= k and self.stopped is None:
210
+ self._advance()
211
+ return self.points[k] if k < len(self.points) else None
212
+
213
+ def _advance(self) -> None:
214
+ try:
215
+ p = self.optimiser.step(self.position)
216
+ except SingularHessian:
217
+ self.stopped = SINGULAR
218
+ return
219
+ if not np.all(np.abs(p) <= DIVERGENCE_LIMIT) or not np.isfinite(self.f.value(p)):
220
+ self.stopped = DIVERGED
221
+ return
222
+ self.position = p
223
+ self.points.append(self._describe(p))
224
+
225
+ def _describe(self, p: np.ndarray) -> dict:
226
+ return {
227
+ "position": p.tolist(),
228
+ "value": self.f.value(p),
229
+ "gradient": self.f.gradient(p).tolist(),
230
+ "hessian": self.f.hessian(p).tolist(),
231
+ }
backend/src/manager 2.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+
6
+ from logic import OPTIMISERS, Objective, Run, parse_function
7
+
8
+ ALGORITHMS = {
9
+ spec["id"]: spec
10
+ for spec in json.loads((Path(__file__).parent / "algorithms.json").read_text())["algorithms"]
11
+ }
12
+
13
+ # Iterations a run can take before stepping stops
14
+ MAX_ITERATIONS = 1000
15
+ # Sample points for the plotted curve (one variable) or surface (two, per side)
16
+ CURVE_POINTS = 401
17
+ GRID_POINTS = 101
18
+ # Gradient arrows per side
19
+ ARROW_POINTS = 15
20
+
21
+
22
+ class UserError(Exception):
23
+ """A mistake the student can fix; its message is shown in the UI."""
24
+
25
+
26
+ def _json_values(values: np.ndarray) -> list:
27
+ """Values as JSON numbers, with null where f has no finite value."""
28
+ return [float(v) if np.isfinite(v) else None for v in values]
29
+
30
+
31
+ class Manager:
32
+ def __init__(self):
33
+ self._objective: Objective | None = None
34
+ self._runs: list[Run] = []
35
+
36
+ def handle_set_function(self, args: dict) -> None:
37
+ """{variables: 1 | 2, expression}; clears the runs."""
38
+ self._objective = None
39
+ self._runs = []
40
+ n = int(args["variables"])
41
+ try:
42
+ expr = parse_function(str(args["expression"]), n)
43
+ except ValueError as e:
44
+ raise UserError(str(e))
45
+ try:
46
+ self._objective = Objective(expr, n)
47
+ except Exception:
48
+ raise UserError("This function can't be differentiated here. Try writing it another way.")
49
+
50
+ def handle_function_values(self, args: dict) -> dict | None:
51
+ """f sampled over the plot's {x_range, y_range}; None with no function."""
52
+ f = self._objective
53
+ if f is None:
54
+ return None
55
+ with np.errstate(all="ignore"):
56
+ if f.n == 1:
57
+ x = np.linspace(*args["x_range"], CURVE_POINTS)
58
+ return {"x": x.tolist(), "f": _json_values(f.values_on(x))}
59
+
60
+ x = np.linspace(*args["x_range"], GRID_POINTS)
61
+ y = np.linspace(*args["y_range"], GRID_POINTS)
62
+ z = f.values_on(*np.meshgrid(x, y))
63
+ ax, ay = np.meshgrid(np.linspace(*args["x_range"], ARROW_POINTS), np.linspace(*args["y_range"], ARROW_POINTS))
64
+ u, v = f.gradient_on(ax, ay)
65
+ return {
66
+ "x": x.tolist(),
67
+ "y": y.tolist(),
68
+ # Rows follow y, columns follow x
69
+ "f": [_json_values(row) for row in z],
70
+ "arrows": {
71
+ "x": ax.ravel().tolist(),
72
+ "y": ay.ravel().tolist(),
73
+ "u": _json_values(u.ravel()),
74
+ "v": _json_values(v.ravel()),
75
+ },
76
+ }
77
+
78
+ def handle_set_runs(self, args: dict) -> None:
79
+ """{runs: [{algorithm, start, params}]}: the optimisers to step together."""
80
+ self._runs = []
81
+ f = self._objective
82
+ if f is None:
83
+ raise ValueError("Function not set")
84
+ runs = []
85
+ for run in args["runs"]:
86
+ spec = ALGORITHMS[run["algorithm"]]
87
+ given = dict(run.get("params", {}))
88
+ params = {p["name"]: float(given.get(p["name"], p["default"])) for p in spec["params"]}
89
+ start = np.array(run["start"], dtype=float)
90
+ if start.shape != (f.n,):
91
+ raise ValueError(f"Start point needs {f.n} coordinates, got {start.tolist()}")
92
+ with np.errstate(all="ignore"):
93
+ if not np.isfinite(f.value(start)):
94
+ point = ", ".join(f"{c:g}" for c in start)
95
+ raise UserError(f"The function has no finite value at the initial point ({point}).")
96
+ runs.append(Run(f, OPTIMISERS[spec["id"]](f, **params), start))
97
+ self._runs = runs
98
+
99
+ def handle_state(self, args: dict) -> dict | None:
100
+ """Every run after {iteration} iterations; None once all have stopped.
101
+
102
+ "stopped" says why a run ended, at its last point and after it (where
103
+ point is None). It is None while the run carries on.
104
+ """
105
+ k = int(args["iteration"])
106
+ if not self._runs or k > MAX_ITERATIONS:
107
+ return None
108
+ points = [run.point(k) for run in self._runs]
109
+ if all(p is None for p in points):
110
+ return None
111
+ # Look one iteration ahead to know whether this is a run's last point
112
+ ended = [p is None or run.point(k + 1) is None for run, p in zip(self._runs, points)]
113
+ return {
114
+ "runs": [
115
+ {"point": p, "stopped": run.stopped if end else None}
116
+ for run, p, end in zip(self._runs, points, ended)
117
+ ]
118
+ }
backend/src/manager.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+
6
+ from logic import OPTIMISERS, Objective, Run, parse_function
7
+
8
+ ALGORITHMS = {
9
+ spec["id"]: spec
10
+ for spec in json.loads((Path(__file__).parent / "algorithms.json").read_text())["algorithms"]
11
+ }
12
+
13
+ # Iterations a run can take before stepping stops
14
+ MAX_ITERATIONS = 1000
15
+ # Sample points for the plotted curve (one variable) or surface (two, per side)
16
+ CURVE_POINTS = 401
17
+ GRID_POINTS = 101
18
+ # Gradient arrows per side
19
+ ARROW_POINTS = 15
20
+
21
+
22
+ class UserError(Exception):
23
+ """A mistake the student can fix; its message is shown in the UI."""
24
+
25
+
26
+ def _json_values(values: np.ndarray) -> list:
27
+ """Values as JSON numbers, with null where f has no finite value."""
28
+ return [float(v) if np.isfinite(v) else None for v in values]
29
+
30
+
31
+ class Manager:
32
+ def __init__(self):
33
+ self._objective: Objective | None = None
34
+ self._runs: list[Run] = []
35
+
36
+ def handle_set_function(self, args: dict) -> None:
37
+ """{variables: 1 | 2, expression}; clears the runs."""
38
+ self._objective = None
39
+ self._runs = []
40
+ n = int(args["variables"])
41
+ try:
42
+ expr = parse_function(str(args["expression"]), n)
43
+ except ValueError as e:
44
+ raise UserError(str(e))
45
+ try:
46
+ self._objective = Objective(expr, n)
47
+ except Exception:
48
+ raise UserError("This function can't be differentiated here. Try writing it another way.")
49
+
50
+ def handle_function_values(self, args: dict) -> dict | None:
51
+ """f sampled over the plot's {x_range, y_range}; None with no function."""
52
+ f = self._objective
53
+ if f is None:
54
+ return None
55
+ with np.errstate(all="ignore"):
56
+ if f.n == 1:
57
+ x = np.linspace(*args["x_range"], CURVE_POINTS)
58
+ return {"x": x.tolist(), "f": _json_values(f.values_on(x))}
59
+
60
+ x = np.linspace(*args["x_range"], GRID_POINTS)
61
+ y = np.linspace(*args["y_range"], GRID_POINTS)
62
+ z = f.values_on(*np.meshgrid(x, y))
63
+ ax, ay = np.meshgrid(np.linspace(*args["x_range"], ARROW_POINTS), np.linspace(*args["y_range"], ARROW_POINTS))
64
+ u, v = f.gradient_on(ax, ay)
65
+ return {
66
+ "x": x.tolist(),
67
+ "y": y.tolist(),
68
+ # Rows follow y, columns follow x
69
+ "f": [_json_values(row) for row in z],
70
+ "arrows": {
71
+ "x": ax.ravel().tolist(),
72
+ "y": ay.ravel().tolist(),
73
+ "u": _json_values(u.ravel()),
74
+ "v": _json_values(v.ravel()),
75
+ },
76
+ }
77
+
78
+ def handle_set_runs(self, args: dict) -> None:
79
+ """{runs: [{algorithm, start, params}]}: the optimisers to step together."""
80
+ self._runs = []
81
+ f = self._objective
82
+ if f is None:
83
+ raise ValueError("Function not set")
84
+ runs = []
85
+ for run in args["runs"]:
86
+ spec = ALGORITHMS[run["algorithm"]]
87
+ given = dict(run.get("params", {}))
88
+ params = {p["name"]: float(given.get(p["name"], p["default"])) for p in spec["params"]}
89
+ start = np.array(run["start"], dtype=float)
90
+ if start.shape != (f.n,):
91
+ raise ValueError(f"Start point needs {f.n} coordinates, got {start.tolist()}")
92
+ with np.errstate(all="ignore"):
93
+ if not np.isfinite(f.value(start)):
94
+ point = ", ".join(f"{c:g}" for c in start)
95
+ raise UserError(f"The function has no finite value at the initial point ({point}).")
96
+ runs.append(Run(f, OPTIMISERS[spec["id"]](f, **params), start))
97
+ self._runs = runs
98
+
99
+ def handle_state(self, args: dict) -> dict | None:
100
+ """Every run after {iteration} iterations; None once all have stopped.
101
+
102
+ "stopped" says why a run ended, at its last point and after it (where
103
+ point is None). It is None while the run carries on.
104
+ """
105
+ k = int(args["iteration"])
106
+ if not self._runs or k > MAX_ITERATIONS:
107
+ return None
108
+ points = [run.point(k) for run in self._runs]
109
+ if all(p is None for p in points):
110
+ return None
111
+ # Look one iteration ahead to know whether this is a run's last point
112
+ ended = [p is None or run.point(k + 1) is None for run, p in zip(self._runs, points)]
113
+ return {
114
+ "runs": [
115
+ {"point": p, "stopped": run.stopped if end else None}
116
+ for run, p, end in zip(self._runs, points, ended)
117
+ ]
118
+ }
backend/tests/test_contract.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Replay each contract example in ../contract against a fresh Manager.
2
+
3
+ Each example lists calls in order, keyed by method name. A call has "args"
4
+ (omitted for methods that take none) and either "result" (the expected return
5
+ value as JSON; floats are compared to 6 significant figures) or "error" (a
6
+ UserError of "kind": "user", whose text contains "message" if given). This
7
+ mirrors what @elvis/pyodide's worker sends to the UI.
8
+ """
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from manager import Manager
16
+
17
+ CONTRACT_DIR = Path(__file__).parent.parent / "contract"
18
+ EXAMPLES = sorted(CONTRACT_DIR.glob("*.json"))
19
+
20
+
21
+ def same(actual, expected) -> bool:
22
+ """Equal JSON values, with floats compared approximately."""
23
+ if isinstance(expected, float) or isinstance(actual, float):
24
+ return isinstance(actual, (int, float)) and actual == pytest.approx(expected, rel=1e-6, abs=1e-12)
25
+ if isinstance(expected, dict):
26
+ return isinstance(actual, dict) and actual.keys() == expected.keys() and all(same(actual[k], expected[k]) for k in expected)
27
+ if isinstance(expected, list):
28
+ return isinstance(actual, list) and len(actual) == len(expected) and all(same(a, e) for a, e in zip(actual, expected))
29
+ return actual == expected
30
+
31
+
32
+ @pytest.mark.parametrize("path", EXAMPLES, ids=[p.stem for p in EXAMPLES])
33
+ def test_contract_example(path: Path) -> None:
34
+ example = json.loads(path.read_text())
35
+ manager = Manager()
36
+
37
+ for method, call in example["calls"].items():
38
+ fn = getattr(manager, method)
39
+ try:
40
+ data = fn(call["args"]) if "args" in call else fn()
41
+ except Exception as e:
42
+ # The worker treats only UserError as a user error; anything else is a bug
43
+ if type(e).__name__ != "UserError":
44
+ raise
45
+ assert "error" in call, f"{method} raised UserError: {e}"
46
+ assert call["error"]["kind"] == "user"
47
+ assert call["error"].get("message", "") in str(e)
48
+ continue
49
+
50
+ assert "error" not in call, f"{method} returned {data!r} instead of raising UserError"
51
+ # Results must be plain JSON values (no NumPy types) to reach the UI unchanged
52
+ actual = json.loads(json.dumps(data))
53
+ assert same(actual, call["result"]), f"{method} returned {actual!r}"
backend/tests/test_logic 2.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import inspect
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from logic import DIVERGED, OPTIMISERS, SINGULAR, Objective, Run, parse_function
9
+ from manager import MAX_ITERATIONS, Manager, UserError
10
+
11
+ SPEC = json.loads((Path(__file__).parent.parent / "src" / "algorithms.json").read_text())["algorithms"]
12
+ DEFAULTS = {spec["id"]: {p["name"]: p["default"] for p in spec["params"]} for spec in SPEC}
13
+
14
+
15
+ def run(expression: str, algorithm: str, start: list[float], **params) -> Run:
16
+ f = Objective(parse_function(expression, len(start)), len(start))
17
+ return Run(f, OPTIMISERS[algorithm](f, **(DEFAULTS[algorithm] | params)), np.array(start, dtype=float))
18
+
19
+
20
+ def test_spec_matches_optimisers():
21
+ assert [spec["id"] for spec in SPEC] == list(OPTIMISERS)
22
+ for spec in SPEC:
23
+ names = list(inspect.signature(OPTIMISERS[spec["id"]]).parameters)[1:]
24
+ assert names == [p["name"] for p in spec["params"]], spec["id"]
25
+
26
+
27
+ @pytest.mark.parametrize("expression, n, expected", [
28
+ ("x^2 - 0.1cos(20x)", 1, 0.81 - 0.1 * np.cos(18)),
29
+ ("2x y + e", 2, 2 * 0.9 * -0.5 + np.e),
30
+ ("sqrt(abs(x)) + ln(exp(y))", 2, np.sqrt(0.9) - 0.5),
31
+ ])
32
+ def test_parse_function(expression, n, expected):
33
+ f = Objective(parse_function(expression, n), n)
34
+ point = np.array([0.9, -0.5][:n])
35
+ assert f.value(point) == pytest.approx(expected)
36
+
37
+
38
+ @pytest.mark.parametrize("expression, message", [
39
+ ("", "Enter a function"),
40
+ ("x^2 + (", "can read"),
41
+ ("x + z", "Unknown name z"),
42
+ ])
43
+ def test_parse_function_errors(expression, message):
44
+ with pytest.raises(ValueError, match=message):
45
+ parse_function(expression, 2)
46
+
47
+
48
+ def test_gradient_and_hessian():
49
+ f = Objective(parse_function("x^2 y + y^3", 2), 2)
50
+ p = np.array([1.0, 2.0])
51
+ assert f.gradient(p).tolist() == [4.0, 13.0]
52
+ assert f.hessian(p).tolist() == [[4.0, 2.0], [2.0, 12.0]]
53
+
54
+
55
+ def test_gradient_descent_on_a_quadratic():
56
+ # x_k = (1 - 2η)^k x_0
57
+ r = run("x^2", "gradient_descent", [1.0], learning_rate=0.1)
58
+ assert r.point(10)["position"][0] == pytest.approx(0.8**10)
59
+
60
+
61
+ def test_momentum_overshoots_and_nesterov_damps_it():
62
+ plain = run("x^2", "gradient_descent", [1.0], learning_rate=0.1, momentum=0.9)
63
+ nesterov = run("x^2", "nesterov", [1.0], learning_rate=0.1, momentum=0.9)
64
+ assert min(plain.point(k)["position"][0] for k in range(20)) < -0.5
65
+ assert abs(nesterov.point(20)["position"][0]) < abs(plain.point(20)["position"][0])
66
+
67
+
68
+ def test_newton_solves_a_quadratic_in_one_step():
69
+ r = run("(x - 1)^2 + 3(y + 2)^2 + x y", "newton", [5.0, 5.0])
70
+ assert r.point(1)["gradient"] == pytest.approx([0, 0], abs=1e-12)
71
+
72
+
73
+ @pytest.mark.parametrize("algorithm", [spec["id"] for spec in SPEC])
74
+ def test_every_optimiser_goes_downhill_on_a_bowl(algorithm):
75
+ r = run("x^2 + 3y^2", algorithm, [0.8, -0.6])
76
+ assert r.point(50)["value"] < r.point(0)["value"]
77
+
78
+
79
+ def test_divergence_stops_the_run():
80
+ r = run("x^2", "gradient_descent", [1.0], learning_rate=1.5)
81
+ # |x| doubles each step, so it passes 1e6 after 20 steps
82
+ assert r.point(20) is None and r.point(19) is not None
83
+ assert r.stopped == DIVERGED
84
+
85
+
86
+ def test_leaving_the_domain_stops_the_run():
87
+ r = run("sqrt(x)", "gradient_descent", [0.5], learning_rate=1.0)
88
+ assert r.point(3) is None
89
+ assert r.stopped == DIVERGED
90
+
91
+
92
+ def test_singular_hessian_stops_newton():
93
+ r = run("x^3", "newton", [0.0])
94
+ assert r.point(1) is None
95
+ assert r.stopped == SINGULAR
96
+
97
+
98
+ def test_singular_hessian_stops_newton_in_pyodide(monkeypatch):
99
+ # Pyodide's solve returns inf rather than raising LinAlgError
100
+ monkeypatch.setattr(np.linalg, "solve", lambda a, b: np.full_like(b, np.inf))
101
+ r = run("x^2 + 0y", "newton", [1.0, 1.0])
102
+ assert r.point(1) is None
103
+ assert r.stopped == SINGULAR
104
+
105
+
106
+ def test_points_are_cached_and_asked_for_in_any_order():
107
+ r = run("x^2 + y^2", "adam", [1.0, 1.0])
108
+ late = r.point(30)
109
+ assert r.point(3) == r.points[3]
110
+ assert r.point(30) == late
111
+
112
+
113
+ def manager_with(expression: str, *runs: dict) -> Manager:
114
+ m = Manager()
115
+ m.handle_set_function({"variables": len(runs[0]["start"]), "expression": expression})
116
+ m.handle_set_runs({"runs": list(runs)})
117
+ return m
118
+
119
+
120
+ def test_state_past_the_last_iteration():
121
+ m = manager_with("x^2", {"algorithm": "gradient_descent", "start": [1.0], "params": {}})
122
+ assert m.handle_state({"iteration": MAX_ITERATIONS}) is not None
123
+ assert m.handle_state({"iteration": MAX_ITERATIONS + 1}) is None
124
+
125
+
126
+ def test_state_ends_when_every_run_has_stopped():
127
+ m = manager_with(
128
+ "x^3 + x^2",
129
+ {"algorithm": "gradient_descent", "start": [1.0], "params": {"learning_rate": 1.5}},
130
+ {"algorithm": "newton", "start": [-1/3], "params": {}}, # f'' = 0 here
131
+ )
132
+ states = [m.handle_state({"iteration": k}) for k in range(MAX_ITERATIONS)]
133
+ end = states.index(None)
134
+ assert 1 < end and all(s is None for s in states[end:])
135
+ # Newton stops first and says why while gradient descent carries on
136
+ assert states[1]["runs"][1] == {"point": None, "stopped": SINGULAR}
137
+ assert states[1]["runs"][0]["point"] is not None
138
+ # Each run says why at its last point
139
+ assert states[0]["runs"][1]["stopped"] == SINGULAR
140
+ assert states[end - 2]["runs"][0]["stopped"] is None
141
+ assert states[end - 1]["runs"][0]["stopped"] == DIVERGED
142
+
143
+
144
+ def test_start_point_outside_the_domain():
145
+ m = Manager()
146
+ m.handle_set_function({"variables": 1, "expression": "log(x)"})
147
+ with pytest.raises(UserError, match="no finite value at the initial point"):
148
+ m.handle_set_runs({"runs": [{"algorithm": "adam", "start": [-1.0], "params": {}}]})
149
+
150
+
151
+ def test_failed_runs_replace_the_old_ones():
152
+ m = manager_with("log(x)", {"algorithm": "adam", "start": [1.0], "params": {}})
153
+ with pytest.raises(UserError):
154
+ m.handle_set_runs({"runs": [{"algorithm": "adam", "start": [-1.0], "params": {}}]})
155
+ assert m.handle_state({"iteration": 0}) is None
156
+
157
+
158
+ def test_function_values_one_variable():
159
+ m = Manager()
160
+ m.handle_set_function({"variables": 1, "expression": "log(x)"})
161
+ values = m.handle_function_values({"x_range": [-1, 1], "y_range": [0, 1]})
162
+ assert len(values["x"]) == len(values["f"])
163
+ assert values["f"][0] is None and values["f"][-1] == 0.0
164
+
165
+
166
+ def test_function_values_two_variables():
167
+ m = Manager()
168
+ m.handle_set_function({"variables": 2, "expression": "x + 2y"})
169
+ values = m.handle_function_values({"x_range": [0, 1], "y_range": [0, 2]})
170
+ assert values["f"][-1][0] == 4.0 # rows follow y
171
+ assert set(values["arrows"]["u"]) == {1.0} and set(values["arrows"]["v"]) == {2.0}
172
+
173
+
174
+ def test_no_function_values_without_a_function():
175
+ assert Manager().handle_function_values({"x_range": [0, 1], "y_range": [0, 1]}) is None
backend/tests/test_logic.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import inspect
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from logic import DIVERGED, OPTIMISERS, SINGULAR, Objective, Run, parse_function
9
+ from manager import MAX_ITERATIONS, Manager, UserError
10
+
11
+ SPEC = json.loads((Path(__file__).parent.parent / "src" / "algorithms.json").read_text())["algorithms"]
12
+ DEFAULTS = {spec["id"]: {p["name"]: p["default"] for p in spec["params"]} for spec in SPEC}
13
+
14
+
15
+ def run(expression: str, algorithm: str, start: list[float], **params) -> Run:
16
+ f = Objective(parse_function(expression, len(start)), len(start))
17
+ return Run(f, OPTIMISERS[algorithm](f, **(DEFAULTS[algorithm] | params)), np.array(start, dtype=float))
18
+
19
+
20
+ def test_spec_matches_optimisers():
21
+ assert [spec["id"] for spec in SPEC] == list(OPTIMISERS)
22
+ for spec in SPEC:
23
+ names = list(inspect.signature(OPTIMISERS[spec["id"]]).parameters)[1:]
24
+ assert names == [p["name"] for p in spec["params"]], spec["id"]
25
+
26
+
27
+ @pytest.mark.parametrize("expression, n, expected", [
28
+ ("x^2 - 0.1cos(20x)", 1, 0.81 - 0.1 * np.cos(18)),
29
+ ("2x y + e", 2, 2 * 0.9 * -0.5 + np.e),
30
+ ("sqrt(abs(x)) + ln(exp(y))", 2, np.sqrt(0.9) - 0.5),
31
+ ])
32
+ def test_parse_function(expression, n, expected):
33
+ f = Objective(parse_function(expression, n), n)
34
+ point = np.array([0.9, -0.5][:n])
35
+ assert f.value(point) == pytest.approx(expected)
36
+
37
+
38
+ @pytest.mark.parametrize("expression, message", [
39
+ ("", "Enter a function"),
40
+ ("x^2 + (", "can read"),
41
+ ("x + z", "Unknown name z"),
42
+ ])
43
+ def test_parse_function_errors(expression, message):
44
+ with pytest.raises(ValueError, match=message):
45
+ parse_function(expression, 2)
46
+
47
+
48
+ def test_gradient_and_hessian():
49
+ f = Objective(parse_function("x^2 y + y^3", 2), 2)
50
+ p = np.array([1.0, 2.0])
51
+ assert f.gradient(p).tolist() == [4.0, 13.0]
52
+ assert f.hessian(p).tolist() == [[4.0, 2.0], [2.0, 12.0]]
53
+
54
+
55
+ def test_gradient_descent_on_a_quadratic():
56
+ # x_k = (1 - 2η)^k x_0
57
+ r = run("x^2", "gradient_descent", [1.0], learning_rate=0.1)
58
+ assert r.point(10)["position"][0] == pytest.approx(0.8**10)
59
+
60
+
61
+ def test_momentum_overshoots_and_nesterov_damps_it():
62
+ plain = run("x^2", "gradient_descent", [1.0], learning_rate=0.1, momentum=0.9)
63
+ nesterov = run("x^2", "nesterov", [1.0], learning_rate=0.1, momentum=0.9)
64
+ assert min(plain.point(k)["position"][0] for k in range(20)) < -0.5
65
+ assert abs(nesterov.point(20)["position"][0]) < abs(plain.point(20)["position"][0])
66
+
67
+
68
+ def test_newton_solves_a_quadratic_in_one_step():
69
+ r = run("(x - 1)^2 + 3(y + 2)^2 + x y", "newton", [5.0, 5.0])
70
+ assert r.point(1)["gradient"] == pytest.approx([0, 0], abs=1e-12)
71
+
72
+
73
+ @pytest.mark.parametrize("algorithm", [spec["id"] for spec in SPEC])
74
+ def test_every_optimiser_goes_downhill_on_a_bowl(algorithm):
75
+ r = run("x^2 + 3y^2", algorithm, [0.8, -0.6])
76
+ assert r.point(50)["value"] < r.point(0)["value"]
77
+
78
+
79
+ def test_divergence_stops_the_run():
80
+ r = run("x^2", "gradient_descent", [1.0], learning_rate=1.5)
81
+ # |x| doubles each step, so it passes 1e6 after 20 steps
82
+ assert r.point(20) is None and r.point(19) is not None
83
+ assert r.stopped == DIVERGED
84
+
85
+
86
+ def test_leaving_the_domain_stops_the_run():
87
+ r = run("sqrt(x)", "gradient_descent", [0.5], learning_rate=1.0)
88
+ assert r.point(3) is None
89
+ assert r.stopped == DIVERGED
90
+
91
+
92
+ def test_singular_hessian_stops_newton():
93
+ r = run("x^3", "newton", [0.0])
94
+ assert r.point(1) is None
95
+ assert r.stopped == SINGULAR
96
+
97
+
98
+ def test_singular_hessian_stops_newton_in_pyodide(monkeypatch):
99
+ # Pyodide's solve returns inf rather than raising LinAlgError
100
+ monkeypatch.setattr(np.linalg, "solve", lambda a, b: np.full_like(b, np.inf))
101
+ r = run("x^2 + 0y", "newton", [1.0, 1.0])
102
+ assert r.point(1) is None
103
+ assert r.stopped == SINGULAR
104
+
105
+
106
+ def test_points_are_cached_and_asked_for_in_any_order():
107
+ r = run("x^2 + y^2", "adam", [1.0, 1.0])
108
+ late = r.point(30)
109
+ assert r.point(3) == r.points[3]
110
+ assert r.point(30) == late
111
+
112
+
113
+ def manager_with(expression: str, *runs: dict) -> Manager:
114
+ m = Manager()
115
+ m.handle_set_function({"variables": len(runs[0]["start"]), "expression": expression})
116
+ m.handle_set_runs({"runs": list(runs)})
117
+ return m
118
+
119
+
120
+ def test_state_past_the_last_iteration():
121
+ m = manager_with("x^2", {"algorithm": "gradient_descent", "start": [1.0], "params": {}})
122
+ assert m.handle_state({"iteration": MAX_ITERATIONS}) is not None
123
+ assert m.handle_state({"iteration": MAX_ITERATIONS + 1}) is None
124
+
125
+
126
+ def test_state_ends_when_every_run_has_stopped():
127
+ m = manager_with(
128
+ "x^3 + x^2",
129
+ {"algorithm": "gradient_descent", "start": [1.0], "params": {"learning_rate": 1.5}},
130
+ {"algorithm": "newton", "start": [-1/3], "params": {}}, # f'' = 0 here
131
+ )
132
+ states = [m.handle_state({"iteration": k}) for k in range(MAX_ITERATIONS)]
133
+ end = states.index(None)
134
+ assert 1 < end and all(s is None for s in states[end:])
135
+ # Newton stops first and says why while gradient descent carries on
136
+ assert states[1]["runs"][1] == {"point": None, "stopped": SINGULAR}
137
+ assert states[1]["runs"][0]["point"] is not None
138
+ # Each run says why at its last point
139
+ assert states[0]["runs"][1]["stopped"] == SINGULAR
140
+ assert states[end - 2]["runs"][0]["stopped"] is None
141
+ assert states[end - 1]["runs"][0]["stopped"] == DIVERGED
142
+
143
+
144
+ def test_start_point_outside_the_domain():
145
+ m = Manager()
146
+ m.handle_set_function({"variables": 1, "expression": "log(x)"})
147
+ with pytest.raises(UserError, match="no finite value at the initial point"):
148
+ m.handle_set_runs({"runs": [{"algorithm": "adam", "start": [-1.0], "params": {}}]})
149
+
150
+
151
+ def test_failed_runs_replace_the_old_ones():
152
+ m = manager_with("log(x)", {"algorithm": "adam", "start": [1.0], "params": {}})
153
+ with pytest.raises(UserError):
154
+ m.handle_set_runs({"runs": [{"algorithm": "adam", "start": [-1.0], "params": {}}]})
155
+ assert m.handle_state({"iteration": 0}) is None
156
+
157
+
158
+ def test_function_values_one_variable():
159
+ m = Manager()
160
+ m.handle_set_function({"variables": 1, "expression": "log(x)"})
161
+ values = m.handle_function_values({"x_range": [-1, 1], "y_range": [0, 1]})
162
+ assert len(values["x"]) == len(values["f"])
163
+ assert values["f"][0] is None and values["f"][-1] == 0.0
164
+
165
+
166
+ def test_function_values_two_variables():
167
+ m = Manager()
168
+ m.handle_set_function({"variables": 2, "expression": "x + 2y"})
169
+ values = m.handle_function_values({"x_range": [0, 1], "y_range": [0, 2]})
170
+ assert values["f"][-1][0] == 4.0 # rows follow y
171
+ assert set(values["arrows"]["u"]) == {1.0} and set(values["arrows"]["v"]) == {2.0}
172
+
173
+
174
+ def test_no_function_values_without_a_function():
175
+ assert Manager().handle_function_values({"x_range": [0, 1], "y_range": [0, 1]}) is None
backend/uv.lock ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version = 1
2
+ revision = 5
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "backend"
7
+ version = "0.1.0"
8
+ source = { virtual = "." }
9
+ dependencies = [
10
+ { name = "numpy" },
11
+ { name = "sympy" },
12
+ ]
13
+
14
+ [package.dev-dependencies]
15
+ dev = [
16
+ { name = "pytest" },
17
+ ]
18
+
19
+ [package.metadata]
20
+ requires-dist = [
21
+ { name = "numpy", specifier = ">=2.4.0" },
22
+ { name = "sympy", specifier = ">=1.14.0" },
23
+ ]
24
+
25
+ [package.metadata.requires-dev]
26
+ dev = [{ name = "pytest", specifier = ">=9.2.0" }]
27
+
28
+ [[package]]
29
+ name = "colorama"
30
+ version = "0.4.6"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
33
+ wheels = [
34
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
35
+ ]
36
+
37
+ [[package]]
38
+ name = "iniconfig"
39
+ version = "2.3.1"
40
+ source = { registry = "https://pypi.org/simple" }
41
+ sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
42
+ wheels = [
43
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
44
+ ]
45
+
46
+ [[package]]
47
+ name = "mpmath"
48
+ version = "1.3.0"
49
+ source = { registry = "https://pypi.org/simple" }
50
+ sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
51
+ wheels = [
52
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
53
+ ]
54
+
55
+ [[package]]
56
+ name = "numpy"
57
+ version = "2.4.0"
58
+ source = { registry = "https://pypi.org/simple" }
59
+ sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" }
60
+ wheels = [
61
+ { url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" },
62
+ { url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" },
63
+ { url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" },
64
+ { url = "https://files.pythonhosted.org/packages/c1/c8/4e0d436b66b826f2e53330adaa6311f5cac9871a5b5c31ad773b27f25a74/numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", size = 6545298, upload-time = "2025-12-20T16:16:12.607Z" },
65
+ { url = "https://files.pythonhosted.org/packages/ef/27/e1f5d144ab54eac34875e79037011d511ac57b21b220063310cb96c80fbc/numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", size = 14398387, upload-time = "2025-12-20T16:16:14.257Z" },
66
+ { url = "https://files.pythonhosted.org/packages/67/64/4cb909dd5ab09a9a5d086eff9586e69e827b88a5585517386879474f4cf7/numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", size = 16363091, upload-time = "2025-12-20T16:16:17.32Z" },
67
+ { url = "https://files.pythonhosted.org/packages/9d/9c/8efe24577523ec6809261859737cf117b0eb6fdb655abdfdc81b2e468ce4/numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", size = 16176394, upload-time = "2025-12-20T16:16:19.524Z" },
68
+ { url = "https://files.pythonhosted.org/packages/61/f0/1687441ece7b47a62e45a1f82015352c240765c707928edd8aef875d5951/numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", size = 18287378, upload-time = "2025-12-20T16:16:22.866Z" },
69
+ { url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" },
70
+ { url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" },
71
+ { url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" },
72
+ { url = "https://files.pythonhosted.org/packages/a7/0d/853fd96372eda07c824d24adf02e8bc92bb3731b43a9b2a39161c3667cc4/numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", size = 16649088, upload-time = "2025-12-20T16:16:31.421Z" },
73
+ { url = "https://files.pythonhosted.org/packages/e3/37/cc636f1f2a9f585434e20a3e6e63422f70bfe4f7f6698e941db52ea1ac9a/numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", size = 12364065, upload-time = "2025-12-20T16:16:33.491Z" },
74
+ { url = "https://files.pythonhosted.org/packages/ed/69/0b78f37ca3690969beee54103ce5f6021709134e8020767e93ba691a72f1/numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", size = 5192640, upload-time = "2025-12-20T16:16:35.636Z" },
75
+ { url = "https://files.pythonhosted.org/packages/1d/2a/08569f8252abf590294dbb09a430543ec8f8cc710383abfb3e75cc73aeda/numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", size = 6541556, upload-time = "2025-12-20T16:16:37.276Z" },
76
+ { url = "https://files.pythonhosted.org/packages/93/e9/a949885a4e177493d61519377952186b6cbfdf1d6002764c664ba28349b5/numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2", size = 14396562, upload-time = "2025-12-20T16:16:38.953Z" },
77
+ { url = "https://files.pythonhosted.org/packages/99/98/9d4ad53b0e9ef901c2ef1d550d2136f5ac42d3fd2988390a6def32e23e48/numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", size = 16351719, upload-time = "2025-12-20T16:16:41.503Z" },
78
+ { url = "https://files.pythonhosted.org/packages/28/de/5f3711a38341d6e8dd619f6353251a0cdd07f3d6d101a8fd46f4ef87f895/numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", size = 16176053, upload-time = "2025-12-20T16:16:44.552Z" },
79
+ { url = "https://files.pythonhosted.org/packages/2a/5b/2a3753dc43916501b4183532e7ace862e13211042bceafa253afb5c71272/numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", size = 18277859, upload-time = "2025-12-20T16:16:47.174Z" },
80
+ { url = "https://files.pythonhosted.org/packages/2c/c5/a18bcdd07a941db3076ef489d036ab16d2bfc2eae0cf27e5a26e29189434/numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", size = 5953849, upload-time = "2025-12-20T16:16:49.554Z" },
81
+ { url = "https://files.pythonhosted.org/packages/4f/f1/719010ff8061da6e8a26e1980cf090412d4f5f8060b31f0c45d77dd67a01/numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", size = 12302840, upload-time = "2025-12-20T16:16:51.227Z" },
82
+ { url = "https://files.pythonhosted.org/packages/f5/5a/b3d259083ed8b4d335270c76966cb6cf14a5d1b69e1a608994ac57a659e6/numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", size = 10308509, upload-time = "2025-12-20T16:16:53.313Z" },
83
+ { url = "https://files.pythonhosted.org/packages/31/01/95edcffd1bb6c0633df4e808130545c4f07383ab629ac7e316fb44fff677/numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", size = 12491815, upload-time = "2025-12-20T16:16:55.496Z" },
84
+ { url = "https://files.pythonhosted.org/packages/59/ea/5644b8baa92cc1c7163b4b4458c8679852733fa74ca49c942cfa82ded4e0/numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", size = 5320321, upload-time = "2025-12-20T16:16:57.468Z" },
85
+ { url = "https://files.pythonhosted.org/packages/26/4e/e10938106d70bc21319bd6a86ae726da37edc802ce35a3a71ecdf1fdfe7f/numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", size = 6641635, upload-time = "2025-12-20T16:16:59.379Z" },
86
+ { url = "https://files.pythonhosted.org/packages/b3/8d/a8828e3eaf5c0b4ab116924df82f24ce3416fa38d0674d8f708ddc6c8aac/numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", size = 14456053, upload-time = "2025-12-20T16:17:01.768Z" },
87
+ { url = "https://files.pythonhosted.org/packages/68/a1/17d97609d87d4520aa5ae2dcfb32305654550ac6a35effb946d303e594ce/numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", size = 16401702, upload-time = "2025-12-20T16:17:04.235Z" },
88
+ { url = "https://files.pythonhosted.org/packages/18/32/0f13c1b2d22bea1118356b8b963195446f3af124ed7a5adfa8fdecb1b6ca/numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", size = 16242493, upload-time = "2025-12-20T16:17:06.856Z" },
89
+ { url = "https://files.pythonhosted.org/packages/ae/23/48f21e3d309fbc137c068a1475358cbd3a901b3987dcfc97a029ab3068e2/numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", size = 18324222, upload-time = "2025-12-20T16:17:09.392Z" },
90
+ { url = "https://files.pythonhosted.org/packages/ac/52/41f3d71296a3dcaa4f456aaa3c6fc8e745b43d0552b6bde56571bb4b4a0f/numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", size = 6076216, upload-time = "2025-12-20T16:17:11.437Z" },
91
+ { url = "https://files.pythonhosted.org/packages/35/ff/46fbfe60ab0710d2a2b16995f708750307d30eccbb4c38371ea9e986866e/numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", size = 12444263, upload-time = "2025-12-20T16:17:13.182Z" },
92
+ { url = "https://files.pythonhosted.org/packages/a3/e3/9189ab319c01d2ed556c932ccf55064c5d75bb5850d1df7a482ce0badead/numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", size = 10378265, upload-time = "2025-12-20T16:17:15.211Z" },
93
+ { url = "https://files.pythonhosted.org/packages/ab/ed/52eac27de39d5e5a6c9aadabe672bc06f55e24a3d9010cd1183948055d76/numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", size = 16647476, upload-time = "2025-12-20T16:17:17.671Z" },
94
+ { url = "https://files.pythonhosted.org/packages/77/c0/990ce1b7fcd4e09aeaa574e2a0a839589e4b08b2ca68070f1acb1fea6736/numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", size = 12374563, upload-time = "2025-12-20T16:17:20.216Z" },
95
+ { url = "https://files.pythonhosted.org/packages/37/7c/8c5e389c6ae8f5fd2277a988600d79e9625db3fff011a2d87ac80b881a4c/numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", size = 5203107, upload-time = "2025-12-20T16:17:22.47Z" },
96
+ { url = "https://files.pythonhosted.org/packages/e6/94/ca5b3bd6a8a70a5eec9a0b8dd7f980c1eff4b8a54970a9a7fef248ef564f/numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", size = 6538067, upload-time = "2025-12-20T16:17:24.001Z" },
97
+ { url = "https://files.pythonhosted.org/packages/79/43/993eb7bb5be6761dde2b3a3a594d689cec83398e3f58f4758010f3b85727/numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", size = 14411926, upload-time = "2025-12-20T16:17:25.822Z" },
98
+ { url = "https://files.pythonhosted.org/packages/03/75/d4c43b61de473912496317a854dac54f1efec3eeb158438da6884b70bb90/numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", size = 16354295, upload-time = "2025-12-20T16:17:28.308Z" },
99
+ { url = "https://files.pythonhosted.org/packages/b8/0a/b54615b47ee8736a6461a4bb6749128dd3435c5a759d5663f11f0e9af4ac/numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", size = 16190242, upload-time = "2025-12-20T16:17:30.993Z" },
100
+ { url = "https://files.pythonhosted.org/packages/98/ce/ea207769aacad6246525ec6c6bbd66a2bf56c72443dc10e2f90feed29290/numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", size = 18280875, upload-time = "2025-12-20T16:17:33.327Z" },
101
+ { url = "https://files.pythonhosted.org/packages/17/ef/ec409437aa962ea372ed601c519a2b141701683ff028f894b7466f0ab42b/numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", size = 6002530, upload-time = "2025-12-20T16:17:35.729Z" },
102
+ { url = "https://files.pythonhosted.org/packages/5f/4a/5cb94c787a3ed1ac65e1271b968686521169a7b3ec0b6544bb3ca32960b0/numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", size = 12435890, upload-time = "2025-12-20T16:17:37.599Z" },
103
+ { url = "https://files.pythonhosted.org/packages/48/a0/04b89db963af9de1104975e2544f30de89adbf75b9e75f7dd2599be12c79/numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", size = 10591892, upload-time = "2025-12-20T16:17:39.649Z" },
104
+ { url = "https://files.pythonhosted.org/packages/53/e5/d74b5ccf6712c06c7a545025a6a71bfa03bdc7e0568b405b0d655232fd92/numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", size = 12494312, upload-time = "2025-12-20T16:17:41.714Z" },
105
+ { url = "https://files.pythonhosted.org/packages/c2/08/3ca9cc2ddf54dfee7ae9a6479c071092a228c68aef08252aa08dac2af002/numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", size = 5322862, upload-time = "2025-12-20T16:17:44.145Z" },
106
+ { url = "https://files.pythonhosted.org/packages/87/74/0bb63a68394c0c1e52670cfff2e309afa41edbe11b3327d9af29e4383f34/numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", size = 6644986, upload-time = "2025-12-20T16:17:46.203Z" },
107
+ { url = "https://files.pythonhosted.org/packages/06/8f/9264d9bdbcf8236af2823623fe2f3981d740fc3461e2787e231d97c38c28/numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", size = 14457958, upload-time = "2025-12-20T16:17:48.017Z" },
108
+ { url = "https://files.pythonhosted.org/packages/8c/d9/f9a69ae564bbc7236a35aa883319364ef5fd41f72aa320cc1cbe66148fe2/numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", size = 16398394, upload-time = "2025-12-20T16:17:50.409Z" },
109
+ { url = "https://files.pythonhosted.org/packages/34/c7/39241501408dde7f885d241a98caba5421061a2c6d2b2197ac5e3aa842d8/numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", size = 16241044, upload-time = "2025-12-20T16:17:52.661Z" },
110
+ { url = "https://files.pythonhosted.org/packages/7c/95/cae7effd90e065a95e59fe710eeee05d7328ed169776dfdd9f789e032125/numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", size = 18321772, upload-time = "2025-12-20T16:17:54.947Z" },
111
+ { url = "https://files.pythonhosted.org/packages/96/df/3c6c279accd2bfb968a76298e5b276310bd55d243df4fa8ac5816d79347d/numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", size = 6148320, upload-time = "2025-12-20T16:17:57.249Z" },
112
+ { url = "https://files.pythonhosted.org/packages/92/8d/f23033cce252e7a75cae853d17f582e86534c46404dea1c8ee094a9d6d84/numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", size = 12623460, upload-time = "2025-12-20T16:17:58.963Z" },
113
+ { url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" },
114
+ ]
115
+
116
+ [[package]]
117
+ name = "packaging"
118
+ version = "26.3"
119
+ source = { registry = "https://pypi.org/simple" }
120
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
121
+ wheels = [
122
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
123
+ ]
124
+
125
+ [[package]]
126
+ name = "pluggy"
127
+ version = "1.7.0"
128
+ source = { registry = "https://pypi.org/simple" }
129
+ sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
130
+ wheels = [
131
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
132
+ ]
133
+
134
+ [[package]]
135
+ name = "pygments"
136
+ version = "2.21.0"
137
+ source = { registry = "https://pypi.org/simple" }
138
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
139
+ wheels = [
140
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
141
+ ]
142
+
143
+ [[package]]
144
+ name = "pytest"
145
+ version = "9.2.0"
146
+ source = { registry = "https://pypi.org/simple" }
147
+ dependencies = [
148
+ { name = "colorama", marker = "sys_platform == 'win32'" },
149
+ { name = "iniconfig" },
150
+ { name = "packaging" },
151
+ { name = "pluggy" },
152
+ { name = "pygments" },
153
+ ]
154
+ sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
155
+ wheels = [
156
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
157
+ ]
158
+
159
+ [[package]]
160
+ name = "sympy"
161
+ version = "1.14.0"
162
+ source = { registry = "https://pypi.org/simple" }
163
+ dependencies = [
164
+ { name = "mpmath" },
165
+ ]
166
+ sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
167
+ wheels = [
168
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
169
+ ]
dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J 2.woff2 ADDED
Binary file (8.36 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 ADDED
Binary file (8.36 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ 2.woff ADDED
Binary file (7.2 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff ADDED
Binary file (7.2 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv 2.woff ADDED
Binary file (7.21 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff ADDED
Binary file (7.21 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag 2.woff2 ADDED
Binary file (8.46 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 ADDED
Binary file (8.46 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv 2.woff ADDED
Binary file (5.82 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff ADDED
Binary file (5.82 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f 2.woff2 ADDED
Binary file (6.91 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 ADDED
Binary file (6.91 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT 2.woff ADDED
Binary file (5.77 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff ADDED
Binary file (5.77 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T 2.woff2 ADDED
Binary file (6.97 kB). View file
 
dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 ADDED
Binary file (6.97 kB). View file
 
dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP 2.woff ADDED
Binary file (13.1 kB). View file
 
dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff ADDED
Binary file (13.1 kB). View file
 
dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y 2.woff2 ADDED
Binary file (14.7 kB). View file
 
dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 ADDED
Binary file (14.7 kB). View file
 
dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo 2.woff ADDED
Binary file (13.2 kB). View file
 
dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff ADDED
Binary file (13.2 kB). View file
 
dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd 2.woff2 ADDED
Binary file (14.9 kB). View file
 
dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 ADDED
Binary file (14.9 kB). View file
 
dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV 2.woff2 ADDED
Binary file (13.3 kB). View file
 
dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 ADDED
Binary file (13.3 kB). View file
 
dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC 2.woff ADDED
Binary file (11.8 kB). View file