numclassify 0.3.2.1__tar.gz → 0.4.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.
Files changed (42) hide show
  1. {numclassify-0.3.2.1 → numclassify-0.4.0}/.gitignore +1 -0
  2. {numclassify-0.3.2.1 → numclassify-0.4.0}/CHANGELOG.md +42 -0
  3. {numclassify-0.3.2.1 → numclassify-0.4.0}/PKG-INFO +2 -2
  4. numclassify-0.4.0/SECURITY.md +24 -0
  5. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/playground.html +1 -1
  6. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/__init__.py +73 -12
  7. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/divisors.py +3 -1
  8. numclassify-0.4.0/numclassify/_core/exam_types.py +388 -0
  9. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/figurate.py +1 -3
  10. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/powers.py +3 -1
  11. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/primes.py +2 -2
  12. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_registry.py +8 -2
  13. {numclassify-0.3.2.1 → numclassify-0.4.0}/pyproject.toml +2 -2
  14. {numclassify-0.3.2.1 → numclassify-0.4.0}/tests/test_registry.py +86 -0
  15. {numclassify-0.3.2.1 → numclassify-0.4.0}/.github/workflows/ci.yml +0 -0
  16. {numclassify-0.3.2.1 → numclassify-0.4.0}/.github/workflows/docs.yml +0 -0
  17. {numclassify-0.3.2.1 → numclassify-0.4.0}/.github/workflows/publish.yml +0 -0
  18. {numclassify-0.3.2.1 → numclassify-0.4.0}/CONTRIBUTING.md +0 -0
  19. {numclassify-0.3.2.1 → numclassify-0.4.0}/LICENSE +0 -0
  20. {numclassify-0.3.2.1 → numclassify-0.4.0}/README.md +0 -0
  21. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/api.md +0 -0
  22. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/changelog.md +0 -0
  23. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/cli.md +0 -0
  24. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/extra/saffron.css +0 -0
  25. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/index.md +0 -0
  26. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/playground.css +0 -0
  27. {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/playground.js +0 -0
  28. {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/basic_usage.py +0 -0
  29. {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/custom_type.py +0 -0
  30. {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/find_perfect_numbers.py +0 -0
  31. {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/random_classify.py +0 -0
  32. {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/stream_large_range.py +0 -0
  33. {numclassify-0.3.2.1 → numclassify-0.4.0}/mkdocs.yml +0 -0
  34. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/__main__.py +0 -0
  35. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/__init__.py +0 -0
  36. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/combinatorial.py +0 -0
  37. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/digital.py +0 -0
  38. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/number_theory.py +0 -0
  39. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/recreational.py +0 -0
  40. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/sequences.py +0 -0
  41. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/cli.py +0 -0
  42. {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/py.typed +0 -0
@@ -10,3 +10,4 @@ dist/
10
10
  .env
11
11
  .venv
12
12
  site/
13
+ .offline-markdown-preview/
@@ -6,11 +6,53 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.4.0] - 2026-06-13
10
+
11
+ ### Fixed
12
+ - `classify()` now returns `notable_score` field — score excluding figurate/centered-figurate
13
+ hits. Prevents misleading score inflation for n=1 (which is the first member of every
14
+ polygonal sequence).
15
+ - `is_unique(n)` now returns `False` for negative `n`. Previously returned `True` due to
16
+ `abs()` stripping the sign.
17
+ - `is_practical(0)` now returns `False`. Previously returned `True` because
18
+ `_factorization(0)` returns `[]` and the loop never ran.
19
+ - Removed leaked internal names (`Optional`, `_version`, `_PackageNotFoundError`) from
20
+ `dir(numclassify)`.
21
+
22
+ ### Changed
23
+ - Development status updated to `5 - Production/Stable` in package classifiers.
24
+
25
+ ### Added
26
+ - `SECURITY.md` with vulnerability reporting instructions.
27
+
28
+ ## [0.3.3] - 2026-06-13
29
+
30
+ ### Added
31
+ - 8 new exam number types in `numclassify/_core/exam_types.py`:
32
+ Strong, Sunny, Buzz, Magic, Fascinating, Trimorphic, Twisted Prime, Unique
33
+ - `classify()` now returns a `"categories"` dict grouping true properties by category
34
+ - `classify()` true_properties list is now sorted by category then name
35
+ - `stream()` now accepts `min_score` and `has_property` filter parameters
36
+ - `most_special_in_range()` now accepts `verbose=True` for progress output
37
+ - `find_any_in_range()` exported in public API (was implemented but not accessible)
38
+ - Auto-generated crash tests: every registered type tested on inputs 0, 1, 2, -1
39
+
40
+ ### Fixed
41
+ - Removed dead `else` branch in `classify()` (unreachable code)
42
+ - Removed `del annotations` no-op in `__init__.py`
43
+ - Fixed wrong docstring example in `is_leyland_prime` (593 is True, not False)
44
+ - Removed dead `pass` block in `is_centered_k_gonal` in `figurate.py`
45
+ - Documented `is_sociable` cycle detection cap (chains > 6 not detected)
46
+
9
47
  ## [0.3.2.1] - 2026-06-12
10
48
 
11
49
  ### Fixed
50
+ - Batch classify input rejected commas/spaces — changed `type="number"` to `type="text"` with `inputmode="numeric"`
12
51
  - Search autocomplete dropdown not appearing — added fallback fetch of property names if initial Pyodide load fails, plus visible "Loading..." state in dropdown
13
52
 
53
+ ### Changed
54
+ - MkDocs site theme changed from indigo to saffron (#FF9933) via custom CSS
55
+
14
56
  ## [0.3.2] - 2026-06-12
15
57
 
16
58
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numclassify
3
- Version: 0.3.2.1
3
+ Version: 0.4.0
4
4
  Summary: The most comprehensive Python library for number classification - 3000+ number types
5
5
  Project-URL: Homepage, https://github.com/aratrikghosh2011-tech/numclassify
6
6
  Project-URL: Repository, https://github.com/aratrikghosh2011-tech/numclassify
@@ -10,7 +10,7 @@ Author-email: Aratrik Ghosh <aratrikghosh2011@gmail.com>
10
10
  License: MIT
11
11
  License-File: LICENSE
12
12
  Keywords: armstrong,classification,figurate,mathematics,number-theory,prime
13
- Classifier: Development Status :: 4 - Beta
13
+ Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Education
15
15
  Classifier: Intended Audience :: Science/Research
16
16
  Classifier: License :: OSI Approved :: MIT License
@@ -0,0 +1,24 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Only the latest release on PyPI is supported.
6
+
7
+ | Version | Supported |
8
+ |---------|-----------|
9
+ | 0.4.x | ✅ |
10
+ | < 0.4 | ❌ |
11
+
12
+ ## Reporting a Vulnerability
13
+
14
+ To report a security vulnerability, email **aratrikghosh2011@gmail.com** with
15
+ the subject line `[numclassify] Security Report`.
16
+
17
+ Please include:
18
+ - A description of the vulnerability
19
+ - Steps to reproduce
20
+ - Potential impact
21
+
22
+ You can expect an acknowledgment within 48 hours. Since `numclassify` is a
23
+ pure computation library with no network access, no file system writes, and no
24
+ external dependencies, the attack surface is minimal.
@@ -45,7 +45,7 @@
45
45
  <div class="card">
46
46
  <div class="card-title">Classify a Number</div>
47
47
  <div class="input-row">
48
- <input type="number" id="input-classify" placeholder="Enter any integer... (or 6, 28, 496 for batch)" min="-999999" max="999999" />
48
+ <input type="text" id="input-classify" inputmode="numeric" pattern="-?[0-9\s,]*" placeholder="Enter any integer... (or 6, 28, 496 for batch)" />
49
49
  <button class="btn" id="btn-classify" onclick="doClassify()">Classify</button>
50
50
  <button class="btn btn-ghost" onclick="doRandom()">Random</button>
51
51
  </div>
@@ -26,7 +26,6 @@ Public API
26
26
  """
27
27
 
28
28
  from __future__ import annotations
29
- del annotations
30
29
 
31
30
  from importlib.metadata import version as _version, PackageNotFoundError as _PackageNotFoundError
32
31
  try:
@@ -44,6 +43,7 @@ from numclassify._core import sequences as _sequences # noqa: F401
44
43
  from numclassify._core import powers as _powers # noqa: F401
45
44
  from numclassify._core import number_theory as _number_theory # noqa: F401
46
45
  from numclassify._core import combinatorial as _combinatorial # noqa: F401
46
+ from numclassify._core import exam_types as _exam_types # noqa: F401
47
47
 
48
48
  # --- Re-export key functions at top level ---
49
49
  from numclassify._core.primes import is_prime # noqa: F401
@@ -56,6 +56,7 @@ from numclassify._registry import ( # noqa: F401
56
56
  print_properties,
57
57
  find_in_range,
58
58
  find_all_in_range,
59
+ find_any_in_range,
59
60
  count_properties,
60
61
  most_special_in_range,
61
62
  )
@@ -86,20 +87,39 @@ def classify(n: int) -> dict:
86
87
  -------
87
88
  {
88
89
  "number": n,
89
- "true_properties": [list of property names that are True],
90
- "score": int, # count of True properties
90
+ "true_properties": [list of property names that are True, sorted by category],
91
+ "score": int,
92
+ "categories": {category_name: [list of property names]},
91
93
  }
92
94
  """
93
- raw = get_true_properties(n)
94
- if isinstance(raw, dict):
95
- true_props = [k for k, v in raw.items() if v]
96
- else:
97
- true_props = list(raw)
95
+ from numclassify._registry import REGISTRY, _normalize
96
+
97
+ all_props = get_all_properties(n)
98
+ true_props_with_cat = []
99
+ for name, val in all_props.items():
100
+ if val:
101
+ key = _normalize(name)
102
+ cat = REGISTRY[key].category if key in REGISTRY else "other"
103
+ true_props_with_cat.append((name, cat))
104
+
105
+ # Sort by category then name
106
+ true_props_with_cat.sort(key=lambda x: (x[1], x[0]))
107
+ true_props = [name for name, _ in true_props_with_cat]
108
+
109
+ # Build categories dict
110
+ categories: dict = {}
111
+ for name, cat in true_props_with_cat:
112
+ categories.setdefault(cat, []).append(name)
98
113
 
99
114
  return {
100
115
  "number": n,
101
116
  "true_properties": true_props,
102
117
  "score": len(true_props),
118
+ "notable_score": sum(
119
+ len(v) for k, v in categories.items()
120
+ if k not in ("figurate", "figurate_centered")
121
+ ),
122
+ "categories": categories,
103
123
  }
104
124
 
105
125
 
@@ -150,10 +170,48 @@ def find_by_property(start: int = 1, end: int = 1000,
150
170
  return results
151
171
 
152
172
 
153
- def stream(start: int, end: int):
154
- """Generator. Yields classify(n) for each n in [start, end]. Memory-safe for large ranges."""
173
+ from typing import Optional
174
+
175
+ def stream(start: int, end: int, min_score: int = 0, has_property: Optional[str] = None):
176
+ """Generator. Yields classify(n) for each n in [start, end]. Memory-safe.
177
+
178
+ Parameters
179
+ ----------
180
+ start, end : int
181
+ Inclusive range.
182
+ min_score : int, optional
183
+ Only yield results with score >= min_score. Default 0 (yield all).
184
+ has_property : str, optional
185
+ Only yield results where this property is True.
186
+ Accepts any registered property name (case-insensitive, spaces or underscores).
187
+
188
+ Example
189
+ -------
190
+ # Only numbers with more than 20 true properties
191
+ for result in nc.stream(1, 10000, min_score=20):
192
+ print(result)
193
+
194
+ # Only fibonacci numbers in a range
195
+ for result in nc.stream(1, 1000, has_property='fibonacci'):
196
+ print(result['number'])
197
+ """
198
+ from numclassify._registry import REGISTRY, _normalize, get_all_properties as _gap
199
+
200
+ prop_key = _normalize(has_property) if has_property else None
201
+
155
202
  for n in range(start, end + 1):
156
- yield classify(n)
203
+ result = classify(n)
204
+ if result["score"] < min_score:
205
+ continue
206
+ if prop_key is not None:
207
+ props = _gap(n)
208
+ # find by normalized key
209
+ matched = any(
210
+ _normalize(k) == prop_key for k, v in props.items() if v
211
+ )
212
+ if not matched:
213
+ continue
214
+ yield result
157
215
 
158
216
 
159
217
  # ---------------------------------------------------------------------------
@@ -162,6 +220,7 @@ def stream(start: int, end: int):
162
220
 
163
221
  __all__ = [
164
222
  "__version__",
223
+ "register",
165
224
  "is_prime",
166
225
  "is_armstrong",
167
226
  "is_perfect",
@@ -170,6 +229,7 @@ __all__ = [
170
229
  "print_properties",
171
230
  "find_in_range",
172
231
  "find_all_in_range",
232
+ "find_any_in_range",
173
233
  "count_properties",
174
234
  "most_special_in_range",
175
235
  "classify",
@@ -182,4 +242,5 @@ __all__ = [
182
242
  # Clean up internal names that leak into dir(nc)
183
243
  del (_primes, _figurate, _digital, _recreational,
184
244
  _divisors, _sequences, _powers, _number_theory,
185
- _combinatorial, _core, _registry)
245
+ _combinatorial, _exam_types, _core, _registry,
246
+ Optional, _version, _PackageNotFoundError)
@@ -273,7 +273,7 @@ def is_amicable(n: int) -> bool:
273
273
 
274
274
 
275
275
  @register(name="Sociable", category="divisors", oeis="A003416",
276
- description="Part of an aliquot cycle of length > 2 (checked up to length 6).")
276
+ description="Part of an aliquot cycle of length 3–6 (longer chains not detected).")
277
277
  def is_sociable(n: int) -> bool:
278
278
  """Return True if n is part of a sociable cycle of length 3–6.
279
279
 
@@ -383,6 +383,8 @@ def is_practical(n: int) -> bool:
383
383
  -------
384
384
  bool
385
385
  """
386
+ if n <= 0:
387
+ return False
386
388
  if n == 1:
387
389
  return True
388
390
  if n % 2 != 0 and n != 1:
@@ -0,0 +1,388 @@
1
+ """
2
+ numclassify._core.exam_types
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Number types frequently asked in school/college Python exams (ICSE, CBSE,
5
+ competitive programming, interview prep). All functions registered via @register.
6
+
7
+ Types added:
8
+ - Strong Number
9
+ - Sunny Number
10
+ - Buzz Number
11
+ - Magic Number
12
+ - Fascinating Number
13
+ - Trimorphic Number
14
+ - Twisted Prime
15
+ - Unique Number (no repeated digits)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from typing import List
21
+
22
+ from numclassify._registry import register
23
+ from numclassify._core.primes import is_prime
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _digit_sum(n: int) -> int:
31
+ return sum(int(d) for d in str(abs(n)))
32
+
33
+
34
+ def _is_perfect_square_exact(n: int) -> bool:
35
+ if n < 0:
36
+ return False
37
+ r = math.isqrt(n)
38
+ return r * r == n
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Strong Number
43
+ # ---------------------------------------------------------------------------
44
+
45
+ @register(
46
+ name="Strong",
47
+ category="digital",
48
+ oeis="A014080",
49
+ description=(
50
+ "A number equal to the sum of the factorials of its digits. "
51
+ "Also called a factorion. Examples: 1, 2, 145, 40585."
52
+ ),
53
+ aliases=["strong_number", "factorion"],
54
+ )
55
+ def is_strong(n: int) -> bool:
56
+ """Return True if n equals the sum of the factorials of its digits.
57
+
58
+ Parameters
59
+ ----------
60
+ n : int
61
+
62
+ Returns
63
+ -------
64
+ bool
65
+
66
+ Examples
67
+ --------
68
+ >>> is_strong(145) # 1! + 4! + 5! = 1 + 24 + 120 = 145
69
+ True
70
+ >>> is_strong(2) # 2! = 2
71
+ True
72
+ >>> is_strong(40585)
73
+ True
74
+ >>> is_strong(100)
75
+ False
76
+ """
77
+ if n <= 0:
78
+ return False
79
+ return sum(math.factorial(int(d)) for d in str(n)) == n
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Sunny Number
84
+ # ---------------------------------------------------------------------------
85
+
86
+ @register(
87
+ name="Sunny",
88
+ category="sequences",
89
+ oeis="A005563",
90
+ description=(
91
+ "A number n where n+1 is a perfect square. "
92
+ "Examples: 3, 8, 15, 24, 35, 48."
93
+ ),
94
+ aliases=["sunny_number"],
95
+ )
96
+ def is_sunny(n: int) -> bool:
97
+ """Return True if n+1 is a perfect square.
98
+
99
+ Parameters
100
+ ----------
101
+ n : int
102
+
103
+ Returns
104
+ -------
105
+ bool
106
+
107
+ Examples
108
+ --------
109
+ >>> is_sunny(3) # 3+1 = 4 = 2^2
110
+ True
111
+ >>> is_sunny(8) # 8+1 = 9 = 3^2
112
+ True
113
+ >>> is_sunny(24) # 24+1 = 25 = 5^2
114
+ True
115
+ >>> is_sunny(5) # 5+1 = 6, not a perfect square
116
+ False
117
+ """
118
+ if n <= 0:
119
+ return False
120
+ return _is_perfect_square_exact(n + 1)
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Buzz Number
125
+ # ---------------------------------------------------------------------------
126
+
127
+ @register(
128
+ name="Buzz",
129
+ category="recreational",
130
+ description=(
131
+ "A number divisible by 7 or ending in 7 (or both). "
132
+ "Named after the Buzz game where players say 'buzz' for such numbers."
133
+ ),
134
+ aliases=["buzz_number"],
135
+ )
136
+ def is_buzz(n: int) -> bool:
137
+ """Return True if n is divisible by 7 or ends in the digit 7.
138
+
139
+ Parameters
140
+ ----------
141
+ n : int
142
+
143
+ Returns
144
+ -------
145
+ bool
146
+
147
+ Examples
148
+ --------
149
+ >>> is_buzz(7) # divisible by 7 and ends in 7
150
+ True
151
+ >>> is_buzz(14) # divisible by 7
152
+ True
153
+ >>> is_buzz(17) # ends in 7
154
+ True
155
+ >>> is_buzz(10)
156
+ False
157
+ """
158
+ if n <= 0:
159
+ return False
160
+ return n % 7 == 0 or n % 10 == 7
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Magic Number
165
+ # ---------------------------------------------------------------------------
166
+
167
+ @register(
168
+ name="Magic",
169
+ category="digital",
170
+ description=(
171
+ "A number whose repeated digit sum (digital root) equals 1. "
172
+ "Examples: 1, 10, 19, 28, 100."
173
+ ),
174
+ aliases=["magic_number"],
175
+ )
176
+ def is_magic(n: int) -> bool:
177
+ """Return True if the digital root of n equals 1.
178
+
179
+ Repeatedly sum the digits until a single digit remains.
180
+ If that digit is 1, the number is magic.
181
+
182
+ Parameters
183
+ ----------
184
+ n : int
185
+
186
+ Returns
187
+ -------
188
+ bool
189
+
190
+ Examples
191
+ --------
192
+ >>> is_magic(1)
193
+ True
194
+ >>> is_magic(10) # 1+0 = 1
195
+ True
196
+ >>> is_magic(19) # 1+9=10, 1+0=1
197
+ True
198
+ >>> is_magic(28) # 2+8=10, 1+0=1
199
+ True
200
+ >>> is_magic(12) # 1+2=3 ≠ 1
201
+ False
202
+ """
203
+ if n <= 0:
204
+ return False
205
+ while n >= 10:
206
+ n = _digit_sum(n)
207
+ return n == 1
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Fascinating Number
212
+ # ---------------------------------------------------------------------------
213
+
214
+ @register(
215
+ name="Fascinating",
216
+ category="digital",
217
+ description=(
218
+ "A 3-digit number n where the concatenation of n, 2n, and 3n "
219
+ "contains all digits 1-9 exactly once (no zeros, no repeats). "
220
+ "Examples: 192, 219, 273, 327, 192."
221
+ ),
222
+ aliases=["fascinating_number"],
223
+ )
224
+ def is_fascinating(n: int) -> bool:
225
+ """Return True if n is a fascinating number.
226
+
227
+ For a 3-digit n, concatenate str(n) + str(2*n) + str(3*n).
228
+ The result must use each digit 1-9 exactly once and contain no 0.
229
+
230
+ Parameters
231
+ ----------
232
+ n : int
233
+
234
+ Returns
235
+ -------
236
+ bool
237
+
238
+ Examples
239
+ --------
240
+ >>> is_fascinating(192) # '192' + '384' + '576' = '192384576'
241
+ True
242
+ >>> is_fascinating(273) # '273' + '546' + '819' = '273546819'
243
+ True
244
+ >>> is_fascinating(100)
245
+ False
246
+ >>> is_fascinating(123)
247
+ False
248
+
249
+ Notes
250
+ -----
251
+ Only 3-digit numbers qualify by definition. Numbers outside [100, 999]
252
+ always return False.
253
+ """
254
+ if n < 100 or n > 999:
255
+ return False
256
+ s = str(n) + str(2 * n) + str(3 * n)
257
+ return len(s) == 9 and set(s) == set('123456789')
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Trimorphic Number
262
+ # ---------------------------------------------------------------------------
263
+
264
+ @register(
265
+ name="Trimorphic",
266
+ category="digital",
267
+ description=(
268
+ "A number whose cube ends with the number itself. "
269
+ "Examples: 1, 5, 6, 25, 76, 376."
270
+ ),
271
+ aliases=["trimorphic_number", "automorphic_cube"],
272
+ )
273
+ def is_trimorphic(n: int) -> bool:
274
+ """Return True if n^3 ends with n.
275
+
276
+ Parameters
277
+ ----------
278
+ n : int
279
+
280
+ Returns
281
+ -------
282
+ bool
283
+
284
+ Examples
285
+ --------
286
+ >>> is_trimorphic(5) # 5^3 = 125, ends in 5
287
+ True
288
+ >>> is_trimorphic(25) # 25^3 = 15625, ends in 25
289
+ True
290
+ >>> is_trimorphic(376) # 376^3 = 53157376, ends in 376
291
+ True
292
+ >>> is_trimorphic(4) # 4^3 = 64, does not end in 4
293
+ False
294
+
295
+ Notes
296
+ -----
297
+ n <= 0 always returns False.
298
+ """
299
+ if n <= 0:
300
+ return False
301
+ return str(n ** 3).endswith(str(n))
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Twisted Prime
306
+ # ---------------------------------------------------------------------------
307
+
308
+ @register(
309
+ name="Twisted Prime",
310
+ category="primes",
311
+ description=(
312
+ "A prime number whose digit sum is also prime. "
313
+ "Examples: 2, 3, 5, 7, 11, 23, 29, 41."
314
+ ),
315
+ aliases=["twisted_prime"],
316
+ )
317
+ def is_twisted_prime(n: int) -> bool:
318
+ """Return True if n is prime and the sum of its digits is also prime.
319
+
320
+ Parameters
321
+ ----------
322
+ n : int
323
+
324
+ Returns
325
+ -------
326
+ bool
327
+
328
+ Examples
329
+ --------
330
+ >>> is_twisted_prime(2) # prime, digit sum 2 (prime)
331
+ True
332
+ >>> is_twisted_prime(23) # prime, digit sum 5 (prime)
333
+ True
334
+ >>> is_twisted_prime(13) # prime, digit sum 4 (not prime)
335
+ False
336
+ >>> is_twisted_prime(29) # prime, digit sum 11 (prime)
337
+ True
338
+ """
339
+ if not is_prime(n):
340
+ return False
341
+ return is_prime(_digit_sum(n))
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Unique Number
346
+ # ---------------------------------------------------------------------------
347
+
348
+ @register(
349
+ name="Unique",
350
+ category="digital",
351
+ description=(
352
+ "A number with no repeated digits. "
353
+ "Examples: 1, 12, 123, 1234, 9876543210."
354
+ ),
355
+ aliases=["unique_number", "unique_digit"],
356
+ )
357
+ def is_unique(n: int) -> bool:
358
+ """Return True if n has no repeated digits.
359
+
360
+ Parameters
361
+ ----------
362
+ n : int
363
+
364
+ Returns
365
+ -------
366
+ bool
367
+
368
+ Examples
369
+ --------
370
+ >>> is_unique(1234) # all digits distinct
371
+ True
372
+ >>> is_unique(1123) # digit 1 repeated
373
+ False
374
+ >>> is_unique(9876)
375
+ True
376
+ >>> is_unique(0)
377
+ True
378
+ >>> is_unique(-5) # negative numbers are not unique
379
+ False
380
+
381
+ Notes
382
+ -----
383
+ Single-digit numbers and 0 are always unique.
384
+ """
385
+ if n < 0:
386
+ return False
387
+ s = str(abs(n))
388
+ return len(s) == len(set(s))
@@ -73,9 +73,7 @@ def is_centered_k_gonal(m: int, k: int) -> bool:
73
73
  # k*n^2 - k*n - 2*(m-1) = 0
74
74
  # discriminant: k^2 + 4*k*2*(m-1) = k^2 + 8*k*(m-1)
75
75
  val = m - 1
76
- if 2 * val % k != 0:
77
- # Check if 2*(m-1) is divisible by k for integer n check via disc
78
- pass # disc check handles this
76
+
79
77
  disc = k * k + 8 * k * val
80
78
  if not _is_perfect_square(disc):
81
79
  return False
@@ -225,7 +225,9 @@ def is_sum_of_three_squares(n: int) -> bool:
225
225
  """
226
226
  if n < 0:
227
227
  return False
228
- # Remove factors of 4
228
+ if n == 0:
229
+ return True # 0 = 0^2 + 0^2 + 0^2
230
+ # Remove factors of 4 (Legendre's three-square theorem)
229
231
  while n % 4 == 0:
230
232
  n //= 4
231
233
  return n % 8 != 7
@@ -799,8 +799,8 @@ def is_leyland_prime(n: int) -> bool:
799
799
  -------
800
800
  >>> is_leyland_prime(17) # 2^3 + 3^2 = 8 + 9 = 17
801
801
  True
802
- >>> is_leyland_prime(593) # 2^7 + 7^2 = 128 + 49... wait, that's 177
803
- False
802
+ >>> is_leyland_prime(593) # 2^9 + 9^2 = 512 + 81 = 593
803
+ True
804
804
  >>> is_leyland_prime(32993) # 2^15 + 15^2 ... verified Leyland prime
805
805
  True
806
806
 
@@ -388,7 +388,7 @@ def find_any_in_range(
388
388
  return [n for n in range(start, end + 1) if any(f(n) for f in funcs)]
389
389
 
390
390
 
391
- def most_special_in_range(start: int, end: int) -> int:
391
+ def most_special_in_range(start: int, end: int, verbose: bool = False) -> int:
392
392
  """Return the integer in ``[start, end]`` with the most ``True`` properties.
393
393
 
394
394
  Ties are broken by returning the smallest such integer.
@@ -399,6 +399,8 @@ def most_special_in_range(start: int, end: int) -> int:
399
399
  Lower bound (inclusive).
400
400
  end:
401
401
  Upper bound (inclusive).
402
+ verbose : bool, optional
403
+ If True, prints progress every 1000 numbers. Default False.
402
404
 
403
405
  Returns
404
406
  -------
@@ -412,9 +414,13 @@ def most_special_in_range(start: int, end: int) -> int:
412
414
  """
413
415
  best_n = start
414
416
  best_count = -1
415
- for n in range(start, end + 1):
417
+ total = end - start + 1
418
+ for i, n in enumerate(range(start, end + 1)):
416
419
  c = count_properties(n)
417
420
  if c > best_count:
418
421
  best_count = c
419
422
  best_n = n
423
+ if verbose and (i + 1) % 1000 == 0:
424
+ pct = (i + 1) / total * 100
425
+ print(f" most_special_in_range: {i+1}/{total} ({pct:.1f}%) — best so far: {best_n} ({best_count} props)")
420
426
  return best_n
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "numclassify"
7
- version = "0.3.2.1"
7
+ version = "0.4.0"
8
8
  description = "The most comprehensive Python library for number classification - 3000+ number types"
9
9
  authors = [{name = "Aratrik Ghosh", email = "aratrikghosh2011@gmail.com"}]
10
10
  readme = "README.md"
@@ -12,7 +12,7 @@ requires-python = ">=3.8"
12
12
  license = {text = "MIT"}
13
13
  keywords = ["number-theory", "mathematics", "classification", "armstrong", "prime", "figurate"]
14
14
  classifiers = [
15
- "Development Status :: 4 - Beta",
15
+ "Development Status :: 5 - Production/Stable",
16
16
  "Intended Audience :: Education",
17
17
  "Intended Audience :: Science/Research",
18
18
  "Topic :: Scientific/Engineering :: Mathematics",
@@ -346,3 +346,89 @@ def test_is_keith():
346
346
  def test_is_factorial():
347
347
  assert is_factorial(24) is True
348
348
  assert is_factorial(25) is False
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Auto-generated crash tests — every registered type, 4 inputs
353
+ # ---------------------------------------------------------------------------
354
+
355
+ def _get_all_registered_funcs():
356
+ """Return list of (name, func) for canonical registry entries.
357
+
358
+ Samples at most 10 entries from figurate / figurate_centered (since all
359
+ use the same parametric functions) and all entries from other categories.
360
+ """
361
+ from numclassify._registry import REGISTRY, _normalize
362
+ seen = set()
363
+ result = []
364
+ sample = {"figurate", "figurate_centered"}
365
+ sample_count = {cat: 0 for cat in sample}
366
+ max_per_cat = 10
367
+ for key, entry in REGISTRY.items():
368
+ if key == _normalize(entry.name):
369
+ if key not in seen:
370
+ seen.add(key)
371
+ cat = entry.category
372
+ if cat in sample:
373
+ if sample_count[cat] < max_per_cat:
374
+ sample_count[cat] += 1
375
+ result.append((entry.name, entry.func))
376
+ else:
377
+ result.append((entry.name, entry.func))
378
+ return result
379
+
380
+
381
+ @pytest.mark.parametrize("name,func", _get_all_registered_funcs())
382
+ def test_no_crash_on_edge_inputs(name, func):
383
+ """Every registered type must not raise on inputs 0, 1, 2."""
384
+ for n in [0, 1, 2]:
385
+ try:
386
+ result = func(n)
387
+ assert isinstance(result, bool), (
388
+ f"{name}({n}) returned {type(result)}, expected bool"
389
+ )
390
+ except Exception as e:
391
+ pytest.fail(f"{name}({n}) raised {type(e).__name__}: {e}")
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # v0.4.0 regression tests
396
+ # ---------------------------------------------------------------------------
397
+
398
+ def test_classify_has_notable_score():
399
+ """classify() must include 'notable_score' key."""
400
+ result = nc.classify(7)
401
+ assert "notable_score" in result
402
+ assert isinstance(result["notable_score"], int)
403
+ assert result["notable_score"] <= result["score"]
404
+
405
+
406
+ def test_classify_n1_notable_score_reasonable():
407
+ """n=1 notable_score must be much less than total score (no figurate inflation)."""
408
+ result = nc.classify(1)
409
+ # notable_score excludes figurate and figurate_centered
410
+ assert result["notable_score"] < 100
411
+ # Total score will still be large (mathematically correct)
412
+ assert result["score"] > 1000
413
+
414
+
415
+ def test_is_unique_negative():
416
+ """is_unique must return False for negative integers."""
417
+ from numclassify._core.exam_types import is_unique
418
+ assert is_unique(-5) is False
419
+ assert is_unique(-1) is False
420
+ assert is_unique(-123) is False
421
+
422
+
423
+ def test_is_practical_zero():
424
+ """is_practical(0) must return False."""
425
+ from numclassify._core.divisors import is_practical
426
+ assert is_practical(0) is False
427
+
428
+
429
+ def test_no_leaked_names():
430
+ """Optional, _version, _PackageNotFoundError must not be in dir(nc)."""
431
+ public_names = [n for n in dir(nc) if not n.startswith("__")]
432
+ assert "Optional" not in public_names
433
+ assert "_version" not in public_names
434
+ assert "_PackageNotFoundError" not in public_names
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes