Skip to content

Commit ed41dd8

Browse files
committed
feat: optional quantum entropy source for casts (v1.1.8)
- scripts/entropy.py: seed / system (CSPRNG, default) / quantum (QuantumRandom, ANU quantum-vacuum noise, degrades to os.urandom with a flag). --entropy on yijing/liuyao/tarot; output carries honest provenance. - tests/test_entropy.py (+12, 977->989): source selection, offline degrade, getrandbits/shuffle/choice, script wiring; network-free. - HONEST: quantum source is physical-randomness only, NOT an accuracy gain — outcomes are uniform regardless of entropy source. Labeled in output.
1 parent 927a09d commit ed41dd8

8 files changed

Lines changed: 294 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@
22

33
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## [1.1.8] — 2026-05-31
6+
7+
Optional quantum entropy source for divination casts.
8+
9+
### Added
10+
- `scripts/entropy.py` — pluggable cast entropy: `seed` (deterministic),
11+
`system` (OS CSPRNG, default), or `quantum` (`QuantumRandom`, physical
12+
randomness from ANU quantum-vacuum noise, gracefully degrading to
13+
`os.urandom` with a `degraded` flag if the source is unreachable).
14+
- `--entropy {system,quantum}` on `yijing_cast`, `liuyao_cast`, `tarot_draw`;
15+
output carries an honest `entropy` provenance block.
16+
- `tests/test_entropy.py` (+12, suite 977 → 989) — source selection,
17+
forced-offline degrade, `getrandbits`/`shuffle`/`choice` correctness, and
18+
script wiring. Network-free (the quantum path is tested via the fallback).
19+
20+
### Note
21+
The `quantum` source is offered as a *physically-true randomness* option only.
22+
It does **not** make a reading more accurate — hexagram/card outcomes are
23+
uniform regardless of entropy source, and divination accuracy has no physical
24+
dependence on where the bits come from. Output always labels the source so the
25+
distinction stays transparent. (Relativity/quantum mechanics cannot improve
26+
divination accuracy; the calendar layer's solar-term precision already uses
27+
relativistic time scales via lunar_python's VSOP87 port, to < 1 s.)
28+
529
## [1.1.6] — 2026-05-31
630

731
Independent-verification + quality-gate release. Cross-checks the calendar

scripts/bazi_calc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
warn,
4949
)
5050

51-
VERSION = "1.1.7"
51+
VERSION = "1.1.8"
5252
ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
5353

5454

