foxmath 0.3.0__tar.gz → 0.5.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.3.0
3
+ Version: 0.5.0
4
4
  Summary: Minimalist CLI for math & cryptography exploration 🦊
5
5
  Author-email: Abhrankan Chakrabarti <abhrankan@gmail.com>
6
6
  License: MIT
@@ -28,12 +28,15 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
28
28
  ## Features
29
29
  - Legendre symbol
30
30
  - Euler/Hermann Machin-like π approximation
31
- - Elliptic curve point addition (over finite fields)
31
+ - Elliptic curve point addition (over finite fields), including
32
+ named curves (secp256k1) with on-curve result validation
32
33
  - Chinese Remainder Theorem solver (handles non-coprime moduli)
33
34
  - Continued fraction expansion and convergents
34
- - 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)
35
39
  - JSON output mode
36
- - Coming soon: challenge mode, more curves
37
40
 
38
41
  ## Installation
39
42
 
@@ -72,6 +75,15 @@ foxmath-ecadd --x1 1 --y1 2 --x2 3 --y2 4 --p 17
72
75
  Result point: (14, 2)
73
76
  ```
74
77
 
78
+ ```bash
79
+ # Named curve (secp256k1) — computes 2G by default
80
+ foxmath ecadd --curve secp256k1
81
+ ```
82
+ ```
83
+ Result point: (89565891926547004231252920425935692360644145829622209833684329913297188986597, 12158399299693830322967808612713398636155367887041628176798871954788371653930)
84
+ ✓ on curve
85
+ ```
86
+
75
87
  ```bash
76
88
  # Chinese Remainder Theorem
77
89
  foxmath crt --r 2 3 2 --m 3 5 7
@@ -91,6 +103,20 @@ foxmath cf --num 355 --den 113
91
103
  355/113
92
104
  ```
93
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
+
94
120
  ```bash
95
121
  # JSON output
96
122
  foxmath pi --terms 100 --json
@@ -9,12 +9,15 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
9
9
  ## Features
10
10
  - Legendre symbol
11
11
  - Euler/Hermann Machin-like π approximation
12
- - Elliptic curve point addition (over finite fields)
12
+ - Elliptic curve point addition (over finite fields), including
13
+ named curves (secp256k1) with on-curve result validation
13
14
  - Chinese Remainder Theorem solver (handles non-coprime moduli)
14
15
  - Continued fraction expansion and convergents
15
- - 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)
16
20
  - JSON output mode
17
- - Coming soon: challenge mode, more curves
18
21
 
19
22
  ## Installation
20
23
 
@@ -53,6 +56,15 @@ foxmath-ecadd --x1 1 --y1 2 --x2 3 --y2 4 --p 17
53
56
  Result point: (14, 2)
54
57
  ```
55
58
 
59
+ ```bash
60
+ # Named curve (secp256k1) — computes 2G by default
61
+ foxmath ecadd --curve secp256k1
62
+ ```
63
+ ```
64
+ Result point: (89565891926547004231252920425935692360644145829622209833684329913297188986597, 12158399299693830322967808612713398636155367887041628176798871954788371653930)
65
+ ✓ on curve
66
+ ```
67
+
56
68
  ```bash
57
69
  # Chinese Remainder Theorem
58
70
  foxmath crt --r 2 3 2 --m 3 5 7
@@ -72,6 +84,20 @@ foxmath cf --num 355 --den 113
72
84
  355/113
73
85
  ```
74
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
+
75
101
  ```bash
76
102
  # JSON output
77
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.3.0"
7
+ version = "0.5.0"
8
8
  description = "Minimalist CLI for math & cryptography exploration 🦊"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -33,3 +33,4 @@ foxmath-pi = "foxmath.foxmath:main"
33
33
  foxmath-ecadd = "foxmath.foxmath:main"
34
34
  foxmath-crt = "foxmath.foxmath:main"
35
35
  foxmath-cf = "foxmath.foxmath:main"
