foxmath 0.6.2__tar.gz → 0.7.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: foxmath
3
- Version: 0.6.2
3
+ Version: 0.7.1
4
4
  Summary: Minimalist CLI for math & cryptography exploration 🦊
5
5
  Author-email: Abhrankan Chakrabarti <abhrankan@gmail.com>
6
6
  License: MIT
@@ -29,6 +29,7 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
29
29
 
30
30
  ## Features
31
31
  - Legendre symbol
32
+ - Modular square root via Tonelli-Shanks (sqrtmod)
32
33
  - Euler/Hermann Machin-like π approximation
33
34
  - Elliptic curve point addition (over finite fields), including
34
35
  named curves (secp256k1) with on-curve result validation
@@ -38,8 +39,8 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
38
39
  - Continued fraction expansion and convergents
39
40
  - Challenge/quiz mode — practice problems with scoring, or a
40
41
  worksheet mode for self-study
41
- - Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd,
42
- foxmath-ecmul, foxmath-crt, foxmath-cf, foxmath-challenge)
42
+ - Symlink-friendly (foxmath-legendre, foxmath-sqrtmod, foxmath-pi,
43
+ foxmath-ecadd, foxmath-ecmul, foxmath-crt, foxmath-cf, foxmath-challenge)
43
44
  - JSON output mode
44
45
 
45
46
  ## Installation
@@ -60,7 +61,7 @@ Check which version you have installed:
60
61
  foxmath --version
61
62
  ```
62
63
  ```
63
- foxmath 0.6.2
64
+ foxmath 0.7.1
64
65
  ```
65
66
 
66
67
  ## Usage Examples
@@ -79,6 +80,17 @@ foxmath legendre 5 17
79
80
  Legendre (5/17) = -1
80
81
  ```
81
82
 
83
+ ```bash
84
+ foxmath sqrtmod 10 13
85
+ ```
86
+ ```
87
+ sqrt(10) = +/-7 (mod 13)
88
+ ```
89
+
90
+ `sqrtmod` uses the Tonelli-Shanks algorithm to find modular square roots
91
+ when they exist. It reports an error when the input is a quadratic
92
+ non-residue modulo the given odd prime.
93
+
82
94
  ```bash
83
95
  # Symlinks (optional)
84
96
  foxmath-ecadd --x1 1 --y1 2 --x2 3 --y2 4 --p 17
@@ -10,6 +10,7 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
10
10
 
11
11
  ## Features
12
12
  - Legendre symbol
13
+ - Modular square root via Tonelli-Shanks (sqrtmod)
13
14
  - Euler/Hermann Machin-like π approximation
14
15
  - Elliptic curve point addition (over finite fields), including
15
16
  named curves (secp256k1) with on-curve result validation
@@ -19,8 +20,8 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
19
20
  - Continued fraction expansion and convergents
20
21
  - Challenge/quiz mode — practice problems with scoring, or a
21
22
  worksheet mode for self-study
22
- - Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd,
23
- foxmath-ecmul, foxmath-crt, foxmath-cf, foxmath-challenge)
23
+ - Symlink-friendly (foxmath-legendre, foxmath-sqrtmod, foxmath-pi,
24
+ foxmath-ecadd, foxmath-ecmul, foxmath-crt, foxmath-cf, foxmath-challenge)
24
25
  - JSON output mode
25
26
 
26
27
  ## Installation
@@ -41,7 +42,7 @@ Check which version you have installed:
41
42
  foxmath --version
42
43
  ```
43
44
  ```
44
- foxmath 0.6.2
45
+ foxmath 0.7.1
45
46
  ```
46
47
 
47
48
  ## Usage Examples
@@ -60,6 +61,17 @@ foxmath legendre 5 17
60
61
  Legendre (5/17) = -1
61
62
  ```
62
63
 
64
+ ```bash
65
+ foxmath sqrtmod 10 13
66
+ ```
67
+ ```
68
+ sqrt(10) = +/-7 (mod 13)
69
+ ```
70
+
71
+ `sqrtmod` uses the Tonelli-Shanks algorithm to find modular square roots
72
+ when they exist. It reports an error when the input is a quadratic
73
+ non-residue modulo the given odd prime.
74
+
63
75
  ```bash
64
76
  # Symlinks (optional)
65
77
  foxmath-ecadd --x1 1 --y1 2 --x2 3 --y2 4 --p 17
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "foxmath"
7
- version = "0.6.2"
7
+ version = "0.7.1"
8
8
  description = "Minimalist CLI for math & cryptography exploration 🦊"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -29,6 +29,7 @@ dependencies = [] # Pure stdlib for now
29
29
  [project.scripts]
30
30
  foxmath = "foxmath.foxmath:main"
31
31
  foxmath-legendre = "foxmath.foxmath:main"
32
+ foxmath-sqrtmod = "foxmath.foxmath:main"
32
33
  foxmath-pi = "foxmath.foxmath:main"
33
34
  foxmath-ecadd = "foxmath.foxmath:main"
34
35
  foxmath-ecmul = "foxmath.foxmath:main"
@@ -22,13 +22,107 @@ def _package_version() -> str:
22
22
 
23
23
  # ------------------- Math Functions -------------------
24
24
 
25
- def legendre_symbol(a: int, p: int) -> int:
26
- """Compute Legendre symbol (a/p)"""
27
- if p < 2 or p % 2 == 0:
25
+ # First 13 primes. As Miller-Rabin bases they make the test *deterministic*
26
+ # for every n below 3,317,044,064,679,887,385,961,981 (~3.3e24); 12 bases
27
+ # would not be enough (399165290221 * 798330580441, ~3.2e23, fools them).
28
+ _PRIME_TEST_BASES = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41)
29
+
30
+ def _is_probable_prime(n: int) -> bool:
31
+ """Miller-Rabin primality test.
32
+
33
+ Exact for n < ~3.3e24. Above that it is a strong probable-prime test:
34
+ ordinary composites essentially never pass, but a composite built
35
+ specifically to fool these 13 bases could.
36
+ """
37
+ if n < 2:
38
+ return False
39
+ for sp in _PRIME_TEST_BASES:
40
+ if n == sp:
41
+ return True
42
+ if n % sp == 0:
43
+ return False
44
+ d, r = n - 1, 0
45
+ while d % 2 == 0:
46
+ d //= 2
47
+ r += 1
48
+ for a in _PRIME_TEST_BASES:
49
+ x = pow(a, d, n)
50
+ if x == 1 or x == n - 1:
51
+ continue
52
+ for _ in range(r - 1):
53
+ x = x * x % n
54
+ if x == n - 1:
55
+ break
56
+ else:
57
+ return False
58
+ return True
59
+
60
+ def _require_odd_prime(p: int) -> None:
61
+ """Enforce the contract the Legendre symbol and Tonelli-Shanks rely on.
62
+
63
+ Euler's criterion is only meaningful for a prime modulus. On a composite
64
+ it returns garbage, and Tonelli-Shanks' search for a non-residue can loop
65
+ forever (mod 9 or mod 561, for example, there is none to find).
66
+ """
67
+ if p < 2 or p % 2 == 0 or not _is_probable_prime(p):
28
68
  raise ValueError("p must be an odd prime")
