j-js commited on
Commit
5ec6d54
·
verified ·
1 Parent(s): a28bd44

Update solver_number_properties.py

Browse files
Files changed (1) hide show
  1. solver_number_properties.py +839 -87
solver_number_properties.py CHANGED
@@ -2,142 +2,894 @@ from __future__ import annotations
2
 
3
  import math
4
  import re
5
- from typing import Optional, List
 
6
 
7
  from models import SolverResult
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  def _nums(text: str) -> List[int]:
11
  return [int(x) for x in re.findall(r"-?\d+", text)]
12
 
13
 
14
- def solve_number_properties(text: str) -> Optional[SolverResult]:
15
- lower = (text or "").lower()
16
- nums = _nums(lower)
17
 
18
- if not any(w in lower for w in [
19
- "divisible", "multiple", "factor", "prime", "gcd", "lcm",
20
- "greatest common factor", "least common multiple", "even", "odd"
21
- ]):
22
  return None
23
 
24
- if "prime" in lower and nums:
25
- n = nums[0]
26
- if n < 2:
27
- is_prime = False
28
- else:
29
- is_prime = all(n % i != 0 for i in range(2, int(n ** 0.5) + 1))
30
- return SolverResult(
31
- domain="quant",
32
- solved=True,
33
- topic="number_properties",
34
- answer_value=str(is_prime),
35
- internal_answer=str(is_prime),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  steps=[
37
- "Check divisibility by integers up to the square root.",
 
38
  ],
39
  )
40
 
41
- if ("gcd" in lower or "greatest common factor" in lower) and len(nums) >= 2:
42
- a, b = nums[0], nums[1]
43
- result = math.gcd(a, b)
44
- return SolverResult(
45
- domain="quant",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  solved=True,
47
- topic="number_properties",
48
- answer_value=str(result),
49
- internal_answer=str(result),
50
  steps=[
51
- "Use the greatest common divisor of the two integers.",
 
52
  ],
53
  )
54
 
55
- if ("lcm" in lower or "least common multiple" in lower) and len(nums) >= 2:
56
- a, b = nums[0], nums[1]
57
- if a == 0 or b == 0:
58
  return None
59
- result = abs(a * b) // math.gcd(a, b)
60
- return SolverResult(
61
- domain="quant",
62
  solved=True,
63
- topic="number_properties",
64
- answer_value=str(result),
65
- internal_answer=str(result),
66
  steps=[
67
- "Use lcm(a,b) = |ab| / gcd(a,b).",
 
68
  ],
69
  )
70
 
71
- if "divisible" in lower and len(nums) >= 2:
72
- a, b = nums[0], nums[1]
73
  if b == 0:
74
  return None
75
- result = a % b == 0
76
- return SolverResult(
77
- domain="quant",
78
  solved=True,
79
- topic="number_properties",
80
- answer_value=str(result),
81
- internal_answer=str(result),
82
  steps=[
83
- "A number is divisible by another if the remainder is 0.",
 
84
  ],
85
  )
86
 
87
- if "multiple" in lower and len(nums) >= 2:
88
- a, b = nums[0], nums[1]
89
- if b == 0:
90
- return None
91
- result = a % b == 0
92
- return SolverResult(
93
- domain="quant",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  solved=True,
95
- topic="number_properties",
96
- answer_value=str(result),
97
  internal_answer=str(result),
 
98
  steps=[
99
- "A is a multiple of B when A ÷ B leaves no remainder.",
 
 
100
  ],
101
  )
102
 
103
- if "factor" in lower and len(nums) >= 2:
104
- a, b = nums[0], nums[1]
105
- if a == 0:
106
- return None
107
- result = b % a == 0
108
- return SolverResult(
109
- domain="quant",
110
  solved=True,
111
- topic="number_properties",
112
- answer_value=str(result),
113
  internal_answer=str(result),
 
114
  steps=[
115
- "A is a factor of B if B is divisible by A.",
 
 
116
  ],
117
  )
118
 
119
- if "even" in lower and nums:
120
- n = nums[0]
121
- result = (n % 2 == 0)
122
- return SolverResult(
123
- domain="quant",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  solved=True,
125
- topic="number_properties",
126
- answer_value=str(result),
127
- internal_answer=str(result),
128
- steps=["A number is even if it is divisible by 2."],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  )
130
 
131
- if "odd" in lower and nums:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  n = nums[0]
133
- result = (n % 2 != 0)
134
- return SolverResult(
135
- domain="quant",
136
  solved=True,
137
- topic="number_properties",
138
- answer_value=str(result),
139
- internal_answer=str(result),
140
- steps=["A number is odd if it leaves remainder 1 when divided by 2."],
 
 
141
  )
