From b5eb72343a7ae930b5ffd225f0c96fbe2da8495c Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sun, 18 Oct 2026 16:20:35 +0800 Subject: [PATCH] fix(kp): place exact angular boundaries on the opening subdivision Upstream 8eec9a54. Floating accumulation and a 120-degree remainder put star, pada, and sub-lord on different sides of the same boundary. Random longitudes away from those boundaries are unchanged. BUG-1383. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 15 +++++ scripts/kp_system.py | 67 ++++++++++++----------- tests/test_kp_exact_angular_boundaries.py | 42 ++++++++++++++ 3 files changed, 91 insertions(+), 33 deletions(-) create mode 100644 tests/test_kp_exact_angular_boundaries.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7f5473f4..433b432e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36533,3 +36533,18 @@ - 相关记录:BUG-1268、BUG-1380。 - 复发自:无。 - 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `3c2dad95`。 + +## BUG-1383 | KP 子分区在精确边界上落到不同半开区间 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-17 / 2026-10-18 +- 来源:issue #80,任务书 T7。上游 `8eec9a54`。检索「get_kp_lords」「sub_sub」,没有更早的同类记录。 +- 现象:星宿、四分和子主星在精确边界上各算各的。按角秒扫一圈,约 129.6 万个点里有 499 处不一致,都在精确边界上。 +- 触发条件:`get_kp_lords` 用浮点累加子区间,再和 120° 取余混用。 +- 根因:区间边界被重复舍入,共享边界没有固定归给新开的那一段。 +- 修复:子区间起点按分数算好一次,再用二分查找。非法输入(布尔、空值、字符串、非有限数)抛出 `ValueError`。调用点已经先做 `float()`,或同时捕获 `TypeError` 和 `ValueError`,没有只依赖旧 `TypeError` 的分支。 +- 验证:`tests/test_kp_exact_angular_boundaries.py`。20 万个随机经度新旧结果 0 处不同。 +- 防复发:上述边界测试。 +- 相关记录:BUG-1381。 +- 复发自:无。 +- 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `8eec9a54`。 diff --git a/scripts/kp_system.py b/scripts/kp_system.py index 661a9dce..3c27e0d9 100644 --- a/scripts/kp_system.py +++ b/scripts/kp_system.py @@ -11,7 +11,10 @@ KP (Krishnamurti Paddhati) 占星系统模块 """ import json +import math +from bisect import bisect_right from datetime import datetime, timedelta +from fractions import Fraction from pathlib import Path from typing import Any, Dict, List, Tuple, Optional @@ -44,6 +47,29 @@ VIMSHOTTARI_YEARS = dict(zip(KP_LORDS, VIMSHOTTARI_DURATION)) NAKSHATRA_SPAN = 360.0 / 27.0 # 13.333... degrees + +def _angular_partition_starts(): + """Exact proportional starts, rounded once at the float API boundary.""" + starts, owners = [], [] + for star in range(27): + sub_start = Fraction(40 * star, 3) + for offset in range(9): + sub = (star + offset) % 9 + sub_span = Fraction(40, 3) * VIMSHOTTARI_DURATION[sub] / 120 + child_start = sub_start + for child_offset in range(9): + child = (sub + child_offset) % 9 + starts.append(float(child_start)) + owners.append((sub, child)) + child_start += sub_span * VIMSHOTTARI_DURATION[child] / 120 + sub_start += sub_span + return starts, owners + + +_KP_STARTS, _KP_OWNERS = _angular_partition_starts() +_STAR_STARTS = [float(Fraction(40 * i, 3)) for i in range(27)] +_PADA_STARTS = [float(Fraction(10 * i, 3)) for i in range(108)] + ROOT = Path(__file__).resolve().parents[1] @@ -228,50 +254,25 @@ def get_kp_lords(degree: float) -> Dict: Returns: KP lords字典 """ + if isinstance(degree, bool) or not isinstance(degree, (int, float)) or not math.isfinite(degree): + raise ValueError('KP longitude must be a finite number') deg = degree % 360 # 1. Sign lord sign_index = int(deg // 30) - # 2. Nakshatra - nakshatra_index = int(deg // NAKSHATRA_SPAN) % 27 - nakshatra_deg = deg % NAKSHATRA_SPAN - pada = int(nakshatra_deg // (NAKSHATRA_SPAN / 4)) + 1 - - # 3. Sublord & SubSubLord(KP核心算法) - # 将Vimshottari 120年周期按比例投影到度数上 - deg_remainder = deg - 120 * int(deg / 120) - deg_cumulative = 0.0 - - for i in range(9): - deg_nl = NAKSHATRA_SPAN # 13.333... degrees per nakshatra - for j in range(i, i + 9): - j_mod = j % 9 - deg_sl = deg_nl * VIMSHOTTARI_DURATION[j_mod] / 120.0 - for k in range(j_mod, j_mod + 9): - k_mod = k % 9 - deg_ss = deg_sl * VIMSHOTTARI_DURATION[k_mod] / 120.0 - deg_cumulative += deg_ss - if deg_cumulative >= deg_remainder: - return { - 'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''), - 'sign': SIGNS[sign_index], - 'nakshatra': NAKSHATRAS[nakshatra_index], - 'nakshatra_lord': STAR_LORDS[nakshatra_index], - 'pada': pada, - 'sub_lord': KP_LORDS[j_mod], - 'sub_sub_lord': KP_LORDS[k_mod], - } - - # Fallback + # 2. Nakshatra. A shared boundary belongs to the subdivision that opens there. + nakshatra_index = bisect_right(_STAR_STARTS, deg) - 1 + pada = (bisect_right(_PADA_STARTS, deg) - 1) % 4 + 1 + sub, child = _KP_OWNERS[bisect_right(_KP_STARTS, deg) - 1] return { 'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''), 'sign': SIGNS[sign_index], 'nakshatra': NAKSHATRAS[nakshatra_index], 'nakshatra_lord': STAR_LORDS[nakshatra_index], 'pada': pada, - 'sub_lord': 'Unknown', - 'sub_sub_lord': 'Unknown', + 'sub_lord': KP_LORDS[sub], + 'sub_sub_lord': KP_LORDS[child], } diff --git a/tests/test_kp_exact_angular_boundaries.py b/tests/test_kp_exact_angular_boundaries.py new file mode 100644 index 00000000..a5c5d328 --- /dev/null +++ b/tests/test_kp_exact_angular_boundaries.py @@ -0,0 +1,42 @@ +"""Synthetic angular fixtures; no birth records or predictive assertions.""" +from fractions import Fraction +import math +import pytest +from scripts.kp_system import get_kp_lords + +ORDER='Ketu Venus Sun Moon Mars Rahu Jupiter Saturn Mercury'.split() +YEARS=(7,20,6,10,7,18,16,19,17) + + +def test_every_subsub_boundary_is_left_closed_right_open(): + starts=[] + for star in range(27): + start=Fraction(star*40,3) + for offset in range(9): + sub=(star+offset)%9;span=Fraction(40*YEARS[sub],360) + for child_offset in range(9): + child=(sub+child_offset)%9 + starts.append((float(start),ORDER[sub],ORDER[child])) + start+=span*YEARS[child]/120 + for i,(angle,sub,child) in enumerate(starts): + for probe in (angle,math.nextafter(angle,math.inf)): + actual=get_kp_lords(probe) + assert (actual['sub_lord'],actual['sub_sub_lord'])==(sub,child) + if i: + before=get_kp_lords(math.nextafter(angle,-math.inf)) + assert (before['sub_lord'],before['sub_sub_lord'])==starts[i-1][1:] + + +def test_quarter_boundaries_and_wrap(): + for i in range(108): + degree=float(Fraction(10*i,3)) + assert get_kp_lords(degree)['pada']==i%4+1 + if i:assert get_kp_lords(math.nextafter(degree,-math.inf))['pada']==(i-1)%4+1 + assert get_kp_lords(70)['pada']==2 + assert get_kp_lords(360)==get_kp_lords(0) + assert get_kp_lords(-1)==get_kp_lords(359) + + +@pytest.mark.parametrize('invalid',[True,None,'70',float('nan'),float('inf'),float('-inf')]) +def test_nonfinite_or_nonnumeric_longitude_rejected(invalid): + with pytest.raises(ValueError):get_kp_lords(invalid)