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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: foxmath
3
- Version: 0.4.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
- - Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd, foxmath-crt, foxmath-cf)
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
- - Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd, foxmath-crt, foxmath-cf)
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.4.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.4.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
- - Symlink-friendly (foxmath-legendre, foxmath-pi, foxmath-ecadd, foxmath-crt, foxmath-cf)
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