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.
- {numclassify-0.3.2.1 → numclassify-0.4.0}/.gitignore +1 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/CHANGELOG.md +42 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/PKG-INFO +2 -2
- numclassify-0.4.0/SECURITY.md +24 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/playground.html +1 -1
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/__init__.py +73 -12
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/divisors.py +3 -1
- numclassify-0.4.0/numclassify/_core/exam_types.py +388 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/figurate.py +1 -3
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/powers.py +3 -1
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/primes.py +2 -2
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_registry.py +8 -2
- {numclassify-0.3.2.1 → numclassify-0.4.0}/pyproject.toml +2 -2
- {numclassify-0.3.2.1 → numclassify-0.4.0}/tests/test_registry.py +86 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/.github/workflows/ci.yml +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/.github/workflows/docs.yml +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/.github/workflows/publish.yml +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/CONTRIBUTING.md +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/LICENSE +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/README.md +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/api.md +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/changelog.md +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/cli.md +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/extra/saffron.css +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/index.md +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/playground.css +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/docs/playground.js +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/basic_usage.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/custom_type.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/find_perfect_numbers.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/random_classify.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/examples/stream_large_range.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/mkdocs.yml +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/__main__.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/__init__.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/combinatorial.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/digital.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/number_theory.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/recreational.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/_core/sequences.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/cli.py +0 -0
- {numclassify-0.3.2.1 → numclassify-0.4.0}/numclassify/py.typed +0 -0
|
@@ -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
|
+
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 ::
|
|
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="
|
|
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,
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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^
|
|
803
|
-
|
|
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
|
-
|
|
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.
|
|
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 ::
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|