36
+ foxmath-challenge = "foxmath.foxmath:main"
@@ -7,7 +7,9 @@ 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 math import gcd
11
13
  from pathlib import Path
12
14
 
13
15
  # ------------------- Math Functions -------------------
@@ -54,6 +56,28 @@ def ec_point_add(x1, y1, x2, y2, a, p):
54
56
  y3 = (lam * (x1 - x3) - y1) % p
55
57
  return x3, y3
56
58
 
59
+ # Named curve presets: a, b, p, and the standard generator point G (if defined).
60
+ CURVES = {
61
+ "secp256k1": {
62
+ "a": 0,
63
+ "b": 7,
64
+ "p": 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F,
65
+ "gx": 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
66
+ "gy": 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8,
67
+ },
68
+ "toy97": { # small curve for quick hand-checkable examples: y^2 = x^3 + 2x + 3 (mod 97)
69
+ "a": 2,
70
+ "b": 3,
71
+ "p": 97,
72
+ "gx": 3,
73
+ "gy": 6,
74
+ },
75
+ }
76
+
77
+ def is_on_curve(x: int, y: int, a: int, b: int, p: int) -> bool:
78
+ """Check y^2 = x^3 + a*x + b (mod p)."""
79
+ return (y * y - (x ** 3 + a * x + b)) % p == 0
80
+
57
81
  def _extended_gcd(a: int, b: int):
58
82
  """Returns (g, x, y) such that a*x + b*y == g == gcd(a, b)."""
59
83
  old_r, r = a, b
@@ -128,6 +152,46 @@ def cf_convergents(cf):
128
152
  k2, k1 = k1, k
129
153
  return convergents
130
154
 
