numclassify 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,403 @@
1
+ """
2
+ numclassify._core.digital
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Digital (digit-based) number classification functions.
5
+
6
+ Helper functions ``digit_sum`` and ``digital_root`` are provided as plain
7
+ module-level utilities and are **not** registered (they return integers, not
8
+ booleans). All boolean predicates are registered via ``@register``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from functools import reduce
14
+ from typing import List
15
+
16
+ from numclassify._registry import register
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Helpers (not registered — return int, not bool)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def digit_sum(n: int) -> int:
24
+ """Return the sum of the decimal digits of *n*.
25
+
26
+ Parameters
27
+ ----------
28
+ n:
29
+ Non-negative integer.
30
+
31
+ Returns
32
+ -------
33
+ int
34
+
35
+ Example
36
+ -------
37
+ >>> digit_sum(493)
38
+ 16
39
+ >>> digit_sum(0)
40
+ 0
41
+ """
42
+ return sum(int(d) for d in str(abs(n)))
43
+
44
+
45
+ def digital_root(n: int) -> int:
46
+ """Return the digital root of *n* (repeated digit sum until single digit).
47
+
48
+ For *n* = 0 the digital root is 0. For *n* > 0, ``digital_root(n)`` is
49
+ equivalent to ``1 + (n - 1) % 9``.
50
+
51
+ Parameters
52
+ ----------
53
+ n:
54
+ Non-negative integer.
55
+
56
+ Returns
57
+ -------
58
+ int in [0, 9]
59
+
60
+ Example
61
+ -------
62
+ >>> digital_root(493)
63
+ 7
64
+ >>> digital_root(0)
65
+ 0
66
+ """
67
+ if n == 0:
68
+ return 0
69
+ return 1 + (n - 1) % 9
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Boolean predicates — all registered
74
+ # ---------------------------------------------------------------------------
75
+
76
+ @register(
77
+ name="Armstrong",
78
+ category="digital",
79
+ oeis="A005188",
80
+ description=(
81
+ "A number equal to the sum of its own digits each raised to the power "
82
+ "of the number of digits (narcissistic number)."
83
+ ),
84
+ aliases=["narcissistic", "pluperfect digital invariant"],
85
+ )
86
+ def is_armstrong(n: int) -> bool:
87
+ """Return ``True`` if *n* is an Armstrong (narcissistic) number.
88
+
89
+ For a *d*-digit number, ``is_armstrong(n)`` iff
90
+ ``n == sum(digit^d for digit in digits(n))``.
91
+
92
+ Parameters
93
+ ----------
94
+ n:
95
+ Non-negative integer.
96
+
97
+ Returns
98
+ -------
99
+ bool
100
+
101
+ Example
102
+ -------
103
+ >>> is_armstrong(153) # 1^3 + 5^3 + 3^3 = 1 + 125 + 27 = 153
104
+ True
105
+ >>> is_armstrong(370)
106
+ True
107
+ >>> is_armstrong(100)
108
+ False
109
+ """
110
+ if n < 0:
111
+ return False
112
+ digits = [int(d) for d in str(n)]
113
+ power = len(digits)
114
+ return sum(d ** power for d in digits) == n
115
+
116
+
117
+ @register(
118
+ name="Spy",
119
+ category="digital",
120
+ oeis="A059716",
121
+ description=(
122
+ "A number whose digit sum equals its digit product. "
123
+ "All single-digit numbers are spy numbers (sum = digit = product)."
124
+ ),
125
+ aliases=["spy number"],
126
+ )
127
+ def is_spy(n: int) -> bool:
128
+ """Return ``True`` if *n* is a spy number.
129
+
130
+ A spy number satisfies ``digit_sum(n) == digit_product(n)``.
131
+ Single-digit numbers (0–9) trivially qualify because their digit sum and
132
+ digit product are both equal to the digit itself.
133
+
134
+ Parameters
135
+ ----------
136
+ n:
137
+ Non-negative integer.
138
+
139
+ Returns
140
+ -------
141
+ bool
142
+
143
+ Example
144
+ -------
145
+ >>> is_spy(1124) # 1+1+2+4=8 and 1*1*2*4=8
146
+ True
147
+ >>> is_spy(5) # single digit: sum=5=product
148
+ True
149
+ >>> is_spy(123) # sum=6, product=6 → True
150
+ True
151
+ >>> is_spy(124) # sum=7, product=8 → False
152
+ False
153
+ """
154
+ if n < 0:
155
+ return False
156
+ digits = [int(d) for d in str(n)]
157
+ s = sum(digits)
158
+ p = 1
159
+ for d in digits:
160
+ p *= d
161
+ return s == p
162
+
163
+
164
+ @register(
165
+ name="Harshad",
166
+ category="digital",
167
+ oeis="A005349",
168
+ description=(
169
+ "A number divisible by its own digit sum (also called a Niven number)."
170
+ ),
171
+ aliases=["niven", "harshad number", "niven number"],
172
+ )
173
+ def is_harshad(n: int) -> bool:
174
+ """Return ``True`` if *n* is a Harshad (Niven) number.
175
+
176
+ A Harshad number is divisible by its digit sum: ``n % digit_sum(n) == 0``.
177
+
178
+ Parameters
179
+ ----------
180
+ n:
181
+ Positive integer.
182
+
183
+ Returns
184
+ -------
185
+ bool
186
+
187
+ Example
188
+ -------
189
+ >>> is_harshad(18) # digit_sum=9, 18 % 9 == 0
190
+ True
191
+ >>> is_harshad(19) # digit_sum=10, 19 % 10 != 0
192
+ False
193
+ """
194
+ if n <= 0:
195
+ return False
196
+ ds = digit_sum(n)
197
+ if ds == 0:
198
+ return False
199
+ return n % ds == 0
200
+
201
+
202
+ @register(
203
+ name="Disarium",
204
+ category="digital",
205
+ oeis="A032799",
206
+ description=(
207
+ "A number equal to the sum of its digits each raised to the power of "
208
+ "their 1-based position from the left."
209
+ ),
210
+ aliases=["disarium number"],
211
+ )
212
+ def is_disarium(n: int) -> bool:
213
+ """Return ``True`` if *n* is a Disarium number.
214
+
215
+ For digits ``d₁ d₂ … dₖ`` (1-indexed from the left),
216
+ ``is_disarium(n)`` iff ``n == d₁¹ + d₂² + … + dₖᵏ``.
217
+
218
+ Parameters
219
+ ----------
220
+ n:
221
+ Non-negative integer.
222
+
223
+ Returns
224
+ -------
225
+ bool
226
+
227
+ Example
228
+ -------
229
+ >>> is_disarium(135) # 1^1 + 3^2 + 5^3 = 1 + 9 + 125 = 135
230
+ True
231
+ >>> is_disarium(89) # 8^1 + 9^2 = 8 + 81 = 89
232
+ True
233
+ >>> is_disarium(136)
234
+ False
235
+ """
236
+ if n < 0:
237
+ return False
238
+ digits = [int(d) for d in str(n)]
239
+ return sum(d ** (i + 1) for i, d in enumerate(digits)) == n
240
+
241
+
242
+ @register(
243
+ name="Happy",
244
+ category="digital",
245
+ oeis="A007770",
246
+ description=(
247
+ "A number that eventually reaches 1 under iteration of the sum-of-"
248
+ "squared-digits map."
249
+ ),
250
+ aliases=["happy number"],
251
+ )
252
+ def is_happy(n: int) -> bool:
253
+ """Return ``True`` if *n* is a happy number.
254
+
255
+ Starting from *n*, repeatedly replace the number by the sum of the squares
256
+ of its digits. If the process reaches 1, *n* is happy. Otherwise it
257
+ enters a cycle not including 1 (the cycle always contains 4 for unhappy
258
+ numbers).
259
+
260
+ Cycle detection uses a *seen* set.
261
+
262
+ Parameters
263
+ ----------
264
+ n:
265
+ Positive integer.
266
+
267
+ Returns
268
+ -------
269
+ bool
270
+
271
+ Example
272
+ -------
273
+ >>> is_happy(19) # 1²+9²=82 → 8²+2²=68 → … → 1
274
+ True
275
+ >>> is_happy(4) # enters cycle: 4→16→37→58→89→145→42→20→4
276
+ False
277
+ """
278
+ if n <= 0:
279
+ return False
280
+ seen = set()
281
+ current = n
282
+ while current != 1:
283
+ if current in seen:
284
+ return False
285
+ seen.add(current)
286
+ current = sum(int(d) ** 2 for d in str(current))
287
+ return True
288
+
289
+
290
+ @register(
291
+ name="Neon",
292
+ category="digital",
293
+ oeis="A208854",
294
+ description=(
295
+ "A number whose digit sum equals the number itself when applied to n²."
296
+ ),
297
+ aliases=["neon number"],
298
+ )
299
+ def is_neon(n: int) -> bool:
300
+ """Return ``True`` if *n* is a neon number.
301
+
302
+ A neon number satisfies ``digit_sum(n²) == n``.
303
+
304
+ Parameters
305
+ ----------
306
+ n:
307
+ Non-negative integer.
308
+
309
+ Returns
310
+ -------
311
+ bool
312
+
313
+ Example
314
+ -------
315
+ >>> is_neon(9) # 9² = 81, digit_sum(81) = 9
316
+ True
317
+ >>> is_neon(1) # 1² = 1, digit_sum(1) = 1
318
+ True
319
+ >>> is_neon(2) # 2² = 4, digit_sum(4) = 4 ≠ 2
320
+ False
321
+ """
322
+ if n < 0:
323
+ return False
324
+ return digit_sum(n * n) == n
325
+
326
+
327
+ @register(
328
+ name="Duck",
329
+ category="digital",
330
+ oeis="",
331
+ description=(
332
+ "A positive number that contains the digit 0 but does not start with 0."
333
+ ),
334
+ aliases=["duck number"],
335
+ )
336
+ def is_duck(n: int) -> bool:
337
+ """Return ``True`` if *n* is a duck number.
338
+
339
+ A duck number is a positive integer that contains at least one digit ``0``
340
+ and does not have a leading zero (i.e. it is not zero itself).
341
+
342
+ Parameters
343
+ ----------
344
+ n:
345
+ Integer to test.
346
+
347
+ Returns
348
+ -------
349
+ bool
350
+
351
+ Example
352
+ -------
353
+ >>> is_duck(1230)
354
+ True
355
+ >>> is_duck(1234)
356
+ False
357
+ >>> is_duck(100)
358
+ True
359
+ """
360
+ if n <= 0:
361
+ return False
362
+ s = str(n)
363
+ return "0" in s # leading zero is impossible for n > 0 as int
364
+
365
+
366
+ @register(
367
+ name="Nude",
368
+ category="digital",
369
+ oeis="",
370
+ description=(
371
+ "A number divisible by each of its non-zero digits."
372
+ ),
373
+ aliases=["nude number"],
374
+ )
375
+ def is_nude(n: int) -> bool:
376
+ """Return ``True`` if *n* is a nude number.
377
+
378
+ A nude number is divisible by every one of its non-zero digits.
379
+
380
+ Parameters
381
+ ----------
382
+ n:
383
+ Positive integer.
384
+
385
+ Returns
386
+ -------
387
+ bool
388
+
389
+ Example
390
+ -------
391
+ >>> is_nude(12) # 12 % 1 == 0 and 12 % 2 == 0
392
+ True
393
+ >>> is_nude(13) # 13 % 3 != 0
394
+ False
395
+ >>> is_nude(36) # 36 % 3 == 0 and 36 % 6 == 0
396
+ True
397
+ """
398
+ if n <= 0:
399
+ return False
400
+ digits = [int(d) for d in str(n) if d != "0"]
401
+ if not digits:
402
+ return False
403
+ return all(n % d == 0 for d in digits)