Skip to content

Commit c85769f

Browse files
committed
fix: 3 silent-wrong bugs found by adversarial audit + value tests (v1.1.5)
- tarot (HIGH): asset {major,minor} dict never loaded (isinstance list check) -> every reading used placeholder text. Add _flatten_asset_deck; real meanings now. - huangli 吉时/凶时 (HIGH): classified by "has any 宜" -> always 12吉/0凶. Now use 时辰黄道/黑道 (getTimeTianShenLuck) for a real split. Drop dead getTimes/getTimeXun. - ziwei 真太阳时 (HIGH): --tz/--longitude accepted but ignored. Wire through longitude_correction, opt-in (only non-default longitude/tz) so default 时辰-granular charts are unchanged. - zodiac (LOW): same-sign pair mis-flagged 三合; require distinct branches. - +5 tests (94->99): value goldens (紫微/奇门/六壬), regression locks (tarot asset, huangli split, ziwei opt-in, same-sign), kill meihua tautology.
1 parent c18018a commit c85769f

7 files changed

Lines changed: 204 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@
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.5] — 2026-05-31
6+
7+
Bug-fix release — three silent-wrong defects found by an adversarial line-by-line
8+
audit, plus value-level test coverage to lock them.
9+
10+
### Fixed
11+
- **塔罗 (HIGH)**`tarot_draw.load_deck` gated on `isinstance(deck, list)`, but
12+
the asset ships as `{major_arcana, minor_arcana}` (dict), so the curated 78-card
13+
deck **never loaded** and every reading silently used placeholder text
14+
("…第N阶: 见详细解读"). Added `_flatten_asset_deck`; readings now carry the real
15+
per-card 正/逆位 meanings.
16+
- **黄历 吉时/凶时 (HIGH)** — classification used "时辰 has any 宜", which is true for
17+
all 12, so output was always 12 吉 / 0 凶 (meaningless). Now derived from the
18+
时辰 黄道/黑道 (`getTimeTianShenLuck`): a real 吉/凶 split. Removed dead
19+
`getTimes()` / `getTimeXun()` calls.
20+
- **紫微 真太阳时 (HIGH)**`--tz`/`--longitude` were accepted but ignored, so a
21+
user-supplied longitude produced an uncorrected chart (silent-wrong near 子时).
22+
Now wired through `longitude_correction` (with day roll-over) — **opt-in**: only
23+
applied when a non-default longitude/tz is given, so 时辰-granular charts on the
24+
default meridian are unchanged (no regression).
25+
- **生肖 (LOW)** — same-sign pairs (e.g. 鼠-鼠) were mis-flagged 三合 because
26+
`da in group and db in group` is true when `da == db`. Now requires distinct
27+
branches.
28+
29+
### Added
30+
- +5 tests (suite 94 → 99): value-golden assertions for 紫微 命宫/身宫/局,
31+
奇门 阳遁8局, 大六壬 丙午日; regression locks for the tarot asset, huangli 黄黑道
32+
吉凶 split, ziwei longitude opt-in, same-sign 三合; replaced a tautological
33+
meihua `relation` truthy-check with a valid-relation assertion.
34+
535
## [1.1.4] — 2026-05-31
636

737
Continuous integration — closes the last engineering gap found in a 4-repo

scripts/bazi_calc.py

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

5151

52-
VERSION = "1.1.4"
52+
VERSION = "1.1.5"
5353
ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
5454

5555

scripts/huangli_query.py

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -34,44 +34,29 @@ def _safe_method(obj, name: str, default=None):
3434

3535