155
+ # ------------------- Challenge / Quiz Mode -------------------
156
+
157
+ _SMALL_ODD_PRIMES = [3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47,
158
+ 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
159
+ _CRT_MODULI_CANDIDATES = [3, 4, 5, 7, 9, 11, 13]
160
+
161
+ def _generate_legendre_problem(rng: random.Random) -> dict:
162
+ p = rng.choice(_SMALL_ODD_PRIMES)
163
+ a = rng.randint(1, p - 1)
164
+ return {
165
+ "topic": "legendre",
166
+ "question": f"Legendre symbol ({a}/{p})",
167
+ "answer": legendre_symbol(a, p),
168
+ }
169
+
170
+ def _generate_crt_problem(rng: random.Random) -> dict:
171
+ while True:
172
+ m1, m2 = rng.sample(_CRT_MODULI_CANDIDATES, 2)
173
+ if gcd(m1, m2) == 1:
174
+ break
175
+ r1, r2 = rng.randrange(m1), rng.randrange(m2)
176
+ x, m = crt_solve([r1, r2], [m1, m2])
177
+ return {
178
+ "topic": "crt",
179
+ "question": f"x ≡ {r1} (mod {m1}), x ≡ {r2} (mod {m2}); find x (0 ≤ x < {m})",
180
+ "answer": x,
181
+ }
182
+
183
+ _TOPIC_GENERATORS = {
184
+ "legendre": _generate_legendre_problem,
185
+ "crt": _generate_crt_problem,
186
+ }
187
+
188
+ def generate_problem(topic: str, rng: random.Random) -> dict:
189
+ if topic == "mixed":
190
+ topic = rng.choice(list(_TOPIC_GENERATORS.keys()))
191
+ if topic not in _TOPIC_GENERATORS:
192
+ raise ValueError(f"Unknown challenge topic: {topic}")
193
+ return _TOPIC_GENERATORS[topic](rng)
194
+
131
195
  # ------------------- CLI -------------------
132
196
 
133
197
  def get_command():
@@ -171,12 +235,14 @@ def main():
171
235
 
172
236
  # Elliptic curve add
173
237
  ec = subparsers.add_parser("ecadd", help="Elliptic curve point addition")
174
- ec.add_argument("--x1", type=int, required=True)
175
- ec.add_argument("--y1", type=int, required=True)
176
- ec.add_argument("--x2", type=int, required=True)
177
- ec.add_argument("--y2", type=int, required=True)
178
- ec.add_argument("--a", type=int, default=-3, help="Curve parameter a")
179
- ec.add_argument("--p", type=int, required=True, help="Prime modulus")
238
+ ec.add_argument("--curve", choices=sorted(CURVES.keys()),
239
+ help="Named curve (provides a, b, p, and generator G)")
240
+ ec.add_argument("--x1", type=int, help="Defaults to G's x if --curve is given")
241
+ ec.add_argument("--y1", type=int, help="Defaults to G's y if --curve is given")
242
+ ec.add_argument("--x2", type=int, help="Defaults to (x1,y1) if omitted (i.e. doubling)")
243
+ ec.add_argument("--y2", type=int, help="Defaults to (x1,y1) if omitted (i.e. doubling)")
244
+ ec.add_argument("--a", type=int, help="Curve parameter a (overrides --curve if given)")
245
+ ec.add_argument("--p", type=int, help="Prime modulus (overrides --curve if given)")
180
246
 
181
247
  # Chinese Remainder Theorem
182
248
  crt = subparsers.add_parser("crt", help="Solve a system of congruences x = r (mod m)")
@@ -189,6 +255,14 @@ def main():
189
255
  cf_cmd.add_argument("--den", type=int, required=True, help="Denominator")
190
256
  cf_cmd.add_argument("--terms", type=int, default=100, help="Max terms to expand")
191
257
 
258
+ # Challenge / quiz mode
259
+ challenge = subparsers.add_parser("challenge", help="Practice problems (Legendre, CRT)")
260
+ challenge.add_argument("--topic", choices=["legendre", "crt", "mixed"], default="mixed")
261
+ challenge.add_argument("--count", type=int, default=5, help="Number of problems")
262
+ challenge.add_argument("--seed", type=int, help="Random seed for reproducible problem sets")
263
+ challenge.add_argument("--reveal", action="store_true",
264
+ help="Print problems with answers, no prompting (worksheet mode)")
265
+
192
266
  args = parser.parse_args(argv)
193
267
 
194
268
  result = {"tool": "foxmath"}
@@ -207,10 +281,41 @@ def main():
207
281
  print(f"π ≈ {approx} ({args.terms} terms)")
208
282
 
209
283
  elif cmd == "ecadd" or args.command == "ecadd":
210
- x3, y3 = ec_point_add(args.x1, args.y1, args.x2, args.y2, args.a, args.p)
211
- result.update({"command": "ecadd", "result": (x3, y3)})
284
+ a, b = args.a, None
285
+ p = args.p
286
+ x1, y1 = args.x1, args.y1
287
+
288
+ if args.curve:
289
+ preset = CURVES[args.curve]
290
+ a = preset["a"] if a is None else a
291
+ p = preset["p"] if p is None else p
292
+ b = preset["b"]
293
+ if x1 is None:
294
+ x1, y1 = preset["gx"], preset["gy"]
295
+ elif a is None:
296
+ a = -3 # historical default for manual (non-curve) mode
297
+
298
+ if a is None or p is None or x1 is None or y1 is None:
299
+ raise ValueError(
300
+ "Provide --curve NAME, or at least --p --x1 --y1 manually "
301
+ "(--a defaults to -3 if omitted)."
302
+ )
303
+
304
+ x2 = x1 if args.x2 is None else args.x2
305
+ y2 = y1 if args.y2 is None else args.y2
306
+
307
+ x3, y3 = ec_point_add(x1, y1, x2, y2, a, p)
308
+ result.update({
309
+ "command": "ecadd", "curve": args.curve,
310
+ "p1": [x1, y1], "p2": [x2, y2], "result": (x3, y3),
311
+ })
312
+ if b is not None:
313
+ result["result_on_curve"] = is_on_curve(x3, y3, a, b, p)
212
314
  if not json_output:
213
315
  print(f"Result point: ({x3}, {y3})")
316
+ if b is not None:
317
+ status = "✓ on curve" if result["result_on_curve"] else "✗ NOT on curve"
318
+ print(f" {status}")
214
319
 
215
320
  elif cmd == "crt" or args.command == "crt":
216
321
  x, m = crt_solve(args.r, args.m)
@@ -230,6 +335,48 @@ def main():
230
335
  for p, q in convs:
231
336
  print(f" {p}/{q}")
232
337
 
338
+ elif cmd == "challenge" or args.command == "challenge":
339
+ if args.count < 1:
340
+ raise ValueError(f"--count must be >= 1 (got {args.count})")
341
+
342
+ rng = random.Random(args.seed)
343
+ problems = [generate_problem(args.topic, rng) for _ in range(args.count)]
344
+
345
+ if args.reveal:
346
+ result.update({"command": "challenge", "topic": args.topic, "problems": problems})
347
+ if not json_output:
348
+ for i, prob in enumerate(problems, 1):
349
+ print(f"{i}. {prob['question']}")
350
+ print(f" Answer: {prob['answer']}")
351
+ else:
352
+ score = 0
353
+ details = []
354
+ for i, prob in enumerate(problems, 1):
355
+ if not json_output:
356
+ print(f"{i}. {prob['question']}")
357
+ try:
358
+ raw = input(" Your answer: ")
359
+ except EOFError:
360
+ raw = ""
361
+ try:
362
+ user_answer = int(raw.strip())
363
+ except ValueError:
364
+ user_answer = None
365
+ correct = user_answer == prob["answer"]
366
+ score += correct
367
+ details.append({**prob, "your_answer": user_answer, "correct": correct})
368
+ if not json_output:
369
+ if correct:
370
+ print(" ✓ Correct!")
371
+ else:
372
+ print(f" ✗ Incorrect (answer: {prob['answer']})")
373
+ result.update({
374
+ "command": "challenge", "topic": args.topic,
375
+ "score": score, "total": args.count, "details": details,
376
+ })
377
+ if not json_output:
378
+ print(f"\nScore: {score}/{args.count}")
379
+
233
380
  except Exception as e:
234
381
  print(f"Error: {e}", file=sys.stderr)
235
382
  sys.exit(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: foxmath
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Minimalist CLI for math & cryptography exploration 🦊
5
5
  Author-email: Abhrankan Chakrabarti <abhrankan@gmail.com>
6
6
  License: MIT
@@ -28,12 +28,15 @@ Built with curiosity — for students, tinkerers, and crypto/math enthusiasts.
28
28
  ## Features
29
29
  - Legendre symbol
30
30
  - Euler/Hermann Machin-like π approximation
31
- - Elliptic curve point addition (over finite fields)
31
+ - Elliptic curve point addition (over finite fields), including
32
+ named curves (secp256k1) with on-curve result validation
32
33
  - Chinese Remainder Theorem solver (handles non-coprime moduli)
33
34
  - Continued fraction expansion and convergents
34
- - 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)
35
39
  - JSON output mode
36
- - Coming soon: challenge mode, more curves
37
40
 
38
41
  ## Installation
39
42
 
@@ -72,6 +75,15 @@ foxmath-ecadd --x1 1 --y1 2 --x2 3 --y2 4 --p 17
72
75
  Result point: (14, 2)
73
76
  ```
74
77
 
78
+ ```bash
79
+ # Named curve (secp256k1) — computes 2G by default
80
+ foxmath ecadd --curve secp256k1
81
+ ```
82
+ ```
83
+ Result point: (89565891926547004231252920425935692360644145829622209833684329913297188986597, 12158399299693830322967808612713398636155367887041628176798871954788371653930)
84
+ ✓ on curve
85
+ ```
86
+
75
87
  ```bash
76
88
  # Chinese Remainder Theorem
77
89
  foxmath crt --r 2 3 2 --m 3 5 7
@@ -91,6 +103,20 @@ foxmath cf --num 355 --den 113
91
103
  355/113
92
104
  ```
93
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
+
94
120
  ```bash
95
121
  # JSON output
96
122
  foxmath pi --terms 100 --json
@@ -1,6 +1,7 @@
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
6
7
  foxmath-legendre = 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)
