-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlambda_ratio_explorer.py
More file actions
514 lines (418 loc) · 15.7 KB
/
Copy pathlambda_ratio_explorer.py
File metadata and controls
514 lines (418 loc) · 15.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
#!/usr/bin/env python3
"""
lambda_ratio_explorer.py
Core library for studying multiplicative-group structure of (Z/nZ)*.
Computes three primary quantities for each n:
phi(n) -- Euler totient: order of (Z/nZ)*
lambda(n) -- Carmichael function: exponent of (Z/nZ)*
C(n) = phi/lambda -- collapse index: index of the cyclic factor
Interpretation:
C(n) = 1 <=> group is cyclic (n is prime, 2p, p^k, etc.)
C(n) > 1 <=> group decomposes; cycles overlap; structure collapses
For n = p*q: C(n) = gcd(p-1, q-1)
-- exactly the cryptographic weakness measure for RSA.
The optional ratio R(q, n) = lambda(q) / log(n) is retained for backward
compatibility but is not the central object: log(n) is just a scalar.
"""
from __future__ import annotations
import argparse
import csv
import math
import random
from dataclasses import dataclass
from functools import reduce
from math import gcd, lcm
from pathlib import Path
from typing import Iterable
@dataclass(frozen=True)
class Row:
q: int
n: int
kind: str
phi_q: int
lambda_q: int
collapse: int
ratio: float
def carmichael_lambda_bruteforce(n: int) -> int:
"""Small/slow definition check: smallest m where a^m = 1 mod n for all gcd(a,n)=1."""
if n < 1:
raise ValueError("n must be positive")
if n == 1:
return 1
coprimes = [a for a in range(1, n) if gcd(a, n) == 1]
for m in range(1, n * n + 1):
if all(pow(a, m, n) == 1 for a in coprimes):
return m
raise RuntimeError(f"bruteforce search failed for n={n}")
# Factors below this bound are stripped by trial division; anything left
# is handled by Miller-Rabin + Pollard rho.
_TRIAL_DIVISION_LIMIT = 10_000
def _pollard_rho(n: int) -> int:
"""Return a nontrivial factor of odd composite n (Floyd cycle detection)."""
while True:
c = random.randrange(1, n)
x = y = random.randrange(2, n)
d = 1
while d == 1:
x = (x * x + c) % n
y = (y * y + c) % n
y = (y * y + c) % n
d = gcd(abs(x - y), n)
if d != n:
return d
def _factor_hard(n: int) -> list[int]:
"""Prime factors (with multiplicity) of n, which has no factor below
_TRIAL_DIVISION_LIMIT."""
if n == 1:
return []
if is_prime(n):
return [n]
d = _pollard_rho(n)
return _factor_hard(d) + _factor_hard(n // d)
def factorize(n: int) -> dict[int, int]:
"""Prime factorization: trial division for small factors, then
Pollard rho for the remaining cofactor. Practical well beyond the
old pure-trial-division limit (e.g. 18-digit semiprimes)."""
if n < 1:
raise ValueError("n must be positive")
factors: dict[int, int] = {}
d = 2
while d * d <= n and d <= _TRIAL_DIVISION_LIMIT:
while n % d == 0:
factors[d] = factors.get(d, 0) + 1
n //= d
d += 1 if d == 2 else 2 # 2, then odd candidates only
for p in _factor_hard(n):
factors[p] = factors.get(p, 0) + 1
return dict(sorted(factors.items()))
def carmichael_prime_power(p: int, k: int) -> int:
"""Carmichael lambda for p^k."""
if p == 2 and k >= 3:
return 2 ** (k - 2)
return (p - 1) * (p ** (k - 1))
def carmichael_lambda(n: int, *, method: str = "fast") -> int:
if method == "brute":
return carmichael_lambda_bruteforce(n)
if n == 1:
return 1
parts = [carmichael_prime_power(p, k) for p, k in factorize(n).items()]
return reduce(lcm, parts, 1)
def euler_totient(n: int) -> int:
"""Euler's totient phi(n) = order of the multiplicative group (Z/nZ)*."""
if n < 1:
raise ValueError("n must be positive")
if n == 1:
return 1
result = 1
for p, k in factorize(n).items():
result *= p ** (k - 1) * (p - 1)
return result
def collapse_index(n: int) -> int:
"""C(n) = phi(n) / lambda(n).
Equals the index of the largest cyclic factor in the decomposition of
(Z/nZ)*. C(n) = 1 iff the group is cyclic. For n = p*q with p, q distinct
odd primes, C(n) = gcd(p-1, q-1).
"""
if n < 1:
raise ValueError("n must be positive")
if n == 1:
return 1
return euler_totient(n) // carmichael_lambda(n)
def divisors(n: int) -> list[int]:
"""Sorted list of all positive divisors of n."""
if n < 1:
raise ValueError("n must be positive")
if n == 1:
return [1]
result = [1]
for p, e in factorize(n).items():
new_divs: list[int] = []
power = 1
for _ in range(e + 1):
for d in result:
new_divs.append(d * power)
power *= p
result = new_divs
return sorted(result)
def _cyclic_factors_of_unit_group_at_prime_power(p: int, k: int) -> list[int]:
"""Cyclic factor orders of (Z/p^k Z)*.
For odd p, (Z/p^k Z)* is cyclic of order phi(p^k).
For p = 2: trivial at k=1, cyclic of order 2 at k=2,
and Z/2 x Z/2^(k-2) for k >= 3.
"""
if p == 2:
if k == 1:
return []
if k == 2:
return [2]
return [2, 2 ** (k - 2)]
return [(p - 1) * p ** (k - 1)]
def invariant_factors(n: int) -> list[int]:
"""Invariant factor decomposition of (Z/nZ)*.
Returns d_1 | d_2 | ... | d_k with d_1 <= ... <= d_k = lambda(n) and
d_1 * d_2 * ... * d_k = phi(n). The length k is the fracture count:
the number of cyclic components in the decomposition.
"""
if n < 1:
raise ValueError("n must be positive")
if n <= 2:
return [1]
cyclic_orders: list[int] = []
for p, k in factorize(n).items():
cyclic_orders.extend(_cyclic_factors_of_unit_group_at_prime_power(p, k))
if not cyclic_orders:
return [1]
prime_to_vals: dict[int, list[int]] = {}
for order in cyclic_orders:
for q, e in factorize(order).items():
prime_to_vals.setdefault(q, []).append(e)
if not prime_to_vals:
return [1]
k_max = max(len(v) for v in prime_to_vals.values())
for q in prime_to_vals:
prime_to_vals[q].sort(reverse=True)
while len(prime_to_vals[q]) < k_max:
prime_to_vals[q].append(0)
factors: list[int] = []
for j in range(k_max):
d = 1
for q, vals in prime_to_vals.items():
d *= q ** vals[j]
if d > 1:
factors.append(d)
factors.sort()
return factors if factors else [1]
def fracture_count(n: int) -> int:
"""Number of cyclic components in the invariant factor decomposition."""
return len(invariant_factors(n))
def element_orders(n: int) -> dict[int, int]:
"""Map each unit a in (Z/nZ)* to its multiplicative order.
Computed by testing divisors of lambda(n) in ascending order, since
every element order divides lambda(n).
"""
if n < 2:
raise ValueError("n must be >= 2")
lam = carmichael_lambda(n)
divs = divisors(lam)
orders: dict[int, int] = {}
for a in range(1, n):
if gcd(a, n) != 1:
continue
for d in divs:
if pow(a, d, n) == 1:
orders[a] = d
break
return orders
def collapse_step(k: int, p: int) -> dict:
"""One step of the collapse propagation theorem.
For prime p with p not dividing k, the multiplicative collapse index
satisfies the identity
C(k * p) = C(k) * gcd(lambda(k), p - 1)
Returns a dict with all quantities relevant to the step:
k, p, phi_k, lambda_k, C_k,
gcd_term, kp, phi_kp, lambda_kp, C_kp.
Raises ValueError if p is not prime, if p divides k, or if k < 1.
The identity is asserted at runtime so this function doubles as a test.
"""
if k < 1:
raise ValueError("k must be >= 1")
if not is_prime(p):
raise ValueError(f"p={p} is not prime")
if k % p == 0:
raise ValueError(f"p={p} divides k={k}; theorem requires gcd(k, p) = 1")
phi_k = euler_totient(k)
lambda_k = carmichael_lambda(k)
C_k = phi_k // lambda_k
gcd_term = gcd(lambda_k, p - 1)
kp = k * p
phi_kp = euler_totient(kp)
lambda_kp = carmichael_lambda(kp)
C_kp = phi_kp // lambda_kp
predicted = C_k * gcd_term
assert C_kp == predicted, (
f"propagation identity violated: C({kp}) = {C_kp} but "
f"C({k}) * gcd(lambda({k}), {p}-1) = {C_k} * {gcd_term} = {predicted}"
)
return {
"k": k,
"p": p,
"phi_k": phi_k,
"lambda_k": lambda_k,
"C_k": C_k,
"gcd_term": gcd_term,
"kp": kp,
"phi_kp": phi_kp,
"lambda_kp": lambda_kp,
"C_kp": C_kp,
}
def collapse_propagation_trace(primes: list[int]) -> list[dict]:
"""Iterate collapse_step starting at k = 1 across the given primes in order.
Returns the full trace as a list of step dicts. Each successive step uses
the previous step's k*p as the new k. The primes must be distinct and the
order matters only in that earlier values appear in lambda_k of later
steps; the final C is independent of order.
"""
if not primes:
return []
seen: set[int] = set()
trace: list[dict] = []
k = 1
for p in primes:
if p in seen:
raise ValueError(f"duplicate prime {p} in propagation trace")
seen.add(p)
step = collapse_step(k, p)
trace.append(step)
k = step["kp"]
return trace
def is_carmichael(n: int) -> bool:
"""Korselt's criterion: n is a Carmichael number iff n is composite,
squarefree, and (p - 1) divides (n - 1) for every prime p dividing n.
Carmichael numbers are exactly the composites with lambda(n) | (n - 1),
so they pass the Fermat primality test for every coprime base. The
smallest is 561 = 3 * 11 * 17.
"""
if n < 561 or is_prime(n):
return False
factors = factorize(n)
if len(factors) < 3:
return False
if any(e > 1 for e in factors.values()):
return False
return all((n - 1) % (p - 1) == 0 for p in factors)
# Deterministic Miller-Rabin witness set: the first 13 primes are exact
# for all n < 3,317,044,064,679,887,385,961,981 ~ 3.3 * 10^24
# (Sorenson & Webster 2015).
_MILLER_RABIN_WITNESSES = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41)
def is_prime(n: int) -> bool:
"""Deterministic Miller-Rabin, exact for all n < 3.3 * 10^24."""
if n < 2:
return False
for p in _MILLER_RABIN_WITNESSES:
if n % p == 0:
return n == p
d = n - 1
r = 0
while d % 2 == 0:
d //= 2
r += 1
for a in _MILLER_RABIN_WITNESSES:
x = pow(a, d, n)
if x == 1 or x == n - 1:
continue
for _ in range(r - 1):
x = x * x % n
if x == n - 1:
break
else:
return False
return True
def kind(q: int) -> str:
if is_prime(q):
return "prime"
if len(factorize(q)) == 1:
return "prime_power"
return "composite"
def ratio(q: int, n: int, *, method: str = "fast") -> float:
if n <= 1:
raise ValueError("n must be > 1 because log(n) is the normalizer")
return carmichael_lambda(q, method=method) / math.log(n)
def scan(q_min: int, q_max: int, n_values: Iterable[int], *, method: str = "fast") -> list[Row]:
rows: list[Row] = []
for n in n_values:
if n <= 1:
raise ValueError("all n values must be > 1")
log_n = math.log(n)
for q in range(q_min, q_max + 1):
lam = carmichael_lambda(q, method=method)
phi = euler_totient(q)
rows.append(Row(
q=q, n=n, kind=kind(q),
phi_q=phi, lambda_q=lam,
collapse=phi // lam,
ratio=lam / log_n,
))
return rows
def print_table(rows: list[Row], *, limit: int | None = None) -> None:
header = f"{'q':>5} {'n':>8} {'kind':>12} {'phi(q)':>8} {'lambda(q)':>10} {'C(q)':>6} {'lambda/log(n)':>15}"
print(header)
print("-" * len(header))
for row in rows[:limit]:
print(f"{row.q:5d} {row.n:8d} {row.kind:>12} {row.phi_q:8d} {row.lambda_q:10d} "
f"{row.collapse:6d} {row.ratio:15.8f}")
def write_csv(rows: list[Row], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=["q", "n", "kind", "phi_q", "lambda_q", "collapse", "ratio"]
)
writer.writeheader()
for row in rows:
writer.writerow(row.__dict__)
def summarize(rows: list[Row], *, top: int = 10) -> None:
print("\nTop collapse indices C(q) = phi(q)/lambda(q):")
for row in sorted(rows, key=lambda r: r.collapse, reverse=True)[:top]:
print(f" q={row.q:<6} {row.kind:<12} phi={row.phi_q:<6} lambda={row.lambda_q:<6} "
f"C={row.collapse}")
by_kind: dict[str, list[Row]] = {}
for row in rows:
by_kind.setdefault(row.kind, []).append(row)
print("\nAverages by kind:")
for k, items in sorted(by_kind.items()):
avg_phi = sum(r.phi_q for r in items) / len(items)
avg_lam = sum(r.lambda_q for r in items) / len(items)
avg_C = sum(r.collapse for r in items) / len(items)
print(f" {k:<12} count={len(items):<5} avg_phi={avg_phi:.2f} "
f"avg_lambda={avg_lam:.2f} avg_C={avg_C:.2f}")
def plot(rows: list[Row], path: Path) -> None:
try:
import matplotlib.pyplot as plt
except ImportError as exc:
raise SystemExit("matplotlib is not installed; rerun without --plot or install matplotlib") from exc
path.parent.mkdir(parents=True, exist_ok=True)
n_values = sorted({r.n for r in rows})
fig, ax = plt.subplots(figsize=(11, 6))
markers = {"prime": "o", "prime_power": "s", "composite": "."}
for n in n_values:
n_rows = [r for r in rows if r.n == n]
for k in sorted({r.kind for r in n_rows}):
items = [r for r in n_rows if r.kind == k]
ax.scatter(
[r.q for r in items],
[r.ratio for r in items],
label=f"{k}, n={n}",
marker=markers.get(k, "."),
s=28 if k != "composite" else 14,
alpha=0.75,
)
ax.set_title("Carmichael lambda ratio scan: lambda(q) / log(n)")
ax.set_xlabel("q")
ax.set_ylabel("lambda(q) / log(n)")
ax.grid(True, alpha=0.25)
ax.legend(fontsize="small", ncols=2)
fig.tight_layout()
fig.savefig(path, dpi=160)
print(f"\nWrote plot: {path}")
def parse_n_values(raw: str) -> list[int]:
return [int(x.strip()) for x in raw.split(",") if x.strip()]
def main() -> None:
parser = argparse.ArgumentParser(description="Explore R(q,n)=lambda(q)/log(n).")
parser.add_argument("--q-min", type=int, default=2)
parser.add_argument("--q-max", type=int, default=50)
parser.add_argument("--n", type=parse_n_values, default=[1000], help="comma-separated n values, e.g. 100,1000,10000")
parser.add_argument("--method", choices=["fast", "brute"], default="fast")
parser.add_argument("--csv", type=Path, help="optional CSV output path")
parser.add_argument("--plot", type=Path, help="optional PNG plot output path; requires matplotlib")
parser.add_argument("--top", type=int, default=10, help="number of ratio spikes to summarize")
parser.add_argument("--limit", type=int, help="limit printed table rows")
args = parser.parse_args()
rows = scan(args.q_min, args.q_max, args.n, method=args.method)
print_table(rows, limit=args.limit)
summarize(rows, top=args.top)
if args.csv:
write_csv(rows, args.csv)
print(f"\nWrote CSV: {args.csv}")
if args.plot:
plot(rows, args.plot)
if __name__ == "__main__":
main()