3636
def _hour_pillars(lunar) -> list[dict]:
37-
"""For each of the 12 双小时, return ganzhi + ji/xiong markers."""
38-
out: list[dict] = []
39-
try:
40-
shichen_list = lunar.getTimes()
41-
# Some versions: getShichenJiXiong / getTimeJiXiong.
42-
except Exception:
43-
shichen_list = []
37+
"""For each of the 12 双小时, return ganzhi + 黄黑道 吉凶 markers.
4438
45-
# Fallback: build 12 time entries by iterating over hour 0..23 step 2
39+
吉凶 comes from the hour's 天神 黄道/黑道 (getTimeTianShenLuck), NOT from
40+
whether the hour has any 宜 — every 时辰 has non-empty 宜, so the latter
41+
would mark all 12 as 吉.
42+
"""
43+
out: list[dict] = []
4644
try:
4745
from lunar_python import Solar # type: ignore
4846
solar = lunar.getSolar()
4947
for hour_pair in range(0, 24, 2):
50-
# 子时归当日 (sect=2). Use middle of the 2-hour window for safety.
51-
h = hour_pair if hour_pair != 23 else 23
5248
s = Solar.fromYmdHms(solar.getYear(), solar.getMonth(),
53-
solar.getDay(), h, 30, 0)
49+
solar.getDay(), hour_pair, 30, 0)
5450
lh = s.getLunar()
55-
time_in_gz = lh.getTimeInGanZhi()
56-
# ji or xiong
57-
try:
58-
jx = lh.getTimeXun() # not exactly ji/xiong but provides 旬
59-
except Exception:
60-
jx = None
61-
try:
62-
yixion = lh.getTimeYi()
63-
ji = lh.getTimeJi()
64-
chong_sha = lh.getTimeChongDesc()
65-
except Exception:
66-
yixion = []
67-
ji = []
68-
chong_sha = None
6951
out.append({
70-
"hour_range": f"{hour_pair:02d}:00-{(hour_pair+2)%24:02d}:00",
71-
"ganzhi": time_in_gz,
72-
"yi": yixion,
73-
"ji": ji,
74-
"chong_sha": chong_sha,
52+
"hour_range": f"{hour_pair:02d}:00-{(hour_pair + 2) % 24:02d}:00",
53+
"ganzhi": lh.getTimeInGanZhi(),
54+
"tian_shen": _safe_method(lh, "getTimeTianShen", None),
55+
"huang_hei_dao": _safe_method(lh, "getTimeTianShenType", None),
56+
"luck": _safe_method(lh, "getTimeTianShenLuck", None),
57+
"yi": _safe_method(lh, "getTimeYi", []),
58+
"ji": _safe_method(lh, "getTimeJi", []),
59+
"chong_sha": _safe_method(lh, "getTimeChongDesc", None),
7560
})
7661
except Exception as e:
7762
warn(f"hour pillars failed: {e}")
@@ -128,10 +113,10 @@ def main(argv: Optional[list[str]] = None) -> int:
128113
peng_zu_gan = _safe_method(lunar, "getPengZuGan", None)
129114
peng_zu_zhi = _safe_method(lunar, "getPengZuZhi", None)
130115

131-
# 今日吉时/凶时
116+
# 今日吉时/凶时 — 按 时辰黄黑道吉凶 (黄道=吉, 黑道=凶), 非"有无宜事"
132117
ji_xiong_shichen = _hour_pillars(lunar)
133-
ji_shi = [s for s in ji_xiong_shichen if s.get("yi")]
134-
xiong_shi = [s for s in ji_xiong_shichen if not s.get("yi") and s.get("ji")]
118+
ji_shi = [s for s in ji_xiong_shichen if s.get("luck") == "吉"]
119+
xiong_shi = [s for s in ji_xiong_shichen if s.get("luck") == "凶"]
135120

136121
# Nearest jieqi
137122
jieqi_now = _safe(lunar.getJieQi, None)

scripts/tarot_draw.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,54 @@ def build_full_deck() -> list[dict]:
123123
return deck
124124

125125