@@ -110,6 +117,29 @@ class TestNormalUsage(unittest.TestCase):
110
117
  self.assertEqual(code, 1)
111
118
  self.assertIn("point at infinity", err)
112
119
 
120
+ def test_ecadd_curve_secp256k1_defaults_to_doubling_generator(self):
121
+ code, out, err = run("ecadd", "--curve", "secp256k1")
122
+ self.assertEqual(code, 0)
123
+ self.assertIn("89565891926547004231252920425935692360644145829622209833684329913297188986597", out)
124
+ self.assertIn("on curve", out)
125
+
126
+ def test_ecadd_curve_manual_a_override(self):
127
+ # --a explicitly provided should override the curve preset's a.
128
+ code, out, err = run("ecadd", "--curve", "toy97", "--a", "2")
129
+ self.assertEqual(code, 0)
130
+
131
+ def test_ecadd_no_args_at_all_errors_clearly(self):
132
+ code, out, err = run("ecadd")
133
+ self.assertEqual(code, 1)
134
+ self.assertIn("Error", err)
135
+
136
+ def test_ecadd_backward_compatible_manual_call(self):
137
+ # Original documented usage: --a omitted, defaults to -3.
138
+ code, out, err = run("ecadd", "--x1", "1", "--y1", "2",
139
+ "--x2", "3", "--y2", "4", "--p", "17")
140
+ self.assertEqual(code, 0)
141
+ self.assertIn("Result point: (14, 2)", out)
142
+
113
143
  def test_crt(self):
