merge origin/staging into rect scoring perf (issue #57)
Independent Staging Quality Gate / validate (push) Successful in 15m24s
Independent Staging Quality Gate / publish (push) Successful in 3m39s

Docs-only conflicts (CHANGELOG, BUG_HISTORY 1318-1322 vs 1323-1325/1345)
resolved by keeping both; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
Claude
2026-10-14 02:16:09 +08:00
15 changed files with 536 additions and 16 deletions
+7
View File
@@ -4,6 +4,13 @@
- 校正每轮重算更快(2 小时窗口约快一倍以上),结果不变。一次要算的分钟数超过 241 会直接拒绝,不再占着计算空位排盘。Skill 版本不 bump(BUG-1323~1325、BUG-1345)。
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
- 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。
- KP 按宫头到下一宫头判宫,报告写明口径。本命宫位表的判宫不变。
- 接口里兜底大运的一年改成和推运脚本一样的 365.25 天。
- 星历路径只核查,没改。Skill 版本不变(BUG-1318~1321;BUG-1322 仍在核查)。
## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging)
- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。
+75
View File
@@ -17720,6 +17720,81 @@
- 复发自:BUG-1306。
- 修复版本:分支 `codex/rectification-step0-entry-20261013`,尚未合入 staging。
## BUG-1318 | 婚恋评级仍写成稳定或婚前咨询,配偶星分数被写成百分制
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `f891a2c1`。任务书 `docs/tasks/TASK-upstream-sync7-20261014.md` T1。
- 现象:D9 综合评级仍写「婚姻稳定」「强烈建议婚前咨询」。这份文字进全读导出包,模型能读到。配偶星证据行把 0 到 1 的指数写成「婚姻质量 x/100」。
- 触发条件:算出婚恋几何指数,或关系时机证据带出配偶星。
- 根因:同步第六轮按标题筛提交时,漏了 10-02 到 10-03 的这条措辞修正。分数行把 0 到 1 当成了百分制。
- 修复:五档改成证据档位,并写明这是结构指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。中文报告用中文档位,英文报告用英文档位。证据行改成「婚姻质量指数 x(0到1)」,不乘 100。
- 验证:`tests/test_d9_evidence_bands.py` 3 通过。公开盘中英文各一条快照。配偶星行不再出现 `/100`。
- 防复发:五档文字不得含「稳定、离婚、再婚、婚前咨询、几段」;快照锁住公开盘的两句。
- 相关记录:BUG-1183。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1319 | KP 落宫沿用本命中点判宫,传了宫头也不声明口径
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `0337b77f`。任务书 T2。
- 现象:KP 用的行星落宫和本命 Bhava Chalit 表一样,按相邻宫头的中点判宫。宫头传进 KP 分析时,结果不写明判宫口径。
- 触发条件:打开 KP。本命 Bhava Chalit 表不走这条。
- 根因:同步第六轮漏了夹在上游隐私提交里的判宫修正。本命表和 KP 共用了中点函数。
- 修复:KP 改成宫头到下一宫头。本命表默认仍是中点,宫位数字不变。传了宫头却不声明口径就拒绝;没传宫头记整宫代理。KP 报告写出判宫口径。
- 验证:`tests/test_kp_cusp_house_assignment.py` 3 通过。同一颗星在两种口径下落入不同宫;默认中点与改前一致。
- 防复发:未知口径直接报错;报告正文必须出现 House occupancy basis。
- 相关记录:BUG-1318。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1320 | 瑜伽角宫主和三方主的顺序随进程哈希变化
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `0337b77f`。任务书 T3。
- 现象:同一张盘的角宫主、三方主列表用了集合去重。集合顺序会跟着进程哈希变,瑜伽参与者顺序不稳定。
- 触发条件:规则表达式读取这两份宫主列表。
- 根因:去重用了不保证顺序的集合。同步第六轮漏了上游把它们改成排序的那一处。
- 修复:两处都改成先去重再按名字排序。
- 验证:`tests/test_yoga_lord_order_stable.py` 1 通过。哈希种子 0 和 1 两个进程算出的参与者顺序相同。
- 防复发:两个子进程的名单必须逐项相同,并且等于排好序的名单。
- 相关记录:BUG-1319。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1321 | 接口里的大运年长和推运脚本差了一点,起止会对不齐
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `4d543209`。任务书 T4。
- 现象:接口的兜底大运和「进入本运几年」用 365.25636 天为一年。同一条时间线的推运脚本用 365.25 天。年限一长,日期会差一天。
- 触发条件:走兜底大运时间线,或计算进入当前大运的年数。
- 根因:两处常量没有跟着推运脚本改。同步第六轮漏了上游 10-03 的这一处。
- 修复:这两处改成 365.25,与推运脚本相同。其它大运系统的年长不动。
- 验证:`tests/test_dasha_year_length_alignment.py` 2 通过。同一大运起止相差 0 天。158 年的旧常量会落到下一天,新常量不会。
- 防复发:兜底时间线必须和推运脚本的起止是同一天;进入本运的年数按 365.25 除。
- 相关记录:BUG-1320。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
## BUG-1322 | 星历路径被设成空字符串后,计算退回低精度星历
- 状态:investigating
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
- 来源:issue #56。上游 `e1909724` 点出的同类问题。任务书 T5,本轮只核查、不改代码。
- 现象:排盘和过境入口都把星历路径设成空字符串。本机随后计算太阳位置时,返回标记含低精度星历位,不含星历文件位。
- 触发条件:调用排盘或过境计算。本轮没有改这条路径。
- 根因:尚未在生产镜像里核实。本机仓库里的星历文件只在参考资料目录,不在计算进程会去找的路径上。空路径是进程全局的,后一次调用会盖掉前面设过的路径。
- 修复:未改。建议另开一单:镜像里放一份星历文件,路径显式设上,并断言返回标记不再是低精度星历。
- 验证:本机一次探针。生产镜像未打开,不能当作已确认。
- 防复发:未做。等修复单再加断言。
- 相关记录:BUG-1321。
- 复发自:无。
- 修复版本:未修复。
## BUG-1323 | 校正引导窗口按候选两两比较,窗口一长就平方变慢
- 状态:fixed-pending-verify(尚未合入 staging)
@@ -0,0 +1,41 @@
# PROGRESS · 上游同步第七轮(2026-10-14)
- 分支:`codex/upstream-sync7-20261014`
- 基线:任务书写的是 `77057a81`。开工时 `origin/staging` 已到 `99ef1bbd`,本分支从这里建。
- 结论:T1 到 T4 已做完并推这个分支。T5 只核查,代码没改。星历在这台电脑上会退回低精度,生产镜像没打开,记 BUG-1322,状态是核查中。
## 做了什么
| 项 | 结果 |
| --- | --- |
| T1 婚恋断语 | 五档改成证据档位,中文给中文报告,英文给英文报告。写明不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。配偶星那一行改成 0 到 1 的指数,不再写 `/100`。BUG-1318,上游 `f891a2c1`。 |
| T2 KP 判宫 | KP 按宫头到下一宫头。本命宫位表仍按中点,默认宫位不变。传了宫头却不说口径就拒绝;没传记整宫代理。报告多一行判宫口径。BUG-1319,上游 `0337b77f`。 |
| T3 瑜伽排序 | 角宫主、三方主先去重再按名字排序。BUG-1320,上游 `0337b77f`。 |
| T4 大运年长 | 接口里两处 365.25636 改成 365.25,和推运脚本同一天。别的大运系统没动。BUG-1321,上游 `4d543209`。 |
| T5 星历 | 见下。不改代码。BUG-1322,核查中。 |
## 检查
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 婚恋档位 | `pytest tests/test_d9_evidence_bands.py` | 3 通过 |
| KP 判宫 | `pytest tests/test_kp_cusp_house_assignment.py` | 3 通过 |
| 本命宫位表 | `pytest tests/test_bhava_chalit.py` | 通过(与上面一批共 20 通过、0 失败) |
| 几何指数旧测试 | `pytest tests/test_marriage_counting_geometry_only.py` | 通过(同上) |
| 瑜伽顺序 | `pytest tests/test_yoga_lord_order_stable.py` | 1 通过 |
| 大运年长 | `pytest tests/test_dasha_year_length_alignment.py` | 2 通过 |
| 接口增长合同 | `pytest tests/test_api_server_growth_contract.py` | 通过 |
| KP 报告标题回归 | `pytest tests/test_report_longform_parity.py` 里 KP 那一条 | 通过 |
| 快速门、前端全量 | 未跑 | 按执行分工,留给验收方 |
| 数据库 | 未跑 | 没动表 |
## T5 星历核查
本机把路径设成空字符串后,算太阳位置的返回标记带低精度星历,不带星历文件。仓库里的星历文件在参考资料目录,计算时用不到。排盘和过境两处都设成同一个空路径,所以这两处彼此一致,但都会退回低精度。路径是整个进程共用的,谁后设谁生效。
生产镜像这台电脑打不开,不能说线上也是这样。健康检查只看星历库能不能导入,看不出用的是哪一套。建议另开一单再改。
## 偏离
- 关系时机里还有一处用 65 和 45 去比这个 0 到 1 的指数。任务书只允许改配偶星那一行文字,所以没动。它现在加不上那一档分数。建议另开小单。
- 生时校正打分文件没有改。
+1 -1
View File
@@ -331,7 +331,7 @@
| `TASK-rectification-probe-supply-research-20260913.md` | — | 研究单:六题后引擎在剩余候选上再出带年月题的四种放宽规则,20 例公开 AA 数据离线量收益,有收益才立实现单 | 已合入(离线测量,不改线上) | `b063c668` |
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
| `TASK-upstream-sync7-20261014.md` | — | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待执行 | — |
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
+23 -2
View File
@@ -272,6 +272,20 @@ class BhavaChalitCalculator:
# fallback: 最近的宫
return 1
def _planet_bhava_from_cusps(self, planet_lon: float, cusps: List[float]) -> int:
"""KP 宫头到宫头:宫 i(0 起)的范围是 cusp[i] → cusp[(i+1)%12]。
落在下一宫头上的点归下一宫。本命 Bhava Chalit 表不用这条。
"""
for i in range(12):
start = cusps[i]
end = cusps[(i + 1) % 12]
arc = _angular_dist(start, end)
pos = _angular_dist(start, planet_lon)
if arc > 0 and pos < arc:
return i + 1
return 1
def _planet_rashi_house(self, planet_lon: float, asc_lon: float) -> int:
"""整宫制宫位 (Rashi house)。"""
p_si = _sign_idx(planet_lon)
@@ -330,7 +344,8 @@ class BhavaChalitCalculator:
asc_lon: float, mc_lon: float,
house_system: str = 'sripati',
jd: float = None, lat: float = None,
lon: float = None) -> Dict:
lon: float = None,
house_assignment: str = 'sandhi') -> Dict:
"""根据 Bhava 边界重新分配行星宫位。
Parameters
@@ -346,13 +361,18 @@ class BhavaChalitCalculator:
dict with keys:
house_system, boundaries, planets
"""
if house_assignment not in ('sandhi', 'cusp_to_cusp'):
raise ValueError("house_assignment must be 'sandhi' or 'cusp_to_cusp'")
cusps = self.calculate_cusps(asc_lon, mc_lon, house_system,
jd, lat, lon)
sandhis = self.calculate_sandhis(cusps)
planets = {}
for pname, plon in planet_lons.items():
bhava = self._planet_bhava(plon, sandhis)
if house_assignment == 'cusp_to_cusp':
bhava = self._planet_bhava_from_cusps(plon, cusps)
else:
bhava = self._planet_bhava(plon, sandhis)
rashi = self._planet_rashi_house(plon, asc_lon)
si = _sign_idx(plon)
deg_in_sign = plon - si * 30
@@ -380,6 +400,7 @@ class BhavaChalitCalculator:
return {
'house_system': house_system,
'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''),
'house_assignment': house_assignment,
'ascendant_lon': round(asc_lon, 4),
'mc_lon': round(mc_lon, 4),
'planets': planets,
+3 -3
View File
@@ -6378,7 +6378,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
periods = []
current = birth_dt
for lord in DASHA_ORDER:
end_date = current + timedelta(days=period_years * 365.25636)
end_date = current + timedelta(days=period_years * 365.25)
periods.append({
'lord': lord,
'years': period_years,
@@ -7398,7 +7398,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
current_idx, current_md = analyzer.find_current(timeline, today)
antardashas = analyzer.build_antardasha(current_md)
current_ad = analyzer.find_current_sub(antardashas, today)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25636)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25)
five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md)
formatted_timeline = [self._format_datetime_period(period) for period in timeline]
formatted_antardashas = [self._format_datetime_period(period) for period in antardashas]
@@ -7551,7 +7551,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
evidence.append({
'label': 'Darakaraka (DK)',
'value': f"{dk_planet} · H{darakaraka.get('dk_house', '-')}",
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量 {darakaraka.get('marriage_quality_score', '-')}/100",
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量指数 {darakaraka.get('marriage_quality_score', '-')}(0到1)",
})
except Exception as exc:
darakaraka = {'error': str(exc), 'dk_planet': dk_planet}
+7 -1
View File
@@ -13963,6 +13963,7 @@ def cmd_kp(args):
)
bhava_chart = kp_house_calculator.get_bhava_chalit_chart(
planet_lons, float(asc_lon), 0.0, 'placidus', jd, args.lat, args.lon,
house_assignment='cusp_to_cusp',
)
bhava_planets = bhava_chart.get('planets') if isinstance(bhava_chart.get('planets'), dict) else {}
@@ -13977,7 +13978,12 @@ def cmd_kp(args):
'house': bhava_row.get('bhava_house', pdata.get('house', 1)),
}
result = calc_kp_analysis(planet_positions, asc_sign, house_cusps=boundaries.get('cusps'))
result = calc_kp_analysis(
planet_positions,
asc_sign,
house_cusps=boundaries.get('cusps'),
house_occupancy_basis='cusp_to_cusp',
)
moon_lon = planets.get('Moon', {}).get('degree_raw', planets.get('Moon', {}).get('degree', 0.0)) if isinstance(planets.get('Moon'), dict) else 0.0
try:
birth_moment = datetime(int(args.year), int(args.month), int(args.day), int(args.hour), int(getattr(args, 'minute', 0) or 0))
+7
View File
@@ -371,6 +371,7 @@ def calc_kp_analysis(
planet_positions: Dict,
asc_sign: str = 'Aries',
house_cusps: Optional[List[float]] = None,
house_occupancy_basis: Optional[str] = None,
) -> Dict:
"""
完整KP分析(基于 diliprk/VedicAstro MIT 算法)。
@@ -379,6 +380,7 @@ def calc_kp_analysis(
planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}}
asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理)
house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。
house_occupancy_basis: 传了宫头就必须声明判宫口径;没传宫头时记 whole_sign_proxy。
Returns:
完整KP分析结果
@@ -405,8 +407,11 @@ def calc_kp_analysis(
}
explicit_cusps = house_cusps is not None
if explicit_cusps and not house_occupancy_basis:
raise ValueError('house_occupancy_basis is required when house_cusps are provided')
if explicit_cusps and len(house_cusps) != 12:
raise ValueError('house_cusps must contain exactly 12 longitudes')
occupancy_basis = house_occupancy_basis if explicit_cusps else 'whole_sign_proxy'
# 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。
houses = []
@@ -438,6 +443,7 @@ def calc_kp_analysis(
'source': 'diliprk/VedicAstro MIT License',
'maturity_profile': kp_maturity_profile(),
'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy',
'house_occupancy_basis': occupancy_basis,
'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})}
for pname, data in kp_planets.items()},
'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
@@ -494,6 +500,7 @@ def build_kp_report_pack(
f"- claim_status: `{maturity.get('claim_status')}`",
f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`",
f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`",
f"- House occupancy basis: `{kp_analysis.get('house_occupancy_basis') or 'whole_sign_proxy'}`",
f"- planet_count: `{len(planets)}`",
f"- house_count: `{len(houses)}`",
f"- dba_period_count: `{len(periods)}`",
+41 -7
View File
@@ -21,6 +21,23 @@ from typing import Dict, Optional, List
SIGN_CN = ['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座',
'天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座']
# D9 结构证据档位(BUG-1318)。中文给报告正文,英文键与英文短句给英文版。
# 不写婚姻稳定、离婚、再婚、段数或婚前咨询。
D9_CLAIM_BOUNDARY = (
'D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询'
)
D9_CLAIM_BOUNDARY_EN = (
'D9 structural evidence index. It does not predict marriage count, stability, '
'divorce, or partner quality, and it does not suggest premarital counseling.'
)
D9_EVIDENCE_BANDS = (
('supportive_evidence_stronger', '支持证据较强', 'Stronger supportive evidence'),
('supportive_evidence_mixed_positive', '支持证据偏正', 'Mixed supportive evidence'),
('mixed_evidence', '证据混杂', 'Mixed evidence'),
('pressure_evidence_mixed', '压力证据偏杂', 'Mixed pressure evidence'),
('pressure_evidence_stronger', '压力证据较强', 'Stronger pressure evidence'),
)
def sign_of(lon: float) -> int:
"""从黄道经度计算星座序号 (0-11)"""
@@ -348,25 +365,42 @@ def _assess_d9_marriage_quality(
quality_points -= 1
factors.append("D9 Ketu在7宫 → 关系中的分离倾向")
# 综合评级
# 证据档位,不是婚姻结局(BUG-1318,上游 f891a2c1)。
if quality_points >= 4:
rating = "高(关系质量好,伴侣支持强,婚姻稳定)"
band = D9_EVIDENCE_BANDS[0]
elif quality_points >= 1:
rating = "中上(关系质量尚可,需经营但基础好)"
band = D9_EVIDENCE_BANDS[1]
elif quality_points >= 0:
rating = "中(关系质量一般,需用心经营)"
band = D9_EVIDENCE_BANDS[2]
elif quality_points >= -2:
rating = "中下(关系有挑战,需提前沟通和调整期望)"
band = D9_EVIDENCE_BANDS[3]
else:
rating = "低(关系质量有重大挑战,强烈建议婚前咨询)"
band = D9_EVIDENCE_BANDS[4]
return {
'quality_rating': rating,
'quality_band': band[0],
'quality_rating': band[1],
'quality_rating_en': band[2],
'quality_points': quality_points,
'factors': factors,
'claim_boundary': D9_CLAIM_BOUNDARY,
'claim_boundary_en': D9_CLAIM_BOUNDARY_EN,
}
def render_d9_evidence_report(quality: Dict, language: str = 'zh') -> str:
"""中文报告用证据档位短句;英文版用英文短句。两边都带同一条边界。"""
if not isinstance(quality, dict):
quality = {}
if language == 'en':
band = quality.get('quality_rating_en') or quality.get('quality_band') or ''
boundary = quality.get('claim_boundary_en') or D9_CLAIM_BOUNDARY_EN
return f"D9 evidence band: {band}\n{boundary}"
band = quality.get('quality_rating') or ''
boundary = quality.get('claim_boundary') or D9_CLAIM_BOUNDARY
return f"D9 证据档位:{band}\n{boundary}"
def _marriage_recommendations(base: Dict, d9_quality: Dict) -> List[str]:
"""几何指数与伴侣数量、事件断语分开(BUG-1183);不随指数大小给不同结论。"""
return [
+1
View File
@@ -2140,6 +2140,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'### p123 KP Chart / Cusps',
'',
f"- House basis:{_cell(kp.get('house_basis'))}",
f"- House occupancy basis:{_cell(kp.get('house_occupancy_basis'))}",
'',
'| House | Sign | Cusp longitude | KP lord chain |',
'|-------|------|----------------|---------------|',
+4 -2
View File
@@ -1687,11 +1687,13 @@ class YogaEngine:
# 辅助:获取角宫主星列表
def _kendra_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
# set 顺序随进程哈希变化;排序后同一张盘的角宫主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
# 辅助:获取三方宫主星列表
def _trikona_lords_list():
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
# set 顺序随进程哈希变化;排序后同一张盘的三方主参与者顺序才稳定。
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
safe_globals = {
# 安全内置函数:只开放规则表达式需要的纯函数,避免 custom 规则因 all/any/len/set 缺失而静默失效。
+134
View File
@@ -0,0 +1,134 @@
"""BUG-1318: D9 marriage rating is an evidence band, not an outcome claim.
Upstream f891a2c1. The five bands must not say the relationship is stable,
ending, repeated, or in need of premarital counseling. The DK evidence line
keeps the 0-1 index and does not print a fake /100 score.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
ROOT = os.path.join(os.path.dirname(__file__), "..")
SCRIPTS = os.path.join(ROOT, "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from jyotish_engine import compute_chart_data # noqa: E402
def _api_handler():
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
spec = importlib.util.spec_from_file_location("_api_security_helpers", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module._handler()
from marriage_counting import ( # noqa: E402
D9_CLAIM_BOUNDARY,
D9_EVIDENCE_BANDS,
_assess_d9_marriage_quality,
marriage_counting_full_analysis,
render_d9_evidence_report,
)
from varga import calc_varga # noqa: E402
BANNED = re.compile(r"稳定|离婚|再婚|婚前咨询|几段")
def _quality(points_chart: dict) -> dict:
return _assess_d9_marriage_quality(points_chart, None)
def test_five_bands_have_no_outcome_claims_and_keep_the_boundary() -> None:
samples = {
"supportive_evidence_stronger": {"Venus": 331.0, "Mars": 271.0, "Jupiter": 91.0},
"supportive_evidence_mixed_positive": {"Venus": 31.0},
"mixed_evidence": {"Venus": 10.0},
"pressure_evidence_mixed": {"Venus": 151.0},
"pressure_evidence_stronger": {"Venus": 151.0, "Mars": 91.0, "Jupiter": 271.0},
}
seen = []
for key, chart in samples.items():
quality = _quality(chart)
assert quality["quality_band"] == key
assert quality["claim_boundary"] == D9_CLAIM_BOUNDARY
assert quality["claim_boundary_en"]
assert not BANNED.search(quality["quality_rating"])
assert not BANNED.search(quality["quality_rating_en"])
seen.append(quality["quality_band"])
assert seen == [band[0] for band in D9_EVIDENCE_BANDS]
def test_public_chart_report_snapshots_in_both_languages() -> None:
cases = json.loads(
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
)["cases"]
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
assert birth["source"]["rodden_rating"] == "AA"
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
chart, *_ = compute_chart_data(
year, month, day, hour, minute,
birth["latitude"], birth["longitude"], birth["timezone_offset"],
ayanamsa_name="raman",
)
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
d9 = {}
for name, lon in longitudes.items():
divisional = calc_varga(lon, 9)
d9[name] = divisional["sign_idx"] * 30 + divisional["degree_in_sign"]
result = marriage_counting_full_analysis(chart["houses"]["house_7"]["lord"], longitudes, d9)
quality = result["d9_marriage_quality"]
assert quality["quality_band"] == "mixed_evidence"
assert quality["quality_points"] == 0
chinese = render_d9_evidence_report(quality, "zh")
english = render_d9_evidence_report(quality, "en")
assert chinese == (
"D9 证据档位:证据混杂\n"
"D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询"
)
assert english == (
"D9 evidence band: Mixed evidence\n"
"D9 structural evidence index. It does not predict marriage count, stability, "
"divorce, or partner quality, and it does not suggest premarital counseling."
)
assert "/100" not in chinese
assert "/100" not in english
def test_dk_evidence_line_prints_the_zero_to_one_index() -> None:
cases = json.loads(
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
)["cases"]
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
year, month, day = (int(part) for part in birth["date"].split("-"))
hour, minute = (int(part) for part in birth["time"].split(":"))
chart, asc_idx, *_ = compute_chart_data(
year, month, day, hour, minute,
birth["latitude"], birth["longitude"], birth["timezone_offset"],
ayanamsa_name="raman",
)
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
handler = _api_handler()
evidence = handler._compute_relationship_timing_evidence(
{},
chart["planets"],
longitudes,
asc_idx,
chart["ascendant"]["sign"],
)
notes = [row["note"] for row in evidence["evidence"] if row.get("label") == "Darakaraka (DK)"]
assert len(notes) == 1
assert "/100" not in notes[0]
assert "婚姻质量指数" in notes[0]
assert "(0到1)" in notes[0]
score = evidence["darakaraka"]["marriage_quality_score"]
assert isinstance(score, float)
assert 0.0 <= score <= 1.0
assert str(score) in notes[0]
+62
View File
@@ -0,0 +1,62 @@
"""BUG-1321: the API fallback dasha year matches dasha_analyzer's 365.25 days.
Upstream 4d543209. The two 365.25636 constants in the API server made the
fallback timeline and the years-into-mahadasha figure drift from the analyzer.
"""
from __future__ import annotations
import importlib.util
import os
import sys
from datetime import datetime, timedelta
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra # noqa: E402
def _api_handler():
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
spec = importlib.util.spec_from_file_location("_api_security_helpers_dasha", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module._handler()
def test_fallback_timeline_matches_analyzer_to_the_day() -> None:
birth = datetime(2000, 1, 1)
nak_info, progress, _pada = lon_to_nakshatra(0.0)
timeline, _elapsed, _remaining, _lord = build_dasha_timeline("2000-01-01", nak_info, progress)
handler = _api_handler()
years = timeline[0]["years"]
periods = handler._fallback_dasha_periods(birth, "generic", {"years": years * 9})
assert periods[0]["lord"] == timeline[0]["lord"]
assert periods[0]["start"] == timeline[0]["start"].strftime("%Y-%m-%d")
assert periods[0]["end"] == timeline[0]["end"].strftime("%Y-%m-%d")
fallback_days = (
datetime.strptime(periods[0]["end"], "%Y-%m-%d")
- datetime.strptime(periods[0]["start"], "%Y-%m-%d")
).days
analyzer_days = (timeline[0]["end"].date() - timeline[0]["start"].date()).days
assert fallback_days - analyzer_days == 0
long_periods = handler._fallback_dasha_periods(birth, "generic", {"years": 1422})
expected = (birth + timedelta(days=158 * 365.25)).strftime("%Y-%m-%d")
stale = (birth + timedelta(days=158 * 365.25636)).strftime("%Y-%m-%d")
assert expected != stale
assert long_periods[0]["end"] == expected
def test_years_into_mahadasha_uses_the_analyzer_year() -> None:
birth = datetime(2000, 1, 1)
today = datetime(2003, 1, 1)
handler = _api_handler()
layer = handler._compute_vimshottari_analysis_layer(birth, 0.0, current_date="2003-01-01")
days = (today - birth).days
years_into = layer["five_levels"]["mahadasha"]["years_into_period"]
assert years_into == days / 365.25
assert years_into != days / 365.25636
+58
View File
@@ -0,0 +1,58 @@
"""BUG-1319: KP house occupancy is cusp-to-cusp and must be declared.
Upstream 0337b77f. Natal Bhava Chalit stays on the sandhi midpoint rule.
"""
from __future__ import annotations
import os
import sys
import pytest
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from bhava_chalit import BhavaChalitCalculator # noqa: E402
from kp_system import build_kp_report_pack, calc_kp_analysis # noqa: E402
def test_cusp_to_cusp_differs_from_sandhi_and_default_houses_stay() -> None:
calc = BhavaChalitCalculator()
planets = {"Sun": 40.0}
sandhi = calc.get_bhava_chalit_chart(planets, 15.0, 270.0, "equal")
explicit = calc.get_bhava_chalit_chart(
planets, 15.0, 270.0, "equal", house_assignment="sandhi",
)
cusp = calc.get_bhava_chalit_chart(
planets, 15.0, 270.0, "equal", house_assignment="cusp_to_cusp",
)
assert sandhi["house_assignment"] == "sandhi"
assert explicit["planets"]["Sun"]["bhava_house"] == sandhi["planets"]["Sun"]["bhava_house"] == 2
assert cusp["house_assignment"] == "cusp_to_cusp"
assert cusp["planets"]["Sun"]["bhava_house"] == 1
assert cusp["planets"]["Sun"]["bhava_house"] != sandhi["planets"]["Sun"]["bhava_house"]
def test_unknown_house_assignment_is_rejected() -> None:
calc = BhavaChalitCalculator()
with pytest.raises(ValueError, match="house_assignment"):
calc.get_bhava_chalit_chart({"Sun": 10.0}, 15.0, 270.0, "equal", house_assignment="whole")
def test_kp_cusps_require_an_occupancy_basis_and_the_report_names_it() -> None:
planets = {"Sun": {"sign": "Taurus", "degree": 10.0, "house": 2}}
cusps = [15.0 + 30.0 * index for index in range(12)]
with pytest.raises(ValueError, match="house_occupancy_basis"):
calc_kp_analysis(planets, "Aries", house_cusps=cusps)
with pytest.raises(ValueError, match="house_occupancy_basis"):
calc_kp_analysis(planets, "Aries", house_cusps=cusps, house_occupancy_basis="")
declared = calc_kp_analysis(
planets, "Aries", house_cusps=cusps, house_occupancy_basis="cusp_to_cusp",
)
assert declared["house_occupancy_basis"] == "cusp_to_cusp"
proxy = calc_kp_analysis(planets, "Aries")
assert proxy["house_occupancy_basis"] == "whole_sign_proxy"
markdown = build_kp_report_pack(declared)["exports"]["markdown"]
assert "House occupancy basis: `cusp_to_cusp`" in markdown
+72
View File
@@ -0,0 +1,72 @@
"""BUG-1320: kendra and trikona lord lists do not follow PYTHONHASHSEED.
Upstream 0337b77f. Two processes with different hash seeds must list the
same yoga participants in the same order.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_PROBE = r"""
import json
import sys
sys.path.insert(0, "scripts")
from yoga_engine import YogaContext, YogaEngine, detect_yogas
planets = {
"Sun": {"sign": "Leo", "house": 5, "degree": 10},
"Moon": {"sign": "Cancer", "house": 4, "degree": 5},
"Mars": {"sign": "Aries", "house": 1, "degree": 8},
"Mercury": {"sign": "Virgo", "house": 6, "degree": 12},
"Jupiter": {"sign": "Sagittarius", "house": 9, "degree": 15},
"Venus": {"sign": "Libra", "house": 7, "degree": 3},
"Saturn": {"sign": "Capricorn", "house": 10, "degree": 20},
"Rahu": {"sign": "Taurus", "house": 2, "degree": 9},
"Ketu": {"sign": "Scorpio", "house": 8, "degree": 9},
}
rules = "references/yoga_rules.json"
engine = YogaEngine(rules)
ctx = YogaContext(planets, "Aries")
namespace = engine._build_custom_exec_globals(ctx, {}, {})
rows = detect_yogas(planets, "Aries", rules)
print(json.dumps({
"kendra": namespace["kendra_lords_list"](),
"trikona": namespace["trikona_lords_list"](),
"participants": [
[row.get("rule_id"), str(row.get("combination"))]
for row in rows
],
}, ensure_ascii=False))
"""
def _run(seed: str) -> dict:
env = os.environ.copy()
env["PYTHONHASHSEED"] = seed
env["PYTHONIOENCODING"] = "utf-8"
completed = subprocess.run(
[sys.executable, "-c", _PROBE],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return json.loads(completed.stdout)
def test_yoga_participants_keep_the_same_order_across_hash_seeds() -> None:
first = _run("0")
second = _run("1")
assert first == second
assert first["kendra"] == sorted(first["kendra"])
assert first["trikona"] == sorted(first["trikona"])
assert first["kendra"] == ["Mars", "Moon", "Saturn", "Venus"]
assert first["trikona"] == ["Jupiter", "Mars", "Sun"]