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 <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-18 16:20:35 +08:00
co-authored by Grok (agent loop) Cursor
parent 5be864a8e4
commit b5eb72343a
3 changed files with 91 additions and 33 deletions
+15
View File
@@ -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`。
+34 -33
View File
@@ -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],
}
+42
View File
@@ -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)