69
+
70
+ def _legendre_unchecked(a: int, p: int) -> int:
29
71
  res = pow(a, (p - 1) // 2, p)
30
72
  return 1 if res == 1 else (-1 if res == p - 1 else 0)
31
73
 
74
+ def legendre_symbol(a: int, p: int) -> int:
75
+ """Compute Legendre symbol (a/p). p must be an odd prime."""
76
+ _require_odd_prime(p)
77
+ return _legendre_unchecked(a, p)
78
+
79
+ def sqrt_mod(a: int, p: int) -> tuple:
80
+ """Return the square root(s) of a modulo an odd prime p."""
81
+ _require_odd_prime(p)
82
+
83
+ a %= p
84
+ symbol = _legendre_unchecked(a, p)
85
+ if symbol == 0:
86
+ return (0,)
87
+ if symbol == -1:
88
+ raise ValueError(f"{a} has no square root modulo {p}")
89
+
90
+ # For p ≡ 3 (mod 4), the root is a^(p+1)/4.
91
+ if p % 4 == 3:
92
+ root = pow(a, (p + 1) // 4, p)
93
+ else:
94
+ # Tonelli-Shanks: write p - 1 = q * 2^s with q odd.
95
+ q, s = p - 1, 0
96
+ while q % 2 == 0:
97
+ q //= 2
98
+ s += 1
99
+
100
+ non_residue = 2
101
+ while _legendre_unchecked(non_residue, p) != -1:
102
+ non_residue += 1
103
+
104
+ c = pow(non_residue, q, p)
105
+ root = pow(a, (q + 1) // 2, p)
106
+ t = pow(a, q, p)
107
+ m = s
108
+
109
+ while t != 1:
110
+ i = 1
111
+ t_squared = (t * t) % p
112
+ while t_squared != 1:
113
+ t_squared = (t_squared * t_squared) % p
114
+ i += 1
115
+ if i == m:
116
+ raise ValueError(f"{a} has no square root modulo {p}")
117
+ b = pow(c, 1 << (m - i - 1), p)
118
+ root = (root * b) % p
119
+ t = (t * b * b) % p
120
+ c = (b * b) % p
121
+ m = i
122
+
123
+ other = (-root) % p
124
+ return tuple(sorted({root, other}))
125
+
32
126
  def _arctan_series(inv_n: Decimal, terms: int) -> Decimal:
33
127
  """arctan(1/n) via its Taylor series, evaluated to `terms` terms. `inv_n` is 1/n."""
34
128
  total = Decimal(0)
@@ -283,6 +377,17 @@ def get_command():
283
377
  return None
284
378
 
285
379
  def main():
380
+ if hasattr(sys.stdout, "reconfigure"):
381
+ try:
382
+ sys.stdout.reconfigure(encoding="utf-8")
383
+ except Exception:
384
+ pass
385
+ if hasattr(sys.stderr, "reconfigure"):
386
+ try:
387
+ sys.stderr.reconfigure(encoding="utf-8")
388
+ except Exception:
389
+ pass
390
+
286
391
  cmd = get_command()
287
392
 
288
393
  # Handle --json independently of argparse subparser scoping: a subparser's
@@ -311,6 +416,10 @@ def main():
311
416
  leg.add_argument("a", type=int, help="Integer a")
312
417
  leg.add_argument("p", type=int, help="Odd prime p")
313
418
 
419
+ sqrt = subparsers.add_parser("sqrtmod", help="Square root(s) modulo an odd prime")
420
+ sqrt.add_argument("a", type=int, help="Integer a")
421
+ sqrt.add_argument("p", type=int, help="Odd prime p")
422
+
314
423
  # Pi
315
424
  pi_cmd = subparsers.add_parser("pi", help="Euler/Hermann Machin-like π approximation")
316
425
  pi_cmd.add_argument("--terms", type=int, default=100)
@@ -366,6 +475,15 @@ def main():
366
475
  if not json_output:
367
476
  print(f"Legendre ({args.a}/{args.p}) = {ls}")
368
477
 
478
+ elif cmd == "sqrtmod" or args.command == "sqrtmod":
479
+ roots = sqrt_mod(args.a, args.p)
480
+ result.update({"command": "sqrtmod", "a": args.a, "p": args.p, "roots": list(roots)})
481
+ if not json_output:
482
+ if len(roots) == 1:
483
+ print(f"sqrt({args.a}) = {roots[0]} (mod {args.p})")
484
+ else:
485
+ print(f"sqrt({args.a}) = +/-{roots[1]} (mod {args.p})")
486
+
369
487
  elif cmd == "pi" or args.command == "pi":
370
488
  approx = euler_hermann_pi_approx(args.terms)
371
489
  result.update({"command": "pi", "terms": args.terms, "approx": str(approx)})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: foxmath
3
- Version: 0.6.2
3
+ Version: 0.7.1
4
4
  Summary: Minimalist CLI for math & cryptography exploration 🦊
5
5
  Author-email: Abhrankan Chakrabarti <abhrankan@gmail.com>
6
6
  License: MIT
@@ -29,6 +29,7 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
29
29
 
30
30
  ## Features
31
31
  - Legendre symbol
32
+ - Modular square root via Tonelli-Shanks (sqrtmod)
32
33
  - Euler/Hermann Machin-like π approximation
33
34
  - Elliptic curve point addition (over finite fields), including
34
35
  named curves (secp256k1) with on-curve result validation
@@ -38,8 +39,8 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
38
39
  - Continued fraction expansion and convergents
39
40
  - Challenge/quiz mode — practice problems with scoring, or a
40
41
  worksheet mode for self-study
41
- - Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd,
42
- foxmath-ecmul, foxmath-crt, foxmath-cf, foxmath-challenge)
42
+ - Symlink-friendly (foxmath-legendre, foxmath-sqrtmod, foxmath-pi,
43
+ foxmath-ecadd, foxmath-ecmul, foxmath-crt, foxmath-cf, foxmath-challenge)
43
44
  - JSON output mode
44
45
 
45
46
  ## Installation
@@ -60,7 +61,7 @@ Check which version you have installed:
60
61
  foxmath --version
61
62
  ```
62
63
  ```
63
- foxmath 0.6.2
64
+ foxmath 0.7.1
64
65
  ```
65
66
 
66
67
  ## Usage Examples
@@ -79,6 +80,17 @@ foxmath legendre 5 17
79
80
  Legendre (5/17) = -1
80
81
  ```
81
82
 
83
+ ```bash
84
+ foxmath sqrtmod 10 13
85
+ ```
86
+ ```
87
+ sqrt(10) = +/-7 (mod 13)
88
+ ```
89
+
90
+ `sqrtmod` uses the Tonelli-Shanks algorithm to find modular square roots
91
+ when they exist. It reports an error when the input is a quadratic
92
+ non-residue modulo the given odd prime.
93
+
82
94
  ```bash
83
95
  # Symlinks (optional)
84
96
  foxmath-ecadd --x1 1 --y1 2 --x2 3 --y2 4 --p 17
@@ -7,3 +7,4 @@ foxmath-ecadd = foxmath.foxmath:main
7
7
  foxmath-ecmul = foxmath.foxmath:main
8
8
  foxmath-legendre = foxmath.foxmath:main
9
9
  foxmath-pi = foxmath.foxmath:main
10
+ foxmath-sqrtmod = foxmath.foxmath:main
@@ -16,7 +16,7 @@ SCRIPT = os.path.join(ROOT, "foxmath.py")
16
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, input=stdin_input,
19
+ capture_output=True, text=True, encoding="utf-8", timeout=15, input=stdin_input,
20
20
  )
21
21
  return result.returncode, result.stdout, result.stderr
22
22
 
@@ -38,6 +38,16 @@ class TestJsonFlag(unittest.TestCase):
38
38
  self.assertEqual(code, 0)
39
39
  self.assertNotIn("{", out)
40
40
 
41
+ def test_sqrtmod(self):
42
+ code, out, err = run("sqrtmod", "10", "13")
43
+ self.assertEqual(code, 0)
44
+ self.assertIn("sqrt(10) = +/-7 (mod 13)", out)
45
+
46
+ def test_sqrtmod_json(self):
47
+ code, out, err = run("sqrtmod", "10", "13", "--json")
48
+ self.assertEqual(code, 0)
49
+ self.assertIn('"roots": [', out)
50
+
41
51
  def test_json_output_is_pure_json_no_preamble(self):
42
52
  # Regression check: --json used to still print the human-readable
43
53
  # line first, breaking anything piping stdout into a JSON parser.
@@ -66,6 +76,12 @@ class TestSymlinkInvocation(unittest.TestCase):
66
76
  self.assertEqual(code, 0)
67
77
  self.assertIn("Legendre (2/7) = 1", out)
68
78
 
79
+ def test_sqrtmod_symlink(self):
80
+ script = self._make_symlink_script("foxmath-sqrtmod")
81
+ code, out, err = run("10", "13", script=script)
82
+ self.assertEqual(code, 0)
83
+ self.assertIn("sqrt(10) = +/-7 (mod 13)", out)
84
+
69
85
  def test_pi_symlink(self):
70
86
  script = self._make_symlink_script("foxmath-pi")
71
87
  code, out, err = run("--terms", "10", script=script)
@@ -275,6 +291,26 @@ class TestNormalUsage(unittest.TestCase):
275
291
  self.assertEqual(code, 1)
276
292
  self.assertIn("Error", err)
277
293
 
294
+ def test_sqrtmod_non_residue_errors(self):
295
+ code, out, err = run("sqrtmod", "5", "17")
296
+ self.assertEqual(code, 1)
297
+ self.assertIn("no square root", err)
298
+
299
+ def test_sqrtmod_zero(self):
300
+ code, out, err = run("sqrtmod", "0", "17")
301
+ self.assertEqual(code, 0)
302
+ self.assertIn("sqrt(0) = 0 (mod 17)", out)
303
+
304
+ def test_legendre_composite_p_errors(self):
305
+ code, out, err = run("legendre", "2", "9")
306
+ self.assertEqual(code, 1)
307
+ self.assertIn("p must be an odd prime", err)
308
+
309
+ def test_sqrtmod_composite_p_errors(self):
310
+ code, out, err = run("sqrtmod", "2", "9")
311
+ self.assertEqual(code, 1)
312
+ self.assertIn("p must be an odd prime", err)
313
+
278
314
 
279
315
  if __name__ == "__main__":
280
316
  unittest.main()
@@ -13,9 +13,9 @@ from decimal import Decimal
13
13
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
14
14
 
15
15
  from foxmath.foxmath import (
16
- legendre_symbol, euler_hermann_pi_approx, ec_point_add, ec_mul, crt_solve,
16
+ legendre_symbol, sqrt_mod, euler_hermann_pi_approx, ec_point_add, ec_mul, crt_solve,
17
17
  continued_fraction, cf_convergents, CURVES, is_on_curve,
18
- generate_problem,
18
+ generate_problem, _is_probable_prime,
19
19
  )
20
20
  import random
21
21
 
@@ -51,6 +51,80 @@ class TestLegendreSymbol(unittest.TestCase):
51
51
  with self.assertRaises(ValueError):
52
52
  legendre_symbol(3, 1)
53
53
 
54
+ def test_rejects_composite_odd_p(self):
55
+ for comp in (9, 15, 21, 25, 27, 33, 35, 561):
56
+ with self.subTest(p=comp):
57
+ with self.assertRaises(ValueError):
58
+ legendre_symbol(2, comp)
59
+
60
+
61
+ class TestSqrtMod(unittest.TestCase):
62
+ def test_tonelli_shanks_returns_both_roots(self):
63
+ roots = sqrt_mod(10, 13)
64
+ self.assertEqual(roots, (6, 7))
65
+ self.assertTrue(all((root * root - 10) % 13 == 0 for root in roots))
66
+
67
+ def test_fast_path_for_prime_three_mod_four(self):
68
+ self.assertEqual(sqrt_mod(9, 11), (3, 8))
69
+
70
+ def test_zero_has_one_root(self):
71
+ self.assertEqual(sqrt_mod(0, 17), (0,))
72
+
73
+ def test_rejects_non_residue(self):
74
+ with self.assertRaises(ValueError):
75
+ sqrt_mod(5, 17)
76
+
77
+ def test_rejects_even_p(self):
78
+ with self.assertRaises(ValueError):
79
+ sqrt_mod(1, 2)
80
+
81
+ def test_rejects_p_less_than_2(self):
82
+ with self.assertRaises(ValueError):
83
+ sqrt_mod(1, 1)
84
+ with self.assertRaises(ValueError):
85
+ sqrt_mod(1, -5)
86
+
87
+ def test_normalizes_a_greater_than_p(self):
88
+ self.assertEqual(sqrt_mod(10 + 26, 13), (6, 7))
89
+
90
+ def test_negative_a(self):
91
+ self.assertEqual(sqrt_mod(-3, 13), (6, 7))
92
+
93
+ def test_larger_primes_tonelli_shanks(self):
94
+ roots_41 = sqrt_mod(8, 41)
95
+ self.assertEqual(roots_41, (7, 34))
96
+ self.assertTrue(all((r * r - 8) % 41 == 0 for r in roots_41))
97
+
98
+ roots_97 = sqrt_mod(2, 97)
99
+ self.assertEqual(roots_97, (14, 83))
100
+ self.assertTrue(all((r * r - 2) % 97 == 0 for r in roots_97))
101
+
102
+ def test_rejects_composite_odd_p(self):
103
+ for comp in (9, 15, 25, 561):
104
+ with self.subTest(p=comp):
105
+ with self.assertRaises(ValueError):
106
+ sqrt_mod(4, comp)
107
+
108
+
109
+ class TestPrimality(unittest.TestCase):
110
+ def test_small_primes(self):
111
+ for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 97, 193):
112
+ with self.subTest(p=p):
113
+ self.assertTrue(_is_probable_prime(p))
114
+
115
+ def test_small_composites(self):
116
+ for c in (0, 1, 4, 6, 8, 9, 10, 12, 14, 15, 16, 18, 20, 21, 25, 27):
117
+ with self.subTest(c=c):
118
+ self.assertFalse(_is_probable_prime(c))
119
+
120
+ def test_carmichael_numbers_rejected(self):
121
+ for cn in (561, 1105, 1729):
122
+ with self.subTest(cn=cn):
123
+ self.assertFalse(_is_probable_prime(cn))
124
+
125
+ def test_mersenne_prime(self):
126
+ self.assertTrue(_is_probable_prime(2147483647))
127
+
54
128
 
55
129
  class TestEulerHermannPiApprox(unittest.TestCase):
56
130
  def test_converges_to_pi(self):
File without changes
File without changes
File without changes