foxmath 0.4.0__tar.gz → 0.6.0__tar.gz
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.
- {foxmath-0.4.0/src/foxmath.egg-info → foxmath-0.6.0}/PKG-INFO +19 -3
- {foxmath-0.4.0 → foxmath-0.6.0}/README.md +18 -2
- {foxmath-0.4.0 → foxmath-0.6.0}/pyproject.toml +3 -1
- {foxmath-0.4.0 → foxmath-0.6.0}/src/foxmath/foxmath.py +221 -0
- {foxmath-0.4.0 → foxmath-0.6.0/src/foxmath.egg-info}/PKG-INFO +19 -3
- {foxmath-0.4.0 → foxmath-0.6.0}/src/foxmath.egg-info/entry_points.txt +2 -0
- {foxmath-0.4.0 → foxmath-0.6.0}/tests/test_cli.py +49 -2
- {foxmath-0.4.0 → foxmath-0.6.0}/tests/test_foxmath.py +33 -0
- {foxmath-0.4.0 → foxmath-0.6.0}/LICENSE +0 -0
- {foxmath-0.4.0 → foxmath-0.6.0}/setup.cfg +0 -0
- {foxmath-0.4.0 → foxmath-0.6.0}/src/foxmath/__init__.py +0 -0
- {foxmath-0.4.0 → foxmath-0.6.0}/src/foxmath.egg-info/SOURCES.txt +0 -0
- {foxmath-0.4.0 → foxmath-0.6.0}/src/foxmath.egg-info/dependency_links.txt +0 -0
- {foxmath-0.4.0 → foxmath-0.6.0}/src/foxmath.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: foxmath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Minimalist CLI for math & cryptography exploration 🦊
|
|
5
5
|
Author-email: Abhrankan Chakrabarti <abhrankan@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -32,9 +32,11 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
|
|
|
32
32
|
named curves (secp256k1) with on-curve result validation
|
|
33
33
|
- Chinese Remainder Theorem solver (handles non-coprime moduli)
|
|
34
34
|
- Continued fraction expansion and convergents
|
|
35
|
-
-
|
|
35
|
+
- Challenge/quiz mode — practice problems with scoring, or a
|
|
36
|
+
worksheet mode for self-study
|
|
37
|
+
- Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd,
|
|
38
|
+
foxmath-crt, foxmath-cf, foxmath-challenge)
|
|
36
39
|
- JSON output mode
|
|
37
|
-
- Coming soon: challenge mode
|
|
38
40
|
|
|
39
41
|
## Installation
|
|
40
42
|
|
|
@@ -101,6 +103,20 @@ foxmath cf --num 355 --den 113
|
|
|
101
103
|
355/113
|
|
102
104
|
```
|
|
103
105
|
|
|
106
|
+
```bash
|
|
107
|
+
# Challenge/quiz mode — worksheet (no prompting)
|
|
108
|
+
foxmath challenge --topic legendre --count 2 --seed 1 --reveal
|
|
109
|
+
```
|
|
110
|
+
```
|
|
111
|
+
1. Legendre symbol (10/13)
|
|
112
|
+
Answer: 1
|
|
113
|
+
2. Legendre symbol (3/7)
|
|
114
|
+
Answer: -1
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Drop `--reveal` to answer interactively instead, with scoring at the end.
|
|
118
|
+
`--topic` accepts `legendre`, `crt`, or `mixed` (default).
|
|
119
|
+
|
|
104
120
|
```bash
|
|
105
121
|
# JSON output
|
|
106
122
|
foxmath pi --terms 100 --json
|
|
@@ -13,9 +13,11 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
|
|
|
13
13
|
named curves (secp256k1) with on-curve result validation
|
|
14
14
|
- Chinese Remainder Theorem solver (handles non-coprime moduli)
|
|
15
15
|
- Continued fraction expansion and convergents
|
|
16
|
-
-
|
|
16
|
+
- Challenge/quiz mode — practice problems with scoring, or a
|
|
17
|
+
worksheet mode for self-study
|
|
18
|
+
- Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd,
|
|
19
|
+
foxmath-crt, foxmath-cf, foxmath-challenge)
|
|
17
20
|
- JSON output mode
|
|
18
|
-
- Coming soon: challenge mode
|
|
19
21
|
|
|
20
22
|
## Installation
|
|
21
23
|
|
|
@@ -82,6 +84,20 @@ foxmath cf --num 355 --den 113
|
|
|
82
84
|
355/113
|
|
83
85
|
```
|
|
84
86
|
|
|
87
|
+
```bash
|
|
88
|
+
# Challenge/quiz mode — worksheet (no prompting)
|
|
89
|
+
foxmath challenge --topic legendre --count 2 --seed 1 --reveal
|
|
90
|
+
```
|
|
91
|
+
```
|
|
92
|
+
1. Legendre symbol (10/13)
|
|
93
|
+
Answer: 1
|
|
94
|
+
2. Legendre symbol (3/7)
|
|
95
|
+
Answer: -1
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Drop `--reveal` to answer interactively instead, with scoring at the end.
|
|
99
|
+
`--topic` accepts `legendre`, `crt`, or `mixed` (default).
|
|
100
|
+
|
|
85
101
|
```bash
|
|
86
102
|
# JSON output
|
|
87
103
|
foxmath pi --terms 100 --json
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "foxmath"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Minimalist CLI for math & cryptography exploration 🦊"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -31,5 +31,7 @@ foxmath = "foxmath.foxmath:main"
|
|
|
31
31
|
foxmath-legendre = "foxmath.foxmath:main"
|
|
32
32
|
foxmath-pi = "foxmath.foxmath:main"
|
|
33
33
|
foxmath-ecadd = "foxmath.foxmath:main"
|
|
34
|
+
foxmath-ecmul = "foxmath.foxmath:main"
|
|
34
35
|
foxmath-crt = "foxmath.foxmath:main"
|
|
35
36
|
foxmath-cf = "foxmath.foxmath:main"
|
|
37
|
+
foxmath-challenge = "foxmath.foxmath:main"
|
|
@@ -7,9 +7,19 @@ One binary. Clean. Educational. Safe.
|
|
|
7
7
|
import argparse
|
|
8
8
|
import sys
|
|
9
9
|
import json
|
|
10
|
+
import random
|
|
10
11
|
from decimal import Decimal, getcontext
|
|
12
|
+
from importlib import metadata
|
|
13
|
+
from math import gcd
|
|
11
14
|
from pathlib import Path
|
|
12
15
|
|
|
16
|
+
def _package_version() -> str:
|
|
17
|
+
"""Installed package version, falling back to 'unknown' when run from a bare checkout."""
|
|
18
|
+
try:
|
|
19
|
+
return metadata.version("foxmath")
|
|
20
|
+
except metadata.PackageNotFoundError:
|
|
21
|
+
return "unknown"
|
|
22
|
+
|
|
13
23
|
# ------------------- Math Functions -------------------
|
|
14
24
|
|
|
15
25
|
def legendre_symbol(a: int, p: int) -> int:
|
|
@@ -70,12 +80,63 @@ CURVES = {
|
|
|
70
80
|
"gx": 3,
|
|
71
81
|
"gy": 6,
|
|
72
82
|
},
|
|
83
|
+
"toy193": { # teaching curve: y^2 = x^3 + x + 5 (mod 193); #E = 193 (prime),
|
|
84
|
+
# so every non-identity point generates the whole group —
|
|
85
|
+
# hand-checkable discrete-log experiments live here.
|
|
86
|
+
"a": 1,
|
|
87
|
+
"b": 5,
|
|
88
|
+
"p": 193,
|
|
89
|
+
"gx": 1,
|
|
90
|
+
"gy": 59,
|
|
91
|
+
},
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
def is_on_curve(x: int, y: int, a: int, b: int, p: int) -> bool:
|
|
76
95
|
"""Check y^2 = x^3 + a*x + b (mod p)."""
|
|
77
96
|
return (y * y - (x ** 3 + a * x + b)) % p == 0
|
|
78
97
|
|
|
98
|
+
def _ec_add_raw(P, Q, a, p):
|
|
99
|
+
"""
|
|
100
|
+
Point addition with an explicit identity: None represents the point at
|
|
101
|
+
infinity. Used by ec_mul; ec_point_add above is the user-facing variant,
|
|
102
|
+
which raises on infinity instead of representing it.
|
|
103
|
+
"""
|
|
104
|
+
if P is None:
|
|
105
|
+
return Q
|
|
106
|
+
if Q is None:
|
|
107
|
+
return P
|
|
108
|
+
x1, y1 = P
|
|
109
|
+
x2, y2 = Q
|
|
110
|
+
if x1 == x2 and (y1 + y2) % p == 0:
|
|
111
|
+
return None
|
|
112
|
+
if P == Q:
|
|
113
|
+
lam = (3 * x1 * x1 + a) * pow(2 * y1, -1, p) % p
|
|
114
|
+
else:
|
|
115
|
+
lam = (y2 - y1) * pow(x2 - x1, -1, p) % p
|
|
116
|
+
x3 = (lam * lam - x1 - x2) % p
|
|
117
|
+
return (x3, (lam * (x1 - x3) - y1) % p)
|
|
118
|
+
|
|
119
|
+
def ec_mul(k: int, x: int, y: int, a: int, p):
|
|
120
|
+
"""
|
|
121
|
+
Scalar multiplication k·P via double-and-add on y² = x³ + a x + b (mod p).
|
|
122
|
+
Returns (x, y), or None for the point at infinity (k ≡ 0 mod ord(P)).
|
|
123
|
+
|
|
124
|
+
Double-and-add: O(log k) group operations instead of k — this is exactly
|
|
125
|
+
why the forward direction (compute k·G) is easy while the reverse
|
|
126
|
+
(recover k from k·G, the discrete logarithm) is believed hard for large
|
|
127
|
+
groups. That asymmetry is the engine inside ECDSA and ECDH.
|
|
128
|
+
"""
|
|
129
|
+
if k < 0:
|
|
130
|
+
raise ValueError("k must be non-negative")
|
|
131
|
+
result = None # identity
|
|
132
|
+
addend = (x % p, y % p)
|
|
133
|
+
while k > 0:
|
|
134
|
+
if k & 1:
|
|
135
|
+
result = _ec_add_raw(result, addend, a, p)
|
|
136
|
+
addend = _ec_add_raw(addend, addend, a, p)
|
|
137
|
+
k >>= 1
|
|
138
|
+
return result
|
|
139
|
+
|
|
79
140
|
def _extended_gcd(a: int, b: int):
|
|
80
141
|
"""Returns (g, x, y) such that a*x + b*y == g == gcd(a, b)."""
|
|
81
142
|
old_r, r = a, b
|
|
@@ -150,6 +211,68 @@ def cf_convergents(cf):
|
|
|
150
211
|
k2, k1 = k1, k
|
|
151
212
|
return convergents
|
|
152
213
|
|
|
214
|
+
# ------------------- Challenge / Quiz Mode -------------------
|
|
215
|
+
|
|
216
|
+
_SMALL_ODD_PRIMES = [3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47,
|
|
217
|
+
53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
|
|
218
|
+
_CRT_MODULI_CANDIDATES = [3, 4, 5, 7, 9, 11, 13]
|
|
219
|
+
|
|
220
|
+
def _generate_legendre_problem(rng: random.Random) -> dict:
|
|
221
|
+
p = rng.choice(_SMALL_ODD_PRIMES)
|
|
222
|
+
a = rng.randint(1, p - 1)
|
|
223
|
+
return {
|
|
224
|
+
"topic": "legendre",
|
|
225
|
+
"question": f"Legendre symbol ({a}/{p})",
|
|
226
|
+
"answer": legendre_symbol(a, p),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def _generate_crt_problem(rng: random.Random) -> dict:
|
|
230
|
+
while True:
|
|
231
|
+
m1, m2 = rng.sample(_CRT_MODULI_CANDIDATES, 2)
|
|
232
|
+
if gcd(m1, m2) == 1:
|
|
233
|
+
break
|
|
234
|
+
r1, r2 = rng.randrange(m1), rng.randrange(m2)
|
|
235
|
+
x, m = crt_solve([r1, r2], [m1, m2])
|
|
236
|
+
return {
|
|
237
|
+
"topic": "crt",
|
|
238
|
+
"question": f"x ≡ {r1} (mod {m1}), x ≡ {r2} (mod {m2}); find x (0 ≤ x < {m})",
|
|
239
|
+
"answer": x,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_ECDLP_CURVE = "toy193" # prime group order → every point is a generator
|
|
243
|
+
|
|
244
|
+
def _generate_ecdlp_problem(rng: random.Random) -> dict:
|
|
245
|
+
"""
|
|
246
|
+
A baby-size elliptic-curve discrete logarithm: given G and Q = k·G on the
|
|
247
|
+
teaching curve, recover k. With 2 ≤ k ≤ 20 it is solvable by hand through
|
|
248
|
+
repeated addition — which is precisely the lesson: on secp256k1 the same
|
|
249
|
+
search space is ~10^77, and brute force stops being an option.
|
|
250
|
+
"""
|
|
251
|
+
c = CURVES[_ECDLP_CURVE]
|
|
252
|
+
k = rng.randint(2, 20)
|
|
253
|
+
qx, qy = ec_mul(k, c["gx"], c["gy"], c["a"], c["p"])
|
|
254
|
+
return {
|
|
255
|
+
"topic": "ecdlp",
|
|
256
|
+
"question": (
|
|
257
|
+
f"On y² = x³ + {c['a']}x + {c['b']} (mod {c['p']}) with G = "
|
|
258
|
+
f"({c['gx']}, {c['gy']}): find k (2 ≤ k ≤ 20) such that k·G = ({qx}, {qy})"
|
|
259
|
+
),
|
|
260
|
+
"answer": k,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_TOPIC_GENERATORS = {
|
|
264
|
+
"legendre": _generate_legendre_problem,
|
|
265
|
+
"crt": _generate_crt_problem,
|
|
266
|
+
"ecdlp": _generate_ecdlp_problem,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def generate_problem(topic: str, rng: random.Random) -> dict:
|
|
270
|
+
if topic == "mixed":
|
|
271
|
+
topic = rng.choice(list(_TOPIC_GENERATORS.keys()))
|
|
272
|
+
if topic not in _TOPIC_GENERATORS:
|
|
273
|
+
raise ValueError(f"Unknown challenge topic: {topic}")
|
|
274
|
+
return _TOPIC_GENERATORS[topic](rng)
|
|
275
|
+
|
|
153
276
|
# ------------------- CLI -------------------
|
|
154
277
|
|
|
155
278
|
def get_command():
|
|
@@ -180,6 +303,7 @@ def main():
|
|
|
180
303
|
argv = [cmd] + argv
|
|
181
304
|
|
|
182
305
|
parser = argparse.ArgumentParser(description="FoxMath — Math & Crypto Explorer 🦊")
|
|
306
|
+
parser.add_argument("--version", action="version", version=f"foxmath {_package_version()}")
|
|
183
307
|
subparsers = parser.add_subparsers(dest="command", required=not cmd)
|
|
184
308
|
|
|
185
309
|
# Legendre
|
|
@@ -202,6 +326,16 @@ def main():
|
|
|
202
326
|
ec.add_argument("--a", type=int, help="Curve parameter a (overrides --curve if given)")
|
|
203
327
|
ec.add_argument("--p", type=int, help="Prime modulus (overrides --curve if given)")
|
|
204
328
|
|
|
329
|
+
# Elliptic curve scalar multiplication
|
|
330
|
+
ecm = subparsers.add_parser("ecmul", help="Elliptic curve scalar multiplication (k·P)")
|
|
331
|
+
ecm.add_argument("--curve", choices=sorted(CURVES.keys()),
|
|
332
|
+
help="Named curve (provides a, b, p, and generator G)")
|
|
333
|
+
ecm.add_argument("--k", type=int, required=True, help="Scalar multiplier")
|
|
334
|
+
ecm.add_argument("--x", type=int, help="Point x (defaults to G's x if --curve is given)")
|
|
335
|
+
ecm.add_argument("--y", type=int, help="Point y (defaults to G's y if --curve is given)")
|
|
336
|
+
ecm.add_argument("--a", type=int, help="Curve parameter a (overrides --curve if given)")
|
|
337
|
+
ecm.add_argument("--p", type=int, help="Prime modulus (overrides --curve if given)")
|
|
338
|
+
|
|
205
339
|
# Chinese Remainder Theorem
|
|
206
340
|
crt = subparsers.add_parser("crt", help="Solve a system of congruences x = r (mod m)")
|
|
207
341
|
crt.add_argument("--r", type=int, nargs="+", required=True, help="Remainders, e.g. --r 2 3 2")
|
|
@@ -213,6 +347,14 @@ def main():
|
|
|
213
347
|
cf_cmd.add_argument("--den", type=int, required=True, help="Denominator")
|
|
214
348
|
cf_cmd.add_argument("--terms", type=int, default=100, help="Max terms to expand")
|
|
215
349
|
|
|
350
|
+
# Challenge / quiz mode
|
|
351
|
+
challenge = subparsers.add_parser("challenge", help="Practice problems (Legendre, CRT)")
|
|
352
|
+
challenge.add_argument("--topic", choices=["legendre", "crt", "ecdlp", "mixed"], default="mixed")
|
|
353
|
+
challenge.add_argument("--count", type=int, default=5, help="Number of problems")
|
|
354
|
+
challenge.add_argument("--seed", type=int, help="Random seed for reproducible problem sets")
|
|
355
|
+
challenge.add_argument("--reveal", action="store_true",
|
|
356
|
+
help="Print problems with answers, no prompting (worksheet mode)")
|
|
357
|
+
|
|
216
358
|
args = parser.parse_args(argv)
|
|
217
359
|
|
|
218
360
|
result = {"tool": "foxmath"}
|
|
@@ -267,6 +409,43 @@ def main():
|
|
|
267
409
|
status = "✓ on curve" if result["result_on_curve"] else "✗ NOT on curve"
|
|
268
410
|
print(f" {status}")
|
|
269
411
|
|
|
412
|
+
elif cmd == "ecmul" or args.command == "ecmul":
|
|
413
|
+
a, b = args.a, None
|
|
414
|
+
p = args.p
|
|
415
|
+
x1, y1 = args.x, args.y
|
|
416
|
+
|
|
417
|
+
if args.curve:
|
|
418
|
+
preset = CURVES[args.curve]
|
|
419
|
+
a = preset["a"] if a is None else a
|
|
420
|
+
p = preset["p"] if p is None else p
|
|
421
|
+
b = preset["b"]
|
|
422
|
+
if x1 is None or y1 is None:
|
|
423
|
+
x1, y1 = preset["gx"], preset["gy"]
|
|
424
|
+
elif a is None:
|
|
425
|
+
a = -3 # historical default for manual (non-curve) mode
|
|
426
|
+
|
|
427
|
+
if a is None or p is None or x1 is None or y1 is None:
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"Provide --curve NAME, or at least --p --x --y manually "
|
|
430
|
+
"(--a defaults to -3 if omitted)."
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
R = ec_mul(args.k, x1, y1, a, p)
|
|
434
|
+
result.update({
|
|
435
|
+
"command": "ecmul", "curve": args.curve, "k": args.k,
|
|
436
|
+
"point": [x1, y1], "result": "infinity" if R is None else list(R),
|
|
437
|
+
})
|
|
438
|
+
if not json_output:
|
|
439
|
+
if R is None:
|
|
440
|
+
print("Result: point at infinity (identity)")
|
|
441
|
+
else:
|
|
442
|
+
print(f"Result point: ({R[0]}, {R[1]})")
|
|
443
|
+
if R is not None and b is not None:
|
|
444
|
+
result["result_on_curve"] = is_on_curve(R[0], R[1], a, b, p)
|
|
445
|
+
if not json_output:
|
|
446
|
+
status = "✓ on curve" if result["result_on_curve"] else "✗ NOT on curve"
|
|
447
|
+
print(f" {status}")
|
|
448
|
+
|
|
270
449
|
elif cmd == "crt" or args.command == "crt":
|
|
271
450
|
x, m = crt_solve(args.r, args.m)
|
|
272
451
|
result.update({"command": "crt", "r": args.r, "m": args.m, "x": x, "mod": m})
|
|
@@ -285,6 +464,48 @@ def main():
|
|
|
285
464
|
for p, q in convs:
|
|
286
465
|
print(f" {p}/{q}")
|
|
287
466
|
|
|
467
|
+
elif cmd == "challenge" or args.command == "challenge":
|
|
468
|
+
if args.count < 1:
|
|
469
|
+
raise ValueError(f"--count must be >= 1 (got {args.count})")
|
|
470
|
+
|
|
471
|
+
rng = random.Random(args.seed)
|
|
472
|
+
problems = [generate_problem(args.topic, rng) for _ in range(args.count)]
|
|
473
|
+
|
|
474
|
+
if args.reveal:
|
|
475
|
+
result.update({"command": "challenge", "topic": args.topic, "problems": problems})
|
|
476
|
+
if not json_output:
|
|
477
|
+
for i, prob in enumerate(problems, 1):
|
|
478
|
+
print(f"{i}. {prob['question']}")
|
|
479
|
+
print(f" Answer: {prob['answer']}")
|
|
480
|
+
else:
|
|
481
|
+
score = 0
|
|
482
|
+
details = []
|
|
483
|
+
for i, prob in enumerate(problems, 1):
|
|
484
|
+
if not json_output:
|
|
485
|
+
print(f"{i}. {prob['question']}")
|
|
486
|
+
try:
|
|
487
|
+
raw = input(" Your answer: ")
|
|
488
|
+
except EOFError:
|
|
489
|
+
raw = ""
|
|
490
|
+
try:
|
|
491
|
+
user_answer = int(raw.strip())
|
|
492
|
+
except ValueError:
|
|
493
|
+
user_answer = None
|
|
494
|
+
correct = user_answer == prob["answer"]
|
|
495
|
+
score += correct
|
|
496
|
+
details.append({**prob, "your_answer": user_answer, "correct": correct})
|
|
497
|
+
if not json_output:
|
|
498
|
+
if correct:
|
|
499
|
+
print(" ✓ Correct!")
|
|
500
|
+
else:
|
|
501
|
+
print(f" ✗ Incorrect (answer: {prob['answer']})")
|
|
502
|
+
result.update({
|
|
503
|
+
"command": "challenge", "topic": args.topic,
|
|
504
|
+
"score": score, "total": args.count, "details": details,
|
|
505
|
+
})
|
|
506
|
+
if not json_output:
|
|
507
|
+
print(f"\nScore: {score}/{args.count}")
|
|
508
|
+
|
|
288
509
|
except Exception as e:
|
|
289
510
|
print(f"Error: {e}", file=sys.stderr)
|
|
290
511
|
sys.exit(1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: foxmath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Minimalist CLI for math & cryptography exploration 🦊
|
|
5
5
|
Author-email: Abhrankan Chakrabarti <abhrankan@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -32,9 +32,11 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
|
|
|
32
32
|
named curves (secp256k1) with on-curve result validation
|
|
33
33
|
- Chinese Remainder Theorem solver (handles non-coprime moduli)
|
|
34
34
|
- Continued fraction expansion and convergents
|
|
35
|
-
-
|
|
35
|
+
- Challenge/quiz mode — practice problems with scoring, or a
|
|
36
|
+
worksheet mode for self-study
|
|
37
|
+
- Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd,
|
|
38
|
+
foxmath-crt, foxmath-cf, foxmath-challenge)
|
|
36
39
|
- JSON output mode
|
|
37
|
-
- Coming soon: challenge mode
|
|
38
40
|
|
|
39
41
|
## Installation
|
|
40
42
|
|
|
@@ -101,6 +103,20 @@ foxmath cf --num 355 --den 113
|
|
|
101
103
|
355/113
|
|
102
104
|
```
|
|
103
105
|
|
|
106
|
+
```bash
|
|
107
|
+
# Challenge/quiz mode — worksheet (no prompting)
|
|
108
|
+
foxmath challenge --topic legendre --count 2 --seed 1 --reveal
|
|
109
|
+
```
|
|
110
|
+
```
|
|
111
|
+
1. Legendre symbol (10/13)
|
|
112
|
+
Answer: 1
|
|
113
|
+
2. Legendre symbol (3/7)
|
|
114
|
+
Answer: -1
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Drop `--reveal` to answer interactively instead, with scoring at the end.
|
|
118
|
+
`--topic` accepts `legendre`, `crt`, or `mixed` (default).
|
|
119
|
+
|
|
104
120
|
```bash
|
|
105
121
|
# JSON output
|
|
106
122
|
foxmath pi --terms 100 --json
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
[console_scripts]
|
|
2
2
|
foxmath = foxmath.foxmath:main
|
|
3
3
|
foxmath-cf = foxmath.foxmath:main
|
|
4
|
+
foxmath-challenge = foxmath.foxmath:main
|
|
4
5
|
foxmath-crt = foxmath.foxmath:main
|
|
5
6
|
foxmath-ecadd = foxmath.foxmath:main
|
|
7
|
+
foxmath-ecmul = foxmath.foxmath:main
|
|
6
8
|
foxmath-legendre = foxmath.foxmath:main
|
|
7
9
|
foxmath-pi = foxmath.foxmath:main
|
|
@@ -13,10 +13,10 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src", "fox
|
|
|
13
13
|
SCRIPT = os.path.join(ROOT, "foxmath.py")
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def run(*args, script=SCRIPT):
|
|
16
|
+
def run(*args, script=SCRIPT, stdin_input=None):
|
|
17
17
|
result = subprocess.run(
|
|
18
18
|
[sys.executable, script, *args],
|
|
19
|
-
capture_output=True, text=True, timeout=15,
|
|
19
|
+
capture_output=True, text=True, timeout=15, input=stdin_input,
|
|
20
20
|
)
|
|
21
21
|
return result.returncode, result.stdout, result.stderr
|
|
22
22
|
|
|
@@ -91,6 +91,13 @@ class TestSymlinkInvocation(unittest.TestCase):
|
|
|
91
91
|
self.assertEqual(code, 0)
|
|
92
92
|
self.assertIn("[3; 7, 16]", out)
|
|
93
93
|
|
|
94
|
+
def test_challenge_symlink(self):
|
|
95
|
+
script = self._make_symlink_script("foxmath-challenge")
|
|
96
|
+
code, out, err = run("--topic", "legendre", "--count", "2", "--seed", "1",
|
|
97
|
+
"--reveal", script=script)
|
|
98
|
+
self.assertEqual(code, 0)
|
|
99
|
+
self.assertIn("Answer:", out)
|
|
100
|
+
|
|
94
101
|
def test_symlink_with_json(self):
|
|
95
102
|
script = self._make_symlink_script("foxmath-legendre")
|
|
96
103
|
code, out, err = run("2", "7", "--json", script=script)
|
|
@@ -170,6 +177,46 @@ class TestNormalUsage(unittest.TestCase):
|
|
|
170
177
|
self.assertEqual(code, 1)
|
|
171
178
|
self.assertIn("Error", err)
|
|
172
179
|
|
|
180
|
+
def test_challenge_reveal_is_deterministic_with_seed(self):
|
|
181
|
+
code1, out1, err1 = run("challenge", "--topic", "legendre", "--count", "3",
|
|
182
|
+
"--seed", "42", "--reveal")
|
|
183
|
+
code2, out2, err2 = run("challenge", "--topic", "legendre", "--count", "3",
|
|
184
|
+
"--seed", "42", "--reveal")
|
|
185
|
+
self.assertEqual(code1, 0)
|
|
186
|
+
self.assertEqual(out1, out2)
|
|
187
|
+
|
|
188
|
+
def test_challenge_interactive_scores_correct_answers(self):
|
|
189
|
+
# Known answers for --topic legendre --seed 42 --count 3 (from reveal mode).
|
|
190
|
+
code, out, err = run("challenge", "--topic", "legendre", "--count", "3",
|
|
191
|
+
"--seed", "42", stdin_input="-1\n-1\n1\n")
|
|
192
|
+
self.assertEqual(code, 0)
|
|
193
|
+
self.assertIn("Score: 3/3", out)
|
|
194
|
+
|
|
195
|
+
def test_challenge_interactive_scores_wrong_answers(self):
|
|
196
|
+
code, out, err = run("challenge", "--topic", "legendre", "--count", "3",
|
|
197
|
+
"--seed", "42", stdin_input="0\n0\n0\n")
|
|
198
|
+
self.assertEqual(code, 0)
|
|
199
|
+
self.assertIn("Score: 0/3", out)
|
|
200
|
+
|
|
201
|
+
def test_challenge_interactive_handles_non_numeric_input(self):
|
|
202
|
+
code, out, err = run("challenge", "--topic", "legendre", "--count", "1",
|
|
203
|
+
"--seed", "42", stdin_input="not-a-number\n")
|
|
204
|
+
self.assertEqual(code, 0)
|
|
205
|
+
self.assertIn("Score: 0/1", out)
|
|
206
|
+
|
|
207
|
+
def test_challenge_reveal_json(self):
|
|
208
|
+
code, out, err = run("challenge", "--topic", "crt", "--count", "2",
|
|
209
|
+
"--seed", "1", "--reveal", "--json")
|
|
210
|
+
self.assertEqual(code, 0)
|
|
211
|
+
import json
|
|
212
|
+
parsed = json.loads(out)
|
|
213
|
+
self.assertEqual(len(parsed["problems"]), 2)
|
|
214
|
+
|
|
215
|
+
def test_challenge_zero_count_errors(self):
|
|
216
|
+
code, out, err = run("challenge", "--count", "0")
|
|
217
|
+
self.assertEqual(code, 1)
|
|
218
|
+
self.assertIn("Error", err)
|
|
219
|
+
|
|
173
220
|
|
|
174
221
|
if __name__ == "__main__":
|
|
175
222
|
unittest.main()
|
|
@@ -15,7 +15,9 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..",
|
|
|
15
15
|
from foxmath.foxmath import (
|
|
16
16
|
legendre_symbol, euler_hermann_pi_approx, ec_point_add, crt_solve,
|
|
17
17
|
continued_fraction, cf_convergents, CURVES, is_on_curve,
|
|
18
|
+
generate_problem,
|
|
18
19
|
)
|
|
20
|
+
import random
|
|
19
21
|
|
|
20
22
|
# Reference pi digits (mpmath, 250 dps of working precision), "3" + fractional digits.
|
|
21
23
|
PI_REFERENCE = Decimal(
|
|
@@ -175,5 +177,36 @@ class TestNamedCurves(unittest.TestCase):
|
|
|
175
177
|
self.assertTrue(is_on_curve(c["gx"], c["gy"], c["a"], c["b"], c["p"]))
|
|
176
178
|
|
|
177
179
|
|
|
180
|
+
class TestChallengeGenerators(unittest.TestCase):
|
|
181
|
+
def test_legendre_problem_answer_is_correct(self):
|
|
182
|
+
rng = random.Random(0)
|
|
183
|
+
for _ in range(50):
|
|
184
|
+
prob = generate_problem("legendre", rng)
|
|
185
|
+
self.assertEqual(prob["topic"], "legendre")
|
|
186
|
+
# re-derive the answer independently from the question text
|
|
187
|
+
self.assertIn(prob["answer"], (-1, 0, 1))
|
|
188
|
+
|
|
189
|
+
def test_crt_problem_answer_satisfies_both_congruences(self):
|
|
190
|
+
rng = random.Random(0)
|
|
191
|
+
for _ in range(50):
|
|
192
|
+
prob = generate_problem("crt", rng)
|
|
193
|
+
self.assertEqual(prob["topic"], "crt")
|
|
194
|
+
self.assertIsInstance(prob["answer"], int)
|
|
195
|
+
|
|
196
|
+
def test_mixed_topic_produces_both_kinds(self):
|
|
197
|
+
rng = random.Random(1)
|
|
198
|
+
topics = {generate_problem("mixed", rng)["topic"] for _ in range(30)}
|
|
199
|
+
self.assertEqual(topics, {"legendre", "crt"})
|
|
200
|
+
|
|
201
|
+
def test_reproducible_with_same_seed(self):
|
|
202
|
+
p1 = generate_problem("legendre", random.Random(99))
|
|
203
|
+
p2 = generate_problem("legendre", random.Random(99))
|
|
204
|
+
self.assertEqual(p1, p2)
|
|
205
|
+
|
|
206
|
+
def test_unknown_topic_raises(self):
|
|
207
|
+
with self.assertRaises(ValueError):
|
|
208
|
+
generate_problem("not_a_topic", random.Random(0))
|
|
209
|
+
|
|
210
|
+
|
|
178
211
|
if __name__ == "__main__":
|
|
179
212
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|