126+
# Suit metadata for flattening the {major_arcana, minor_arcana} asset shape.
127+
_ASSET_SUITS: dict[str, dict[str, str]] = {
128+
"wands": {"zh": "权杖", "en": "Wands", "element": "火", "domain": "事业/行动"},
129+
"cups": {"zh": "圣杯", "en": "Cups", "element": "水", "domain": "情感/关系"},
130+
"swords": {"zh": "宝剑", "en": "Swords", "element": "风", "domain": "思想/冲突"},
131+
"pentacles": {"zh": "星币", "en": "Pentacles", "element": "土", "domain": "物质/财务"},
132+
}
133+
_RANK_NUM: dict[str, int] = {
134+
"Ace": 1, "Page": 11, "Knight": 12, "Queen": 13, "King": 14,
135+
}
136+
137+
138+
def _flatten_asset_deck(data: dict) -> list[dict]:
139+
"""Convert the {major_arcana:[...], minor_arcana:{suit:[...]}} asset into
140+
the flat card schema used by draw_cards. Returns [] on shape mismatch so
141+
the caller can fall back to the embedded deck."""
142+
major = data.get("major_arcana")
143+
minor = data.get("minor_arcana")
144+
if not isinstance(major, list) or not isinstance(minor, dict):
145+
return []
146+
deck: list[dict] = []
147+
for m in major:
148+
deck.append({
149+
"arcana": "major",
150+
"number": m.get("number"),
151+
"en": m.get("name_en"), "zh": m.get("name_zh"),
152+
"suit": None, "element": m.get("element"),
153+
"keywords_up": "、".join(m.get("keywords_upright", [])) or m.get("upright_meaning", ""),
154+
"keywords_rev": "、".join(m.get("keywords_reversed", [])) or m.get("reversed_meaning", ""),
155+
})
156+
for suit_key, cards in minor.items():
157+
meta = _ASSET_SUITS.get(suit_key, {"zh": suit_key, "en": suit_key,
158+
"element": None, "domain": ""})
159+
for c in cards:
160+
rank = str(c.get("rank", ""))
161+
deck.append({
162+
"arcana": "minor",
163+
"number": _RANK_NUM.get(rank, int(rank) if rank.isdigit() else 0),
164+
"en": f"{rank} of {meta['en']}",
165+
"zh": c.get("name_zh"),
166+
"suit": meta["zh"], "suit_en": meta["en"],
167+
"element": meta["element"], "domain": meta["domain"],
168+
"keywords_up": c.get("upright", ""),
169+
"keywords_rev": c.get("reversed", ""),
170+
})
171+
return deck
172+
173+
126174
def load_deck() -> list[dict]:
127175
here = os.path.dirname(os.path.abspath(__file__))
128176
candidates = [
@@ -133,9 +181,17 @@ def load_deck() -> list[dict]:
133181
if asset:
134182
try:
135183
with open(asset, "r", encoding="utf-8") as f:
136-
deck = json.load(f)
137-
if isinstance(deck, list) and len(deck) >= 22:
184+
raw = json.load(f)
185+
# Asset ships as {major_arcana, minor_arcana}; flatten it.
186+
if isinstance(raw, dict):
187+
deck = _flatten_asset_deck(raw)
188+
elif isinstance(raw, list):
189+
deck = raw
190+
else:
191+
deck = []
192+
if len(deck) >= 78:
138193
return deck
194+
warn(f"tarot78.json yielded {len(deck)} cards (<78); using embedded deck")
139195
except Exception as e:
140196
warn(f"failed to load tarot78.json: {e}")
141197
return build_full_deck()

scripts/ziwei_calc.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@
3838
WUHU_DUN,
3939
jiazi_index,
4040
json_print,
41+
longitude_correction,
4142
require_lunar,
4243
warn,
4344
)
4445

4546

46-
VERSION = "1.1.4"
47+
VERSION = "1.1.5"
4748

4849

4950
# --------------------------------------------------------------------------- #
@@ -853,14 +854,40 @@ def main(argv: Optional[list[str]] = None) -> int:
853854
})
854855
return 1
855856

857+
# 真太阳时校正 — 仅当用户显式提供非默认经度/时区时应用。
858+
# 紫微输入通常是粗粒度时辰; 默认经度(120)/时区(8) 表示"未提供精确出生地",
859+
# 此时保留所输入时辰原样, 避免 EOT 把临界时辰(如 01:00)误推过 时辰边界。
860+
# 给出真实经度即视为有精确出生地, 按 bazi 同法校正(近子时可滚日)。
861+
tst_applied = False
862+
birth_hour = args.hour
863+
if args.longitude != 120.0 or args.tz != 8.0:
864+
try:
865+
day_off, corr_hour, corr_minute = longitude_correction(
866+
args.hour, args.minute, args.longitude, args.tz,
867+
year=solar.getYear(), month=solar.getMonth(), day=solar.getDay(),
868+
)
869+
if (day_off, corr_hour, corr_minute) != (0, args.hour, args.minute):
870+
from datetime import date, timedelta
871+
base = date(solar.getYear(), solar.getMonth(), solar.getDay())
872+
if day_off:
873+
base = base + timedelta(days=day_off)
874+
solar = Solar.fromYmdHms(base.year, base.month, base.day,
875+
corr_hour, corr_minute, 0)
876+
lunar = solar.getLunar()
877+
birth_hour = corr_hour
878+
tst_applied = True
879+
except Exception as exc:
880+
warn(f"true_solar_time correction skipped: {exc}")
881+
birth_hour = args.hour
882+
856883
year_stem = lunar.getYearGan()
857884
year_branch = lunar.getYearZhi()
858885
lunar_month = abs(lunar.getMonth())
859886
lunar_day = lunar.getDay()
860887

