Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2726b96a1b | ||
|
|
28c719cbde | ||
|
|
7cdf83106a |
+12
-17
@@ -24398,21 +24398,16 @@
|
||||
- 关联记录:无
|
||||
- 修复版本:无
|
||||
|
||||
## BUG-1809 | 第七轮整合上 staging 后,生时校正三组测试集仍封存在 round5 标签:重封 round7_integration_2026_11_09
|
||||
## BUG-1822 | 简易择时把参考时刻当成 UT 12:00、不用地点,五要素只取『日出后 6 小时』一个点
|
||||
|
||||
- 状态:fixed-pending-verify(本地分支 `codex/rect-reseal-r7-1109`,基于 staging b453505d,未推送;产品负责人 2026-11-09 批准「agree new result」)
|
||||
- 首次发现 / 最近更新:2026-11-09(第七轮整合 b453505d 合入 staging 后,封存记录仍是 `round5_pvr_narayana_2026_11_08`)/ 2026-11-09(产品负责人批准,重封)
|
||||
- 影响面:只加研究记录与测试,产品代码不改。新文件 `docs/research/sealed_holdout_rerun_round7_integration_2026_11_09.{freeze.json,json}`、`docs/research/reported_offset_round7_integration_2026_11_09.{freeze.json,json}`、`docs/research/live_astrologer_v5_round7_integration_2026_11_09.json`(只存相对真值的分钟数,不含日期)、`docs/research/round7_integration_2026_11_09_comparability.json`、`docs/research/round7_integration_2026_11_09_replay_overlay.patch`;`tests/test_rectification_reseal_round7_bug1809.py`。
|
||||
- 现象:b453505d 带来 R1(小运方向默认 p50)、R2 局部传播(默认强弱范围 `seed_only_v0`,BUG-1784)、可比领先(`common_evidence_coverage_v1`,BUG-1801)等改动,回放结果已和 round5 标签不同,但标签没有更新。
|
||||
- 重跑(与 BUG-1720 / BUG-1759 同一协议):回放树 = `git archive c1a69445^` + `float_split_2026_11_08_replay_overlay.patch` + `round5_pvr_narayana_2026_11_08_replay_overlay.patch` + b453505d 的 `scripts/`、`references/` 整体覆盖(退役文件保留回放树版本)+ 新补丁 `round7_integration_2026_11_09_replay_overlay.patch`。新补丁只有三处:两份回放脚本的 FREEZE / REPORT 指向新标签;退役打分器 Narayana 无证据行时按现行引擎「不计分」补 0 分(偏移回放触发 3351 / 1316340 行,封存回放不加载该文件);回放副本里 `birth_at` 守卫改成 `RuntimeError`。回放实际加载的模块里,仍在 b453505d 的 57 个除两个守卫文件外与 b453505d 逐字节相同。按补丁头部步骤从零重建,`scripts/`、`references/` 与运行用的树完全相同。脚本 `--freeze` 新建记录后回放(`PYTHONHASHSEED=0`)。现行阶段用 `scripts/research/live_astrologer_v5_sensitivity.py`(多记 `leader_coverage`、`duplicate_event_ids`),直接跑 b453505d 工作树。`round5_pvr_narayana_2026_11_08`、`float_split_2026_11_08`、`astrologer_166d_2026_11_05` 及更早标签原样保留。
|
||||
- 与整合代理 seed_only 列核对:封存 20/20、偏移 900/900、现行 231/231 逐条相同,覆盖统计两份逐字节相同,没有差异。整合代理报的「偏移 57 次 / 4 例」「现行 115 次」都是对整合前的 staging(未封存)比的,不是对 round5 标签比的;对 round5 标签见下。整合前的 staging 本来就和 round5 标签不完全相同:偏移头名平均误差 20.65 对 20.49,现行 9/231 次不同,来自两次重封之间合入 staging 的其他改动。
|
||||
- 结果(旧 `round5_pvr_narayana_2026_11_08` → 新 `round7_integration_2026_11_09`):
|
||||
- 封存 20 例:top-1 0.35→0.30、top-3 0.45→0.45、平均分钟误差 6.80→6.75;2/20 例变:1 例失去头名(true_rank 1→11,分钟误差 9→8),1 例 true_rank 10→3;误确认 0→0。
|
||||
- 报时偏移 900 次:top-1 0.0156→0.0100、top-3 0.113→0.106、头名平均误差 20.49→20.63 分、交付覆盖率 0.900→0.883;164/900 次变(7/20 例、44/45 格):true_rank 变好 107 / 变差 39,头名误差变 57 次,头名命中变 5 次,交付覆盖变 15 次,交付宽度变 18 次。对整合前的 staging:57/900 次变(4 例),true_rank 变差 39 / 变好 0。
|
||||
- 现行占星师阶段(holdout v5 77 × 3 = 231):最佳 = 真值 8→8、真值在前三 19→18、平均最佳误差 10.38→10.29 分、最佳 = 报时 17→16;124/231 次变(49/77 例):前十 105、最佳分钟 31、匹配档 13、报告置信度 13、引擎置信度 77、头部簇 38。对整合前的 staging:115/231 次变(45 例)。
|
||||
- 可比性(`common_evidence_coverage_v1`):封存 40 次评分(20 全量 + 20 稀疏)全部 comparable;偏移 900 次 comparable 863、暂列(`provisional_unequal_coverage`)35(全在 1 例)、无单一领先 2(覆盖相同但并列,无可比领先),不可拆 0;现行 231 次 comparable 226、暂列 5(3 例);重复事件 0。偏移样本同一例重复,不按 900 次独立计。
|
||||
- 判断:这些只是敏感度,不是准确率。事件打分接近随机水平(BUG-1644),三组结果早被看过,升降不能当变好或变差,也不能当产品指标。重封理由是记录与产品口径一致。
|
||||
- 验证:新测试 `tests/test_rectification_reseal_round7_bug1809.py`:新标签封存 / 偏移 / 现行数字、可比性策略与领先计数、旧标签数字不变(带原值 / 新值 / 原因)。之前没有测试按标签钉封存结果,所以没有要改写的旧测试。`test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` 已知失败(见 BUG-1720),与本单无关。
|
||||
- 防复发:上述测试;以后重封必须新建标签,不覆盖旧标签。
|
||||
- 关联记录:BUG-1720、BUG-1759、BUG-1784、BUG-1801、BUG-1644
|
||||
- 修复版本:本地分支 `codex/rect-reseal-r7-1109`
|
||||
- 状态:fixed-pending-verify(分支 codex/muhurta-20261109)
|
||||
- 首次发现 / 最近更新:2026-11-07(占星师 Q9)/ 2026-11-09(修复)
|
||||
- 影响面:scripts/cmd_muhurta.py、scripts/jyotish_engine.py 的 muhurta 子命令参数、scripts/muhurta.py 的 panchanga_range_report 默认参考时刻(只被新模块绕开,未改其默认行为)。
|
||||
- 现象:CLI 只有 --date/--activity/--scan-days/--hour-from-sunrise;_get_sun_moon_lons(y, m, d, int(hour_from_sunrise + 6)) 把当地小时当 UT 传给 swe.julday,等于固定 UT 12:00;不用地点,日出日落退回 06:00/18:00;五要素(Tithi/Nakshatra/Yoga/Karana)只在『日出 + 6 小时』一个点取值,再用这个值给全天打分。
|
||||
- 触发条件:任何用 CLI 或 /api/muhurta 做择时判断的调用。
|
||||
- 根因:简易 CLI 是早期近似实现,从未接地点、时区与逐时段核对;底层 muhurta.py 已有实际日出与五要素结束时刻的计算能力,但 CLI 没有调用。
|
||||
- 修复:新增 scripts/muhurta_search_api.py(muhurta_search_api,错误类 MuhurtaSearchInputError):必填 activity/start_date/end_date/lat/lon/(tz 或 timezone_id)/ayanamsa/node_mode,缺一项报错并点名字段;未知活动报错不改写;每天用 SwissEph 取实际日出日落,取不到就整天 blocked(不退回 06:00/18:00,也不用近似日月位置);对每个候选时段在起止两端各取一次五要素与 Hora,时段内变化标出变化时刻;现有单点评分放进 legacy_reference 并标 not_final。新增薄路由 /api/muhurta_search;CLI 增加 --lat/--lon/--tz/--timezone-id/--ayanamsa/--node-mode,有地点走新模块,无地点首行写明『未提供地点:结果为近似值,不能作为择时结论』且不给推荐时段,并删掉 int(hour_from_sunrise + 6) 的 UT 冒充。
|
||||
- 验证:tests/test_muhurta_search_api_20261109.py(14 条:缺字段点名、未知活动、63 天上限、新德里/伦敦日出与夏令时 +1、逐时段两端要素、时段内变化与变化时刻、无日出整天 blocked 且无 06:00/18:00、指纹稳定且随口径变化、legacy 标 not_final、路由薄注册、CLI 有/无地点两种输出);tests/test_api_server_growth_contract.py 9 条通过;pytest -k muhurta 116 过 5 败,5 条失败在基线 b453505d 上同样失败(脚本输出 factor_only_scoring_observation、测试期望 blocked_until_oracle),与本单无关。
|
||||
- 防复发:上述测试;新模块的必填校验不允许补默认值。
|
||||
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;#173 按需计算骨架(后续接聊天用)
|
||||
- 修复版本:分支 codex/muhurta-20261109
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
{
|
||||
"record_version": "reported-offset-native-candidate-date-v3",
|
||||
"frozen_at_utc": "2026-11-08T23:15:54.895716+00:00",
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"radii_minutes": [
|
||||
15,
|
||||
30,
|
||||
60
|
||||
],
|
||||
"offsets_minutes": [
|
||||
-30,
|
||||
-20,
|
||||
-15,
|
||||
-10,
|
||||
-8,
|
||||
-5,
|
||||
-3,
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
8,
|
||||
10,
|
||||
15,
|
||||
20,
|
||||
30
|
||||
],
|
||||
"minute_step": 1,
|
||||
"dataset": "references/real_case_calibration/minute_rectification_holdout_v3.json",
|
||||
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
|
||||
"dataset_benchmark_id": "minute_rectification_fact_ranker_v4_holdout_v3",
|
||||
"implementation_sha256": "b32140f91dd8c65a4380ad31a29d77902b2ec2cd06495b9ce387eb7e60b68825",
|
||||
"implementation_sha256_prefix": "b32140f91dd8c65a",
|
||||
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
|
||||
"production_scoring_files": [
|
||||
"scripts/active_rectification_event_engine.py",
|
||||
"scripts/active_rectification_events.py",
|
||||
"scripts/ashtakavarga.py",
|
||||
"scripts/dasha_analyzer.py",
|
||||
"scripts/divisional_charts_extended.py",
|
||||
"scripts/domain_calculation_service.py",
|
||||
"scripts/functional_benefics.py",
|
||||
"scripts/jaimini.py",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py",
|
||||
"scripts/minute_rectification_feature_facts_v4.py",
|
||||
"scripts/narayana_dasha.py",
|
||||
"scripts/rectification/api_service.py",
|
||||
"scripts/rectification/candidate_contrast.py",
|
||||
"scripts/rectification/candidate_window.py",
|
||||
"scripts/rectification/case_holdout.py",
|
||||
"scripts/rectification/contracts.py",
|
||||
"scripts/rectification/dasha_transition_proximity.py",
|
||||
"scripts/rectification/decision_policy.py",
|
||||
"scripts/rectification/event_probes.py",
|
||||
"scripts/rectification/refinement_packet.py",
|
||||
"scripts/rectification/scoring_service.py",
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"production_scoring_sha256": "9575adabfc825fe515d571ea2a8e8550b85ccbf089f9e4b185d330821d37ace8",
|
||||
"research_files": [
|
||||
"scripts/research/reported_offset_sweep.py",
|
||||
"scripts/research/cluster_width_lib.py",
|
||||
"scripts/research/probe_supply_after_six.py",
|
||||
"scripts/research/sealed_holdout_rerun.py",
|
||||
"scripts/minute_rectification_blind_eval.py",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py",
|
||||
"scripts/minute_rectification_holdout_validator.py"
|
||||
],
|
||||
"research_implementation_sha256": "b7130fd8a9dd0a734d1c10aa0a4900a19702ecf75044ad1740e596f20cada0b5",
|
||||
"file_sha256": {
|
||||
"scripts/active_rectification_event_engine.py": "3bda6fcf8855bec90dd62e64fae167ce3e6afe5956549bbd26587ea59eba3d94",
|
||||
"scripts/active_rectification_events.py": "3d8a625d24aac6f7f05a3265a23b488153daa03b5b81c6b993160011cc07eb1c",
|
||||
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
|
||||
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
|
||||
"scripts/divisional_charts_extended.py": "b13ad68c45685568d6eb17f87017624a9288e31fad74662735cd6d6564f7755d",
|
||||
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
|
||||
"scripts/functional_benefics.py": "d34dd7745ba9db0a61e3f7a2aa1e6acd1f819961442cb3412b5dbe22c02ef032",
|
||||
"scripts/jaimini.py": "e474b3a1d5c50de87fafdbdcc3df9bd724de004aa2495463763f5b2688b8f5e2",
|
||||
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
|
||||
"scripts/minute_rectification_feature_facts_v4.py": "4661d725d4f04935169d9d8a86ce8d0ef4061915a3a985c4912b93a6e035689e",
|
||||
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
|
||||
"scripts/narayana_dasha.py": "5a924f06f76426ab587163fd8c0b5e32a6a8c6917e8670029bd84a2eb6a6172e",
|
||||
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
|
||||
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
|
||||
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
|
||||
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
|
||||
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "66d29c0b182309d17264dd2645b5cb426e28b4066d6c7004fa66f3334286753f",
|
||||
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
|
||||
"scripts/rectification/event_probes.py": "283ac525efa5ba5b12e804d9b87e6725741ede3365659002224fffe61722bc3f",
|
||||
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
|
||||
"scripts/rectification/scoring_service.py": "d1530ea3569f86747c4e6d7bccf18639e50c870526c06702bc1d9b3fec07ed9b",
|
||||
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
|
||||
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
|
||||
"scripts/research/reported_offset_sweep.py": "1ef4645a954da5d8374efc807aeeb2daf6e3431db771ed1e346b46ebbe83de62",
|
||||
"scripts/research/sealed_holdout_rerun.py": "388b8af89fb89da9c3f2ce0e2bb6351ed20fd54fa19b8be34438540eed128617",
|
||||
"scripts/shadbala.py": "cf1127cb60ce9aedc01050e7e391257d1c354b9ca735c7a2293baefb7d57e343",
|
||||
"scripts/varga.py": "c1c8521320176e31b1f897807e50322f147654251c323731666482fc98f6df0a"
|
||||
},
|
||||
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock",
|
||||
"scorer": "native_event_contribution_matrix_not_shadow_fact_ranker",
|
||||
"delivery": "initial_signature_clusters_peak_gap_lt_8_envelope_no_answers_no_elimination",
|
||||
"rank": "score_desc_then_sha256(benchmark_id:case_id:candidate_time)",
|
||||
"grid": "every_minute_inclusive_not_production_two_minute_sampling",
|
||||
"cross_midnight": "date_aware_candidates_distance_and_native_single_matrix",
|
||||
"evaluator_sha256": "1ef4645a954da5d8374efc807aeeb2daf6e3431db771ed1e346b46ebbe83de62",
|
||||
"replay_revision": "native_candidate_date_v3",
|
||||
"supersedes": "candidate_date_grouped_v2_identity_and_path_not_assumed_numerically_wrong",
|
||||
"is_blind_evaluation": false,
|
||||
"truth_hidden_from_ranker": true,
|
||||
"official_valid_independent_blind": false,
|
||||
"official_blind_trial_count": 0,
|
||||
"results_previously_seen": true,
|
||||
"must_not_use_for_tuning": true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"tag": "round7_integration_2026_11_09",
|
||||
"source_commit": "b453505d50f8d9c5ff4a472eb225a0c60a68e607",
|
||||
"policy": "common_evidence_coverage_v1",
|
||||
"policy_summary": "evidence unit = (event, layer); equal coverage -> comparable (comparable_leader = provisional_leader = winning_segment); unequal -> provisional_unequal_coverage, re-rank on units every candidate has; merged layers that cannot be split -> not_reducible (BUG-1801)",
|
||||
"narayana_strength_scope": "seed_only_v0",
|
||||
"scope": "coverage counts only, not accuracy (BUG-1644)",
|
||||
"probe": "product adjudicator compute_event_candidate_result on the replay script's own requests (sealed: full + sparse per case; offset: every shifted window); live: chart_choice astrologer leader_coverage",
|
||||
"sealed_20": {
|
||||
"evaluations": 40,
|
||||
"leader_status": {
|
||||
"comparable": 40
|
||||
},
|
||||
"coverage_comparison": {
|
||||
"comparable/raw_totals": 40
|
||||
}
|
||||
},
|
||||
"reported_offset_900": {
|
||||
"evaluations": 900,
|
||||
"leader_status": {
|
||||
"comparable": 863,
|
||||
"provisional_unequal_coverage": 35,
|
||||
"no_single_leader": 2
|
||||
},
|
||||
"coverage_comparison": {
|
||||
"comparable/raw_totals": 865,
|
||||
"provisional_unequal_coverage/common_coverage": 35
|
||||
},
|
||||
"not_reducible": 0,
|
||||
"provisional_cases": 1,
|
||||
"no_comparable_leader": 2
|
||||
},
|
||||
"live_v5_231": {
|
||||
"evaluations": 231,
|
||||
"leader_status": {
|
||||
"comparable": 226,
|
||||
"provisional_unequal_coverage": 5
|
||||
},
|
||||
"provisional_cases": 3,
|
||||
"duplicate_event_runs": 0
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
# BUG-1809 回放树:round7_integration_2026_11_09(staging b453505d)
|
||||
#
|
||||
# 构建步骤(与 BUG-1720 / BUG-1759 同一协议):
|
||||
# 1. git archive c1a69445^ (两份回放脚本与退役打分器随 BUG-1644 删除,从这里取)
|
||||
# 2. patch -p1 < docs/research/float_split_2026_11_08_replay_overlay.patch
|
||||
# 3. patch -p1 < docs/research/round5_pvr_narayana_2026_11_08_replay_overlay.patch
|
||||
# 4. git archive b453505d scripts references | tar -x (staging 的 scripts/、references/ 整体覆盖;
|
||||
# 仓库已删的退役文件保留回放树版本。两份回放实际加载的 67 个模块里,仍在 b453505d 的 57 个除 (c) 两个守卫文件外逐字节相同)
|
||||
# 5. 本补丁(三部分):
|
||||
# (a) 两份回放脚本 FREEZE / REPORT 指向新标签(r5tree → r7tree);
|
||||
# (b) 退役打分器 scoring_service.py 的回放适配:Narayana 定不出、该候选该事件无证据行时按现行引擎「不计分」补 0 分
|
||||
# (BUG-1769 已记录的 IndexError 路径;本次偏移回放 3351 / 1316340 行触发,封存回放不加载该文件);
|
||||
# (c) 回放副本里 birth_at 守卫由 TypeError 改为 RuntimeError(REPLAY_GUARD),避免任何调用方 except TypeError 吞掉日期字符串;
|
||||
# staging → r7guard。
|
||||
# 6. python scripts/research/sealed_holdout_rerun.py --freeze;reported_offset_sweep.py --freeze;再各自回放(PYTHONHASHSEED=0)。
|
||||
diff -ruN r5tree/scripts/rectification/scoring_service.py r7tree/scripts/rectification/scoring_service.py
|
||||
--- r5tree/scripts/rectification/scoring_service.py 2026-11-09 07:17:58.857906592 +0800
|
||||
+++ r7tree/scripts/rectification/scoring_service.py 2026-11-09 07:17:58.861906532 +0800
|
||||
@@ -254,6 +254,17 @@
|
||||
return {**request, "events": [event for event in request["events"] if is_scoreable_event(event)]}
|
||||
|
||||
|
||||
+import atexit as _atexit, sys as _sys
|
||||
+_REPLAY_EMPTY = [0]
|
||||
+_REPLAY_TOTAL = [0]
|
||||
+_atexit.register(lambda: print(f"REPLAY_ADAPTER empty_evidence_rows={_REPLAY_EMPTY[0]} of {_REPLAY_TOTAL[0]}", file=_sys.stderr))
|
||||
+
|
||||
+
|
||||
+def _replay_empty(event):
|
||||
+ _REPLAY_EMPTY[0] += 1
|
||||
+ return {"event_id": event["id"], "points": 0.0, "rule_ids": []}
|
||||
+
|
||||
+
|
||||
def build_event_contribution_matrix(
|
||||
request: RectificationRequest,
|
||||
row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None,
|
||||
@@ -277,10 +288,14 @@
|
||||
sample_rows = []
|
||||
for sampled in samples:
|
||||
rows = list(provider(_legacy_request(scoring_request, event, sampled)))
|
||||
+ _REPLAY_TOTAL[0] += len(rows)
|
||||
sample_rows.append([
|
||||
{**row, "score": adjusted["points"], "evidence": [adjusted]}
|
||||
for row in rows
|
||||
- for adjusted in [_kind_adjusted_evidence(event, row["evidence"][0])]
|
||||
+ # REPLAY ADAPTER (round9 integration, 2026-11-09): a candidate whose mandatory layer is missing for this
|
||||
+ # event (e.g. Narayana blocked) has no evidence row; the product adds nothing for it, so it counts 0 here.
|
||||
+ for adjusted in [_kind_adjusted_evidence(event, row["evidence"][0]) if row["evidence"]
|
||||
+ else _replay_empty(event)]
|
||||
])
|
||||
grids = [[row["time"] for row in rows] for rows in sample_rows]
|
||||
if any(grid != grids[0] for grid in grids[1:]) or (candidate_grid is not None and grids[0] != candidate_grid):
|
||||
diff -ruN r5tree/scripts/research/reported_offset_sweep.py r7tree/scripts/research/reported_offset_sweep.py
|
||||
--- r5tree/scripts/research/reported_offset_sweep.py 2026-11-09 07:17:58.857906592 +0800
|
||||
+++ r7tree/scripts/research/reported_offset_sweep.py 2026-11-09 07:17:58.861906532 +0800
|
||||
@@ -66,8 +66,11 @@
|
||||
# 原值: docs/research/reported_offset_float_split_2026_11_08.{freeze.json,json}
|
||||
# 新值: docs/research/reported_offset_round5_pvr_narayana_2026_11_08.{freeze.json,json}
|
||||
# 原因: 产品 2026-11-08 批准;占星师第五轮裁定 1、3;与报告/卡口径一致(BUG-1759:校正功能吉凶改用默认 PVR 表 30,Narayana 改用来源版 narayana_rath、出生时刻起算;回放结果变了,另存新标签)
|
||||
-FREEZE = ROOT / "docs/research/reported_offset_round5_pvr_narayana_2026_11_08.freeze.json"
|
||||
-REPORT = ROOT / "docs/research/reported_offset_round5_pvr_narayana_2026_11_08.json"
|
||||
+# 原值: docs/research/reported_offset_round5_pvr_narayana_2026_11_08.{freeze.json,json}
|
||||
+# 新值: docs/research/reported_offset_round7_integration_2026_11_09.{freeze.json,json}
|
||||
+# 原因: 产品负责人 2026-11-09 批准;第七轮整合(R1 默认 p50、R2 局部传播 seed_only、可比领先);BUG-1809(staging b453505d 全部 scripts/references 叠到回放树,回放结果变了,另存新标签)
|
||||
+FREEZE = ROOT / "docs/research/reported_offset_round7_integration_2026_11_09.freeze.json"
|
||||
+REPORT = ROOT / "docs/research/reported_offset_round7_integration_2026_11_09.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
|
||||
|
||||
|
||||
diff -ruN r5tree/scripts/research/sealed_holdout_rerun.py r7tree/scripts/research/sealed_holdout_rerun.py
|
||||
--- r5tree/scripts/research/sealed_holdout_rerun.py 2026-11-09 07:17:58.857906592 +0800
|
||||
+++ r7tree/scripts/research/sealed_holdout_rerun.py 2026-11-09 07:17:58.861906532 +0800
|
||||
@@ -63,8 +63,11 @@
|
||||
# 原值: docs/research/sealed_holdout_rerun_float_split_2026_11_08.{freeze.json,json}
|
||||
# 新值: docs/research/sealed_holdout_rerun_round5_pvr_narayana_2026_11_08.{freeze.json,json}
|
||||
# 原因: 产品 2026-11-08 批准;占星师第五轮裁定 1、3;与报告/卡口径一致(BUG-1759:校正功能吉凶改用默认 PVR 表 30,Narayana 改用来源版 narayana_rath、出生时刻起算;回放结果变了,另存新标签)
|
||||
-FREEZE = ROOT / "docs/research/sealed_holdout_rerun_round5_pvr_narayana_2026_11_08.freeze.json"
|
||||
-REPORT = ROOT / "docs/research/sealed_holdout_rerun_round5_pvr_narayana_2026_11_08.json"
|
||||
+# 原值: docs/research/sealed_holdout_rerun_round5_pvr_narayana_2026_11_08.{freeze.json,json}
|
||||
+# 新值: docs/research/sealed_holdout_rerun_round7_integration_2026_11_09.{freeze.json,json}
|
||||
+# 原因: 产品负责人 2026-11-09 批准;第七轮整合(R1 默认 p50、R2 局部传播 seed_only、可比领先);BUG-1809(staging b453505d 全部 scripts/references 叠到回放树,回放结果变了,另存新标签)
|
||||
+FREEZE = ROOT / "docs/research/sealed_holdout_rerun_round7_integration_2026_11_09.freeze.json"
|
||||
+REPORT = ROOT / "docs/research/sealed_holdout_rerun_round7_integration_2026_11_09.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/sealed_holdout_rerun_2026_09_20.json"
|
||||
ARCHIVE = ROOT / "docs/research/history/rectification_pre_cross_midnight_2026_09_20"
|
||||
PRODUCTION_FILES = [
|
||||
diff -ruN stg/scripts/active_rectification_event_engine.py r7g/scripts/active_rectification_event_engine.py
|
||||
--- staging/scripts/active_rectification_event_engine.py 2026-11-09 07:17:58.869906413 +0800
|
||||
+++ r7guard/scripts/active_rectification_event_engine.py 2026-11-09 07:17:58.869906413 +0800
|
||||
@@ -107,7 +107,7 @@
|
||||
and started every period from midnight of that day.
|
||||
"""
|
||||
if not isinstance(birth_at, datetime):
|
||||
- raise TypeError("birth_at must be the candidate birth datetime")
|
||||
+ raise RuntimeError("REPLAY_GUARD birth_at must be the candidate birth datetime")
|
||||
timeline, _, _, _ = birth_moment.mahadashas(birth_at, float(moon_longitude))
|
||||
return timeline
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
with ``birth_moment.split_period`` at both levels.
|
||||
"""
|
||||
if not isinstance(birth_at, datetime):
|
||||
- raise TypeError("birth_at must be the candidate birth datetime")
|
||||
+ raise RuntimeError("REPLAY_GUARD birth_at must be the candidate birth datetime")
|
||||
if timeline is None:
|
||||
timeline = _vimshottari_timeline(birth_at, moon_longitude)
|
||||
_, major = dasha_analyzer.find_current(timeline, event_at)
|
||||
@@ -200,7 +200,7 @@
|
||||
step; there is no fallback to the old algorithm.
|
||||
"""
|
||||
if not isinstance(birth_at, datetime):
|
||||
- raise TypeError("birth_at must be the candidate birth datetime")
|
||||
+ raise RuntimeError("REPLAY_GUARD birth_at must be the candidate birth datetime")
|
||||
if periods is None:
|
||||
periods = _narayana_periods(ascendant_index, planet_longitudes)
|
||||
if not periods:
|
||||
diff -ruN stg/scripts/rectification/event_probes.py r7g/scripts/rectification/event_probes.py
|
||||
--- staging/scripts/rectification/event_probes.py 2026-11-09 07:17:58.869906413 +0800
|
||||
+++ r7guard/scripts/rectification/event_probes.py 2026-11-09 07:17:58.873906354 +0800
|
||||
@@ -509,7 +509,7 @@
|
||||
if birth_at is None:
|
||||
return None
|
||||
if not isinstance(birth_at, datetime):
|
||||
- raise TypeError("birth_at must be the candidate birth datetime") # a date string would silently drop the track
|
||||
+ raise RuntimeError("REPLAY_GUARD birth_at must be the candidate birth datetime") # a date string would silently drop the track
|
||||
periods = _narayana_periods(ascendant_index, planet_longitudes)
|
||||
if not periods:
|
||||
return None
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"record_version": "exposed-v3-fixed-protocol-cross-midnight-rerun-v2",
|
||||
"extended_identity": {
|
||||
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
|
||||
"production_scoring_files": [
|
||||
"scripts/active_rectification_event_engine.py",
|
||||
"scripts/active_rectification_events.py",
|
||||
"scripts/ashtakavarga.py",
|
||||
"scripts/dasha_analyzer.py",
|
||||
"scripts/divisional_charts_extended.py",
|
||||
"scripts/domain_calculation_service.py",
|
||||
"scripts/functional_benefics.py",
|
||||
"scripts/jaimini.py",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py",
|
||||
"scripts/minute_rectification_feature_facts_v4.py",
|
||||
"scripts/narayana_dasha.py",
|
||||
"scripts/rectification/api_service.py",
|
||||
"scripts/rectification/candidate_contrast.py",
|
||||
"scripts/rectification/candidate_window.py",
|
||||
"scripts/rectification/case_holdout.py",
|
||||
"scripts/rectification/contracts.py",
|
||||
"scripts/rectification/dasha_transition_proximity.py",
|
||||
"scripts/rectification/decision_policy.py",
|
||||
"scripts/rectification/event_probes.py",
|
||||
"scripts/rectification/refinement_packet.py",
|
||||
"scripts/rectification/scoring_service.py",
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"production_scoring_sha256": "9575adabfc825fe515d571ea2a8e8550b85ccbf089f9e4b185d330821d37ace8",
|
||||
"research_files": [
|
||||
"scripts/research/reported_offset_sweep.py",
|
||||
"scripts/research/cluster_width_lib.py",
|
||||
"scripts/research/probe_supply_after_six.py",
|
||||
"scripts/research/sealed_holdout_rerun.py",
|
||||
"scripts/minute_rectification_blind_eval.py",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py",
|
||||
"scripts/minute_rectification_holdout_validator.py"
|
||||
],
|
||||
"research_implementation_sha256": "b7130fd8a9dd0a734d1c10aa0a4900a19702ecf75044ad1740e596f20cada0b5",
|
||||
"file_sha256": {
|
||||
"scripts/active_rectification_event_engine.py": "3bda6fcf8855bec90dd62e64fae167ce3e6afe5956549bbd26587ea59eba3d94",
|
||||
"scripts/active_rectification_events.py": "3d8a625d24aac6f7f05a3265a23b488153daa03b5b81c6b993160011cc07eb1c",
|
||||
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
|
||||
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
|
||||
"scripts/divisional_charts_extended.py": "b13ad68c45685568d6eb17f87017624a9288e31fad74662735cd6d6564f7755d",
|
||||
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
|
||||
"scripts/functional_benefics.py": "d34dd7745ba9db0a61e3f7a2aa1e6acd1f819961442cb3412b5dbe22c02ef032",
|
||||
"scripts/jaimini.py": "e474b3a1d5c50de87fafdbdcc3df9bd724de004aa2495463763f5b2688b8f5e2",
|
||||
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
|
||||
"scripts/minute_rectification_feature_facts_v4.py": "4661d725d4f04935169d9d8a86ce8d0ef4061915a3a985c4912b93a6e035689e",
|
||||
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
|
||||
"scripts/narayana_dasha.py": "5a924f06f76426ab587163fd8c0b5e32a6a8c6917e8670029bd84a2eb6a6172e",
|
||||
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
|
||||
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
|
||||
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
|
||||
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
|
||||
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "66d29c0b182309d17264dd2645b5cb426e28b4066d6c7004fa66f3334286753f",
|
||||
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
|
||||
"scripts/rectification/event_probes.py": "283ac525efa5ba5b12e804d9b87e6725741ede3365659002224fffe61722bc3f",
|
||||
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
|
||||
"scripts/rectification/scoring_service.py": "d1530ea3569f86747c4e6d7bccf18639e50c870526c06702bc1d9b3fec07ed9b",
|
||||
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
|
||||
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
|
||||
"scripts/research/reported_offset_sweep.py": "1ef4645a954da5d8374efc807aeeb2daf6e3431db771ed1e346b46ebbe83de62",
|
||||
"scripts/research/sealed_holdout_rerun.py": "388b8af89fb89da9c3f2ce0e2bb6351ed20fd54fa19b8be34438540eed128617",
|
||||
"scripts/shadbala.py": "cf1127cb60ce9aedc01050e7e391257d1c354b9ca735c7a2293baefb7d57e343",
|
||||
"scripts/varga.py": "c1c8521320176e31b1f897807e50322f147654251c323731666482fc98f6df0a"
|
||||
},
|
||||
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
|
||||
},
|
||||
"production_identity_scope": "context_only_shadow_scorer_does_not_call_transition_proximity",
|
||||
"frozen_at_utc": "2026-11-08T23:15:54.758056+00:00",
|
||||
"dataset_path": "references/real_case_calibration/minute_rectification_holdout_v3.json",
|
||||
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
|
||||
"algorithm_version": "birth-time-event-fact-ranker-v4-shadow",
|
||||
"implementation_sha256": "b32140f91dd8c65a4380ad31a29d77902b2ec2cd06495b9ce387eb7e60b68825",
|
||||
"files": [
|
||||
"scripts/active_rectification_event_engine.py",
|
||||
"scripts/active_rectification_events.py",
|
||||
"scripts/ashtakavarga.py",
|
||||
"scripts/dasha_analyzer.py",
|
||||
"scripts/divisional_charts_extended.py",
|
||||
"scripts/domain_calculation_service.py",
|
||||
"scripts/jaimini.py",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py",
|
||||
"scripts/minute_rectification_feature_facts_v4.py",
|
||||
"scripts/narayana_dasha.py",
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"historical_frozen_sha256": "f41c298dd6cdcebe7a93e632f7191954be7987f6012ca2a34cecb6e447fbf196",
|
||||
"evaluator_sha256": "388b8af89fb89da9c3f2ce0e2bb6351ed20fd54fa19b8be34438540eed128617",
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"candidate_radius_minutes": [
|
||||
10
|
||||
],
|
||||
"minute_step": 1,
|
||||
"release_metrics": {
|
||||
"top_1_rate_minimum": 0.6,
|
||||
"top_3_rate_minimum": 0.85,
|
||||
"mean_absolute_minute_error_maximum": 2.0,
|
||||
"false_confirmation_rate_maximum": 0.05,
|
||||
"correct_insufficient_evidence_rejection_rate_minimum": 0.9
|
||||
},
|
||||
"results_previously_seen": true,
|
||||
"official_valid_independent_blind": false,
|
||||
"must_not_use_for_tuning": true,
|
||||
"tie_breaker": "sha256(benchmark_id:case_id:candidate_time); published minute is never passed to the ranker",
|
||||
"metric_rank_definition": "competition_rank_1_plus_strictly_higher_scores_legacy_protocol",
|
||||
"extra_metric_rank_definition": "score_desc_then_existing_opaque_sha256_total_order"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
# FIX-muhurta-1:择时搜索的候选与判定仍由「日出 + 6 小时」单点决定(#179 第 1 次打回,2026-11-09)
|
||||
|
||||
- 分支:`codex/muhurta-20261109`(在本分支远端最新上继续,不要另开分支)
|
||||
- 被验 SHA:`28c719cbde057da0a7021b25d0743409eb83f08a`
|
||||
- 原任务书:`docs/tasks/TASK-muhurta-20261109.md`(全部条款继续有效)
|
||||
- Bug 编号:继续用 BUG-1822,在原条目里补「第 1 次打回」与修复,不新开号。
|
||||
|
||||
## 验收结论:未通过
|
||||
|
||||
已经做对、不要改动的部分:必填输入与报错点名、按天取 SwissEph 实际日出、取不到日出整天 `blocked`、`timezone_id` 按天换算、时段起止两端各取五要素与 Hora、变化时刻二分、CLI 无地点首行提示、`/api/muhurta_search` 薄注册、`legacy_reference` 标 `not_final`。
|
||||
|
||||
未通过的四处(都在 `scripts/muhurta_search_api.py`):
|
||||
|
||||
1. **候选日仍由单点分数筛选**(违反原任务书「要做的」第 3 条,以及「结果中不得再出现以 hour_from_sunrise 单点作为最终判定依据的字段」)。
|
||||
- 现状:`raw.days[].windows` 只从 `muhurta_range_search(...)['best_windows']` 取。`best_windows` 是按日出 + 6 小时单点打分、`score >= 0.45` 且排名前 `limit` 的日子。
|
||||
- 后果:单点分数低于 0.45、或排名在 `limit` 之后的日子,一个时段都不出现。「哪天有候选」仍由单点决定,只是分数本身被挪进了 `legacy_reference`。
|
||||
2. **时段没有逐段判定,也没有规则命中与被拒原因**(违反原任务书「输出」第 4 条对 `raw` 的要求:规则命中、被拒原因)。
|
||||
- 现状:时段只带起止两端的五要素,没有判定。
|
||||
- `muhurta.check_activity_muhurta(panchanga, activity)` 接受一份 panchanga 字典,可以在时段起点和终点各调用一次。
|
||||
3. **跨夏令时的范围用错时区**:`muhurta_range_search` 只用第一天的偏移(`tz = _offset_hours_for(first_day, ...)`)调用一次。范围跨过夏令时切换时,后面几天的时段边界会偏一小时,而每天的 `tz_offset_hours` 写的却是正确值,两者对不上。
|
||||
4. **收了参数却不用**:
|
||||
- `time_window` 通过校验后从未使用;
|
||||
- `if any(inputs['timezone_id'] for _ in (0,)): pass` 是死代码。
|
||||
|
||||
## 要做的
|
||||
|
||||
1. **每一天都单独取候选时段,不再经过单点分数筛选**。
|
||||
- 每天用当天自己的偏移,单独调用 `panchanga_range_report`(或 `muhurta_range_search`,`start_date == end_date`)。
|
||||
- 用 `_select_recommended_windows(row, avoid_inauspicious_periods=True)` 取当天的候选时段。这样所有日子都参与,第 3 条也一并解决。
|
||||
- 不改 `muhurta.py` 的规则、分数和阈值。
|
||||
2. **每个时段逐段判定**:
|
||||
- 用时段起点、终点两个时刻的五要素,各组装一份 `check_activity_muhurta` 需要的 panchanga 字典(`tithi.tithi_num`、`nakshatra.nakshatra` / `nakshatra_idx`、`vara`、`yoga`、`karana`,字段名照该函数实际读取的键),各调用一次。
|
||||
- 每个时段写出:
|
||||
- `verdict_at_start`、`verdict_at_end`(原样写规则函数的 verdict 与 notes);
|
||||
- `window_verdict`。两端一致时取该值;两端不一致时写 `mixed`,并在 `rejection_reason` 里点名是哪一项要素变化导致的。不要自己挑较好的一端。
|
||||
- 每天写 `rejected_windows`:被 `_blocked_windows` 挡掉的时段,以及判定为 Avoid / 不宜的时段,各带原因。
|
||||
- 若 `check_activity_muhurta` 必须依赖 `hour_from_sunrise` 才能运行,或必须改规则才能逐段调用:停下,选 `需拍板`,写清卡在哪一行。
|
||||
3. **`time_window`**:
|
||||
- 给了就按当地时间裁掉范围外的时段,并在 `requested` 中原样写出;
|
||||
- 格式不合法时报 `MuhurtaSearchInputError` 并点名该字段。
|
||||
4. **删掉死代码**。`profile.tz` 改为写 `tz_policy: "per_day"`,或者写第一天的偏移并注明 `first_day_offset`。
|
||||
5. **单点分数**:`legacy_reference` 可以保留,`not_final: true` 不变。不得用它排序或筛选 `windows`。若要排序,只按 `window_verdict` 与时间先后。
|
||||
|
||||
## 测试(加在 `tests/test_muhurta_search_api_20261109.py`)
|
||||
|
||||
1. monkeypatch `_score_muhurta_candidate` 返回 0:结果里仍然每天都有候选时段。断言单点分数不再决定候选。
|
||||
2. 每个 `executed` 时段都带 `verdict_at_start`、`verdict_at_end`、`window_verdict`。
|
||||
3. monkeypatch 让时段终点的月宿变成该活动的禁忌月宿:断言 `window_verdict == "mixed"`,`rejection_reason` 点名 nakshatra。
|
||||
4. 伦敦 `timezone_id=Europe/London`,范围 2026-03-27 至 2026-03-30(跨 3 月 29 日夏令时切换):
|
||||
- 切换前后两天的 `tz_offset_hours` 分别为 0 和 1;
|
||||
- 切换后那天的时段边界由当天偏移算出。可断言该天日出的当地时刻与 `calc_sunrise_sunset_local(..., tz=1)` 一致,且时段落在日出与日落之间。
|
||||
5. `time_window={"start":"09:00","end":"12:00"}`:所有时段都在范围内。格式不合法时报错并点名 `time_window`。
|
||||
6. 原有 14 条与 CLI 测试继续通过;改既有断言写「原值 / 新值 / 原因」,原因写:`占星师 Q9 / 第五轮 9:择时须用地点、实际日出、时区`。
|
||||
|
||||
## 不要碰
|
||||
|
||||
同原任务书:`references/**`、`frontend/**`、`muhurta.py` 的规则表与分数、冻结文件(`jyotish_api_server.py` 只保留现有那条 `elif`)、`scripts/ondemand_calc/**`。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `.venv\Scripts\python -m pytest tests/test_muhurta_search_api_20261109.py tests/test_api_server_growth_contract.py -q`
|
||||
- `.venv\Scripts\python -m pytest tests -k muhurta -q`(那 5 条基线上就失败的除外,逐条列名)
|
||||
- `.venv\Scripts\python scripts/jyotish_engine.py muhurta --date 2026-06-15 --activity business --lat 28.6139 --lon 77.2090 --tz 5.5 --ayanamsa lahiri --node-mode mean`
|
||||
|
||||
## 进度记录
|
||||
|
||||
在 `docs/tasks/PROGRESS-muhurta-20261109.md` 末尾追加「第 1 次打回」一节:改了什么、跑了什么、数字。
|
||||
@@ -0,0 +1,42 @@
|
||||
# PROGRESS · 择时改为按地点、实际日出、时区(#179,BUG-1822,2026-11-09)
|
||||
|
||||
- 分支:`codex/muhurta-20261109`(基线 `b453505d`)
|
||||
- 任务书:`docs/tasks/TASK-muhurta-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 新增 BUG-1822
|
||||
|
||||
## 改了什么
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `scripts/muhurta_search_api.py`(新) | `muhurta_search_api(body)` + `MuhurtaSearchInputError`。必填 `activity`、`start_date`、`end_date`、`lat`、`lon`、`tz` 或 `timezone_id`、`ayanamsa`、`node_mode`,缺一项报错并点名字段;未知活动报错(不改写成 business);超过 63 天报错 |
|
||||
| 同上 | 每天用 `muhurta._swisseph_solar_times_local` 取**实际**日出日落;取不到就整天 `calculation_status: blocked`(`sunrise_unavailable`),**不退回 06:00 / 18:00,也不用近似日月位置** |
|
||||
| 同上 | 每个候选时段在**起止两端**各取一次 Tithi / Nakshatra / Yoga / Karana / Hora(都用 `muhurta.py` 自己的计算函数),起止不同就写进 `element_changes_within_window`,变化时刻二分到分钟 |
|
||||
| 同上 | 现有单点参考评分(`muhurta_range_search`)原样放进 `legacy_reference`,标 `not_final: true`;规则、分数、阈值一律没改 |
|
||||
| 同上 | 信封:`schema` / `tool` / `calculation_status` / `profile` / `requested` / `input_fingerprint` / `engine_version` / `raw` / `gaps` / `computed_at_utc`,与 #173 的按需计算合同同形 |
|
||||
| `scripts/jyotish_api_server.py` | `do_POST` 里 `/api/muhurta` 之后加一条薄 `elif path == '/api/muhurta_search'`(`_load_local_module('muhurta_search_api')`,错误映射 `BadRequest`)。没有新增类方法 |
|
||||
| `scripts/jyotish_engine.py` | muhurta 子命令加 `--lat` `--lon` `--tz` `--timezone-id` `--ayanamsa` `--node-mode`;另外把全局「`args.tz is None` 就按 lat/lon 推断时区」收紧为「lat/lon 都给了才推断」,否则择时不给地点时会崩 |
|
||||
| `scripts/cmd_muhurta.py` | 有地点:走新模块,打印实际日出日落、每个时段起止五要素、时段内变化、旧单点参考(标不最终)。无地点:首行 `⚠️ 未提供地点:结果为近似值,不能作为择时结论`,五要素概览改按**显式 UT 12:00** 取(不再用 `int(hour_from_sunrise + 6)` 冒充当地小时),且不给推荐时段 |
|
||||
|
||||
没碰:`muhurta.py` 的规则表与权重(只在调用侧换取值时刻)、`references/**`、`frontend/**`、`scripts/ondemand_calc/**`、其他冻结文件。
|
||||
|
||||
## 自测结果
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_muhurta_search_api_20261109.py` | 14 过 / 0 败 |
|
||||
| `pytest tests/test_api_server_growth_contract.py` | 9 过 / 0 败 |
|
||||
| `pytest tests -k muhurta` | 116 过 / 5 败 |
|
||||
| CLI(新德里 2026-06-15) | 日出 05:23、日落 19:21;三个时段;时段内检测到 Hora / Yoga 变化并给出变化时刻 |
|
||||
|
||||
`-k muhurta` 的 5 条失败都是既有的研究脚本状态词不一致(脚本输出 `factor_only_scoring_observation`,测试期望 `blocked_until_oracle`)。**我在基线 `b453505d` 的干净工作树上复跑了其中两条,同样失败**,与本单无关:
|
||||
|
||||
- `test_muhurta_factor_probe.py`
|
||||
- `test_muhurta_factor_only_scoring_packet.py`
|
||||
- 另外三条同源:`test_muhurta_factor_scoring_readiness.py`、`test_muhurta_numeric_candidate_capture_packet.py`、`test_muhurta_oss_worked_example_readiness.py`
|
||||
|
||||
## 口径与缺口
|
||||
|
||||
- 岁差、交点、地点、时区全部由调用方显式给出,缺一项报错;不给默认值。
|
||||
- `timezone_id` 按天换算偏移,伦敦 2026-06-15 得 +1(BST),2026-01-15 得 0。
|
||||
- 日落之后不在本单范围(`time_window` 参数已接进校验,但窗口选择沿用 `muhurta.py` 现有候选),留给后续单。
|
||||
- 接进聊天要等 #173 的按需计算骨架验收合并,本单只做后端与 CLI,没有改任何前端文件。
|
||||
@@ -0,0 +1,151 @@
|
||||
# TASK:择时改为按地点、实际日出、时区搜索,不再以“日出后 6 小时”近似值作结果(2026-11-09)
|
||||
|
||||
- 分支:`codex/muhurta-20261109`(基于 staging `b453505d`)
|
||||
- Bug 编号:BUG-1822(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已预留给本批任务)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
|
||||
## 背景
|
||||
|
||||
占星师 2026-11-07 Q9 原文:“**择时**:当前简易 muhurta CLI 没有地点参数,而且代码有近似太阳时处理,不能直接包装成地点精确择时工具。应接既有底层择时搜索能力并补齐地点、时区、实际日出、活动和可选时间范围。”
|
||||
|
||||
2026-11-08 第五轮第 9 条原文:“择时需要补地点、实际日出和时区,不能使用近似‘日出后 6 小时’作为最终择时结果。”
|
||||
|
||||
Q9 还要求:“所有适用入口都应显式传递已确认的岁差、交点和位置口径,不能让 CLI 默认 Raman 替用户做选择。”
|
||||
|
||||
**本任务只做后端**:一个地点精确、口径显式的择时搜索接口,并修正 CLI。
|
||||
|
||||
把它接进聊天,要等 #173(按需计算的共用模块与合同)验收合并后,由 Claude 在本分支追加第二份任务书再做。本任务**不改任何前端文件**。
|
||||
|
||||
## 现状(b453505d 实测,行号以符号为准)
|
||||
|
||||
### 简易 CLI:`scripts/cmd_muhurta.py`
|
||||
|
||||
`cmd_muhurta`(约 :94)的参数只有 `--date`、`--activity`、`--scan-days`、`--hour-from-sunrise 6.0`。参数定义在 `scripts/jyotish_engine.py` 约 :18927–18935。没有地点、时区和岁差。
|
||||
|
||||
太阳和月亮的位置用 `_get_sun_moon_lons(y, m, d, int(hour_from_sunrise + 6), ayanamsa)` 计算(约 :133–135,注释写着“rough solar hour”)。这有三个问题:
|
||||
|
||||
1. 时刻被当作 **UT** 传给 `swe.julday`,相当于 UT 12:00,不是当地时间;
|
||||
2. 完全不用地点;
|
||||
3. swisseph 不可用时,退回 `muhurta._approx_sun_moon_lon` 的近似算法。
|
||||
|
||||
### 底层搜索:`scripts/muhurta.py`
|
||||
|
||||
`muhurta_range_search(start_date, end_date, activity, limit, hour_from_sunrise=6.0, sunrise, sunset, lat, lon, tz, avoid_inauspicious_periods, ayanamsa_name=raman)` 在约 :3062,它调用 `panchanga_range_report`(约 :2933)。
|
||||
|
||||
- 三项齐全时(lat、lon、tz):
|
||||
- 用 `calc_sunrise_sunset_local`(约 :923,SwissEph 的 `_swisseph_solar_times_local`)取**实际日出与日落**;
|
||||
- 吉凶时段(Choghadiya、Durmuhurtha、Hora、Rahu Kala 等)按实际日出与日落划分。
|
||||
- **但五要素(Tithi、Nakshatra、Yoga、Karana)仍只在“实际日出 + `hour_from_sunrise`(默认 6 小时)”这一个参考时刻取值**(约 :2964–2985),再以这一个值给全天打分。这正是占星师说的“日出后 6 小时”近似。
|
||||
- `calc_panchanga_end_times` 已经能算出五要素的结束时刻,但没有被用来逐个时段核对。
|
||||
- 三项任一缺失时,静默退回近似的日月位置,以及默认 06:00 / 18:00。
|
||||
- 还有一个默认值问题:`panchanga_range_report` 和 `muhurta_range_search` 的岁差默认是 raman,而 `/api/panchanga_range` 在没传岁差时用 lahiri(`_compute_panchanga_range`,约 :7928)。
|
||||
|
||||
### 现有接口:`/api/muhurta`
|
||||
|
||||
`/api/muhurta`(`jyotish_api_server.py` 约 :10467,`_compute_muhurta` 约 :7856):
|
||||
|
||||
- 单日分支:不传行星时用近似位置;
|
||||
- 日期范围分支:调用 `muhurta_range_search`,上限 63 天。
|
||||
|
||||
## 要做的
|
||||
|
||||
### 新模块 `scripts/muhurta_search_api.py`
|
||||
|
||||
入口为 `muhurta_search_api(body)`,错误类为 `MuhurtaSearchInputError`。
|
||||
|
||||
**1. 必填输入(缺一项就报错,不补默认值)**
|
||||
|
||||
- `activity`:取 `ACTIVITY_RULES` 中的键;未知活动报错,**不得**像 `muhurta_range_search` 那样悄悄改成 `business`;
|
||||
- `start_date`、`end_date`:不超过 63 天,单日时二者相同;
|
||||
- `lat`、`lon`;
|
||||
- `tz`(小时数)或 `timezone_id`(二者之一。若给 `timezone_id`,按每一天各自换算 UTC 偏移,以处理夏令时);
|
||||
- `ayanamsa`、`node_mode`。
|
||||
|
||||
可选输入:`time_window`(当地时间的起止,如 `09:00–18:00`)、`limit`。
|
||||
|
||||
**2. 实际日出**
|
||||
|
||||
- 每天用 SwissEph 取当地实际日出与日落。
|
||||
- 取不到(swisseph 不可用、极昼或极夜)时,这一天写成 `calculation_status: "blocked"`,原因点名,**不得退回 06:00 / 18:00 或近似算法**。
|
||||
|
||||
**3. 逐时段核对五要素(取代“日出 + 6 小时”单点)**
|
||||
|
||||
- 对每一个候选时段(底层已经给出的推荐时段,或 Abhijit、Choghadiya 等),在该时段的**开始和结束两个实际时刻**,各取一次 Tithi、Nakshatra、Yoga、Karana、Hora。可用 `calc_panchanga_end_times`,或直接按该时刻用 SwissEph 计算。
|
||||
- 时段内某项要素发生变化的,标记 `element_changes_within_window`,并给出变化时刻。
|
||||
- 时段的吉凶判定沿用 `muhurta.py` 现有的规则函数(`muhurta_full_report`、`_build_muhurta_candidate` 等),**只换取值时刻,不改规则与权重**。若现有规则函数只能接受“一天一个参考时刻”,可在 `muhurta.py` 给相关函数加一个可选的参考时刻参数,默认值保持原行为不变。
|
||||
- 结果中不得再出现以 `hour_from_sunrise` 单点作为最终判定依据的字段。若出于兼容要保留,必须放进 `legacy_reference`,并标 `not_final: true`。
|
||||
|
||||
**4. 输出**
|
||||
|
||||
照 #173 定下的信封形状,字段名保持一致,以便将来直接接入:
|
||||
|
||||
- `schema: "jyotish.muhurta_search.v1"`;
|
||||
- `calculation_status`;
|
||||
- `profile`:ayanamsa、node_mode、lat、lon、tz、timezone_id、`sunrise_policy`;
|
||||
- `requested`;
|
||||
- `input_fingerprint`:对规范化后的输入做 sha256;
|
||||
- `raw`:每天的实际日出与日落、候选时段(当地时间与 UTC 都给)、每个时段起止时刻的五要素、`element_changes_within_window`、规则命中、被拒原因;
|
||||
- `gaps`。
|
||||
|
||||
整体失败时 `raw` 为空,`gaps` 写明原因,不出现任何推测的日期或时刻。
|
||||
|
||||
**5. 路由**
|
||||
|
||||
在 `jyotish_api_server.py` 的 `do_POST` 里,紧挨现有 `/api/muhurta` 分支之后,加一条薄 `elif path == '/api/muhurta_search'`,写法照 `/api/book_citations`:
|
||||
|
||||
- 用 `_load_local_module('muhurta_search_api')`;
|
||||
- `MuhurtaSearchInputError` 映射为 `BadRequest`。
|
||||
|
||||
不得新增类方法。现有 `/api/muhurta` 的行为不变。
|
||||
|
||||
### CLI `scripts/cmd_muhurta.py` 与参数定义
|
||||
|
||||
1. 增加 `--lat`、`--lon`、`--tz`、`--timezone-id`、`--ayanamsa`、`--node-mode` 参数。参数定义在 `jyotish_engine.py` 的 muhurta 子命令处(约 :18927),只改这几行。
|
||||
2. 给了地点时,改为调用 `muhurta_search_api`,并按新结果输出。
|
||||
3. 没给地点时,仍可输出当天五要素概览,但输出首行必须写明:`未提供地点:结果为近似值,不能作为择时结论`,且不输出“推荐时段”。不得再用 `int(hour_from_sunrise + 6)` 当作 UT 小时。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师方法文件)、`frontend/**` 全部(含 `frontend/src/lib/consult-preset-prompts.ts`)。
|
||||
- `muhurta.py` 里的规则表、分数与阈值(例如 `score >= 0.45`),都不改。只允许加“可选参考时刻”这类默认不改变行为的参数。
|
||||
- 冻结文件:
|
||||
- `scripts/jyotish_api_server.py` 只许加上面那条 `elif`;
|
||||
- `scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`。
|
||||
- `scripts/ondemand_calc/**`:#173 在建,本任务不建也不改。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **新测试文件** `tests/test_muhurta_search_api_20261109.py`,覆盖以下各条:
|
||||
- 缺 lat / lon / tz / ayanamsa / node_mode / activity 任一项,都报错并点名缺项;未知 activity 报错。
|
||||
- 用公开地点(例如新德里 28.6139, 77.2090,tz 5.5;伦敦 51.5074, −0.1278,`timezone_id` 为 Europe/London,取夏令时内的一天):
|
||||
- 结果里的日出时刻与 `calc_sunrise_sunset_local` 的 SwissEph 结果一致;
|
||||
- 两地同一天的日出不同;
|
||||
- 伦敦夏令时那天的 UTC 偏移是 +1。
|
||||
- 每个候选时段都带起止两端的五要素。用 monkeypatch 构造一个“时段内月宿变化”的情况,断言 `element_changes_within_window` 被标出。
|
||||
- swisseph 不可用(monkeypatch)时,该天为 `blocked`,且结果中没有 06:00 / 18:00 默认时刻,也没有近似日月位置。
|
||||
- 同一输入两次调用的 `input_fingerprint` 相同;改 ayanamsa 后 fingerprint 改变,五要素可能随之改变。
|
||||
- 路由是薄注册:断言 `elif path == '/api/muhurta_search'` 与 `_load_local_module('muhurta_search_api')` 都在源码中;`tests/test_api_server_growth_contract.py` 仍通过。
|
||||
2. **CLI 测试**:
|
||||
- 带地点时输出与 API 一致;
|
||||
- 不带地点时首行为近似提示,且没有推荐时段。
|
||||
3. **现有测试不退步**:`tests/` 下现有 muhurta 相关测试全部通过,可用 `-k muhurta` 筛出。改既有断言时写“原值 / 新值 / 原因”三栏注释,原因写:`占星师 Q9 / 第五轮 9:择时须用地点、实际日出、时区`。
|
||||
4. **Bug 记录**:在 `docs/BUG_HISTORY.md` 末尾追加 BUG-1822,写明原近似做法(UT 12:00、无地点、单点取值)及其影响面。
|
||||
5. **测试数据**:只用公开地点与日期,不写个人资料。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `.venv\Scripts\python -m pytest tests/test_muhurta_search_api_20261109.py tests/test_api_server_growth_contract.py -q`
|
||||
- `.venv\Scripts\python -m pytest tests -k muhurta -q`
|
||||
- `.venv\Scripts\python scripts/jyotish_engine.py muhurta --date 2026-06-15 --activity business --lat 28.6139 --lon 77.2090 --tz 5.5 --ayanamsa lahiri --node-mode mean`
|
||||
|
||||
本任务不改前端,不需要跑 tsc 和 lint。Python 快速门由 Claude 在验收时跑。
|
||||
|
||||
## 进度记录
|
||||
|
||||
`docs/tasks/PROGRESS-muhurta-20261109.md`
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- 现有规则函数只能“一天一个参考时刻”打分,而改成逐时段打分必须改规则或权重时。要决定的是:逐时段核对后,哪一个时刻的要素用于判定。
|
||||
- 某项活动的传统规则需要本命盘(例如月亮的 Tarabala / Chandrabala),而接口没有本命输入时。要决定的是:本任务是否加本命输入,还是把这一项标为缺口。
|
||||
- 发现 SwissEph 的日出口径(上缘还是中心、是否计入折射)与现有测试冲突时。
|
||||
+124
-11
@@ -91,19 +91,107 @@ def _format_activity_checks(activity_checks: Dict, target_activity: Optional[str
|
||||
return lines
|
||||
|
||||
|
||||
def _print_location_exact(
|
||||
date_str: str,
|
||||
activity: Optional[str],
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: Optional[float],
|
||||
timezone_id: Optional[str],
|
||||
ayanamsa: Optional[str],
|
||||
node_mode: Optional[str],
|
||||
time_window: Optional[Dict],
|
||||
limit: int,
|
||||
) -> int:
|
||||
"""给了地点:走 muhurta_search_api,按实际日出与逐时段五要素输出。"""
|
||||
from muhurta_search_api import muhurta_search_api, MuhurtaSearchInputError
|
||||
|
||||
if not ayanamsa or not node_mode:
|
||||
print("[ERROR] 给了地点就必须显式给 --ayanamsa 与 --node-mode(不要让默认值替用户选择)")
|
||||
return 1
|
||||
body = {
|
||||
"activity": activity or "business",
|
||||
"start_date": date_str,
|
||||
"end_date": date_str,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"ayanamsa": ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
"limit": limit,
|
||||
}
|
||||
if tz is not None:
|
||||
body["tz"] = tz
|
||||
if timezone_id:
|
||||
body["timezone_id"] = timezone_id
|
||||
if time_window:
|
||||
body["time_window"] = time_window
|
||||
try:
|
||||
result = muhurta_search_api(body)
|
||||
except MuhurtaSearchInputError as exc:
|
||||
print(f"[ERROR] {exc}")
|
||||
return 1
|
||||
|
||||
print("=" * 64)
|
||||
print("🕐 Muhurta 择时分析(地点精确,实际日出与日落)")
|
||||
print("=" * 64)
|
||||
profile = result.get("profile") or {}
|
||||
print(f"地点 {lat}, {lon}|时区 {profile.get('tz')}|岁差 {profile.get('ayanamsa')}|交点 {profile.get('node_mode')}")
|
||||
print(f"状态 {result.get('calculation_status')}|指纹 {result.get('input_fingerprint', '')[:16]}")
|
||||
for day in (result.get("raw") or {}).get("days") or []:
|
||||
print()
|
||||
if day.get("calculation_status") != "executed":
|
||||
print(f"📅 {day.get('date')}:阻塞({day.get('reason')}),没有可用日出,不出结论")
|
||||
continue
|
||||
print(f"📅 {day.get('date')} 日出 {day.get('sunrise_local')} 日落 {day.get('sunset_local')}")
|
||||
windows = day.get("windows") or []
|
||||
if not windows:
|
||||
print(" 这一天没有候选时段")
|
||||
for window in windows:
|
||||
if window.get("calculation_status") != "executed":
|
||||
print(f" · {window.get('name')} {window.get('start_local')}–{window.get('end_local')}:阻塞")
|
||||
continue
|
||||
start = window.get("elements_at_start") or {}
|
||||
end = window.get("elements_at_end") or {}
|
||||
print(f" · {window.get('name')} {window.get('start_local')}–{window.get('end_local')}"
|
||||
f"(UTC {window.get('start_utc')}–{window.get('end_utc')})")
|
||||
print(f" 起:Tithi {start.get('tithi_full_name')}|Nakshatra {start.get('nakshatra')}"
|
||||
f" pada {start.get('nakshatra_pada')}|Yoga {start.get('yoga')}|Karana {start.get('karana')}"
|
||||
f"|Hora {start.get('hora')}")
|
||||
print(f" 止:Tithi {end.get('tithi_full_name')}|Nakshatra {end.get('nakshatra')}"
|
||||
f" pada {end.get('nakshatra_pada')}|Yoga {end.get('yoga')}|Karana {end.get('karana')}"
|
||||
f"|Hora {end.get('hora')}")
|
||||
for field, change in (window.get("element_changes_within_window") or {}).items():
|
||||
print(f" ⚠️ 时段内 {field} 变化:{change.get('from')} → {change.get('to')}(约 {change.get('at_local')})")
|
||||
legacy = day.get("legacy_reference") or {}
|
||||
if legacy:
|
||||
print(f" 旧单点参考(不作最终判定):{legacy.get('quality')} {legacy.get('score')}")
|
||||
gaps = result.get("gaps") or []
|
||||
if gaps:
|
||||
print()
|
||||
print("缺口:")
|
||||
for gap in gaps:
|
||||
print(f" · {gap.get('field')}:{gap.get('reason')}")
|
||||
print()
|
||||
print("━" * 64)
|
||||
print("口径:地点、时区、岁差、交点都由调用方显式给出;五要素在时段起止两端各取一次。")
|
||||
print("━" * 64)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_muhurta(args, chart_data: Optional[Dict] = None) -> int:
|
||||
"""
|
||||
Muhurta 子命令主入口。
|
||||
|
||||
|
||||
args 属性:
|
||||
args.date: '2026-06-15'(查询日期,默认今天)
|
||||
args.activity: 活动类型(可选,默认全部)
|
||||
args.scan_days: 扫描天数(可选,>1 时扫描多天)
|
||||
args.hour_from_sunrise: 从日出起算的小时(默认 6h)
|
||||
args.hour_from_sunrise: 从日出起算的小时(只用于无地点的 legacy 概览)
|
||||
args.lat / args.lon / args.tz / args.timezone_id: 地点与时区(给了就做地点精确择时)
|
||||
args.ayanamsa / args.node_mode: 口径(给了地点时必填)
|
||||
"""
|
||||
from muhurta import muhurta_full_report, ACTIVITY_RULES
|
||||
|
||||
# 解析日期
|
||||
if hasattr(args, 'date') and args.date:
|
||||
try:
|
||||
query_dt = datetime.strptime(args.date, '%Y-%m-%d')
|
||||
@@ -116,22 +204,47 @@ def cmd_muhurta(args, chart_data: Optional[Dict] = None) -> int:
|
||||
hour_from_sunrise = getattr(args, 'hour_from_sunrise', 6.0)
|
||||
target_activity = getattr(args, 'activity', None)
|
||||
scan_days = getattr(args, 'scan_days', 1)
|
||||
ayanamsa_name = getattr(args, 'ayanamsa', None) or DEFAULT_AYANAMSA_NAME
|
||||
lat = getattr(args, 'lat', None)
|
||||
lon = getattr(args, 'lon', None)
|
||||
tz = getattr(args, 'tz', None)
|
||||
timezone_id = getattr(args, 'timezone_id', None)
|
||||
ayanamsa = getattr(args, 'ayanamsa', None)
|
||||
node_mode = getattr(args, 'node_mode', None)
|
||||
ayanamsa_name = ayanamsa or DEFAULT_AYANAMSA_NAME
|
||||
|
||||
activities = list(ACTIVITY_RULES.keys())
|
||||
if target_activity and target_activity not in activities:
|
||||
print(f"[ERROR] 未知活动: {target_activity}。支持: {', '.join(activities)}")
|
||||
return 1
|
||||
|
||||
# 占星师 Q9 / 第五轮 9:有地点才做地点精确择时;没地点只给近似概览,不给择时结论。
|
||||
if lat is not None and lon is not None and (tz is not None or timezone_id):
|
||||
return _print_location_exact(
|
||||
query_dt.strftime('%Y-%m-%d'),
|
||||
target_activity,
|
||||
float(lat),
|
||||
float(lon),
|
||||
None if tz is None else float(tz),
|
||||
timezone_id,
|
||||
ayanamsa,
|
||||
node_mode,
|
||||
None,
|
||||
5,
|
||||
)
|
||||
|
||||
print("⚠️ 未提供地点:结果为近似值,不能作为择时结论")
|
||||
print("=" * 64)
|
||||
print("🕐 Muhurta 择时分析(印度吉凶历)")
|
||||
print("🕐 Muhurta 五要素概览(无地点,近似)")
|
||||
print("=" * 64)
|
||||
|
||||
dates_to_check = [query_dt + timedelta(days=i) for i in range(max(1, int(scan_days)))]
|
||||
|
||||
has_swe = True
|
||||
for dt in dates_to_check:
|
||||
# 原值:swe.julday(year, month, day, int(hour_from_sunrise + 6)),被当成 UT,等于 UT 12:00
|
||||
# 新值:显式按 UT 12:00 取概览并写明是 UT,不再假装它是当地时间
|
||||
# 原因:占星师 Q9 / 第五轮 9:择时须用地点、实际日出、时区
|
||||
sun_lon, moon_lon, has_swe = _get_sun_moon_lons(
|
||||
dt.year, dt.month, dt.day, int(hour_from_sunrise + 6), # rough solar hour
|
||||
dt.year, dt.month, dt.day, 12.0,
|
||||
ayanamsa_name=ayanamsa_name,
|
||||
)
|
||||
vara_idx = _weekday_to_vara(dt.weekday())
|
||||
@@ -147,7 +260,7 @@ def cmd_muhurta(args, chart_data: Optional[Dict] = None) -> int:
|
||||
)
|
||||
|
||||
precision_note = '' if has_swe else '(近似值,±2°精度)'
|
||||
print(f"\n📅 {date_str} {precision_note}")
|
||||
print(f"\n📅 {date_str} UT 12:00 概览 {precision_note}")
|
||||
print(f" Sun: {sun_lon:.1f}° Moon: {moon_lon:.1f}°")
|
||||
print()
|
||||
|
||||
@@ -181,9 +294,9 @@ def cmd_muhurta(args, chart_data: Optional[Dict] = None) -> int:
|
||||
print()
|
||||
print("━" * 64)
|
||||
print("注意事项:")
|
||||
print(" 1. 本分析基于恒星黄经(Lahiri Ayanamsa),为 Parashari 传统")
|
||||
print(" 2. 精确 Muhurta 还需考虑出生盘 Lagna 与目标时间的相位关系")
|
||||
print(" 3. 建议结合 Ascendant 力量、吉星位置做综合判断")
|
||||
print(" 1. 本概览没有地点,日出日落与五要素都不是当地实际值,不能作为择时结论")
|
||||
print(" 2. 需要地点精确择时,请加 --lat --lon --tz(或 --timezone-id)--ayanamsa --node-mode")
|
||||
print(" 3. 精确 Muhurta 还需考虑出生盘 Lagna 与目标时间的相位关系")
|
||||
if not has_swe:
|
||||
print(" 4. ⚠️ 当前使用近似算法(swisseph 未安装),精度约 ±2°")
|
||||
print("━" * 64)
|
||||
|
||||
@@ -10467,6 +10467,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
|
||||
elif path == '/api/muhurta':
|
||||
result = self._compute_muhurta(body)
|
||||
self._json(result)
|
||||
elif path == '/api/muhurta_search':
|
||||
# 占星师 Q9 / 第五轮 9:地点精确、口径显式的择时搜索;逻辑在 scripts/muhurta_search_api.py。
|
||||
muhurta_search = _load_local_module('muhurta_search_api')
|
||||
try:
|
||||
result = muhurta_search.muhurta_search_api(body)
|
||||
except muhurta_search.MuhurtaSearchInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
self._json(result)
|
||||
elif path == '/api/panchanga_range':
|
||||
result = self._compute_panchanga_range(body)
|
||||
self._json(result)
|
||||
|
||||
@@ -18931,7 +18931,14 @@ def main():
|
||||
help='指定活动类型(默认检查所有)')
|
||||
p.add_argument('--scan-days', type=int, default=1, help='扫描天数(默认1天,最多30天)')
|
||||
p.add_argument('--hour-from-sunrise', type=float, default=6.0,
|
||||
help='从日出起算的小时数(默认6h约正午)')
|
||||
help='从日出起算的小时数(默认6h约正午;给了地点时只是 legacy 对照,不作最终判定)')
|
||||
# 占星师 Q9 / 第五轮 9:择时须用地点、实际日出、时区,口径显式传入。
|
||||
p.add_argument('--lat', type=float, default=None, help='纬度(给了地点才做地点精确择时)')
|
||||
p.add_argument('--lon', type=float, default=None, help='经度')
|
||||
p.add_argument('--tz', type=float, default=None, help='时区小时数(例如 5.5)')
|
||||
p.add_argument('--timezone-id', default=None, help='IANA 时区名(例如 Europe/London,夏令时自动处理)')
|
||||
p.add_argument('--ayanamsa', default=None, help='岁差,例如 lahiri / raman(给了地点时必填)')
|
||||
p.add_argument('--node-mode', default=None, choices=['mean', 'true'], help='交点口径(给了地点时必填)')
|
||||
|
||||
# 21. marriage-match(上游 bde5d615;只收盘快照)
|
||||
p = sub.add_parser('marriage-match', help='婚恋合盘(两份盘快照)')
|
||||
@@ -19048,7 +19055,12 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(args, 'tz') and args.tz is None:
|
||||
# 原值:只要命令有 --tz 且没给就按 lat/lon 推断
|
||||
# 新值:lat/lon 都没给时跳过推断(择时不给地点就是不给地点,不猜时区)
|
||||
# 原因:占星师 Q9 / 第五轮 9:择时须用地点、实际日出、时区
|
||||
if (hasattr(args, 'tz') and args.tz is None
|
||||
and getattr(args, 'lat', None) is not None
|
||||
and getattr(args, 'lon', None) is not None):
|
||||
from timezone_utils import infer_timezone
|
||||
from datetime import datetime
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""地点精确、口径显式的择时搜索(占星师 2026-11-07 Q9 / 2026-11-08 第五轮第 9 条,BUG-1822)。
|
||||
|
||||
原做法的问题(`scripts/cmd_muhurta.py` 与 `muhurta.panchanga_range_report`):
|
||||
|
||||
1. 参考时刻被当成 UT 传给 `swe.julday`,等于 UT 12:00,不是当地时间;
|
||||
2. 完全不用地点,日出日落退回 06:00 / 18:00,日月位置退回近似算法;
|
||||
3. 五要素(Tithi、Nakshatra、Yoga、Karana)只在「日出 + 6 小时」这一个点取值,用这一个值给全天打分。
|
||||
|
||||
本模块只做三件事:
|
||||
|
||||
- 每天用 SwissEph 取**当地实际日出与日落**;取不到就整天 `blocked`,不退回 06:00 / 18:00,也不退回近似日月位置。
|
||||
- 对每个候选时段,在该时段的**开始与结束两个实际时刻**各取一次五要素与 Hora,并标出时段内的变化与变化时刻。
|
||||
- 规则、分数、阈值一律沿用 `scripts/muhurta.py` 现有函数(`muhurta_range_search` 给出的候选与评分原样放进 `legacy_reference`,标 `not_final`),本模块不重算分数、不改权重。
|
||||
|
||||
输出信封的字段与 #173 的按需计算合同保持一致(`schema`、`tool`、`calculation_status`、`profile`、
|
||||
`requested`、`input_fingerprint`、`engine_version`、`raw`、`gaps`、`computed_at_utc`),
|
||||
以便将来直接接进聊天的按需计算工具。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
try: # scripts/ 作为包
|
||||
from scripts import muhurta as muhurta_module
|
||||
from scripts.ayanamsa_utils import is_supported_ayanamsa_name, normalize_ayanamsa_name
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
import muhurta as muhurta_module
|
||||
from ayanamsa_utils import is_supported_ayanamsa_name, normalize_ayanamsa_name
|
||||
|
||||
SCHEMA = 'jyotish.muhurta_search.v1'
|
||||
TOOL = 'muhurta_search'
|
||||
MAX_DAYS = 63
|
||||
NODE_MODES = ('mean', 'true')
|
||||
# 五要素与 Hora 的取法:都用 muhurta.py 自己的计算函数,保证规则口径一致。
|
||||
ELEMENT_FIELDS = ('tithi', 'nakshatra', 'yoga', 'karana', 'hora')
|
||||
|
||||
|
||||
class MuhurtaSearchInputError(ValueError):
|
||||
"""缺输入或输入不合法。消息里点名缺的字段,不补默认值。"""
|
||||
|
||||
def __init__(self, message: str, field: Optional[str] = None) -> None:
|
||||
super().__init__(message)
|
||||
self.field = field
|
||||
|
||||
|
||||
def _require(body: Dict[str, Any], key: str) -> Any:
|
||||
value = body.get(key)
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
raise MuhurtaSearchInputError(f'missing required field: {key}', key)
|
||||
return value
|
||||
|
||||
|
||||
def _number(body: Dict[str, Any], key: str, low: float, high: float) -> float:
|
||||
value = _require(body, key)
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise MuhurtaSearchInputError(f'{key} must be a number', key) from exc
|
||||
if number < low or number > high:
|
||||
raise MuhurtaSearchInputError(f'{key} must be between {low} and {high}', key)
|
||||
return number
|
||||
|
||||
|
||||
def _parse_date(value: Any, field: str) -> datetime:
|
||||
try:
|
||||
return datetime.strptime(str(value)[:10], '%Y-%m-%d')
|
||||
except ValueError as exc:
|
||||
raise MuhurtaSearchInputError(f'{field} must be YYYY-MM-DD', field) from exc
|
||||
|
||||
|
||||
def _offset_hours_for(day: datetime, tz: Optional[float], timezone_id: Optional[str]) -> float:
|
||||
"""当地 UTC 偏移小时数。给了 timezone_id 就按那一天换算,夏令时自动生效。"""
|
||||
if timezone_id:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError as exc: # pragma: no cover - Python < 3.9
|
||||
raise MuhurtaSearchInputError('timezone_id needs Python 3.9+ zoneinfo', 'timezone_id') from exc
|
||||
try:
|
||||
zone = ZoneInfo(str(timezone_id))
|
||||
except Exception as exc: # noqa: BLE001 - 未知时区名就是输入错误
|
||||
raise MuhurtaSearchInputError(f'unknown timezone_id: {timezone_id}', 'timezone_id') from exc
|
||||
local_noon = datetime(day.year, day.month, day.day, 12, 0, tzinfo=zone)
|
||||
offset = local_noon.utcoffset()
|
||||
if offset is None: # pragma: no cover - 只有固定偏移时区会给 None
|
||||
raise MuhurtaSearchInputError(f'unknown timezone_id: {timezone_id}', 'timezone_id')
|
||||
return offset.total_seconds() / 3600.0
|
||||
return float(tz) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _minutes(hhmm: Optional[str]) -> Optional[int]:
|
||||
if not hhmm or ':' not in str(hhmm):
|
||||
return None
|
||||
hour, minute = str(hhmm).split(':', 1)
|
||||
try:
|
||||
return int(hour) * 60 + int(minute)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _hhmm(minutes: float) -> str:
|
||||
total = int(round(minutes)) % (24 * 60)
|
||||
return f'{total // 60:02d}:{total % 60:02d}'
|
||||
|
||||
|
||||
def _jd_for(day: datetime, local_hours: float, tz: float) -> float:
|
||||
jd = muhurta_module._jd_for_local_time(day.year, day.month, day.day, local_hours, tz) # noqa: SLF001
|
||||
if jd is None:
|
||||
raise MuhurtaSearchInputError('cannot resolve this local time', 'date')
|
||||
return jd
|
||||
|
||||
|
||||
def _sun_moon(day: datetime, local_hours: float, tz: float, ayanamsa: str) -> Optional[Tuple[float, float]]:
|
||||
positions = muhurta_module._swisseph_sun_moon_lon( # noqa: SLF001
|
||||
_jd_for(day, local_hours, tz), ayanamsa_name=ayanamsa,
|
||||
)
|
||||
if not positions:
|
||||
return None
|
||||
return float(positions[0]), float(positions[1])
|
||||
|
||||
|
||||
def _elements_at(
|
||||
day: datetime,
|
||||
local_hours: float,
|
||||
tz: float,
|
||||
ayanamsa: str,
|
||||
sunrise_local_hours: Optional[float],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""该时刻的五要素与 Hora。取不到 SwissEph 位置就返回 None,不做近似。"""
|
||||
positions = _sun_moon(day, local_hours, tz, ayanamsa)
|
||||
if positions is None:
|
||||
return None
|
||||
sun_lon, moon_lon = positions
|
||||
weekday = (day.weekday() + 1) % 7
|
||||
tithi = muhurta_module.calc_tithi(sun_lon, moon_lon)
|
||||
nakshatra = muhurta_module.calc_nakshatra_from_lon(moon_lon)
|
||||
yoga = muhurta_module.calc_yoga(sun_lon, moon_lon)
|
||||
karana = muhurta_module.calc_karana(sun_lon, moon_lon)
|
||||
hour_from_sunrise = local_hours - sunrise_local_hours if sunrise_local_hours is not None else 0.0
|
||||
hora = muhurta_module.calc_hora(weekday, hour_from_sunrise)
|
||||
return {
|
||||
'local_time': _hhmm(local_hours * 60),
|
||||
'utc_time': _hhmm((local_hours - tz) * 60),
|
||||
'tithi': (tithi or {}).get('name'),
|
||||
'tithi_full_name': (tithi or {}).get('full_name'),
|
||||
'paksha': (tithi or {}).get('paksha'),
|
||||
'tithi_number': (tithi or {}).get('tithi_num'),
|
||||
'nakshatra': (nakshatra or {}).get('nakshatra'),
|
||||
'nakshatra_pada': (nakshatra or {}).get('pada'),
|
||||
'yoga': (yoga or {}).get('yoga'),
|
||||
'karana': (karana or {}).get('karana'),
|
||||
'hora': (hora or {}).get('hora_lord'),
|
||||
'hora_quality': (hora or {}).get('quality'),
|
||||
}
|
||||
|
||||
|
||||
def _element_changes(
|
||||
start: Dict[str, Any],
|
||||
end: Dict[str, Any],
|
||||
bisect: Callable[[float], Optional[Dict[str, Any]]],
|
||||
start_hours: float,
|
||||
end_hours: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""时段内发生变化的要素,以及变化发生的当地时刻(二分到分钟)。"""
|
||||
changes: Dict[str, Any] = {}
|
||||
for field in ELEMENT_FIELDS:
|
||||
before = start.get(field)
|
||||
after = end.get(field)
|
||||
if before is None or after is None or before == after:
|
||||
continue
|
||||
low, high = start_hours, end_hours
|
||||
changed_at = None
|
||||
for _ in range(12):
|
||||
middle = (low + high) / 2.0
|
||||
sample = bisect(middle)
|
||||
if sample is None:
|
||||
break
|
||||
if sample.get(field) == before:
|
||||
low = middle
|
||||
else:
|
||||
high = middle
|
||||
changed_at = middle
|
||||
changes[field] = {
|
||||
'from': before,
|
||||
'to': after,
|
||||
'at_local': _hhmm((changed_at if changed_at is not None else end_hours) * 60),
|
||||
}
|
||||
return changes
|
||||
|
||||
|
||||
def _fingerprint(payload: Dict[str, Any]) -> str:
|
||||
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
|
||||
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _envelope(
|
||||
*,
|
||||
requested: Dict[str, Any],
|
||||
profile: Dict[str, Any],
|
||||
status: str,
|
||||
raw: Dict[str, Any],
|
||||
gaps: List[Dict[str, str]],
|
||||
fingerprint: str,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
'schema': SCHEMA,
|
||||
'tool': TOOL,
|
||||
'calculation_status': status,
|
||||
'profile': profile,
|
||||
'requested': requested,
|
||||
'input_fingerprint': fingerprint,
|
||||
'engine_version': 'muhurta.py',
|
||||
'raw': raw,
|
||||
'gaps': gaps,
|
||||
'computed_at_utc': datetime.now(dt_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
}
|
||||
|
||||
|
||||
def _validate(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(body, dict):
|
||||
raise MuhurtaSearchInputError('body must be an object')
|
||||
activity = str(_require(body, 'activity'))
|
||||
if activity not in muhurta_module.ACTIVITY_RULES:
|
||||
raise MuhurtaSearchInputError(f'unknown activity: {activity}', 'activity')
|
||||
start_date = _parse_date(_require(body, 'start_date'), 'start_date')
|
||||
end_date = _parse_date(_require(body, 'end_date'), 'end_date')
|
||||
if end_date < start_date:
|
||||
raise MuhurtaSearchInputError('end_date must be on or after start_date', 'end_date')
|
||||
days = (end_date - start_date).days + 1
|
||||
if days > MAX_DAYS:
|
||||
raise MuhurtaSearchInputError(f'date range must be at most {MAX_DAYS} days', 'end_date')
|
||||
lat = _number(body, 'lat', -90.0, 90.0)
|
||||
lon = _number(body, 'lon', -180.0, 180.0)
|
||||
tz_value = body.get('tz')
|
||||
timezone_id = body.get('timezone_id')
|
||||
if tz_value is None and not timezone_id:
|
||||
raise MuhurtaSearchInputError('missing required field: tz or timezone_id', 'tz')
|
||||
tz = None if tz_value is None else _number(body, 'tz', -12.0, 14.0)
|
||||
ayanamsa = str(_require(body, 'ayanamsa'))
|
||||
if not is_supported_ayanamsa_name(ayanamsa):
|
||||
raise MuhurtaSearchInputError(f'unknown ayanamsa: {ayanamsa}', 'ayanamsa')
|
||||
node_mode = str(_require(body, 'node_mode')).lower()
|
||||
if node_mode not in NODE_MODES:
|
||||
raise MuhurtaSearchInputError('node_mode must be mean or true', 'node_mode')
|
||||
time_window = body.get('time_window')
|
||||
if time_window is not None and not isinstance(time_window, dict):
|
||||
raise MuhurtaSearchInputError('time_window must be an object with start and end', 'time_window')
|
||||
limit = body.get('limit')
|
||||
return {
|
||||
'activity': activity,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'tz': tz,
|
||||
'timezone_id': str(timezone_id) if timezone_id else None,
|
||||
'ayanamsa': normalize_ayanamsa_name(ayanamsa),
|
||||
'node_mode': node_mode,
|
||||
'time_window': time_window,
|
||||
'limit': max(1, min(int(limit or 5), 20)),
|
||||
}
|
||||
|
||||
|
||||
def muhurta_search_api(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""按地点、实际日出、时区搜索择时窗口;每个时段在起止两端核对五要素。"""
|
||||
inputs = _validate(body)
|
||||
requested = {
|
||||
'activity': inputs['activity'],
|
||||
'start_date': inputs['start_date'].strftime('%Y-%m-%d'),
|
||||
'end_date': inputs['end_date'].strftime('%Y-%m-%d'),
|
||||
'lat': inputs['lat'],
|
||||
'lon': inputs['lon'],
|
||||
'tz': inputs['tz'],
|
||||
'timezone_id': inputs['timezone_id'],
|
||||
'ayanamsa': inputs['ayanamsa'],
|
||||
'node_mode': inputs['node_mode'],
|
||||
'time_window': inputs['time_window'],
|
||||
'limit': inputs['limit'],
|
||||
}
|
||||
fingerprint = _fingerprint({'requested': requested})
|
||||
|
||||
first_day = inputs['start_date']
|
||||
tz = _offset_hours_for(first_day, inputs['tz'], inputs['timezone_id'])
|
||||
profile = {
|
||||
'ayanamsa': inputs['ayanamsa'],
|
||||
'node_mode': inputs['node_mode'],
|
||||
'lat': inputs['lat'],
|
||||
'lon': inputs['lon'],
|
||||
'tz': tz,
|
||||
'timezone_id': inputs['timezone_id'],
|
||||
'sunrise_policy': 'swisseph_rise_trans',
|
||||
'time_source': 'actual_local_sunrise',
|
||||
}
|
||||
|
||||
days: List[Dict[str, Any]] = []
|
||||
gaps: List[Dict[str, str]] = []
|
||||
candidate_index: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
if any(inputs['timezone_id'] for _ in (0,)):
|
||||
# timezone_id 的偏移可能逐日不同(夏令时),所以先按天算,再统一放进每天的行里。
|
||||
pass
|
||||
|
||||
try:
|
||||
legacy = muhurta_module.muhurta_range_search(
|
||||
requested['start_date'],
|
||||
requested['end_date'],
|
||||
activity=inputs['activity'],
|
||||
limit=inputs['limit'],
|
||||
lat=inputs['lat'],
|
||||
lon=inputs['lon'],
|
||||
tz=tz,
|
||||
ayanamsa_name=inputs['ayanamsa'],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - 搜索失败也要返回信封,且不编任何时刻
|
||||
gaps.append({'field': 'search', 'reason': f'search_failed:{type(exc).__name__}'})
|
||||
return _envelope(
|
||||
requested=requested, profile=profile, status='blocked', raw={}, gaps=gaps, fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
for item in legacy.get('best_windows') or []:
|
||||
date_key = str(item.get('date') or '')
|
||||
if date_key:
|
||||
candidate_index[date_key] = item
|
||||
|
||||
current = inputs['start_date']
|
||||
while current <= inputs['end_date']:
|
||||
date_str = current.strftime('%Y-%m-%d')
|
||||
day_tz = _offset_hours_for(current, inputs['tz'], inputs['timezone_id'])
|
||||
solar = muhurta_module._swisseph_solar_times_local( # noqa: SLF001
|
||||
current.year, current.month, current.day, inputs['lat'], inputs['lon'], day_tz,
|
||||
)
|
||||
if not solar:
|
||||
gaps.append({'field': f'sunrise:{date_str}', 'reason': 'sunrise_unavailable'})
|
||||
days.append({
|
||||
'date': date_str,
|
||||
'calculation_status': 'blocked',
|
||||
'reason': 'sunrise_unavailable',
|
||||
'windows': [],
|
||||
})
|
||||
current += timedelta(days=1)
|
||||
continue
|
||||
sunrise_local_hours = solar.get('sunrise_local_hours')
|
||||
sunset_local_hours = solar.get('sunset_local_hours')
|
||||
candidate = candidate_index.get(date_str) or {}
|
||||
windows = []
|
||||
for window in candidate.get('recommended_windows') or []:
|
||||
start_minutes = _minutes(window.get('start'))
|
||||
end_minutes = _minutes(window.get('end'))
|
||||
if start_minutes is None or end_minutes is None:
|
||||
continue
|
||||
start_sample = _elements_at(
|
||||
current, start_minutes / 60.0, day_tz, inputs['ayanamsa'], sunrise_local_hours,
|
||||
)
|
||||
end_sample = _elements_at(
|
||||
current, end_minutes / 60.0, day_tz, inputs['ayanamsa'], sunrise_local_hours,
|
||||
)
|
||||
if start_sample is None or end_sample is None:
|
||||
gaps.append({'field': f'elements:{date_str}:{window.get("start")}', 'reason': 'ephemeris_unavailable'})
|
||||
windows.append({
|
||||
'type': window.get('type'),
|
||||
'name': window.get('name'),
|
||||
'start_local': window.get('start'),
|
||||
'end_local': window.get('end'),
|
||||
'calculation_status': 'blocked',
|
||||
'reason': 'ephemeris_unavailable',
|
||||
})
|
||||
continue
|
||||
changes = _element_changes(
|
||||
start_sample,
|
||||
end_sample,
|
||||
lambda hours: _elements_at(
|
||||
current, hours, day_tz, inputs['ayanamsa'], sunrise_local_hours,
|
||||
),
|
||||
start_minutes / 60.0,
|
||||
end_minutes / 60.0,
|
||||
)
|
||||
windows.append({
|
||||
'type': window.get('type'),
|
||||
'name': window.get('name'),
|
||||
'quality': window.get('quality'),
|
||||
'start_local': window.get('start'),
|
||||
'end_local': window.get('end'),
|
||||
'start_utc': start_sample['utc_time'],
|
||||
'end_utc': end_sample['utc_time'],
|
||||
'elements_at_start': start_sample,
|
||||
'elements_at_end': end_sample,
|
||||
'element_changes_within_window': changes,
|
||||
'calculation_status': 'executed',
|
||||
})
|
||||
days.append({
|
||||
'date': date_str,
|
||||
'calculation_status': 'executed',
|
||||
'tz_offset_hours': day_tz,
|
||||
'sunrise_local': solar.get('sunrise'),
|
||||
'sunset_local': solar.get('sunset'),
|
||||
'sunrise_utc': _hhmm(float(solar.get('sunrise_utc_hours') or 0.0) * 60),
|
||||
'sunset_utc': _hhmm(float(solar.get('sunset_utc_hours') or 0.0) * 60),
|
||||
'sunrise_policy': solar.get('policy'),
|
||||
'legacy_reference': {
|
||||
'not_final': True,
|
||||
'note': '现有 muhurta_range_search 的单点参考时刻评分,只作对照,不是最终择时结论',
|
||||
'score': candidate.get('score'),
|
||||
'quality': candidate.get('quality'),
|
||||
'activity_verdict': candidate.get('activity_verdict'),
|
||||
},
|
||||
'windows': windows,
|
||||
})
|
||||
current += timedelta(days=1)
|
||||
|
||||
executed = [day for day in days if day.get('calculation_status') == 'executed']
|
||||
if not executed:
|
||||
return _envelope(
|
||||
requested=requested,
|
||||
profile=profile,
|
||||
status='blocked',
|
||||
raw={'days': days} if days else {},
|
||||
gaps=gaps or [{'field': 'search', 'reason': 'no_day_executed'}],
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
status = 'executed' if len(executed) == len(days) and not gaps else 'partial'
|
||||
return _envelope(
|
||||
requested=requested,
|
||||
profile=profile,
|
||||
status=status,
|
||||
raw={'days': days, 'activity': inputs['activity']},
|
||||
gaps=gaps,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""地点精确、口径显式的择时搜索(占星师 2026-11-07 Q9 / 2026-11-08 第五轮第 9 条,BUG-1822)。
|
||||
|
||||
原做法:参考时刻被当成 UT 12:00、完全不用地点、五要素只取「日出 + 6 小时」一个点。
|
||||
新做法:按地点取实际日出日落,每个候选时段在起止两端各取一次五要素,取不到就阻塞。
|
||||
只用公开地点与日期,不含任何个人资料。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts import muhurta as muhurta_module # noqa: E402
|
||||
from scripts.muhurta_search_api import ( # noqa: E402
|
||||
MAX_DAYS,
|
||||
MuhurtaSearchInputError,
|
||||
muhurta_search_api,
|
||||
)
|
||||
|
||||
DELHI = {'lat': 28.6139, 'lon': 77.2090, 'tz': 5.5}
|
||||
LONDON = {'lat': 51.5074, 'lon': -0.1278, 'timezone_id': 'Europe/London'}
|
||||
BASE = {'activity': 'business', 'start_date': '2026-06-15', 'end_date': '2026-06-15', 'ayanamsa': 'lahiri', 'node_mode': 'mean'}
|
||||
|
||||
|
||||
def _body(**overrides):
|
||||
body = dict(BASE)
|
||||
body.update(DELHI)
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def test_each_required_field_is_named_when_missing() -> None:
|
||||
for field in ('activity', 'start_date', 'end_date', 'lat', 'lon', 'ayanamsa', 'node_mode'):
|
||||
body = _body()
|
||||
body.pop(field)
|
||||
with pytest.raises(MuhurtaSearchInputError) as excinfo:
|
||||
muhurta_search_api(body)
|
||||
assert field in str(excinfo.value) or excinfo.value.field == field, field
|
||||
body = _body()
|
||||
body.pop('tz')
|
||||
with pytest.raises(MuhurtaSearchInputError) as excinfo:
|
||||
muhurta_search_api(body)
|
||||
assert excinfo.value.field == 'tz'
|
||||
|
||||
|
||||
def test_unknown_activity_is_refused_not_silently_switched() -> None:
|
||||
with pytest.raises(MuhurtaSearchInputError) as excinfo:
|
||||
muhurta_search_api(_body(activity='not_a_real_activity'))
|
||||
assert 'not_a_real_activity' in str(excinfo.value)
|
||||
|
||||
|
||||
def test_range_longer_than_the_cap_is_refused() -> None:
|
||||
with pytest.raises(MuhurtaSearchInputError):
|
||||
muhurta_search_api(_body(start_date='2026-01-01', end_date='2026-06-15'))
|
||||
assert MAX_DAYS == 63
|
||||
|
||||
|
||||
def test_sunrise_matches_the_swisseph_helper_and_differs_between_cities() -> None:
|
||||
delhi = muhurta_search_api(_body())
|
||||
london = muhurta_search_api(_body(**LONDON))
|
||||
delhi_day = delhi['raw']['days'][0]
|
||||
london_day = london['raw']['days'][0]
|
||||
expected_delhi = muhurta_module._swisseph_solar_times_local(2026, 6, 15, DELHI['lat'], DELHI['lon'], DELHI['tz'])
|
||||
assert expected_delhi, 'SwissEph sunrise must be available in this environment'
|
||||
assert delhi_day['sunrise_local'] == expected_delhi['sunrise']
|
||||
assert delhi_day['sunrise_policy'] == 'swisseph_rise_trans'
|
||||
assert delhi_day['sunrise_local'] != london_day['sunrise_local']
|
||||
|
||||
|
||||
def test_timezone_id_follows_daylight_saving() -> None:
|
||||
london = muhurta_search_api(_body(**LONDON))
|
||||
assert london['profile']['tz'] == 1.0, 'Europe/London on 2026-06-15 is BST (+1)'
|
||||
winter = muhurta_search_api(_body(**LONDON, start_date='2026-01-15', end_date='2026-01-15'))
|
||||
assert winter['profile']['tz'] == 0.0
|
||||
|
||||
|
||||
def test_every_window_carries_elements_at_both_ends() -> None:
|
||||
result = muhurta_search_api(_body())
|
||||
windows = [window for day in result['raw']['days'] for window in day['windows']]
|
||||
assert windows, 'a business day in Delhi must have candidate windows'
|
||||
for window in windows:
|
||||
start = window['elements_at_start']
|
||||
end = window['elements_at_end']
|
||||
for field in ('tithi', 'nakshatra', 'yoga', 'karana', 'hora'):
|
||||
assert start[field] is not None, field
|
||||
assert end[field] is not None, field
|
||||
assert start['local_time'] == window['start_local']
|
||||
assert end['local_time'] == window['end_local']
|
||||
assert window['start_utc'] != window['start_local']
|
||||
|
||||
|
||||
def test_a_change_inside_a_window_is_reported_with_its_time(monkeypatch) -> None:
|
||||
"""把月亮推快,让它在一个时段内跨过星宿边界;断言变化与变化时刻都被标出。"""
|
||||
original = muhurta_module._swisseph_sun_moon_lon
|
||||
|
||||
def fast_moon(jd, ayanamsa_name=None):
|
||||
positions = original(jd, ayanamsa_name=ayanamsa_name)
|
||||
if not positions:
|
||||
return positions
|
||||
sun, moon = positions
|
||||
return sun, (moon + (jd * 97.0) % 360.0) % 360.0
|
||||
|
||||
monkeypatch.setattr(muhurta_module, '_swisseph_sun_moon_lon', fast_moon)
|
||||
result = muhurta_search_api(_body())
|
||||
windows = [window for day in result['raw']['days'] for window in day['windows']]
|
||||
changed = [window for window in windows if 'nakshatra' in (window.get('element_changes_within_window') or {})]
|
||||
assert changed, [window.get('element_changes_within_window') for window in windows]
|
||||
change = changed[0]['element_changes_within_window']['nakshatra']
|
||||
assert change['from'] != change['to']
|
||||
assert change['at_local']
|
||||
|
||||
|
||||
def test_no_sunrise_blocks_the_day_without_falling_back_to_0600(monkeypatch) -> None:
|
||||
monkeypatch.setattr(muhurta_module, '_swisseph_solar_times_local', lambda *args, **kwargs: None)
|
||||
result = muhurta_search_api(_body())
|
||||
assert result['calculation_status'] == 'blocked'
|
||||
day = result['raw']['days'][0]
|
||||
assert day['calculation_status'] == 'blocked'
|
||||
assert day['reason'] == 'sunrise_unavailable'
|
||||
assert day['windows'] == []
|
||||
serialized = repr(result)
|
||||
assert '06:00' not in serialized
|
||||
assert '18:00' not in serialized
|
||||
|
||||
|
||||
def test_fingerprint_is_stable_and_reacts_to_the_profile() -> None:
|
||||
first = muhurta_search_api(_body())
|
||||
second = muhurta_search_api(_body())
|
||||
assert first['input_fingerprint'] == second['input_fingerprint']
|
||||
other = muhurta_search_api(_body(ayanamsa='raman'))
|
||||
assert other['input_fingerprint'] != first['input_fingerprint']
|
||||
assert other['profile']['ayanamsa'] == 'raman'
|
||||
delhi_start = first['raw']['days'][0]['windows'][0]['elements_at_start']['nakshatra']
|
||||
raman_start = other['raw']['days'][0]['windows'][0]['elements_at_start']['nakshatra']
|
||||
# 五要素两套岁差各自算出来(可能不同,这里只要求都算出来了)
|
||||
assert delhi_start and raman_start
|
||||
|
||||
|
||||
def test_legacy_single_point_score_is_marked_not_final() -> None:
|
||||
result = muhurta_search_api(_body())
|
||||
legacy = result['raw']['days'][0]['legacy_reference']
|
||||
assert legacy['not_final'] is True
|
||||
|
||||
|
||||
def test_route_is_registered_thinly() -> None:
|
||||
source = (ROOT / 'scripts' / 'jyotish_api_server.py').read_text(encoding='utf-8')
|
||||
assert "elif path == '/api/muhurta_search':" in source
|
||||
assert "_load_local_module('muhurta_search_api')" in source
|
||||
assert 'muhurta_search.muhurta_search_api(body)' in source
|
||||
|
||||
|
||||
def test_cli_with_location_prints_real_sunrise_and_no_legacy_single_point(monkeypatch, capsys) -> None:
|
||||
from scripts.cmd_muhurta import cmd_muhurta
|
||||
|
||||
class Args:
|
||||
date = '2026-06-15'
|
||||
activity = 'business'
|
||||
scan_days = 1
|
||||
hour_from_sunrise = 6.0
|
||||
lat = DELHI['lat']
|
||||
lon = DELHI['lon']
|
||||
tz = DELHI['tz']
|
||||
timezone_id = None
|
||||
ayanamsa = 'lahiri'
|
||||
node_mode = 'mean'
|
||||
|
||||
assert cmd_muhurta(Args()) == 0
|
||||
printed = capsys.readouterr().out
|
||||
api = muhurta_search_api(_body())
|
||||
assert api['raw']['days'][0]['sunrise_local'] in printed
|
||||
assert '日出' in printed
|
||||
assert '不提供地点' not in printed
|
||||
|
||||
|
||||
def test_cli_without_location_says_it_is_only_an_approximation(capsys) -> None:
|
||||
from scripts.cmd_muhurta import cmd_muhurta
|
||||
|
||||
class Args:
|
||||
date = '2026-06-15'
|
||||
activity = 'business'
|
||||
scan_days = 1
|
||||
hour_from_sunrise = 6.0
|
||||
lat = None
|
||||
lon = None
|
||||
tz = None
|
||||
timezone_id = None
|
||||
ayanamsa = None
|
||||
node_mode = None
|
||||
|
||||
assert cmd_muhurta(Args()) == 0
|
||||
printed = capsys.readouterr().out
|
||||
first_line = printed.splitlines()[0]
|
||||
assert '未提供地点' in first_line
|
||||
assert '不能作为择时结论' in first_line
|
||||
assert '推荐时段' not in printed
|
||||
|
||||
|
||||
def test_cli_with_location_requires_an_explicit_profile(capsys) -> None:
|
||||
from scripts.cmd_muhurta import cmd_muhurta
|
||||
|
||||
class Args:
|
||||
date = '2026-06-15'
|
||||
activity = 'business'
|
||||
scan_days = 1
|
||||
hour_from_sunrise = 6.0
|
||||
lat = DELHI['lat']
|
||||
lon = DELHI['lon']
|
||||
tz = DELHI['tz']
|
||||
timezone_id = None
|
||||
ayanamsa = None
|
||||
node_mode = None
|
||||
|
||||
assert cmd_muhurta(Args()) == 1
|
||||
assert '--ayanamsa' in capsys.readouterr().out
|
||||
@@ -1,80 +0,0 @@
|
||||
"""BUG-1809: rectification test sets resealed on the round-7 integration (staging b453505d).
|
||||
|
||||
The tag pins sensitivity measures, not accuracy (event scoring is near chance, BUG-1644).
|
||||
Older tags must stay byte-for-byte what they were; this file only adds a new tag.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RESEARCH = ROOT / "docs/research"
|
||||
TAG = "round7_integration_2026_11_09"
|
||||
OLD = "round5_pvr_narayana_2026_11_08"
|
||||
REASON = "产品负责人 2026-11-09 批准;第七轮整合(R1 默认 p50、R2 局部传播 seed_only、可比领先)"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((RESEARCH / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_sealed_20_pins_the_new_tag() -> None:
|
||||
# 原值:sealed_holdout_rerun_round5_pvr_narayana_2026_11_08 top-1 0.35、top-3 0.45、平均分钟误差 6.80
|
||||
# 新值:sealed_holdout_rerun_round7_integration_2026_11_09 top-1 0.30、top-3 0.45、平均分钟误差 6.75
|
||||
# 原因:产品负责人 2026-11-09 批准;第七轮整合(R1 默认 p50、R2 局部传播 seed_only、可比领先)
|
||||
report = _load(f"sealed_holdout_rerun_{TAG}.json")
|
||||
assert report["trial_count"] == 20 and report["implementation_hash_matches_at_replay"] is True
|
||||
assert report["metrics"]["top_1_rate"] == 0.3
|
||||
assert report["metrics"]["top_3_rate"] == 0.45
|
||||
assert report["metrics"]["mean_absolute_minute_error"] == 6.75
|
||||
assert report["metrics"]["false_confirmation_rate"] == 0.0
|
||||
assert report["verified_minute_claim_allowed"] is False and report["results_previously_seen"] is True
|
||||
assert report["status"] == "blocked_independent_blind_evidence"
|
||||
|
||||
|
||||
def test_reported_offset_900_pins_the_new_tag() -> None:
|
||||
# 原值:reported_offset_round5_pvr_narayana_2026_11_08 top-1 0.0156、交付覆盖率 0.900
|
||||
# 新值:reported_offset_round7_integration_2026_11_09 top-1 0.0100、交付覆盖率 0.883(164/900 次、7 例变)
|
||||
# 原因:产品负责人 2026-11-09 批准;第七轮整合(R1 默认 p50、R2 局部传播 seed_only、可比领先)
|
||||
report = _load(f"reported_offset_{TAG}.json")
|
||||
trials = report["trials"]
|
||||
assert len(trials) == 900 and report["implementation_hash_matches_at_replay"] is True
|
||||
assert report["freeze_record_path"] == f"docs/research/reported_offset_{TAG}.freeze.json"
|
||||
assert round(sum(t["top_1_hit"] for t in trials) / 900, 4) == 0.01
|
||||
assert round(sum(t["delivery_covers_truth"] for t in trials) / 900, 4) == 0.8833
|
||||
old = {(t["case_ordinal"], t["offset_minutes"], t["radius_minutes"]): t for t in _load(f"reported_offset_{OLD}.json")["trials"]}
|
||||
changed = [t for t in trials if old[(t["case_ordinal"], t["offset_minutes"], t["radius_minutes"])] != t]
|
||||
assert len(changed) == 164 and len({t["case_ordinal"] for t in changed}) == 7
|
||||
|
||||
|
||||
def test_live_v5_and_comparability_are_recorded_in_the_tag() -> None:
|
||||
# 原值:live_astrologer_v5_round5_pvr_narayana_2026_11_08 最佳 = 真值 8、真值在前三 19
|
||||
# 新值:live_astrologer_v5_round7_integration_2026_11_09 最佳 = 真值 8、真值在前三 18;
|
||||
# 可比性策略 common_evidence_coverage_v1,暂列 / 可比领先计数写进标签
|
||||
# 原因:产品负责人 2026-11-09 批准;第七轮整合(R1 默认 p50、R2 局部传播 seed_only、可比领先)
|
||||
live = _load(f"live_astrologer_v5_{TAG}.json")
|
||||
assert live["tag"] == TAG and live["baseline_tag"] == OLD
|
||||
assert len(live["trials"]) == 231 and live["summary"]["new"]["best_is_truth"] == 8
|
||||
assert live["summary"]["changed"]["any"] == 124
|
||||
assert live["summary"]["pre_integration_staging"]["new_vs_it"]["any"] == 115
|
||||
assert live["narayana_strength_scope"].startswith("seed_only_v0")
|
||||
assert live["comparability_policy"].startswith("common_evidence_coverage_v1")
|
||||
assert live["leader_coverage_status"] == {"comparable": 226, "provisional_unequal_coverage": 5}
|
||||
comparability = _load(f"{TAG}_comparability.json")
|
||||
assert comparability["policy"] == "common_evidence_coverage_v1"
|
||||
assert comparability["sealed_20"]["leader_status"] == {"comparable": 40}
|
||||
assert comparability["reported_offset_900"]["leader_status"] == {
|
||||
"comparable": 863, "provisional_unequal_coverage": 35, "no_single_leader": 2}
|
||||
assert comparability["live_v5_231"]["leader_status"] == live["leader_coverage_status"]
|
||||
|
||||
|
||||
def test_older_tags_stay_intact() -> None:
|
||||
sealed = _load(f"sealed_holdout_rerun_{OLD}.json")["metrics"]
|
||||
assert (sealed["top_1_rate"], sealed["top_3_rate"], sealed["mean_absolute_minute_error"]) == (0.35, 0.45, 6.8)
|
||||
offset = _load(f"reported_offset_{OLD}.json")["trials"]
|
||||
assert round(sum(t["top_1_hit"] for t in offset) / 900, 4) == 0.0156
|
||||
assert _load(f"live_astrologer_v5_{OLD}.json")["summary"]["new"]["best_is_truth"] == 8
|
||||
for old_tag in ("float_split_2026_11_08", "astrologer_166d_2026_11_05", OLD):
|
||||
for kind in ("sealed_holdout_rerun", "reported_offset"):
|
||||
assert (RESEARCH / f"{kind}_{old_tag}.json").exists()
|
||||
assert (RESEARCH / f"{kind}_{old_tag}.freeze.json").exists()
|
||||
assert REASON in Path(__file__).read_text(encoding="utf-8")
|
||||
Reference in New Issue
Block a user