114
144
  code, out, err = run("crt", "--r", "2", "3", "2", "--m", "3", "5", "7")
115
145
  self.assertEqual(code, 0)
@@ -147,6 +177,46 @@ class TestNormalUsage(unittest.TestCase):
147
177
  self.assertEqual(code, 1)
148
178
  self.assertIn("Error", err)
149
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
+
150
220
 
151
221
  if __name__ == "__main__":
152
222
  unittest.main()
@@ -14,8 +14,10 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..",
14
14
 
15
15
  from foxmath.foxmath import (
16
16
  legendre_symbol, euler_hermann_pi_approx, ec_point_add, crt_solve,
17
- continued_fraction, cf_convergents,
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(
@@ -152,5 +154,59 @@ class TestContinuedFractions(unittest.TestCase):
152
154
  self.assertEqual(convs[-1], (expected.numerator, expected.denominator))
153
155
 
154
156
 
157
+ class TestNamedCurves(unittest.TestCase):
158
+ def test_secp256k1_generator_is_on_curve(self):
159
+ c = CURVES["secp256k1"]
160
+ self.assertTrue(is_on_curve(c["gx"], c["gy"], c["a"], c["b"], c["p"]))
161
+
162
+ def test_secp256k1_doubling_matches_known_value(self):
163
+ # Cross-checked independently against the `ecdsa` library's
164
+ # SECP256k1.generator * 2.
165
+ c = CURVES["secp256k1"]
166
+ x2, y2 = ec_point_add(c["gx"], c["gy"], c["gx"], c["gy"], c["a"], c["p"])
167
+ self.assertEqual(
168
+ x2, 89565891926547004231252920425935692360644145829622209833684329913297188986597
169
+ )
170
+ self.assertEqual(
171
+ y2, 12158399299693830322967808612713398636155367887041628176798871954788371653930
172
+ )
173
+ self.assertTrue(is_on_curve(x2, y2, c["a"], c["b"], c["p"]))
174
+
175
+ def test_toy97_generator_is_on_curve(self):
176
+ c = CURVES["toy97"]
177
+ self.assertTrue(is_on_curve(c["gx"], c["gy"], c["a"], c["b"], c["p"]))
178
+
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
+
155
211
  if __name__ == "__main__":
156
212
  unittest.main()
File without changes
File without changes
File without changes