142
 
143
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import math
4
  import re
5
+ from collections import Counter
6
+ from typing import Dict, List, Optional, Tuple
7
 
8
  from models import SolverResult
9
 
10
 
11
+ # ============================================================
12
+ # basic parsing helpers
13
+ # ============================================================
14
+
15
+ def _clean(text: str) -> str:
16
+ return (text or "").strip()
17
+
18
+
19
+ def _lower(text: str) -> str:
20
+ return _clean(text).lower()
21
+
22
+
23
  def _nums(text: str) -> List[int]:
24
  return [int(x) for x in re.findall(r"-?\d+", text)]
25
 
26
 
27
+ def _positive_ints(text: str) -> List[int]:
28
+ return [n for n in _nums(text) if n > 0]
29
+
30
 
31
+ def _safe_int(value: str) -> Optional[int]:
32
+ try:
33
+ return int(value)
34
+ except Exception:
35
  return None
36
 
37
+
38
+ def _has_any(text: str, phrases: List[str]) -> bool:
39
+ return any(p in text for p in phrases)
40
+
41
+
42
+ def _normalize_math_words(text: str) -> str:
43
+ t = _lower(text)
44
+ replacements = {
45
+ "greatest common factor": "gcf",
46
+ "greatest common divisor": "gcd",
47
+ "highest common factor": "gcf",
48
+ "least common multiple": "lcm",
49
+ "lowest common multiple": "lcm",
50
+ "smallest common multiple": "lcm",
51
+ "divides evenly": "divisible",
52
+ "is a divisor of": "factor of",
53
+ "is a factor of": "factor of",
54
+ "odd integer": "odd",
55
+ "even integer": "even",
56
+ "composite number": "composite",
57
+ "prime number": "prime",
58
+ "perfect square": "square",
59
+ "perfect cube": "cube",
60
+ }
61
+ for old, new in replacements.items():
62
+ t = t.replace(old, new)
63
+ return t
64
+
65
+
66
+ # ============================================================
67
+ # number theory core helpers
68
+ # ============================================================
69
+
70
+ def _is_prime(n: int) -> bool:
71
+ if n < 2:
72
+ return False
73
+ if n == 2:
74
+ return True
75
+ if n % 2 == 0:
76
+ return False
77
+ limit = int(math.isqrt(n))
78
+ for d in range(3, limit + 1, 2):
79
+ if n % d == 0:
80
+ return False
81
+ return True
82
+
83
+
84
+ def _prime_factorization(n: int) -> Dict[int, int]:
85
+ n = abs(n)
86
+ factors: Dict[int, int] = {}
87
+ if n < 2:
88
+ return factors
89
+
90
+ while n % 2 == 0:
91
+ factors[2] = factors.get(2, 0) + 1
92
+ n //= 2
93
+
94
+ d = 3
95
+ while d * d <= n:
96
+ while n % d == 0:
97
+ factors[d] = factors.get(d, 0) + 1
98
+ n //= d
99
+ d += 2
100
+
101
+ if n > 1:
102
+ factors[n] = factors.get(n, 0) + 1
103
+
104
+ return factors
105
+
106
+
107
+ def _factorization_string(factors: Dict[int, int]) -> str:
108
+ if not factors:
109
+ return ""
110
+ parts = []
111
+ for p in sorted(factors):
112
+ exp = factors[p]
113
+ parts.append(f"{p}^{exp}" if exp > 1 else str(p))
114
+ return " * ".join(parts)
115
+
116
+
117
+ def _num_divisors_from_factors(factors: Dict[int, int]) -> int:
118
+ total = 1
119
+ for exp in factors.values():
120
+ total *= (exp + 1)
121
+ return total
122
+
123
+
124
+ def _sum_divisors_from_factors(factors: Dict[int, int]) -> int:
125
+ total = 1
126
+ for p, exp in factors.items():
127
+ total *= (p ** (exp + 1) - 1) // (p - 1)
128
+ return total
129
+
130
+
131
+ def _proper_divisors(n: int) -> List[int]:
132
+ n = abs(n)
133
+ if n <= 1:
134
+ return []
135
+ divisors = {1}
136
+ root = int(math.isqrt(n))
137
+ for d in range(2, root + 1):
138
+ if n % d == 0:
139
+ divisors.add(d)
140
+ divisors.add(n // d)
141
+ return sorted(divisors)
142
+
143
+
144
+ def _is_perfect_number(n: int) -> bool:
145
+ if n <= 1:
146
+ return False
147
+ return sum(_proper_divisors(n)) == n
148
+
149
+
150
+ def _is_perfect_square(n: int) -> bool:
151
+ if n < 0:
152
+ return False
153
+ r = int(math.isqrt(n))
154
+ return r * r == n
155
+
156
+
157
+ def _is_perfect_cube(n: int) -> bool:
158
+ if n == 0:
159
+ return True
160
+ sign = -1 if n < 0 else 1
161
+ m = abs(n)
162
+ r = round(m ** (1 / 3))
163
+ return (r ** 3 == m) and sign in (-1, 1)
164
+
165
+
166
+ def _gcd_list(values: List[int]) -> int:
167
+ g = 0
168
+ for v in values:
169
+ g = math.gcd(g, v)
170
+ return abs(g)
171
+
172
+
173
+ def _lcm(a: int, b: int) -> int:
174
+ if a == 0 or b == 0:
175
+ return 0
176
+ return abs(a * b) // math.gcd(a, b)
177
+
178
+
179
+ def _lcm_list(values: List[int]) -> int:
180
+ result = 1
181
+ for v in values:
182
+ result = _lcm(result, v)
183
+ return result
184
+
185
+
186
+ def _digit_sum(n: int) -> int:
187
+ return sum(int(ch) for ch in str(abs(n)))
188
+
189
+
190
+ def _alternating_digit_sum_for_11(n: int) -> int:
191
+ digits = [int(ch) for ch in str(abs(n))]
192
+ s1 = sum(digits[::2])
193
+ s2 = sum(digits[1::2])
194
+ return s1 - s2
195
+
196
+
197
+ def _divisibility_rule_result(n: int, d: int) -> Optional[bool]:
198
+ if d == 2:
199
+ return abs(n) % 2 == 0
200
+ if d == 3:
201
+ return _digit_sum(n) % 3 == 0
202
+ if d == 4:
203
+ return abs(n) % 100 % 4 == 0
204
+ if d == 5:
205
+ return str(abs(n))[-1] in {"0", "5"}
206
+ if d == 6:
207
+ return (abs(n) % 2 == 0) and (_digit_sum(n) % 3 == 0)
208
+ if d == 8:
209
+ return abs(n) % 1000 % 8 == 0
210
+ if d == 9:
211
+ return _digit_sum(n) % 9 == 0
212
+ if d == 10:
213
+ return str(abs(n))[-1] == "0"
214
+ if d == 11:
215
+ return _alternating_digit_sum_for_11(n) % 11 == 0
216
+ if d == 12:
217
+ return (_digit_sum(n) % 3 == 0) and (abs(n) % 100 % 4 == 0)
218
+ if d == 25:
219
+ return abs(n) % 100 in {0, 25, 50, 75}
220
+ return None
221
+
222
+
223
+ def _remainder(a: int, b: int) -> Optional[int]:
224
+ if b == 0:
225
+ return None
226
+ return a % b
227
+
228
+
229
+ def _consecutive_terms_from_text(text: str) -> Optional[int]:
230
+ patterns = [
231
+ r"(\d+)\s+consecutive",
232
+ r"consecutive\s+(\d+)\s+(?:integers|numbers|terms)",
233
+ r"sum of the first\s+(\d+)",
234
+ r"product of\s+(\d+)\s+consecutive",
235
+ ]
236
+ for pat in patterns:
237
+ m = re.search(pat, text)
238
+ if m:
239
+ return int(m.group(1))
240
+ return None
241
+
242
+
243
+ # ============================================================
244
+ # explanation builders
245
+ # ============================================================
246
+
247
+ def _sr(
248
+ solved: bool,
249
+ internal_answer: Optional[str],
250
+ steps: List[str],
251
+ answer_value: Optional[str] = None,
252
+ ) -> SolverResult:
253
+ return SolverResult(
254
+ domain="quant",
255
+ solved=solved,
256
+ topic="number_properties",
257
+ answer_value=answer_value,
258
+ internal_answer=internal_answer,
259
+ steps=steps,
260
+ )
261
+
262
+
263
+ def _generic_number_theory_steps() -> List[str]:
264
+ return [
265
+ "Identify the number property being tested: divisibility, factors, primes, GCD/LCM, parity, remainder, or square structure.",
266
+ "Rewrite the number using prime factors or modular relationships if that makes the pattern easier to see.",
267
+ "Use the relevant rule, then check whether the condition is satisfied.",
268
+ ]
269
+
270
+
271
+ # ============================================================
272
+ # recognizers / solver blocks
273
+ # ============================================================
274
+
275
+ def _solve_prime_or_composite(text: str, nums: List[int]) -> Optional[SolverResult]:
276
+ if not nums:
277
+ return None
278
+
279
+ if not _has_any(text, ["prime", "composite", "primality"]):
280
+ return None
281
+
282
+ n = nums[0]
283
+ prime = _is_prime(n)
284
+ result = "prime" if prime else "composite" if n > 1 else "not prime"
285
+
286
+ return _sr(
287
+ solved=True,
288
+ internal_answer=result,
289
+ answer_value=None,
290
+ steps=[
291
+ "This is a primality check.",
292
+ "Test divisibility only up to the square root of the number.",
293
+ "If no smaller factor pair exists, the number is prime; otherwise it is composite.",
294
+ ],
295
+ )
296
+
297
+
298
+ def _solve_prime_factorization(text: str, nums: List[int]) -> Optional[SolverResult]:
299
+ if not nums:
300
+ return None
301
+
302
+ triggers = [
303
+ "prime factorization",
304
+ "prime factors",
305
+ "factorize",
306
+ "factorise",
307
+ "written as a product of primes",
308
+ ]
309
+ if not _has_any(text, triggers):
310
+ return None
311
+
312
+ n = nums[0]
313
+ if abs(n) < 2:
314
+ return _sr(
315
+ solved=False,
316
+ internal_answer=None,
317
+ answer_value=None,
318
  steps=[
319
+ "Prime factorization is only useful for integers with absolute value greater than 1.",
320
+ "Send the full integer to factor.",
321
  ],
322
  )
323
 
324
+ fac = _prime_factorization(n)
325
+ fac_str = _factorization_string(fac)
326
+
327
+ return _sr(
328
+ solved=True,
329
+ internal_answer=fac_str,
330
+ answer_value=None,
331
+ steps=[
332
+ "Break the number into smaller factor pairs.",
333
+ "Continue until every remaining factor is prime.",
334
+ "Group repeated primes using exponents.",
335
+ ],
336
+ )
337
+
338
+
339
+ def _solve_factor_count(text: str, nums: List[int]) -> Optional[SolverResult]:
340
+ if not nums:
341
+ return None
342
+
343
+ triggers = [
344
+ "number of factors",
345
+ "how many factors",
346
+ "count factors",
347
+ "total factors",
348
+ "number of divisors",
349
+ "how many divisors",
350
+ ]
351
+ if not _has_any(text, triggers):
352
+ return None
353
+
354
+ n = nums[0]
355
+ if abs(n) < 1:
356
+ return None
357
+
358
+ fac = _prime_factorization(n)
359
+ total = _num_divisors_from_factors(fac)
360
+
361
+ return _sr(
362
+ solved=True,
363
+ internal_answer=str(total),
364
+ answer_value=None,
365
+ steps=[
366
+ "Start with the prime factorization.",
367
+ "If n = p^a * q^b * ..., then the number of positive factors is (a+1)(b+1)...",
368
+ "Each exponent contributes one more choice than its power because you can use 0 through that power.",
369
+ ],
370
+ )
371
+
372
+
373
+ def _solve_sum_of_factors(text: str, nums: List[int]) -> Optional[SolverResult]:
374
+ if not nums:
375
+ return None
376
+
377
+ triggers = [
378
+ "sum of factors",
379
+ "sum of divisors",
380
+ "sum of all factors",
381
+ "sum of all divisors",
382
+ ]
383
+ if not _has_any(text, triggers):
384
+ return None
385
+
386
+ n = nums[0]
387
+ if abs(n) < 1:
388
+ return None
389
+
390
+ fac = _prime_factorization(n)
391
+ total = _sum_divisors_from_factors(fac)
392
+
393
+ return _sr(
394
+ solved=True,
395
+ internal_answer=str(total),
396
+ answer_value=None,
397
+ steps=[
398
+ "Write the prime factorization first.",
399
+ "Use the geometric-series factor formula for each prime power.",
400
+ "Multiply the separate prime-power sums together.",
401
+ ],
402
+ )
403
+
404
+
405
+ def _solve_factor_or_multiple_or_divisible(text: str, nums: List[int]) -> Optional[SolverResult]:
406
+ if len(nums) < 2:
407
+ return None
408
+
409
+ a, b = nums[0], nums[1]
410
+
411
+ if "factor of" in text or _has_any(text, ["is factor", "a factor of", "factor?"]):
412
+ if a == 0:
413
+ return None
414
+ ok = (b % a == 0)
415
+ return _sr(
416
  solved=True,
417
+ internal_answer=str(ok),
418
+ answer_value=None,
 
419
  steps=[
420
+ "A is a factor of B exactly when B divided by A leaves remainder 0.",
421
+ "So the job is to test clean divisibility, not estimate size.",
422
  ],
423
  )
424
 
425
+ if _has_any(text, ["multiple of", "is a multiple", "multiple?"]):
426
+ if b == 0:
 
427
  return None
428
+ ok = (a % b == 0)
429
+ return _sr(
 
430
  solved=True,
431
+ internal_answer=str(ok),
432
+ answer_value=None,
 
433
  steps=[
434
+ "A is a multiple of B when A = Bk for some integer k.",
435
+ "Equivalent test: A divided by B leaves remainder 0.",
436
  ],
437
  )
438
 
439
+ if _has_any(text, ["divisible by", "divisible", "evenly divisible"]):
 
440
  if b == 0:
441
  return None
442
+ ok = (a % b == 0)
443
+ return _sr(
 
444
  solved=True,
445
+ internal_answer=str(ok),
446
+ answer_value=None,
 
447
  steps=[
448
+ "Divisibility means there is no remainder.",
449
+ "Translate the question into a remainder test.",
450
  ],
451
  )
452
 
453
+ return None
454
+
455
+
456
+ def _solve_divisibility_rules(text: str, nums: List[int]) -> Optional[SolverResult]:
457
+ if len(nums) < 2:
458
+ return None
459
+
460
+ if not _has_any(text, ["divisible by", "divisibility rule", "is divisible"]):
461
+ return None
462
+
463
+ n, d = nums[0], nums[1]
464
+ rule_result = _divisibility_rule_result(n, d)
465
+ if rule_result is None:
466
+ return None
467
+
468
+ rule_steps_map = {
469
+ 2: "Check whether the last digit is even.",
470
+ 3: "Add the digits and test whether that sum is divisible by 3.",
471
+ 4: "Check whether the last two digits form a multiple of 4.",
472
+ 5: "Check whether the last digit is 0 or 5.",
473
+ 6: "A number must be divisible by both 2 and 3.",
474
+ 8: "Check whether the last three digits form a multiple of 8.",
475
+ 9: "Add the digits and test whether that sum is divisible by 9.",
476
+ 10: "Check whether the number ends in 0.",
477
+ 11: "Take the alternating digit sum and test divisibility by 11.",
478
+ 12: "A number must be divisible by both 3 and 4.",
479
+ 25: "Check whether the last two digits are 00, 25, 50, or 75.",
480
+ }
481
+
482
+ return _sr(
483
+ solved=True,
484
+ internal_answer=str(rule_result),
485
+ answer_value=None,
486
+ steps=[
487
+ "This is a divisibility-rule question.",
488
+ rule_steps_map[d],
489
+ "Use the shortcut rule instead of doing full division.",
490
+ ],
491
+ )
492
+
493
+
494
+ def _solve_gcd_lcm(text: str, nums: List[int]) -> Optional[SolverResult]:
495
+ if len(nums) < 2:
496
+ return None
497
+
498
+ relevant = _has_any(text, ["gcd", "gcf", "lcm"])
499
+ if not relevant:
500
+ return None
501
+
502
+ values = nums[:]
503
+ if "gcd" in text or "gcf" in text:
504
+ result = _gcd_list(values)
505
+ return _sr(
506
  solved=True,
 
 
507
  internal_answer=str(result),
508
+ answer_value=None,
509
  steps=[
510
+ "Find the common prime factors shared by all numbers.",
511
+ "For GCD/GCF, keep the smallest exponent of each shared prime.",
512
+ "Multiply those shared prime parts together.",
513
  ],
514
  )
515
 
516
+ if "lcm" in text:
517
+ result = _lcm_list(values)
518
+ return _sr(
 
 
 
 
519
  solved=True,
 
 
520
  internal_answer=str(result),
521
+ answer_value=None,
522
  steps=[
523
+ "Find the prime factorization of each number.",
524
+ "For LCM, keep every prime that appears, using the largest exponent needed.",
525
+ "Multiply those required prime powers together.",
526
  ],
527
  )
528
 
529
+ return None
530
+
531
+
532
+ def _solve_gcd_lcm_product_relation(text: str, nums: List[int]) -> Optional[SolverResult]:
533
+ if len(nums) < 3:
534
+ return None
535
+
536
+ triggers = [
537
+ "ab = gcd*lcm",
538
+ "product of two numbers equals gcd times lcm",
539
+ "gcd and lcm",
540
+ "gcf and lcm",
541
+ ]
542
+ if not _has_any(text, triggers):
543
+ return None
544
+
545
+ # Heuristic:
546
+ # if 3 numbers and asking for a missing one, often the known values are a, gcd, lcm
547
+ a, b, c = nums[0], nums[1], nums[2]
548
+ candidates = [
549
+ ("x_from_a_gcd_lcm", (b * c) // a if a != 0 and (b * c) % a == 0 else None),
550
+ ("x_from_gcd_lcm_a", (a * c) // b if b != 0 and (a * c) % b == 0 else None),
551
+ ("x_from_gcd_lcm_a_alt", (a * b) // c if c != 0 and (a * b) % c == 0 else None),
552
+ ]
553
+ vals = [v for _, v in candidates if v is not None]
554
+ if not vals:
555
+ return None
556
+
557
+ return _sr(
558
+ solved=True,
559
+ internal_answer=str(vals[0]),
560
+ answer_value=None,
561
+ steps=[
562
+ "Use the identity: product of two integers = GCD × LCM.",
563
+ "Substitute the known values and isolate the missing quantity.",
564
+ "Then check that the result is consistent with integer conditions.",
565
+ ],
566
+ )
567
+
568
+
569
+ def _solve_even_odd(text: str, nums: List[int]) -> Optional[SolverResult]:
570
+ if not nums:
571
+ return None
572
+
573
+ n = nums[0]
574
+
575
+ if "even" in text and not _has_any(text, ["odd", "evenly spaced", "evenly"]):
576
+ return _sr(
577
  solved=True,
578
+ internal_answer=str(n % 2 == 0),
579
+ answer_value=None,
580
+ steps=[
581
+ "An integer is even when it is divisible by 2.",
582
+ "Equivalently, it leaves remainder 0 when divided by 2.",
583
+ ],
584
+ )
585
+
586
+ if "odd" in text:
587
+ return _sr(
588
+ solved=True,
589
+ internal_answer=str(n % 2 != 0),
590
+ answer_value=None,
591
+ steps=[
592
+ "An integer is odd when it leaves remainder 1 when divided by 2.",
593
+ "Equivalently, it is not divisible by 2.",
594
+ ],
595
+ )
596
+
597
+ parity_triggers = [
598
+ "even + even", "even + odd", "odd + odd",
599
+ "even - even", "even - odd", "odd - odd",
600
+ "even * even", "even * odd", "odd * odd",
601
+ "parity"
602
+ ]
603
+ if _has_any(text, parity_triggers):
604
+ return _sr(
605
+ solved=False,
606
+ internal_answer=None,
607
+ answer_value=None,
608
+ steps=[
609
+ "Translate each term into parity form: even = 2k, odd = 2k+1.",
610
+ "Then simplify the expression.",
611
+ "Use parity rules: even±even=even, even±odd=odd, odd±odd=even, and any product with an even factor is even.",
612
+ ],
613
+ )
614
+
615
+ return None
616
+
617
+
618
+ def _solve_remainder(text: str, nums: List[int]) -> Optional[SolverResult]:
619
+ if len(nums) < 2:
620
+ return None
621
+
622
+ triggers = [
623
+ "remainder",
624
+ "mod",
625
+ "modulo",
626
+ "when divided by",
627
+ "leaves remainder",
628
+ ]
629
+ if not _has_any(text, triggers):
630
+ return None
631
+
632
+ # direct form: "remainder when a is divided by b"
633
+ a, b = nums[0], nums[1]
634
+ r = _remainder(a, b)
635
+ if r is None:
636
+ return None
637
+
638
+ return _sr(
639
+ solved=True,
640
+ internal_answer=str(r),
641
+ answer_value=None,
642
+ steps=[
643
+ "Use the division algorithm: dividend = divisor × quotient + remainder.",
644
+ "The remainder must be at least 0 and smaller than the divisor.",
645
+ "Compute the leftover after dividing by the given base.",
646
+ ],
647
+ )
648
+
649
+
650
+ def _solve_square_cube(text: str, nums: List[int]) -> Optional[SolverResult]:
651
+ if not nums:
652
+ return None
653
+
654
+ n = nums[0]
655
+
656
+ if _has_any(text, ["perfect square", "is square", "square?"]):
657
+ return _sr(
658
+ solved=True,
659
+ internal_answer=str(_is_perfect_square(n)),
660
+ answer_value=None,
661
+ steps=[
662
+ "A perfect square has even exponents in its prime factorization.",
663
+ "You can also compare the number to the square of its integer root.",
664
+ ],
665
+ )
666
+
667
+ if _has_any(text, ["perfect cube", "is cube", "cube?"]):
668
+ fac = _prime_factorization(n)
669
+ ok = all(exp % 3 == 0 for exp in fac.values()) if fac else _is_perfect_cube(n)
670
+ return _sr(
671
+ solved=True,
672
+ internal_answer=str(ok),
673
+ answer_value=None,
674
+ steps=[
675
+ "A perfect cube has prime-factor exponents that are multiples of 3.",
676
+ "Alternatively, compare the number with the cube of its nearest integer root.",
677
+ ],
678
+ )
679
+
680
+ if _has_any(text, ["least number to multiply", "smallest number to multiply", "make it a square"]):
681
+ fac = _prime_factorization(n)
682
+ needed = 1
683
+ for p, exp in fac.items():
684
+ if exp % 2 == 1:
685
+ needed *= p
686
+ return _sr(
687
+ solved=True,
688
+ internal_answer=str(needed),
689
+ answer_value=None,
690
+ steps=[
691
+ "Write the prime factorization.",
692
+ "For a perfect square, every prime exponent must be even.",
693
+ "Multiply by exactly the primes whose exponents are currently odd.",
694
+ ],
695
  )
696
 
697
+ if _has_any(text, ["least number to divide", "smallest number to divide", "divide to make it a square"]):
698
+ fac = _prime_factorization(n)
699
+ needed = 1
700
+ for p, exp in fac.items():
701
+ if exp % 2 == 1:
702
+ needed *= p
703
+ return _sr(
704
+ solved=True,
705
+ internal_answer=str(needed),
706
+ answer_value=None,
707
+ steps=[
708
+ "Write the prime factorization.",
709
+ "To become a perfect square after division, remove primes with odd exponents.",
710
+ "So divide by the product of the odd-exponent prime parts.",
711
+ ],
712
+ )
713
+
714
+ return None
715
+
716
+
717
+ def _solve_consecutive_integers(text: str, nums: List[int]) -> Optional[SolverResult]:
718
+ if not _has_any(text, ["consecutive", "consecutive integers", "consecutive numbers"]):
719
+ return None
720
+
721
+ k = _consecutive_terms_from_text(text)
722
+
723
+ if _has_any(text, ["sum of", "sum is divisible", "sum divisible"]) and k is not None:
724
+ divisible = (k % 2 == 1)
725
+ return _sr(
726
+ solved=True,
727
+ internal_answer=str(divisible),
728
+ answer_value=None,
729
+ steps=[
730
+ "For a set of consecutive integers, the sum equals average × number of terms.",
731
+ "When the number of terms is odd, the average is an integer middle term, so the sum is divisible by the number of terms.",
732
+ "When the number of terms is even, the average falls halfway between two integers, so that divisibility fails.",
733
+ ],
734
+ )
735
+
736
+ if _has_any(text, ["product of", "product divisible"]) and k is not None:
737
+ factorial_val = math.factorial(k)
738
+ return _sr(
739
+ solved=True,
740
+ internal_answer=str(factorial_val),
741
+ answer_value=None,
742
+ steps=[
743
+ "The product of k consecutive integers is always divisible by k!.",
744
+ "Think of those consecutive terms as containing one complete set of factors needed for 1 through k.",
745
+ ],
746
+ )
747
+
748
+ return _sr(
749
+ solved=False,
750
+ internal_answer=None,
751
+ answer_value=None,
752
+ steps=[
753
+ "For consecutive integers, define them with a central variable or starting variable.",
754
+ "Use symmetry: average = (first + last) / 2.",
755
+ "Then rewrite the sum or divisibility condition in that form.",
756
+ ],
757
+ )
758
+
759
+
760
+ def _solve_positive_negative_sign(text: str, nums: List[int]) -> Optional[SolverResult]:
761
+ if not _has_any(text, ["positive", "negative", "sign"]):
762
+ return None
763
+
764
+ if len(nums) < 2:
765
+ return _sr(
766
+ solved=False,
767
+ internal_answer=None,
768
+ answer_value=None,
769
+ steps=[
770
+ "Track the sign separately from the magnitude.",
771
+ "A product or quotient is negative when there are an odd number of negative factors.",
772
+ "It is positive when there are an even number of negative factors.",
773
+ ],
774
+ )
775
+
776
+ return None
777
+
778
+
779
+ def _solve_units_digit_or_last_digit(text: str, nums: List[int]) -> Optional[SolverResult]:
780
+ triggers = ["last digit", "units digit", "unit digit"]
781
+ if not _has_any(text, triggers):
782
+ return None
783
+
784
+ if len(nums) == 1:
785
  n = nums[0]
786
+ return _sr(
 
 
787
  solved=True,
788
+ internal_answer=str(abs(n) % 10),
789
+ answer_value=None,
790
+ steps=[
791
+ "The last digit of an integer is its remainder when divided by 10.",
792
+ "Only the final digit matters for units-digit questions.",
793
+ ],
794
  )
795
 
796
+ # simple product form: use all listed integers
797
+ product_last = 1
798
+ for n in nums:
799
+ product_last = (product_last * (abs(n) % 10)) % 10
800
+
801
+ return _sr(
802
+ solved=True,
803
+ internal_answer=str(product_last),
804
+ answer_value=None,
805
+ steps=[
806
+ "For last-digit products, only the last digit of each factor matters.",
807
+ "Reduce each factor to its units digit, then multiply cyclically mod 10.",
808
+ "You never need the full product.",
809
+ ],
810
+ )
811
+
812
+
813
+ def _solve_integer_or_real_type(text: str, nums: List[int]) -> Optional[SolverResult]:
814
+ if _has_any(text, ["integer", "integers"]) and "consecutive" not in text:
815
+ return _sr(
816
+ solved=False,
817
+ internal_answer=None,
818
+ answer_value=None,
819
+ steps=[
820
+ "An integer is a whole number: ..., -2, -1, 0, 1, 2, ...",
821
+ "Fractions and decimals are not integers unless they simplify to a whole number.",
822
+ "Check whether the expression must evaluate to a whole-number value.",
823
+ ],
824
+ )
825
+
826
+ if _has_any(text, ["rational", "irrational", "real number", "terminating decimal", "repeating decimal"]):
827
+ return _sr(
828
+ solved=False,
829
+ internal_answer=None,
830
+ answer_value=None,
831
+ steps=[
832
+ "Rational numbers can be written as a fraction of integers.",
833
+ "Terminating and repeating decimals are rational; non-terminating non-repeating decimals are irrational.",
834
+ "Use the decimal pattern or fraction form to classify the number.",
835
+ ],
836
+ )
837
+
838
+ return None
839
+
840
+
841
+ # ============================================================
842
+ # master solver
843
+ # ============================================================
844
+
845
+ def solve_number_properties(text: str) -> Optional[SolverResult]:
846
+ raw = _clean(text)
847
+ if not raw:
848
+ return None
849
+
850
+ lower = _normalize_math_words(raw)
851
+ nums = _nums(lower)
852
+
853
+ broad_triggers = [
854
+ "divisible", "multiple", "factor", "prime", "composite",
855
+ "gcd", "gcf", "lcm", "even", "odd", "remainder", "mod",
856
+ "square", "cube", "consecutive", "integer", "positive", "negative",
857
+ "last digit", "units digit", "divisor", "number of factors",
858
+ "sum of factors", "prime factorization"
859
+ ]
860
+ if not _has_any(lower, broad_triggers):
861
+ return None
862
+
863
+ # ordered from most specific to more general
864
+ blocks = [
865
+ _solve_prime_factorization,
866
+ _solve_factor_count,
867
+ _solve_sum_of_factors,
868
+ _solve_gcd_lcm_product_relation,
869
+ _solve_gcd_lcm,
870
+ _solve_divisibility_rules,
871
+ _solve_factor_or_multiple_or_divisible,
872
+ _solve_prime_or_composite,
873
+ _solve_square_cube,
874
+ _solve_remainder,
875
+ _solve_consecutive_integers,
876
+ _solve_units_digit_or_last_digit,
877
+ _solve_even_odd,
878
+ _solve_positive_negative_sign,
879
+ _solve_integer_or_real_type,
880
+ ]
881
+
882
+ for block in blocks:
883
+ try:
884
+ result = block(lower, nums)
885
+ if result is not None:
886
+ return result
887
+ except Exception:
888
+ continue
889
+
890
+ return _sr(
891
+ solved=False,
892
+ internal_answer=None,
893
+ answer_value=None,
894
+ steps=_generic_number_theory_steps(),
895
+ )