scripts/entropy.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Entropy sources for divination casts (起卦 / 洗牌).
2+
3+
Three sources, selected per cast:
4+
5+
* ``seed`` — ``random.Random(seed)``: deterministic, for tests / reproducible casts.
6+
* ``system`` — ``random.SystemRandom``: OS CSPRNG (default). Cryptographically
7+
strong pseudo-randomness.
8+
* ``quantum``— ``QuantumRandom``: physical randomness from quantum vacuum noise
9+
(ANU QRNG). Falls back to ``os.urandom`` (flagged ``degraded``)
10+
if the quantum source is unreachable.
11+
12+
HONEST FRAMING — read this before using ``quantum``:
13+
A quantum entropy source does NOT make a divination "more accurate" or "more
14+
true". Hexagram/card outcomes are uniform regardless of whether the bits come
15+
from a CPU CSPRNG or a photon beam-splitter; accuracy of a reading has no
16+
physical dependence on the entropy source. ``quantum`` is offered only as a
17+
*physically-true randomness* option (philosophically meaningful to some
18+
practitioners), never as an accuracy improvement. Output always labels the
19+
source so the distinction stays transparent.
20+
"""
21+
from __future__ import annotations
22+
23+
import json
24+
import os
25+
import random
26+
import urllib.request
27+
28+
# ANU Quantum Random Number Generator (quantum vacuum fluctuations).
29+
# Modern authenticated endpoint needs an API key (env ANU_QRNG_API_KEY);
30+
# the legacy free endpoint is attempted as a fallback. Either may be down —
31+
# QuantumRandom degrades to os.urandom rather than ever blocking a cast.
32+
_ANU_AUTH_URL = "https://api.quantumnumbers.anu.edu.au"
33+
_ANU_LEGACY_URL = "https://qrng.anu.edu.au/API/jsonI.php"
34+
_BLOCK = 256 # bytes fetched per refill
35+
36+
37+
def _fetch_quantum_bytes(n: int, timeout: float = 6.0) -> bytes | None:
38+
"""Return ``n`` quantum-random bytes, or None if unavailable."""
39+
key = os.environ.get("ANU_QRNG_API_KEY")
40+
try:
41+
if key:
42+
req = urllib.request.Request(
43+
f"{_ANU_AUTH_URL}?length={n}&type=uint8",
44+
headers={"x-api-key": key},
45+
)
46+
else:
47+
req = urllib.request.Request(
48+
f"{_ANU_LEGACY_URL}?length={n}&type=uint8"
49+
)
50+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
51+
payload = json.loads(resp.read().decode("utf-8"))
52+
if payload.get("success") and isinstance(payload.get("data"), list):
53+
data = bytes(int(b) & 0xFF for b in payload["data"])
54+
return data[:n] if len(data) >= n else None
55+
except Exception:
56+
return None
57+
return None
58+
59+
60+
class QuantumRandom(random.Random):
61+
"""``random.Random`` backed by a quantum entropy pool.
62+
63+
Consumes bytes from the ANU QRNG; refills on demand. Any fetch failure
64+
flips ``degraded = True`` and the pool is topped up from ``os.urandom``
65+
(still a CSPRNG) so a cast never blocks or crashes.
66+
"""
67+
68+
def __init__(self) -> None:
69+
self._pool = bytearray()
70+
self.degraded = False
71+
super().__init__()
72+
73+
def _ensure(self, n: int) -> None:
74+
while len(self._pool) < n:
75+
qb = _fetch_quantum_bytes(_BLOCK)
76+
if qb:
77+
self._pool.extend(qb)
78+
else:
79+
self.degraded = True
80+
self._pool.extend(os.urandom(_BLOCK))
81+
82+
def _take(self, n: int) -> int:
83+
self._ensure(n)
84+
chunk = bytes(self._pool[:n])
85+
del self._pool[:n]
86+
return int.from_bytes(chunk, "big")
87+
88+
def getrandbits(self, k: int) -> int:
89+
if k <= 0:
90+
raise ValueError("number of bits must be greater than zero")
91+
nbytes = (k + 7) // 8
92+
value = self._take(nbytes)
93+
return value >> (nbytes * 8 - k) # trim to exactly k bits
94+
95+
def random(self) -> float:
96+
# 53-bit mantissa float in [0, 1), per CPython convention.
97+
return self.getrandbits(53) / (1 << 53)
98+
99+
def seed(self, *args, **kwargs) -> None: # noqa: D401
100+
"""No-op: entropy is externally sourced, not seedable."""
101+
return None
102+
103+
104+
def get_rng(seed: int | None = None, source: str = "system"):
105+
"""Return an RNG for a cast.
106+
107+
seed given -> deterministic random.Random(seed) (source ignored)
108+
source == quantum -> QuantumRandom (physical, degrades to os.urandom)
109+
otherwise -> random.SystemRandom (OS CSPRNG, default)
110+
"""
111+
if seed is not None:
112+
return random.Random(seed)
113+
if source == "quantum":
114+
return QuantumRandom()
115+
return random.SystemRandom()
116+
117+
118+
def describe(rng, source: str, seed: int | None) -> dict:
119+
"""Structured, honest provenance for the output JSON."""
120+
if seed is not None:
121+
return {"source": "seed", "seed": seed, "reproducible": True,
122+
"note": "确定性种子, 仅供复现/测试"}
123+
if source == "quantum":
124+
degraded = getattr(rng, "degraded", False)
125+
return {
126+
"source": "os_urandom_fallback" if degraded else "quantum_vacuum_anu",
127+
"degraded": degraded,
128+
"note": ("量子源不可达, 已降级到 OS CSPRNG" if degraded
129+
else "ANU 量子真空噪声物理真随机; 不影响卦象准确度, 仅熵源差异"),
130+
}
131+
return {"source": "system_csprng",
132+
"note": "OS 加密级伪随机 (random.SystemRandom)"}

scripts/liuyao_cast.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
from __future__ import annotations
1212

1313
import argparse
14-
import random
1514
import sys
1615
from datetime import datetime
1716

17+
import entropy
1818
from utils import (
1919
BAGUA,
2020
BINARY_TO_TRIGRAM,
@@ -406,6 +406,8 @@ def build_parser() -> argparse.ArgumentParser:
406406
help="起卦时 HH:MM (默认现在)")
407407
pc.add_argument("--question", type=str, default=None,
408408
help="所问之事 (用于推断用神)")
409+
pc.add_argument("--entropy", choices=["system", "quantum"], default="system",
410+
help="熵源: system=OS CSPRNG (默认), quantum=ANU 量子真空真随机")
409411
return p
410412

411413

@@ -439,7 +441,7 @@ def main(argv: list[str] | None = None) -> int:
439441
day_branch = lunar.getDayZhi()
440442
month_branch = lunar.getMonthZhi()
441443

442-
rng = random.Random(args.seed) if args.seed is not None else random.SystemRandom()
444+
rng = entropy.get_rng(args.seed, args.entropy)
443445
lines = cast_coins(rng)
444446

445447
main_chart = dress_chart(lines, day_stem, day_branch, month_branch)
@@ -465,6 +467,7 @@ def main(argv: list[str] | None = None) -> int:
465467
out = {
466468
"method": "coins",
467469
"question": args.question,
470+
"entropy": entropy.describe(rng, args.entropy, args.seed),
468471
"yongshen_hint": yongshen_hint(args.question),
469472
"cast_time": {
470473
"solar": f"{y:04d}-{m:02d}-{d:02d} {h:02d}:{mi:02d}",

scripts/tarot_draw.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import random
2424
import sys
2525

26+
import entropy
2627
from utils import json_print, warn
2728

2829
# --------------------------------------------------------------------------- #
@@ -249,6 +250,8 @@ def build_parser() -> argparse.ArgumentParser:
249250
ps = sub.add_parser(s, help=f"{s} 牌阵")
250251
ps.add_argument("--seed", type=int, default=None)
251252
ps.add_argument("--question", type=str, default=None)
253+
ps.add_argument("--entropy", choices=["system", "quantum"], default="system",
254+
help="熵源: system=OS CSPRNG (默认), quantum=ANU 量子真空真随机")
252255
return p
253256

254257

@@ -261,7 +264,7 @@ def main(argv: list[str] | None = None) -> int:
261264
json_print({"error": "unknown_spread", "valid": list(SPREAD_DEFS.keys())})
262265
return 2
263266

264-
rng = random.Random(args.seed) if args.seed is not None else random.SystemRandom()
267+
rng = entropy.get_rng(args.seed, args.entropy)
265268
cards = draw_cards(rng, len(positions), deck)
266269

267270
out_cards = []
@@ -288,6 +291,7 @@ def main(argv: list[str] | None = None) -> int:
288291
}.get(args.spread),
289292
"question": args.question,
290293
"seed": args.seed,
294+
"entropy": entropy.describe(rng, args.entropy, args.seed),
291295
"cards": out_cards,
292296
"summary": position_summary(positions, cards),
293297
}

scripts/yijing_cast.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import sys
2121
from datetime import datetime
2222

23+
import entropy
2324
from utils import (
2425
BAGUA,
2526
BINARY_TO_TRIGRAM,
@@ -358,6 +359,8 @@ def build_parser() -> argparse.ArgumentParser:
358359
pc = sub.add_parser("coins", help="三枚硬币 6 次")
359360
pc.add_argument("--seed", type=int, default=None, help="可选随机种子")
360361
pc.add_argument("--question", type=str, default=None)
362+
pc.add_argument("--entropy", choices=["system", "quantum"], default="system",
363+
help="熵源: system=OS CSPRNG (默认), quantum=ANU 量子真空真随机")
361364

362365
pn = sub.add_parser("numbers", help="三数起卦 (上/下/动爻)")
363366
pn.add_argument("--upper", type=int, required=True)
@@ -420,9 +423,9 @@ def main(argv: list[str] | None = None) -> int:
420423
args = build_parser().parse_args(argv)
421424

422425
if args.method == "coins":
423-
rng = random.Random(args.seed) if args.seed is not None else random.SystemRandom()
426+
rng = entropy.get_rng(args.seed, args.entropy)
424427
lines = cast_coins(rng)
425-
meta = {"seed": args.seed}
428+
meta = {"seed": args.seed, "entropy": entropy.describe(rng, args.entropy, args.seed)}
426429
out = cast("coins", lines, meta, args.question)
427430
elif args.method == "numbers":
428431
lines = from_numbers(args.upper, args.lower, args.change)

scripts/ziwei_calc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
warn,
4343
)
4444

45-
VERSION = "1.1.7"
45+
VERSION = "1.1.8"
4646

4747

4848
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)