861888
# 1. 命宫 / 身宫 / 宫干.
862-
mg_branch = calc_ming_gong(lunar_month, args.hour)
863-
sg_branch = calc_shen_gong(lunar_month, args.hour)
889+
mg_branch = calc_ming_gong(lunar_month, birth_hour)
890+
sg_branch = calc_shen_gong(lunar_month, birth_hour)
864891
mg_stem = stem_of_palace(year_stem, mg_branch)
865892

866893
# 2. 五行局.
@@ -871,14 +898,14 @@ def main(argv: Optional[list[str]] = None) -> int:
871898
main_pos = place_main_stars(zw_branch)
872899

873900
# 4. 六吉 + 六煞 + 杂曜.
874-
lucky_pos = place_lucky_stars(year_stem, lunar_month, args.hour)
875-
malefic_pos = place_malefic_stars(year_stem, year_branch, args.hour)
901+
lucky_pos = place_lucky_stars(year_stem, lunar_month, birth_hour)
902+
malefic_pos = place_malefic_stars(year_stem, year_branch, birth_hour)
876903
misc_pos = place_miscellaneous_stars(year_branch)
877904

878905
# 5. 命主 / 身主 / 斗君.
879906
ming_zhu = MING_ZHU.get(year_branch, "?")
880907
shen_zhu = SHEN_ZHU.get(year_branch, "?")
881-
dou_jun_branch = calc_dou_jun(lunar_month, args.hour)
908+
dou_jun_branch = calc_dou_jun(lunar_month, birth_hour)
882909

883910
# 6. 反向索引 — branch -> list of stars by category.
884911
branch_to_main: dict[str, list[str]] = {b: [] for b in DIZHI}

scripts/zodiac_compat.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ def compat(a: str, b: str) -> dict:
176176
score += 4
177177
in_sanhe = False
178178
for group in SAN_HE_GROUPS:
179-
if da in group and db in group:
179+
# 三合需两个 *不同* 地支同组; 同生肖(da==db)是比和/自刑, 非三合。
180+
if da != db and da in group and db in group:
180181
relations.append("三合")
181182
score += 3
182183
in_sanhe = True

tests/test_engines.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,10 @@ def test_meihua_structure():
6666
d = run("meihua_cast.py", "numbers", "--upper", "3", "--lower", "5", "--question", "x")
6767
has(d, "main_hex", "changed_hex", "nuclear_hex", "ti_yong")
6868
assert 1 <= d["main_hex"]["number"] <= 64
69-
# body/use relation must be one of the 5 五行 relations
70-
assert d["ti_yong"]["relation"]
69+
# body/use relation must be one of the 5 五行 relations (not just truthy).
70+
rel = d["ti_yong"]["relation"]
71+
assert any(rel.startswith(r) for r in
72+
("体生用", "用生体", "体克用", "用克体", "比和")), rel
7173

7274

7375
# --------------------------------------------------------------------------- #
@@ -94,6 +96,26 @@ def test_xiaoliuren_structure():
9496
assert d["result"]["tone"] in {"吉", "凶", "中"}
9597

9698

99+
# --------------------------------------------------------------------------- #
100+
# 塔罗 — REGRESSION: the curated 78-card asset must actually load (the dict-vs-
101+
# list check used to discard it, silently degrading to placeholder text).
102+
# --------------------------------------------------------------------------- #
103+
104+
def test_tarot_loads_real_asset_not_placeholder():
105+
d = run("tarot_draw.py", "three", "--seed", "42", "--question", "x")
106+
assert len(d["cards"]) == 3
107+
for c in d["cards"]:
108+
assert c["card_name_zh"]
109+
assert c["meaning_brief"]
110+
# The degraded fallback emits "...第N阶: 见详细解读"; real asset never does.
111+
assert "见详细解读" not in c["meaning_brief"]
112+
113+
def test_tarot_seed_reproducible():
114+
a = run("tarot_draw.py", "celtic", "--seed", "7", "--question", "x")
115+
b = run("tarot_draw.py", "celtic", "--seed", "7", "--question", "x")
116+
assert [c["card_name_zh"] for c in a["cards"]] == [c["card_name_zh"] for c in b["cards"]]
117+
118+
97119
# --------------------------------------------------------------------------- #
98120
# 生肖合婚 — deterministic; 虎申 must be 相冲 (score low)
99121
# --------------------------------------------------------------------------- #
@@ -109,6 +131,11 @@ def test_zodiac_compat_sanhe_high():
109131
d = run("zodiac_compat.py", "compat", "--a", "虎", "--b", "马")
110132
assert d["score"] >= 6
111133

134+
def test_zodiac_same_sign_not_sanhe():
135+
# 同生肖(鼠-鼠)是比和/自刑, 不能误判为三合 (子∈申子辰).
136+
d = run("zodiac_compat.py", "compat", "--a", "鼠", "--b", "鼠")
137+
assert "三合" not in d["relations"]
138+
112139

113140
# --------------------------------------------------------------------------- #
114141
# 奇门遁甲 — deterministic by date/time
@@ -121,6 +148,8 @@ def test_qimen_structure():
121148
has(d, "ju_type", "ju_number", "ganzhi")
122149
assert d["ju_type"] in {"阳遁", "阴遁"}
123150
assert 1 <= d["ju_number"] <= 9
151+
# Value golden — 芒种前 阳遁, this date/time resolves to 阳遁 8局.
152+
assert (d["ju_type"], d["ju_number"]) == ("阳遁", 8)
124153

125154

126155
# --------------------------------------------------------------------------- #
@@ -132,6 +161,8 @@ def test_liuren_structure():
132161
d = run("liuren_cast.py", "--date", "2026-06-01", "--time", "14:30", "--question", "x")
133162
assert d["ok"] is True
134163
has(d, "ri_gan", "ri_zhi", "month_zhi")
164+
# Value golden — 2026-06-01 is 丙午 day, 占时 within 巳 month-general window.
165+
assert (d["ri_gan"], d["ri_zhi"]) == ("丙", "午")
135166

136167

137168
# --------------------------------------------------------------------------- #
@@ -146,6 +177,35 @@ def test_huangli_structure():
146177
assert isinstance(d["ji"], list)
147178

148179

180+
@needs_lunar
181+
def test_huangli_jishi_uses_huangdao_not_all12():
182+
"""REGRESSION: 吉时/凶时 must come from 时辰黄黑道 (吉/凶), not "has any 宜".
183+
The old logic marked all 12 时辰 吉 and 0 凶."""
184+
d = run("huangli_query.py", "--date", "2026-06-01")
185+
ji, xiong = d["ji_shi"], d["xiong_shi"]
186+
assert len(ji) + len(xiong) == 12 # every 时辰 classified
187+
assert 0 < len(ji) < 12 # not all-吉 (the bug)
188+
assert len(xiong) > 0 # 凶时 exist
189+
assert all(s.get("luck") == "吉" for s in ji)
190+
assert all(s.get("huang_hei_dao") == "黄道" for s in ji)
191+
192+
193+
@needs_lunar
194+
def test_ziwei_value_golden_and_longitude_optin():
195+
"""ziwei standard chart value golden + longitude correction is opt-in
196+
(default longitude must NOT shift the 时辰-granular chart)."""
197+
d = run("ziwei_calc.py", "--year", "1995", "--month", "7", "--day", "20",
198+
"--hour", "1", "--gender", "female", "--lunar")
199+
assert (d["ming_gong"]["branch"], d["shen_gong"]["branch"],
200+
d["wuxing_ju"]["name"]) == ("未", "酉", "木三局")
201+
# Explicit far-west longitude near midnight DOES correct (different 命宫).
202+
near = run("ziwei_calc.py", "--year", "2000", "--month", "6", "--day", "15",
203+
"--hour", "23", "--minute", "30", "--gender", "male")
204+
west = run("ziwei_calc.py", "--year", "2000", "--month", "6", "--day", "15",
205+
"--hour", "23", "--minute", "30", "--longitude", "75", "--gender", "male")
206+
assert near["ming_gong"]["branch"] != west["ming_gong"]["branch"]
207+
208+
149209
# --------------------------------------------------------------------------- #
150210
# 紫微斗数 — structure of the hand-rolled engine
151211
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)