Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 74a739b2dc feat(card): seven acceptance counterexamples end to end (BUG-1812)
- career layer gets the D1 longitudes, so the paksha has one source (Obama
  base vs career mismatch fixed)
- card carries movable_lagna_moon.reason, Narayana ad_status / ad_reason and
  the mahadasha cycle_index; running line says "sub-period undecided"
- engine keeps the MD when the AD is undecided; timingFollowing skips
  zero_duration rows explicitly
- timingKeys allowlist: adstatus / adreason / cycleindex / zeroduration
- fixtures + capture script, 16 engine tests, 7 card tests, PROGRESS, BUG-1812

Co-Authored-By: Grok (agent loop)
2026-11-09 08:38:56 +08:00
ClaudeandClaude Opus 5.5 41862634f3 docs: task brief for #169 counterexamples (2026-11-09)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 07:18:12 +08:00
25 changed files with 7481 additions and 63232 deletions
+26 -17
View File
@@ -24398,21 +24398,30 @@
- 关联记录:无
- 修复版本:无
## BUG-1809 | 第七轮整合上 staging 后,生时校正三组测试集仍封存在 round5 标签:重封 round7_integration_2026_11_09
## BUG-1812 | 验收清单七个反例:事业层与基础层「月相」不一致,数据卡丢掉月相原因 / 小运未决状态 / 第几轮大运
- 状态:fixed-pending-verify(本地分支 `codex/counterexamples-20261109`,未推送)
- 首次发现 / 最近更新:2026-11-09(占星师 2026-11-08《近期六轮问题闭环与网站验收清单》§7)/ 2026-11-09
- 影响面:`scripts/consultation_native_layers.py`(`build_career_analysis`、`build_dasha_systems`)、`scripts/narayana_legacy_label.py`(`_period`、`rath_narayana_view`)、`frontend/src/lib/consultation-evidence-card.ts`(`functionalProfileFields`、`NARAYANA_MD_CARD_KEYS`、`DASHA_ROW_KEYS`、`baseTiming`、`narayanaRunningLine`、`timingFollowing`)、`frontend/src/mastra/consultation-workflow.ts`(`timingKeys` 白名单)。
- 现象(三处,同一批验收反例):
1. **同一张盘两层月相不一致。** 一张动星座上升、月相可从 D1 经度算出的公开盘,基础层 `functional_benefic_malefic.movable_lagna_moon` 是 `derived_from_table_30_note`(waning / neutral),事业层 `consultation_native_layers.career_analysis…movable_lagna_moon` 却是 `blocked` / `moon_paksha_missing`。
2. **卡上说不出「为什么没月相」。** `functionalProfileFields` 只抄 `status` / `moon_paksha` / `role`,丢掉 `reason`,「未给月相」与「表里没这一格」在卡上同形。
3. **小运未决被写成「没算」。** 小运定不出时 `current_dasha` 整段没有 `ad`,卡上分不开「子运未决」与「子运没算」;`narayanaRunningLine` 只写主运。**第几轮大运**(`cycle_index`)和 **0 年段**(`zero_duration`)同样没有上卡,卡上无法区分第一轮与第二轮、也无法说明 0 年段不是当前段。
- 触发条件:(1) 动星座(Aries / Libra / Capricorn)上升且事业层从 `_affliction_planets` 的行拿行星;(2) 任何缺月相输入;(3) 该大运的 AD 起点比较未决(例如土星与计都同在运星座,`saturn_ketu_priority_unresolved`)、年龄进入第二轮、或盘里存在 0 年段。
- 根因:
1. `build_career_analysis` 交给 `analyze_career` 的行来自 `_affliction_planets`,只有 `house` / `sign`,没有绝对经度;`analyze_career` 内的 `moon_paksha_from_planets` 因此判不出盈亏,而基础层用的是真实的 D1 经度 —— 同一个判定有两个数据源。
2. 卡上的 `movable_lagna_moon` 字段清单是手写白名单,加了 `status` 没加 `reason`。
3. `rath_narayana_view` 只在 AD 选定后写 `current_dasha`,AD 未决时整段省略;`_period` 的字段清单里没有 `cycle_index` / `zero_duration`;卡的 `DASHA_ROW_KEYS` / `NARAYANA_MD_CARD_KEYS` 也没有对应键,且 `timingKeys` 白名单同样没有,新字段即使引擎给了也会在投给模型的 `timing` 投影里被裁掉。
- 修复:
1. 事业层与基础层同源:`build_career_analysis` 把 D1 的 `lon` 并入交给 `analyze_career` 的行(缺经度时仍返回 `moon_paksha_missing`,不猜)。
2. `functionalProfileFields` 在 `movable_lagna_moon` 上照抄 `reason`;缺月相时卡上明确 `reason = moon_paksha_missing`,只有月亮不进功能吉凶三档。
3. `rath_narayana_view` 在 AD 未决时保留大运并写 `ad_status = blocked` / `ad_reason`;`_period` 带 `cycle_index`(有值时)与 `zero_duration`(为真时);`build_dasha_systems` 把这四项原样带到 `dasha_systems` 行;卡上 `NARAYANA_MD_CARD_KEYS` 加 `cycle_index`、`DASHA_ROW_KEYS` 与 `timingKeys` 加 `ad_status` / `ad_reason` / `cycle_index` / `zero_duration`、`narayanaRunningLine` 在 AD 未决时写「子运未决(原因)」、`timingFollowing` 显式跳过 `zero_duration: true` 的行。
- 验证:
- 引擎:`tests/test_counterexamples_engine_20261109.py`(16 条)。七个反例各一条,外加「引擎当前输出 == fixture」与「fixture 覆盖七个反例」「fixture 不含个人资料」。断言了:缺月相只阻塞月亮且两层一致;未决步骤被点名且 `used_for_scoring` 为假、没有补出来的当前大运;AD 未决时 MD 星座 / 轮次 / 日期保留、`ad_reason` 被点名、无 `ad`;自参照格局 `independent_condition_count = 0`;出生小运同时留父段起点与可见起点且 9 条边界不移动;第二轮 `cycle_index = 2`、年数 11;0 年段留在数据里带 `zero_duration`、当前段是 Aquarius。
- 数据卡:`frontend/tests/consult-counterexamples-card-20261109.test.ts`(7 条),读同一份 fixture 经真实链路 `parse → toAgentConsultationContext → toModelOutput → buildEvidenceCard`。修前同一批测试在基线 `b453505d` 上 3 条失败((a)、(c)、(f)),修后 7/7 通过;连同 `consult-self-reference-pipeline-20261108`、`consult-evidence-card-20260927`、`consult-evidence-card-v2-20260927` 共 36/36 通过、0 cancelled。
- `tsc --noEmit` 0 错;`npm run lint` 0 error。
- 防复发:上述两套测试;fixture 由 `scripts/research/capture_counterexample_fixtures_20261109.py` 生成,引擎行为一变 `test_fixture_is_fresh_for_every_counterexample` 立即失败。卡上字段清单(`NARAYANA_MD_CARD_KEYS` / `DASHA_ROW_KEYS` / `timingKeys`)与引擎字段一一对应,改一处必须同时改另一处,否则对应用例失败。
- 未处理:数据卡上的大运(MD)仍拿不到 `parent_period_start` / `visible_period_start` —— 唯一生产者是冻结文件 `scripts/jyotish_api_server.py::_consultation_dasha_sub_periods` / `_consultation_birth_moment_mahadashas`(只返回 `lord` / `start` / `end`),本单按任务书不碰冻结文件;出生余额的 MD 裁剪改为在引擎层断言(`sub_periods.current.mahadasha.start` 早于出生)。要上卡需另开一单。
- 关联记录:BUG-1732(Narayana 逐步未决)、BUG-1735(运星座与日期上卡)、BUG-1741(五步 profile)、BUG-1783(`profile_config`)、BUG-1812 所在验收清单 §3 / §5 / §7。
- 修复版本:本地分支 `codex/counterexamples-20261109`
- 状态: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`
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,74 @@
# PROGRESS:占星师验收清单的七个反例(#169 / BUG-1812)
- 分支:`codex/counterexamples-20261109`(基于任务书提交,代码基线 `b453505d`)
- 任务书:`docs/tasks/TASK-counterexamples-20261109.md`
- 开工时 `docs/BUG_HISTORY.md` 最大编号:BUG-1804;按任务书用 BUG-1812
- 完成日期:2026-11-09
## 结论
七个反例各有引擎侧与数据卡侧测试,共 16 条 Python + 7 条 TS,全部通过。三处卡上漏字段(月相 `reason`、小运 `ad_status` / `ad_reason`、大运 `cycle_index`)已补,Obama 盘事业层与基础层「月相缺失」不一致已修。
## 七个反例
| 反例 | 引擎函数 | 卡上字段 | 修前结果 | 修后结果 | 测试名 |
| --- | --- | --- | --- | --- | --- |
| (a) 缺月相 | `build_career_analysis` → `analyze_career` / `derive_functional_benefic_malefic` | `base.natal.functional_benefic_malefic.movable_lagna_moon`、`domain_sections.career.career_analysis.*.movable_lagna_moon` | Obama 基础层 `derived_from_table_30_note` / waning / neutral,事业层 `blocked` / `moon_paksha_missing`;卡上 `movable_lagna_moon` 没有 `reason`,两种情形分不开 | 两层同源(事业层拿到 D1 经度);缺月相时两层都 `blocked` + `moon_paksha_missing`,卡上带 `reason`,只有月亮被阻塞、其余六颗照常分类 | `test_a_missing_moon_paksha_blocks_only_the_moon_and_both_layers_agree` / `(a) missing moon paksha` |
| (b) 低档强弱未决 | `narayana_rath.compare_signs` / `calc_narayana_rath` / `shadbala.calc_shadbala` + `attach_parameter_sensitivity` | `base.timing.narayana.method_status`、`domain_sections.career.shadbala` | 未决通过 `method_status` 能到卡,但没有一条测试证明「未定步骤被点名且不补当前大运」;Shadbala 的 `parameter_sensitive` 也没有端到端用例 | `comparison_status=unresolved`、`blocked_step=seed`、`unresolved_steps=["seed"]`、`chat_use=no_judgment`、`md` 缺失进 gaps;Jupiter 保留 `minimum_check=parameter_sensitive` 与开放问题,不写达标 / 不达标 | `test_b_low_grade_comparison_undecided_names_the_step_and_scores_nothing` / `(b) undecided low-grade comparison` |
| (c) 小运未决、大运已定 | `narayana_rath.antardasha` / `branch_under` / `product_narayana` / `build_dasha_systems` | `base.timing.narayana.ad_status`、`ad_reason`;`dasha_systems.systems.narayana.ad_status`、`ad_reason`;`method_status.running` | `current_dasha` 在 AD 未决时干脆没有 `ad`,卡上「没有 ad」与「没算」分不开;`running` 只写主运 | 大运 Taurus(第 2 轮)与日期原样保留;`ad_status=blocked`、`ad_reason=saturn_ketu_priority_unresolved`;`running` 写「子运未决(saturn_ketu_priority_unresolved)」;没有任何补出来的 AD | `test_c_ad_undecided_keeps_the_md_and_names_the_reason` / `(c) undecided sub-period…` |
| (d) 月亮自参照 | `neecha_bhanga_rows` / `build_yoga_conditions` | `domain_sections.career.independence.self_reference_only`、`.self_reference` | 已有单测与 2026-11-08 的管线测试,但没有「引擎行 → fixture → 卡」这一环 | 自参照格局进 `self_reference_only`、带自参照说明;注入这一行与不注入时 `support` 逐项相同(一票都不加) | `test_d_moon_self_reference_is_never_independent_confirmation` / `(d) Moon-only self-reference…` |
| (e) 出生余额 | `chart_dasha.birth_moment.antardashas` / `_consultation_dasha_sub_periods` | `base.timing.vimshottari.antardashas_in_mahadasha[].parent_period_start` / `visible_period_start`、`base.timing.vimshottari.mahadasha` | golden 三张盘都是成年人,卡上的裁剪分支从未被真实数据覆盖 | 合成年轻盘(2020-05-01,余额 Mercury 剩 10.72 年):出生小运同时带父段起点 `2020-04-12` 与可见起点 `2020-05-01`,9 条子运边界与未裁剪版本逐日相同 | `test_e_birth_balance_keeps_the_full_parent_start_beside_the_visible_one` / `(e) birth balance…` |
| (f) 第二轮大运 | `narayana_rath.mahadasha_sequence` / `product_narayana` / `birth_moment.next_cycle_mahadashas` | `base.timing.narayana.md.cycle_index` | `_period` 不抄 `cycle_index`,卡上说不出「第几轮」 | 当前大运 Aquarius、`cycle_index=2`、`years=11` 与引擎行一致;24 行里只有一行覆盖参考年龄;Vimshottari 跨 120 年后当前段 `cycle=2`(Moon,10 年) | `test_f_second_cycle_is_named_and_the_running_row_is_unique` / `(f) second cycle…` |
| (g) 0 年大运 | `narayana_rath.mahadasha_sequence`(`zero_duration`) | `base.timing.narayana.md`、`narayana.next` | 卡上只靠 `years > 0` 间接排除 0 年段,引擎的 `zero_duration` 标记没被用上 | 0 年段(Sagittarius 第 1 轮、`start_age == end_age == 38`)留在 `mahadasha` 数据里并带 `zero_duration`;参考日恰在段起点时当前大运是 Aquarius(第 2 轮,11 年);`next` 不含 0 年段 | `test_g_zero_year_period_is_kept_in_the_data_and_never_current` / `(g) zero-year period…` |
## 修前 / 修后
修前用基线 `b453505d` 另开一次性工作树(`counterexamples-before`,跑完已删除),把同一批测试与 fixture 放进去:
- 数据卡 7 条:3 条失败 —— `(a)`、`(c)`、`(f)`,正好是卡上三处漏字段。
- 引擎 16 条:`test_fixture_is_fresh_for_every_counterexample` 失败(引擎输出与 fixture 不一致),其余通过(断言读 fixture,不重跑引擎)。
修后同一条命令全绿(见下)。工作树已删,`git worktree list` 不再有 `counterexamples-before`。
## 改了什么
- `scripts/consultation_native_layers.py`
- `build_career_analysis`:把 D1 经度并入交给 `analyze_career` 的行(原值只给 `house` / `sign`),事业层与基础层同源。
- `build_dasha_systems`:Narayana 大运带 `cycle_index` / `zero_duration`,整行带 `ad_status` / `ad_reason`(都照抄引擎)。
- `scripts/narayana_legacy_label.py`
- `_period` 带 `cycle_index`(有值时)与 `zero_duration`(为真时)。
- `rath_narayana_view`:AD 未决时 `current_dasha` 照给大运,另带 `ad_status=blocked` / `ad_reason`;AD 选定时 `ad_status=selected`。
- `frontend/src/lib/consultation-evidence-card.ts`
- `functionalProfileFields`:`movable_lagna_moon` 带 `reason`。
- `NARAYANA_MD_CARD_KEYS` 加 `cycle_index`。
- `DASHA_ROW_KEYS` 加 `ad_status` / `ad_reason`。
- `baseTiming`:`narayana` 带 `ad_status` / `ad_reason`。
- `narayanaRunningLine`:AD 未决时写「子运未决(原因)」。
- `timingFollowing`:显式跳过 `zero_duration: true` 的行。
- `frontend/src/mastra/consultation-workflow.ts`:`timingKeys` 白名单加 `adstatus` / `adreason` / `cycleindex` / `zeroduration`(否则这些字段在投给模型的 `timing` 里被裁掉)。
- 新增:`scripts/research/capture_counterexample_fixtures_20261109.py`、7 份 fixture、`tests/test_counterexamples_engine_20261109.py`、`frontend/tests/consult-counterexamples-card-20261109.test.ts`。
## 偏离与原因
1. **卡上 MD 没有 `parent_period_start` / `visible_period_start`。** 任务书要求「卡上 MD 与 AD 同时带」,但 MD 这两个字段的唯一生产者是冻结文件 `scripts/jyotish_api_server.py`:`_consultation_birth_moment_mahadashas` 只返回 `lord` / `start` / `end`,`boundary()` 因此拿不到父段起点。任务书把该文件列为只读,所以卡上只断言出生小运的两个字段,MD 的裁剪在引擎层断言(`sub_periods.current.mahadasha.start` 早于出生、`end` 晚于出生;`birth_moment.antardashas` 的边界不移动)。若要 MD 也上卡,需要另开一单改冻结文件。
2. **改了 `scripts/narayana_legacy_label.py`。** 任务书「要改的文件」没列它,但它是 `current_dasha` 的唯一生产者:`ad_status` / `cycle_index` 不在这里带出,卡上就没有来源。只加字段,不动 `narayana_rath` 的公式、规则与年数。
3. **改了 `frontend/src/mastra/consultation-workflow.ts` 的 `timingKeys`。** 不加白名单,新字段在 `toModelOutput` 的 `timing` 投影里被裁掉,卡上永远看不到。只加四个键名,不加字段、不改结构。
4. **本工作树的环境。** `frontend/node_modules` 与 `.venv` 不随 worktree 复制:node_modules 用目录联接指向 `.worktrees/agent-loop/frontend/node_modules`(git 不跟踪),Python 用同一工作树的 venv(`.venv\Scripts\python.exe`)。不是代码改动。
## 跑过的命令与结果
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| fixture 生成 | `.venv\Scripts\python scripts/research/capture_counterexample_fixtures_20261109.py` | 7 份写入 `frontend/tests/fixtures/counterexamples-20261109/` |
| 引擎测试 | `.venv\Scripts\python -m pytest tests/test_counterexamples_engine_20261109.py -q` | 16 passed |
| 数据卡测试 | `node node_modules/tsx/dist/cli.mjs --test tests/consult-counterexamples-card-20261109.test.ts` | 7 pass / 0 fail |
| 相关卡片回归 | 同上再带 `consult-self-reference-pipeline-20261108`、`consult-evidence-card-20260927`、`consult-evidence-card-v2-20260927` | 36 tests / 36 pass / 0 fail / 0 cancelled,退出码 0 |
| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 0 错 |
| lint | `npm run lint`(frontend) | 0 error / 62 warning(既有警告,未新增) |
前端全量测试与 Python 快速门按任务书留给 Claude 验收。
## 没做 / 留给验收
- 回答阶段(模型读卡后怎么写)的盲测由 Claude 另做。
- 生产 / staging 部署核对由 Claude 在合入后做。
+170
View File
@@ -0,0 +1,170 @@
# TASK:占星师验收清单的七个反例,从引擎到数据卡端到端测试(2026-11-09)
- 分支:`codex/counterexamples-20261109`(基于 staging `b453505d`)
- Bug 编号:BUG-1812(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已预留给本批任务)
- 负责验收:Claude(jyotisha-2a)
## 背景
占星师 2026-11-08《近期六轮问题闭环与网站验收清单》§7 原文:“另加入缺月相、低档强弱未决、AD未决但MD已定、Moon自参照、出生余额、第二周期与0年等反例。”
§5 的要求:每个问题沿“计算 → 数据卡 → 模型输入 → 回答 → 导出”五个阶段验收。原文:“不要只测第一阶段,再从最后一阶段‘读起来不错’推断中间过程正确。”数据卡阶段必须检查:“是否丢失必要分盘、父段、限制、冲突;不能把未送达写成未计算。”
§3 P0 的通过标准:“只阻塞依赖项;旧候选不可冒充主结果;缺失不能产生评分;独立确定的MD保留。”
**本任务把七个反例都做成从计算一路到数据卡的测试。** 测试暴露的数据卡漏字段,在本任务内补上。回答阶段的盲测由 Claude 另做。
## 测试怎么做(现有机制)
仓库里没有在同一次运行里同时调用 Python 和 TS 数据卡的测试。现有做法分两层:
1. **Python 层**:调用真实引擎函数,断言状态字段。
2. **TS 层**:用真实引擎输出抓取的 golden,经过真实前端链路生成数据卡。链路是 `consultationWorkflowResponseSchema.parse` → `toAgentConsultationContext` → `toModelOutput` → `buildEvidenceCard`。
- 模板:`frontend/tests/consult-self-reference-pipeline-20261108.test.ts`。它对 golden 做 `structuredClone`,把形状与引擎一致的行注入 `chart.consultation_native_layers.*`。
- golden 文件:`frontend/tests/fixtures/consult-evidence-card-golden.json`。生成脚本是 `scripts/research/capture_consult_evidence_card_golden.py`,含 Jobs、Obama、Taylor 三张公开盘。
**端到端的要求**:每个反例都要有一个 Python 测试。这个测试调用真实引擎函数,把输出的那一段 JSON 写成 fixture,放在 `frontend/tests/fixtures/counterexamples-20261109/<case>.json`。
- 生成方式写成脚本 `scripts/research/capture_counterexample_fixtures_20261109.py`,可重复运行。
- Python 测试断言:“现在引擎的输出 == fixture”,防止 fixture 过期。
TS 测试读同一份 fixture,经真实前端链路生成数据卡,再断言卡上的字段。**不得在 TS 里手造引擎形状。**
只用公开名人盘(golden 里已有的那些)或合成输入(`tests/neutral_subject.py`,或像 `tests/test_round6_r1_r2_narayana.py` 那样构造的 lagna + 经度字典)。
## 七个反例(b453505d 实测位置,行号以符号为准)
### (a) 缺月相
- **引擎**:`scripts/functional_benefics.py` 的 `_pvr_roles`(约 :276–301)。动宫上升、`moon_paksha=None` 时,结果为 `movable_lagna_moon = {status:"blocked", reason:"moon_paksha_missing", ...}`,`blocked_planets:["Moon"]`。只阻塞月亮。
- **已知真实缺陷,本任务要修**:Obama 盘(golden 内)出现两层结果不一致。
- `chart.modules.functional_benefic_malefic.movable_lagna_moon` 为 waning / neutral。
- `consultation_native_layers.career_analysis.functional_benefic_malefic.movable_lagna_moon` 却是 `blocked / moon_paksha_missing`。
- 原因:`scripts/consultation_native_layers.py` 的 `build_career_analysis`(约 :1280)传入的是 `_affliction_planets`(约 :1210)的行。这些行没有度数,`scripts/career_analysis.py`(约 :93)因此算不出月相。
- 修法:让事业层拿到月亮与太阳的经度,或直接拿到基础层已经算好的月相。修复点只在 `consultation_native_layers.py` 或 `career_analysis.py`。
- **数据卡**:`frontend/src/lib/consultation-evidence-card.ts` 的 `functionalProfileFields`(约 :1325–1345)只抄了 `{status, moon_paksha, role}`,丢掉了 `reason`。要补上 `reason`。
- **断言**:
- 真缺月相(合成盘不给月亮经度)时:卡上为 `blocked` 且 `reason` 为 `moon_paksha_missing`;只有月亮被阻塞,其他行星的功能吉凶照常;月亮不参与吉凶计票。
- Obama 盘修复后,两层月相一致。
### (b) 低档强弱未决
- **Narayana**:`scripts/narayana_rath.py` 的 `compare_signs`(约 :562–614)返回 `status:"undecided"`、`comparison_status:"unresolved"`、`blocked_level`。
- `calc_narayana_rath`(约 :1001)在种子判不出时为 `status:"blocked"`、`blocked_step:"seed"`;年数判不出时为 `status:"partial_blocked"`、`blocked_step:"years"`。
- 产品视图:`scripts/narayana_legacy_label.py` 的 `rath_narayana_view`(约 :252)与 `narayana_method_status`(约 :194)。
- 卡上:`consultation-evidence-card.ts` 的 `narayanaMethodStatus`(约 :1703)与 `baseTiming`(约 :1786、:1834–1846)。
- **Shadbala**:
- `scripts/shadbala_service/sensitivity.py` 的 `attach_parameter_sensitivity`(约 :237);
- `scripts/shadbala_display.py` 的 `row_fields`(约 :47–63),输出 `minimum_check:"parameter_sensitive"`;
- 卡上:`SHADBALA_CARD_KEYS` 与 `shadbalaCardRows`(约 :1438–1449)。
- **断言**:
- 卡上 Narayana 的未决步骤被点名(种子 / 年数 / 双宫主),不出现“已确认”字样的状态,`used_for_scoring` 为 false。
- Shadbala 为 `parameter_sensitive` 的行星,卡上保留这个标记,并且不以“达标 / 不达标”计入支持或压力(第五轮第 4 条:“只能标为 parameter_sensitive,不能用它决定聊天里的吉凶结论”)。
### (c) 小运(AD)未决,大运(MD)已定
- **引擎**:`narayana_rath.py` 的 `antardasha`(约 :921)返回 `{status:"blocked", reason, rows:[]}`;`branch_under`(约 :1362)返回 `ad:None, ad_status:"blocked", ad_reason`。
- 默认 `STRENGTH_SCOPE_DEFAULT = seed_only_v0`(BUG-1784)。默认设置下,现实的触发条件是土星与计都同在大运星座(`saturn_ketu_priority_unresolved`),或者像 `tests/test_round6_r1_r2_narayana.py:86` 那样在测试里显式放宽范围。两种都要测。
- **卡上的缺口**:`scripts/consultation_native_layers.py` 的 `build_dasha_systems`(约 :1676–1760)在 AD 未决时省略了 `nar["ad"]`。`consultation-evidence-card.ts` 的 `baseTiming`(约 :1826–1830)只在有值时才抄 md / ad / pd。所以现在 AD 未决在卡上只表现为“没有 ad”,与“没算”分不开。这违反 §5 的“不能把未送达写成未计算”。
- **修法**:卡上 Narayana 增加 `ad_status` 与 `ad_reason`(从引擎原样带过来),`narayanaRunningLine`(约 :1769)在 AD 未决时写明“子运未决”。
- **断言**:
- MD 的星座和日期保留,并与引擎相等;
- AD 为未决,且原因被点名;
- 卡上不出现由旧算法补出的 AD。
### (d) 月亮自参照
- **引擎**:`scripts/main_yoga_detectors.py` 的 `neecha_bhanga_rows`(约 :129–163)输出 `self_reference`、`independent_confirmation_allowed:false`。
- **卡上**:`consultation-evidence-card.ts` 的 `yogaSelfReference`(约 :1008–1030)与 `evidenceCardIndependence`(约 :1065)。
- **已有测试**:`frontend/tests/consult-self-reference-pipeline-20261108.test.ts`、`tests/test_round6_self_reference.py`。本任务不重写这两个,只补端到端缺的一环:引擎真输出 → fixture → 卡。
- **断言**:只由月亮自参照形成的格局,不进入 `support`;同一事实重复引用只算一票(现有规则见 BUG-1773、BUG-1774)。
### (e) 出生余额
- **引擎**:`scripts/chart_dasha/birth_moment.py` 的 `antardashas(md, visible_from=)`(约 :88–112):`parent_period_start` 是未裁剪的父段起点,`visible_period_start = max(start, birth)`。
- **服务器**:`scripts/jyotish_api_server.py` 的 `_consultation_dasha_sub_periods` 下的 `boundary()`(约 :1504–1514)。**只读,不改。**
- **卡上**:`CLIPPED_PERIOD_KEYS`(约 :1349)与 `baseTiming`(约 :1786–1812)。
- **缺口**:golden 里三位都是成年人,参考日 2026-09-27 时都已不在出生大运内,所以卡上的“裁剪”分支从未被真实数据覆盖。请用一张合成的年轻盘(参考日落在出生 MD 之内)。
- **断言**:
- 卡上 MD 与 AD 同时带 `parent_period_start` 和 `visible_period_start`,二者不同,且都等于引擎值;
- 子运边界不因裁剪而改变(第五轮第 5 条)。
### (f) 第二周期
- **引擎**:
- `narayana_rath.py` 的 `mahadasha_sequence`(约 :820)设 `cycle_index` 为 1 或 2(第二周期用 12 减去年数);
- `branch_under` 设 `md_cycle`;
- Vimshottari 用 `birth_moment.py` 的 `next_cycle_mahadashas`(约 :35)。
- **缺口**:卡上 `NARAYANA_MD_CARD_KEYS`(约 :1782)没有 `cycle_index`,卡说不出“这是第二周期”。要补上 `cycle_index`。
- **断言**:
- 合成盘的参考年龄落在 Narayana 第二周期内时,卡上当前 MD 带 `cycle_index: 2`,年数等于引擎值;
- Vimshottari 跨过 120 年后,当前段等于引擎值(可参照 `tests/test_vimshottari_second_cycle.py`);
- 其他大运 `not_available_yet` 的行,在卡上进入 gaps,不显示为“当前”。
### (g) 0 年大运
- **引擎**:`narayana_rath.py` 的 `mahadasha_sequence`(约 :828/:860/:886)设 `zero_duration`。
- 判断“当前”的区间是半开的 `[start, end)`:`_running_age`(约 :1355)、`narayana_legacy_label.py` 的 `_running`(约 :235)、`consultation_native_layers.py` 的 `_running`(约 :1505)。
- **卡上**:`timingFollowing`(约 :1633、过滤在 :1667–1671)。golden 里没有 `zero_duration:true` 的行。
- **断言**:
- 合成一张会产生 0 年 Narayana 段的盘(可参照 `tests/test_round5_item_reply_n1_k3_20261108.py:181` 的人马座 0 年 / 12 年构造),参考日恰好落在该段起点;
- 卡上当前 MD 不是 0 年段;
- 0 年段保留在数据里,并带 `zero_duration` 标记(若卡上需要展示时序,就补上这个键)。
## 要改的文件(以实际代码为准)
- **新建**:
- `scripts/research/capture_counterexample_fixtures_20261109.py`
- `frontend/tests/fixtures/counterexamples-20261109/*.json`
- `tests/test_counterexamples_engine_20261109.py`
- `frontend/tests/consult-counterexamples-card-20261109.test.ts`
- **修改**:
- `frontend/src/lib/consultation-evidence-card.ts`:只改上面点名的函数和常量,即 `functionalProfileFields`、`NARAYANA_MD_CARD_KEYS`、`baseTiming` 的 Narayana 段、`narayanaRunningLine`、`timingFollowing`。**不做结构性重排**,压缩是 #170 的事。
- `scripts/consultation_native_layers.py`:(a) 的事业层月相、(c) 的 `ad_status` 透传。
- `scripts/career_analysis.py`:如 (a) 的修复需要。
- 若卡片新增字段导致 `frontend/tests/consult-evidence-card-20260927.test.ts` 的体积上限超出,只许按实测增量放宽,并写三栏注释。
## 不要碰
- `references/**`(占星师方法文件)、`frontend/src/lib/consult-preset-prompts.ts`、`references/product_runtime_contract.md`。
- 冻结文件:
- `scripts/jyotish_api_server.py`(只读);
- `scripts/functional_benefics.py`、`scripts/narayana_dasha.py`、`scripts/dasha_analyzer.py`、`scripts/domain_calculation_service.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`。
- `scripts/narayana_rath.py`:不改公式与规则,本任务只测。
- 其他单子的地盘:
- `frontend/src/mastra/consultation-tools.ts` 全文(#171/#173 在改);
- `frontend/src/app/api/consult/route.ts`(#172);
- `consultation-evidence-card.ts` 里的 `EVIDENCE_LOOKUP_SECTIONS` 与 `evidenceCardIndependence` 的结构(#170/#171)。
- 不改大运计算公式,不改计分权重。
## 验收标准
1. 七个反例各有一条 Python 引擎测试,以及一条读同一 fixture 的数据卡测试,共 14 条以上,全部通过。
2. fixture 由脚本重新生成后与仓内一致。Python 测试断言“引擎当前输出 == fixture”。
3. (a)、(c)、(f) 三处卡上缺字段已补,Obama 两层月相不一致已修。每处修复各有一条“修前会失败”的回归断言。在 PROGRESS 里写明:先在未修代码上跑出失败,再修。
4. 所有“未决 / 阻塞”的反例都断言了不计分、不增票:不进入 `support` 或压力计数,`used_for_scoring` 为 false。
5. 改既有断言时写“原值 / 新值 / 原因”三栏注释。原因写:`占星师 2026-11-08 验收清单 §7 反例:未决不得写成未计算`。
6. 在 `docs/BUG_HISTORY.md` 末尾追加 BUG-1812。记录 Obama 事业层月相不一致、卡上缺 `reason` / `ad_status` / `cycle_index` 这三处。不写个人出生资料。
7. 只用公开名人盘或合成数据。
## 自测命令(Windows / PowerShell)
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
- 在 `frontend` 下运行 `npm run lint`
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs --test tests/consult-counterexamples-card-20261109.test.ts tests/consult-self-reference-pipeline-20261108.test.ts tests/consult-evidence-card-20260927.test.ts tests/consult-evidence-card-v2-20260927.test.ts`
- `.venv\Scripts\python scripts/research/capture_counterexample_fixtures_20261109.py`
- `.venv\Scripts\python -m pytest tests/test_counterexamples_engine_20261109.py tests/test_round6_r1_r2_narayana.py tests/test_round5_item_reply_n1_k3_20261108.py tests/test_round6_self_reference.py -q`
前端全量测试和 Python 快速门由 Claude 在验收时跑。
## 进度记录
`docs/tasks/PROGRESS-counterexamples-20261109.md`。
其中要有一张七行的表,列为:反例 | 引擎函数 | 卡上字段 | 修前结果 | 修后结果 | 测试名。
## 何时停下来选“需拍板”
- 某个反例要修好,就必须改冻结文件(例如 `functional_benefics.py`)或 `narayana_rath.py` 的规则时。
- 引擎本身给出的状态就与占星师的裁定相反时,例如 AD 未决却把 MD 也一起阻塞了。不要自己改引擎,写清楚现象。
- 找不到能产生某个反例(例如 0 年段落在当前)的合法合成输入时。写清楚试过哪些输入。
+27 -3
View File
@@ -1340,7 +1340,14 @@ function functionalProfileFields(value: unknown): JsonRecord {
}
const moon = record(source.movable_lagna_moon);
if (typeof moon.status === "string") {
out.movable_lagna_moon = { status: moon.status, moon_paksha: copy(moon.moon_paksha) ?? null, role: copy(moon.role) ?? null };
// 占星师 2026-11-08 验收清单 §7 反例「缺月相」:卡上原只抄 status / moon_paksha / role,
// 丢掉 reason,于是「未给月相」与「表里没这一格」分不开。reason 原样带上(BUG-1812)。
out.movable_lagna_moon = {
status: moon.status,
moon_paksha: copy(moon.moon_paksha) ?? null,
role: copy(moon.role) ?? null,
...(typeof moon.reason === "string" ? { reason: moon.reason } : {}),
};
}
out.functional_profile_note = EVIDENCE_CARD_LABELS.functionalProfileNote;
return out;
@@ -1668,7 +1675,10 @@ function timingFollowing(
const index = narayanaRows.findIndex((row) => record(row).sign === md.sign && record(row).start_age === md.start_age);
if (index < 0) return;
// A zero-year sign (start age = end age) is not a period anyone lives through.
// 占星师 2026-11-08 验收清单 §7 反例「0 年大运」:引擎行带 zero_duration:true 时同样跳过,
// 不靠 years>0 这一条间接推断(BUG-1812)。
const following = narayanaRows.slice(index + 1).filter((row) => {
if (record(row).zero_duration === true) return false;
const years = record(row).years;
return typeof years !== "number" || years > 0;
});
@@ -1775,11 +1785,18 @@ export function narayanaRunningLine(row: Record<string, unknown>): string | unde
return `${label} ${period.sign}${house}${dates}`;
};
const parts = [part("主运", row.md), part("子运", row.ad)].filter((item): item is string => Boolean(item));
// 占星师 2026-11-08 验收清单 §7 反例「小运未决但大运已定」:大运在、小运未决时,
// 这一行写「子运未决(原因)」,不把未决留成空白(BUG-1812)。
if (!record(row.ad).sign && row.ad_status === "blocked") {
const reason = typeof row.ad_reason === "string" && row.ad_reason ? `(${row.ad_reason})` : "";
parts.push(`子运未决${reason}`);
}
if (!parts.length) return undefined;
return `${parts.join(";")}(名义计算边界)。写成单体系候选时也照写主运、子运的星座和起止`;
}
const NARAYANA_MD_CARD_KEYS = ["sign", "lord", "years", "start_age", "end_age", "dual_lord_basis", "dual_lord_basis_en"] as const;
// 占星师 2026-11-08 验收清单 §7 反例「第二轮大运」:卡上要说得出这是第几轮大运(cycle_index 照抄引擎行)。
const NARAYANA_MD_CARD_KEYS = ["sign", "lord", "years", "start_age", "end_age", "cycle_index", "dual_lord_basis", "dual_lord_basis_en"] as const;
const LEGACY_MD_CARD_KEYS = ["sign", "start_age", "end_age"] as const;
function baseTiming(timing: Record<string, unknown>, gaps: string[], horizon: TimingHorizon | null = null) {
@@ -1825,6 +1842,11 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[], horizon: Ti
}
const remaining = copy(currentNarayana.remaining_years);
if (remaining !== undefined) narayana.remaining_years = remaining;
// 占星师 2026-11-08 验收清单 §7 反例:小运未决时 md 旁边写明 ad_status / ad_reason(引擎原值)。
for (const key of ["ad_status", "ad_reason"] as const) {
const value = copy(currentNarayana[key]);
if (value !== undefined) narayana[key] = value as Json;
}
// BUG-1215 / BUG-1601: say which Narayana this is (the engine's short label).
const algorithmLabel = copy(narayanaModule.algorithm_label);
if (typeof algorithmLabel === "string" && algorithmLabel && (narayana.md || narayanaModule.status === "blocked")) {
@@ -1875,7 +1897,9 @@ const DASHA_ROW_KEYS = [
"applicable", "status", "reason", "profile", "rule", "year_rule_status", "confidence_status", "depth_status",
"date_basis", "cycle_end", "md", "ad", "pd",
// 产品 2026-11-07: the next Narayana AD; where a ported upstream row came from and its status.
"next_ad", "source", "status_note",
// 占星师 2026-11-08 验收清单 §7 反例「小运未决但大运已定」:ad_status / ad_reason 原样带上,
// 卡上才分得清「子运未决」与「子运没算」(BUG-1812)。
"next_ad", "source", "status_note", "ad_status", "ad_reason",
// 占星师第三轮审查 2026-11-07: a gap / overlap between consecutive periods (absent when continuous).
"continuity_issue",
] as const;
@@ -410,6 +410,9 @@ const timingKeys = new Set([
// Without these keys the projection kept `current_dasha` as an empty object
// and dropped the pratyantar dates, although the engine had both.
"md", "ad", "pd", "years", "startage", "endage", "remainingyears", "pratyantardashatimeline",
// 占星师 2026-11-08 验收清单 §7 反例:小运未决的 ad_status / ad_reason、第几轮大运的
// cycle_index、0 年段的 zero_duration 要随 current_dasha 一起到达数据卡(BUG-1812)。
"adstatus", "adreason", "cycleindex", "zeroduration",
// BUG-1643: the next Vimshottari mahadasha with its antardashas (engine `dasha_sub_periods.next_mahadasha`).
"nextmahadasha",
// BUG-1215: the running Narayana's label travels with it.
@@ -0,0 +1,177 @@
// 占星师 2026-11-08 验收清单 §7 的七个反例:从引擎到数据卡的端到端断言(BUG-1812)。
//
// 引擎切片由 scripts/research/capture_counterexample_fixtures_20261109.py 抓进
// fixtures/counterexamples-20261109/*.json(同一个脚本也是 Python 测试的期望值来源),
// 这里把它们注入一张真实 golden workflow,经真实链路
// parse → toAgentConsultationContext → toModelOutput → buildEvidenceCard,
// 再断言卡上的状态、日期与「未决不计票」。TS 里不手造引擎形状。
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildEvidenceCard, EVIDENCE_CARD_SELF_REFERENCE_NOTE, evidenceCardFactId } from "../src/lib/consultation-evidence-card.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import { consultationWorkflowResponseSchema, toAgentConsultationContext, toModelOutput } from "../src/mastra/consultation-workflow.ts";
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads deep engine JSON without a schema
type Json = Record<string, any>;
const FIXTURES = new URL("./fixtures/counterexamples-20261109/", import.meta.url);
const golden = JSON.parse(readFileSync(new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url), "utf8")) as {
charts: Array<{ id: string; workflow: Json }>;
};
function fixture(name: string): Json {
return JSON.parse(readFileSync(new URL(`${name}.json`, FIXTURES), "utf8")) as Json;
}
/** A real golden workflow (public AA chart) with only the engine slices of one case replaced. */
function cardFor(name: string, inject: (chart: Json, engine: Json) => void) {
const data = fixture(name);
const workflow = structuredClone(golden.charts.find((chart) => chart.id === "barack_obama")!.workflow);
inject(workflow.chart, data.engine);
const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(workflow));
const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: "career" }), "career");
return { card: buildEvidenceCard([{ domain: "career", packet, context }]), engine: data.engine };
}
test("(a) missing moon paksha: the card names the reason and both layers agree", () => {
const { card, engine } = cardFor("a_missing_moon_paksha", (chart, data) => {
chart.modules.functional_benefic_malefic = data.blocked.base;
chart.consultation_native_layers.career_analysis.functional_benefic_malefic = data.blocked.career;
});
const natal = (card.base.natal.functional_benefic_malefic as Json).movable_lagna_moon as Json;
assert.deepEqual(natal, { status: "blocked", moon_paksha: null, role: null, reason: "moon_paksha_missing" });
// Only the Moon is blocked: the other planets keep their role, the Moon joins no list.
assert.ok((card.base.natal.functional_benefic_malefic as Json).functional_benefics.length > 0);
for (const key of ["functional_benefics", "functional_malefics", "functional_neutrals"]) {
assert.ok(!((card.base.natal.functional_benefic_malefic as Json)[key] as string[]).includes("Moon"), key);
}
// Career section shows the same verdict as the base layer (engine fix: one paksha source).
const career = ((card.domain_sections.career!.career_analysis as Json).functional_benefic_malefic as Json).movable_lagna_moon as Json;
assert.equal(career.status, natal.status);
assert.equal(career.reason, natal.reason);
assert.equal(engine.blocked.career.movable_lagna_moon.reason, "moon_paksha_missing");
});
test("(b) undecided low-grade comparison: a named blocked step, no invented running period", () => {
const { card } = cardFor("b_low_grade_undecided", (chart, data) => {
chart.modules.narayana_dasha = data.narayana.product;
chart.shadbala = data.shadbala;
});
const narayana = card.base.timing.narayana as Json;
assert.equal(narayana.status, "blocked");
assert.equal(narayana.md, undefined);
assert.deepEqual(narayana.method_status.unresolved_steps, ["seed"]);
assert.equal(narayana.method_status.status, "blocked");
assert.equal(narayana.method_status.chat_use, "no_judgment");
assert.ok(card.meta.gaps.includes("base.narayana.md"), JSON.stringify(card.meta.gaps));
// Shadbala: the flipping planet keeps parameter_sensitive and is never written as pass / fail.
const jupiter = (card.domain_sections.career!.shadbala as Json).Jupiter as Json;
assert.equal(jupiter.minimum_check, "parameter_sensitive");
assert.ok(Array.isArray(jupiter.minimum_open_questions) && jupiter.minimum_open_questions.length > 0);
const text = JSON.stringify(jupiter);
for (const wording of ["达标", "不达标", "meets_minimum"]) assert.ok(!text.includes(wording), wording);
});
test("(c) undecided sub-period keeps the mahadasha and says the sub-period is undecided", () => {
const { card, engine } = cardFor("c_ad_blocked_md_known", (chart, data) => {
chart.modules.narayana_dasha = data.narayana;
// The dasha_systems layer the card reads comes from build_dasha_systems (same engine run);
// only this one row is replaced so the injected Narayana and the table agree.
const layer = chart.consultation_native_layers.dasha_systems as Json;
layer.rows = [...layer.rows.filter((row: Json) => row.system !== "narayana"),
{ ...data.dasha_systems_narayana }];
});
const narayana = card.base.timing.narayana as Json;
assert.equal(narayana.md.sign, engine.md.sign);
assert.equal(narayana.md.cycle_index, engine.md.cycle_index);
assert.equal(narayana.md.start_age, engine.md.start_age);
assert.equal(narayana.ad, undefined);
assert.equal(narayana.ad_status, "blocked");
assert.equal(narayana.ad_reason, "saturn_ketu_priority_unresolved");
// The dasha table row carries the same status and one plain sentence (not "not computed").
const row = ((card.base.timing.dasha_systems as Json).systems as Json).narayana as Json;
assert.equal(row.md.sign, engine.md.sign);
assert.equal(row.ad, undefined);
assert.equal(row.ad_status, "blocked");
assert.equal(row.ad_reason, "saturn_ketu_priority_unresolved");
assert.match(narayana.method_status.running as string, /子运未决/);
assert.match(narayana.method_status.running as string, /saturn_ketu_priority_unresolved/);
});
test("(d) Moon-only self-reference adds no support vote on the card", () => {
const { card } = cardFor("d_moon_self_reference", (chart, data) => {
// The engine caps yoga rows (YOGA_CONDITION_ROW_CAP); the injected row takes the last slot.
const rows = chart.consultation_native_layers.yoga_conditions.rows as Json[];
chart.consultation_native_layers.yoga_conditions.rows = [...rows.slice(0, rows.length - 1), data.row];
chart.consultation_native_layers.yoga_domains.rows =
[...chart.consultation_native_layers.yoga_domains.rows, { name: "Neecha Bhanga", hit: true, domain: "career" }];
});
const section = card.domain_sections.career! as Json;
assert.ok(section.yogas.hits.includes("Neecha Bhanga"));
const independence = section.independence as Json;
assert.deepEqual(independence.self_reference_only, ["Neecha Bhanga"]);
assert.equal(independence.self_reference["Neecha Bhanga"], EVIDENCE_CARD_SELF_REFERENCE_NOTE);
const jupiter = evidenceCardFactId(card.base, "Jupiter")!;
assert.ok(independence.facts[jupiter].includes("Neecha Bhanga(自参照)"));
// 不增票:同一张盘不注入这一行时,support 与注入后逐项相同(自参照格局一票都不加)。
const baseline = cardFor("d_moon_self_reference", () => {}).card.domain_sections.career! as Json;
assert.deepEqual([...(independence.support ?? [])].sort(), [...((baseline.independence as Json).support ?? [])].sort());
assert.deepEqual((baseline.independence as Json).self_reference_only, undefined);
});
test("(e) birth balance: the clipped sub-period keeps its parent start beside the visible one", () => {
const { card, engine } = cardFor("e_birth_balance", (chart, data) => {
chart.modules.dasha_sub_periods = data.sub_periods;
});
const vim = card.base.timing.vimshottari as Json;
const md = vim.mahadasha as Json;
assert.equal(md.lord, engine.sub_periods.current.mahadasha.lord);
assert.equal(md.start, engine.sub_periods.current.mahadasha.start);
assert.ok(md.start < engine.birth.date && engine.birth.date < md.end);
const boundaries = vim.antardashas_in_mahadasha as Json[];
assert.equal(boundaries.length, engine.sub_periods.boundaries.length);
const clipped = boundaries.filter((row) => !Array.isArray(row) && typeof row.parent_period_start === "string");
assert.equal(clipped.length, 1, JSON.stringify(boundaries));
assert.equal(clipped[0]!.parent_period_start, engine.sub_periods.boundaries[0].parent_period_start);
assert.equal(clipped[0]!.visible_period_start, engine.birth.date);
assert.ok(clipped[0]!.parent_period_start < clipped[0]!.visible_period_start);
// The running sub-period is still the engine's, with its own unclipped boundaries.
assert.equal((vim.antardasha as Json).lord, engine.sub_periods.current.antardasha.lord);
assert.equal((vim.antardasha as Json).start, engine.sub_periods.current.antardasha.start);
});
test("(f) second cycle: the card names the cycle and keeps the engine's years", () => {
const { card, engine } = cardFor("f_second_cycle", (chart, data) => {
chart.modules.narayana_dasha = data.narayana;
});
const md = (card.base.timing.narayana as Json).md as Json;
const engineMd = engine.narayana.current_dasha.md as Json;
assert.equal(engineMd.cycle_index, 2);
assert.equal(md.cycle_index, 2);
assert.equal(md.sign, engineMd.sign);
assert.equal(md.years, engineMd.years);
assert.equal(md.start_age, engineMd.start_age);
});
test("(g) zero-year period: never the running one, and it never appears as the next sign", () => {
const { card, engine } = cardFor("g_zero_duration", (chart, data) => {
chart.modules.narayana_dasha = data.narayana;
});
const narayana = card.base.timing.narayana as Json;
const md = narayana.md as Json;
const zeroSigns = (engine.narayana.mahadasha as Json[])
.filter((row) => row.zero_duration === true)
.map((row) => row.sign as string);
assert.ok(zeroSigns.length > 0);
assert.ok(!zeroSigns.includes(md.sign), `${md.sign} is a zero-year sign`);
assert.equal(md.sign, engine.narayana.current_dasha.md.sign);
assert.equal(md.start_age, engine.narayana.current_dasha.md.start_age);
for (const next of (narayana.next as Json[]) ?? []) {
assert.ok(!zeroSigns.includes(next.sign as string), `next sign ${next.sign} is a zero-year sign`);
// A zero-year row has start_age === end_age; no "next" row may be one.
assert.notEqual(next.start_age, next.end_age);
if (typeof next.years === "number") assert.ok(next.years > 0);
}
});
@@ -0,0 +1,537 @@
{
"ayanamsa": "raman",
"case": "a_missing_moon_paksha",
"engine": {
"ascendant": "Capricorn",
"blocked": {
"base": {
"ambiguous_cell_notes": {
"Mercury": {
"applies_to_profile": "bphs_exception_profile",
"basis": "专条明确支持",
"basis_en": "sign verse",
"pvr_table_30_role": "benefic",
"summary": "偏吉,保留 6 宫主职责",
"summary_en": "mildly benefic; keeps 6th-house duties"
}
},
"ascendant": "Capricorn",
"assertion_label": "parameter_sensitive",
"blocked_planets": [
"Moon"
],
"conditions": [
"Neutral/mixed is association-dependent, not an absence of risk.",
"Natural nature, strength, maraka roles and dasha activation remain separate.",
"Strict yogakarakas do not exhaust the text's broader yoga-producing associations.",
"Dual lordship needs an integrated judgment of both houses (PVR §13.2)."
],
"dual_lordship_note": "PVR §13.2:管两个星座的行星要把两宫的指示综合判断;表 30 是综合后的摘要,不是只看一个宫位的机械规则。",
"dual_lordship_note_en": "PVR §13.2: for a planet owning two rasis the two indications must be judiciously combined; Table 30 is that integrated summary, not a mechanical one-house rule.",
"effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。",
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars"
],
"functional_neutrals": [
"Sun"
],
"functional_profile": "pvr_table30_v1",
"functional_profile_ruling": "占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;BPHS 特殊格另存为具名 profile,只在被明确选中时参与打分",
"lagna_lord": "Saturn",
"movable_lagna_moon": {
"listed_in_table_30": false,
"moon_paksha": null,
"reason": "moon_paksha_missing",
"role": null,
"rule": "Moon is not listed for movable rasis, because his functional nature depends on whether he is waxing or waning. Waxing Moon is a natural benefic and he becomes a functional malefic with quadrant ownership. Waning Moon, on the other hand, is a natural malefic and quadrant ownership makes him functionally neutral.",
"rule_zh": "PVR 表 30 不列动星座上升的月亮:盈月(自然吉星)管角宫 → 功能凶;亏月(自然凶星)管角宫 → 功能中性",
"source": "PVR §13.2 note under Table 30 (printed p. 166-167; PDF p. 178-179)",
"status": "blocked"
},
"owned_houses": {
"Jupiter": [
3,
12
],
"Mars": [
4,
11
],
"Mercury": [
6,
9
],
"Moon": [
7
],
"Saturn": [
1,
2
],
"Sun": [
8
],
"Venus": [
5,
10
]
},
"prior_rulings": [],
"profile": "pvr_integrated_approach_13_2_table_30",
"profile_divergence": {
"cells": [
{
"bphs_exception_profile": "malefic",
"kind": "movable_lagna_moon_paksha",
"planet": "Moon",
"pvr_table30_v1": "not_classified"
}
],
"display_rule": "两档不同时并列显示,不合并成一张表;默认档参与判断,另一档只作对照",
"other_profile": "bphs_exception_profile",
"other_profile_roles": {
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars",
"Moon"
],
"functional_neutrals": [
"Sun"
],
"yogakarakas": [
"Venus"
]
},
"other_profile_scored": false,
"selected_profile": "pvr_table30_v1",
"status": "differs"
},
"profile_is_not": "bphs_ch34_general_with_sign_exceptions_v3_166q4",
"profile_source": "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (p. 167; PDF p. 179)",
"role_basis": {
"Jupiter": "PVR §13.2 Table 30",
"Mars": "PVR §13.2 Table 30",
"Mercury": "PVR §13.2 Table 30",
"Moon": "PVR §13.2 Table 30 does not list this Moon; paksha unknown: blocked (moon_paksha_missing)",
"Saturn": "PVR §13.2 Table 30",
"Sun": "PVR §13.2 Table 30",
"Venus": "PVR §13.2 Table 30"
},
"rule_reference": "references/functional-role-profile.md",
"source": "strict_functional_benefic_malefic_v2",
"status": "used",
"yogakaraka_definition": "distinct lordships: one of 4/7/10 and one of 5/9",
"yogakarakas": [
"Venus"
]
},
"career": {
"ambiguous_cell_notes": {
"Mercury": {
"applies_to_profile": "bphs_exception_profile",
"basis": "专条明确支持",
"basis_en": "sign verse",
"pvr_table_30_role": "benefic",
"summary": "偏吉,保留 6 宫主职责",
"summary_en": "mildly benefic; keeps 6th-house duties"
}
},
"ascendant": "Capricorn",
"assertion_label": "parameter_sensitive",
"blocked_planets": [
"Moon"
],
"conditions": [
"Neutral/mixed is association-dependent, not an absence of risk.",
"Natural nature, strength, maraka roles and dasha activation remain separate.",
"Strict yogakarakas do not exhaust the text's broader yoga-producing associations.",
"Dual lordship needs an integrated judgment of both houses (PVR §13.2)."
],
"dual_lordship_note": "PVR §13.2:管两个星座的行星要把两宫的指示综合判断;表 30 是综合后的摘要,不是只看一个宫位的机械规则。",
"dual_lordship_note_en": "PVR §13.2: for a planet owning two rasis the two indications must be judiciously combined; Table 30 is that integrated summary, not a mechanical one-house rule.",
"effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。",
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars"
],
"functional_neutrals": [
"Sun"
],
"functional_profile": "pvr_table30_v1",
"functional_profile_ruling": "占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;BPHS 特殊格另存为具名 profile,只在被明确选中时参与打分",
"lagna_lord": "Saturn",
"movable_lagna_moon": {
"listed_in_table_30": false,
"moon_paksha": null,
"reason": "moon_paksha_missing",
"role": null,
"rule": "Moon is not listed for movable rasis, because his functional nature depends on whether he is waxing or waning. Waxing Moon is a natural benefic and he becomes a functional malefic with quadrant ownership. Waning Moon, on the other hand, is a natural malefic and quadrant ownership makes him functionally neutral.",
"rule_zh": "PVR 表 30 不列动星座上升的月亮:盈月(自然吉星)管角宫 → 功能凶;亏月(自然凶星)管角宫 → 功能中性",
"source": "PVR §13.2 note under Table 30 (printed p. 166-167; PDF p. 178-179)",
"status": "blocked"
},
"owned_houses": {
"Jupiter": [
3,
12
],
"Mars": [
4,
11
],
"Mercury": [
6,
9
],
"Moon": [
7
],
"Saturn": [
1,
2
],
"Sun": [
8
],
"Venus": [
5,
10
]
},
"prior_rulings": [],
"profile": "pvr_integrated_approach_13_2_table_30",
"profile_divergence": {
"cells": [
{
"bphs_exception_profile": "malefic",
"kind": "movable_lagna_moon_paksha",
"planet": "Moon",
"pvr_table30_v1": "not_classified"
}
],
"display_rule": "两档不同时并列显示,不合并成一张表;默认档参与判断,另一档只作对照",
"other_profile": "bphs_exception_profile",
"other_profile_roles": {
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars",
"Moon"
],
"functional_neutrals": [
"Sun"
],
"yogakarakas": [
"Venus"
]
},
"other_profile_scored": false,
"selected_profile": "pvr_table30_v1",
"status": "differs"
},
"profile_is_not": "bphs_ch34_general_with_sign_exceptions_v3_166q4",
"profile_source": "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (p. 167; PDF p. 179)",
"role_basis": {
"Jupiter": "PVR §13.2 Table 30",
"Mars": "PVR §13.2 Table 30",
"Mercury": "PVR §13.2 Table 30",
"Moon": "PVR §13.2 Table 30 does not list this Moon; paksha unknown: blocked (moon_paksha_missing)",
"Saturn": "PVR §13.2 Table 30",
"Sun": "PVR §13.2 Table 30",
"Venus": "PVR §13.2 Table 30"
},
"rule_reference": "references/functional-role-profile.md",
"source": "strict_functional_benefic_malefic_v2",
"status": "used",
"yogakaraka_definition": "distinct lordships: one of 4/7/10 and one of 5/9",
"yogakarakas": [
"Venus"
]
}
},
"paksha_known": {
"base": {
"ambiguous_cell_notes": {
"Mercury": {
"applies_to_profile": "bphs_exception_profile",
"basis": "专条明确支持",
"basis_en": "sign verse",
"pvr_table_30_role": "benefic",
"summary": "偏吉,保留 6 宫主职责",
"summary_en": "mildly benefic; keeps 6th-house duties"
}
},
"ascendant": "Capricorn",
"assertion_label": "parameter_sensitive",
"blocked_planets": [],
"conditions": [
"Neutral/mixed is association-dependent, not an absence of risk.",
"Natural nature, strength, maraka roles and dasha activation remain separate.",
"Strict yogakarakas do not exhaust the text's broader yoga-producing associations.",
"Dual lordship needs an integrated judgment of both houses (PVR §13.2)."
],
"dual_lordship_note": "PVR §13.2:管两个星座的行星要把两宫的指示综合判断;表 30 是综合后的摘要,不是只看一个宫位的机械规则。",
"dual_lordship_note_en": "PVR §13.2: for a planet owning two rasis the two indications must be judiciously combined; Table 30 is that integrated summary, not a mechanical one-house rule.",
"effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。",
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars"
],
"functional_neutrals": [
"Moon",
"Sun"
],
"functional_profile": "pvr_table30_v1",
"functional_profile_ruling": "占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;BPHS 特殊格另存为具名 profile,只在被明确选中时参与打分",
"lagna_lord": "Saturn",
"movable_lagna_moon": {
"listed_in_table_30": false,
"moon_paksha": "waning",
"role": "neutral",
"rule": "Moon is not listed for movable rasis, because his functional nature depends on whether he is waxing or waning. Waxing Moon is a natural benefic and he becomes a functional malefic with quadrant ownership. Waning Moon, on the other hand, is a natural malefic and quadrant ownership makes him functionally neutral.",
"rule_zh": "PVR 表 30 不列动星座上升的月亮:盈月(自然吉星)管角宫 → 功能凶;亏月(自然凶星)管角宫 → 功能中性",
"source": "PVR §13.2 note under Table 30 (printed p. 166-167; PDF p. 178-179)",
"status": "derived_from_table_30_note"
},
"owned_houses": {
"Jupiter": [
3,
12
],
"Mars": [
4,
11
],
"Mercury": [
6,
9
],
"Moon": [
7
],
"Saturn": [
1,
2
],
"Sun": [
8
],
"Venus": [
5,
10
]
},
"prior_rulings": [],
"profile": "pvr_integrated_approach_13_2_table_30",
"profile_divergence": {
"cells": [
{
"bphs_exception_profile": "malefic",
"kind": "movable_lagna_moon_paksha",
"planet": "Moon",
"pvr_table30_v1": "neutral"
}
],
"display_rule": "两档不同时并列显示,不合并成一张表;默认档参与判断,另一档只作对照",
"other_profile": "bphs_exception_profile",
"other_profile_roles": {
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars",
"Moon"
],
"functional_neutrals": [
"Sun"
],
"yogakarakas": [
"Venus"
]
},
"other_profile_scored": false,
"selected_profile": "pvr_table30_v1",
"status": "differs"
},
"profile_is_not": "bphs_ch34_general_with_sign_exceptions_v3_166q4",
"profile_source": "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (p. 167; PDF p. 179)",
"role_basis": {
"Jupiter": "PVR §13.2 Table 30",
"Mars": "PVR §13.2 Table 30",
"Mercury": "PVR §13.2 Table 30",
"Moon": "PVR §13.2 Table 30 note: movable-lagna Moon by paksha",
"Saturn": "PVR §13.2 Table 30",
"Sun": "PVR §13.2 Table 30",
"Venus": "PVR §13.2 Table 30"
},
"rule_reference": "references/functional-role-profile.md",
"source": "strict_functional_benefic_malefic_v2",
"status": "used",
"yogakaraka_definition": "distinct lordships: one of 4/7/10 and one of 5/9",
"yogakarakas": [
"Venus"
]
},
"career": {
"ambiguous_cell_notes": {
"Mercury": {
"applies_to_profile": "bphs_exception_profile",
"basis": "专条明确支持",
"basis_en": "sign verse",
"pvr_table_30_role": "benefic",
"summary": "偏吉,保留 6 宫主职责",
"summary_en": "mildly benefic; keeps 6th-house duties"
}
},
"ascendant": "Capricorn",
"assertion_label": "parameter_sensitive",
"blocked_planets": [],
"conditions": [
"Neutral/mixed is association-dependent, not an absence of risk.",
"Natural nature, strength, maraka roles and dasha activation remain separate.",
"Strict yogakarakas do not exhaust the text's broader yoga-producing associations.",
"Dual lordship needs an integrated judgment of both houses (PVR §13.2)."
],
"dual_lordship_note": "PVR §13.2:管两个星座的行星要把两宫的指示综合判断;表 30 是综合后的摘要,不是只看一个宫位的机械规则。",
"dual_lordship_note_en": "PVR §13.2: for a planet owning two rasis the two indications must be judiciously combined; Table 30 is that integrated summary, not a mechanical one-house rule.",
"effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。",
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars"
],
"functional_neutrals": [
"Moon",
"Sun"
],
"functional_profile": "pvr_table30_v1",
"functional_profile_ruling": "占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;BPHS 特殊格另存为具名 profile,只在被明确选中时参与打分",
"lagna_lord": "Saturn",
"movable_lagna_moon": {
"listed_in_table_30": false,
"moon_paksha": "waning",
"role": "neutral",
"rule": "Moon is not listed for movable rasis, because his functional nature depends on whether he is waxing or waning. Waxing Moon is a natural benefic and he becomes a functional malefic with quadrant ownership. Waning Moon, on the other hand, is a natural malefic and quadrant ownership makes him functionally neutral.",
"rule_zh": "PVR 表 30 不列动星座上升的月亮:盈月(自然吉星)管角宫 → 功能凶;亏月(自然凶星)管角宫 → 功能中性",
"source": "PVR §13.2 note under Table 30 (printed p. 166-167; PDF p. 178-179)",
"status": "derived_from_table_30_note"
},
"owned_houses": {
"Jupiter": [
3,
12
],
"Mars": [
4,
11
],
"Mercury": [
6,
9
],
"Moon": [
7
],
"Saturn": [
1,
2
],
"Sun": [
8
],
"Venus": [
5,
10
]
},
"prior_rulings": [],
"profile": "pvr_integrated_approach_13_2_table_30",
"profile_divergence": {
"cells": [
{
"bphs_exception_profile": "malefic",
"kind": "movable_lagna_moon_paksha",
"planet": "Moon",
"pvr_table30_v1": "neutral"
}
],
"display_rule": "两档不同时并列显示,不合并成一张表;默认档参与判断,另一档只作对照",
"other_profile": "bphs_exception_profile",
"other_profile_roles": {
"functional_benefics": [
"Mercury",
"Saturn",
"Venus"
],
"functional_malefics": [
"Jupiter",
"Mars",
"Moon"
],
"functional_neutrals": [
"Sun"
],
"yogakarakas": [
"Venus"
]
},
"other_profile_scored": false,
"selected_profile": "pvr_table30_v1",
"status": "differs"
},
"profile_is_not": "bphs_ch34_general_with_sign_exceptions_v3_166q4",
"profile_source": "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (p. 167; PDF p. 179)",
"role_basis": {
"Jupiter": "PVR §13.2 Table 30",
"Mars": "PVR §13.2 Table 30",
"Mercury": "PVR §13.2 Table 30",
"Moon": "PVR §13.2 Table 30 note: movable-lagna Moon by paksha",
"Saturn": "PVR §13.2 Table 30",
"Sun": "PVR §13.2 Table 30",
"Venus": "PVR §13.2 Table 30"
},
"rule_reference": "references/functional-role-profile.md",
"source": "strict_functional_benefic_malefic_v2",
"status": "used",
"yogakaraka_definition": "distinct lordships: one of 4/7/10 and one of 5/9",
"yogakarakas": [
"Venus"
]
}
}
},
"reference_date": "2026-11-09",
"source": "scripts/research/capture_counterexample_fixtures_20261109.py"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
{
"ayanamsa": "raman",
"case": "d_moon_self_reference",
"engine": {
"row": {
"category": "neecha_bhanga",
"conditions_met": [
"该星入旺星座的主星月亮在月亮起第1宫(角宫)"
],
"formation_evidence": {
"cancellation_reasons": [
{
"condition": "exaltation_sign_lord_kendra_from_moon",
"independent_confirmation_allowed": false,
"planet": "Moon",
"reference": "Moon",
"relative_house": 1,
"self_reference": true
}
],
"independent_condition_count": 0,
"self_reference_only": true
},
"name": "Neecha Bhanga",
"planets": {
"Jupiter": {}
},
"running_dasha_levels": [],
"status": "computed"
},
"yoga_conditions_status": "executed"
},
"reference_date": "2026-11-09",
"source": "scripts/research/capture_counterexample_fixtures_20261109.py"
}
@@ -0,0 +1,202 @@
{
"ayanamsa": "raman",
"case": "e_birth_balance",
"engine": {
"balance": {
"elapsed_years": 6.28,
"lord": "Mercury",
"remaining_years": 10.72
},
"birth": {
"date": "2020-05-01",
"hour": 10,
"minute": 30,
"second": 0
},
"dasha_periods": [
{
"end": "2031-01-18",
"lord": "Mercury",
"start": "2014-01-18",
"years": 17
},
{
"end": "2038-01-18",
"lord": "Ketu",
"start": "2031-01-18",
"years": 7
},
{
"end": "2058-01-18",
"lord": "Venus",
"start": "2038-01-18",
"years": 20
},
{
"end": "2064-01-18",
"lord": "Sun",
"start": "2058-01-18",
"years": 6
},
{
"end": "2074-01-18",
"lord": "Moon",
"start": "2064-01-18",
"years": 10
},
{
"end": "2081-01-17",
"lord": "Mars",
"start": "2074-01-18",
"years": 7
},
{
"end": "2099-01-18",
"lord": "Rahu",
"start": "2081-01-17",
"years": 18
},
{
"end": "2115-01-19",
"lord": "Jupiter",
"start": "2099-01-18",
"years": 16
},
{
"end": "2134-01-19",
"lord": "Saturn",
"start": "2115-01-19",
"years": 19
}
],
"moon_lon": 111.5956938195655,
"sub_periods": {
"boundaries": [
{
"end": "2021-02-17",
"lord": "Sun",
"parent_period_start": "2020-04-12",
"start": "2020-05-01",
"visible_period_start": "2020-05-01"
},
{
"end": "2022-07-19",
"lord": "Moon",
"start": "2021-02-17"
},
{
"end": "2023-07-16",
"lord": "Mars",
"start": "2022-07-19"
},
{
"end": "2026-02-02",
"lord": "Rahu",
"start": "2023-07-16"
},
{
"end": "2028-05-10",
"lord": "Jupiter",
"start": "2026-02-02"
},
{
"end": "2031-01-18",
"lord": "Saturn",
"start": "2028-05-10"
}
],
"boundary_count": 6,
"current": {
"antardasha": {
"end": "2028-05-10",
"lord": "Jupiter",
"start": "2026-02-02"
},
"mahadasha": {
"end": "2031-01-18",
"lord": "Mercury",
"start": "2014-01-18"
}
},
"method": "vimshottari_antardasha_proportional",
"next": {
"end": "2031-01-18",
"lord": "Saturn",
"start": "2028-05-10"
},
"next_mahadasha": {
"boundaries": [
{
"end": "2031-06-16",
"lord": "Ketu",
"start": "2031-01-18"
},
{
"end": "2032-08-15",
"lord": "Venus",
"start": "2031-06-16"
},
{
"end": "2032-12-21",
"lord": "Sun",
"start": "2032-08-15"
},
{
"end": "2033-07-22",
"lord": "Moon",
"start": "2032-12-21"
},
{
"end": "2033-12-18",
"lord": "Mars",
"start": "2033-07-22"
},
{
"end": "2035-01-06",
"lord": "Rahu",
"start": "2033-12-18"
},
{
"end": "2035-12-12",
"lord": "Jupiter",
"start": "2035-01-06"
},
{
"end": "2037-01-20",
"lord": "Saturn",
"start": "2035-12-12"
},
{
"end": "2038-01-18",
"lord": "Mercury",
"start": "2037-01-20"
}
],
"mahadasha": {
"end": "2038-01-18",
"lord": "Ketu",
"start": "2031-01-18"
}
},
"pratyantar_dasha_timeline": {
"boundary": "candidate window inside the running antardasha; not a verified event date",
"current": {
"end": "2027-01-26",
"lord": "Mercury",
"start": "2026-10-01"
},
"next": {
"end": "2027-03-16",
"lord": "Ketu",
"start": "2027-01-26"
},
"status": "ready"
},
"source": "chart.dasha.periods + chart_dasha.birth_moment.antardashas",
"status": "ready",
"summary": "当前 Mercury 大运下的小运为 Jupiter,边界 2026-02-02 至 2028-05-10。更细的 Pratyantardasha 与行运触发不在本层计算范围内。"
}
},
"reference_date": "2026-11-09",
"source": "scripts/research/capture_counterexample_fixtures_20261109.py"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -1289,6 +1289,16 @@ def build_career_analysis(engine, chart: dict, modules: dict, body: dict) -> dic
if asc_sign not in SIGNS:
return _blocked("ascendant_sign_missing")
planets = _affliction_planets(engine, chart)
# 原值:事业层只拿到 house / sign(_affliction_planets 的行),analyze_career 内的
# moon_paksha_from_planets 读不到经度,动星座上升的 movable_lagna_moon 因此 blocked;
# 同一张盘的基础层却按 D1 经度给出 waning / neutral,两层结论不一致。
# 新值:把 D1 经度一并交给 analyze_career,两层用同一个盈亏判定。
# 原因:占星师 2026-11-08 验收清单 §7 反例「缺月相」:同一张盘两层必须同源(BUG-1812)。
d1_planets = _record(chart.get("planets"))
career_planets = {}
for name, row in planets.items():
lon = _planet_lon(d1_planets.get(name))
career_planets[name] = {**row, **({"lon": lon} if lon is not None else {})}
semantic_modules = {**modules, "chart": {"ascendant": chart.get("ascendant"), "planets": chart.get("planets")}}
western = modules.get("western_evidence_packet") if isinstance(modules.get("western_evidence_packet"), dict) else None
vimsopaka = modules.get("vimsopaka_semantic_summary") if isinstance(modules.get("vimsopaka_semantic_summary"), dict) else None
@@ -1300,7 +1310,7 @@ def build_career_analysis(engine, chart: dict, modules: dict, body: dict) -> dic
industry = body.get("user_industry") or body.get("industry")
industry = str(industry).strip() if isinstance(industry, str) and industry.strip() else None
analysis = analyze_career(
planets, asc_sign,
career_planets, asc_sign,
d10_positions=_record(modules.get("varga_full")).get("D10_Dasamsa"),
dasha_info=_record(modules.get("dasha")),
shadbala=_record(modules.get("shadbala")),
@@ -1719,7 +1729,17 @@ def build_dasha_systems(chart: dict, modules: dict, *, birth_payload: dict, refe
if houses:
period["lord_houses"] = houses
period.update(_narayana_dates(birth, item, zone))
# 占星师 2026-11-08 验收清单 §7 反例「第二轮大运」/「0 年大运」:卡上要能说出
# 这是第几轮、哪一行是 0 年段;值一律照抄引擎行,不在这里重判(BUG-1812)。
for key in ("cycle_index", "zero_duration"):
if item.get(key) is not None:
period[key] = item[key]
nar[level] = period
# 占星师 2026-11-08 验收清单 §7 反例「小运未决但大运已定」:小运未决必须被点名,
# 不能只表现为「没有 ad」(「没算」与「未决」分开;BUG-1812)。
for key in ("ad_status", "ad_reason"):
if nar_current.get(key) is not None:
nar[key] = nar_current[key]
# 产品 2026-11-07: the next Narayana AD with its dates (Rath equal ADs, narayana_rath.antardasha).
md_item = _record(nar_current.get("md"))
ad_item = _record(nar_current.get("ad"))
+19 -4
View File
@@ -85,7 +85,14 @@ def age_to_date(birth_dt: Optional[datetime], age: Any) -> Optional[str]:
def _period(row: Dict[str, Any], birth_year: Optional[int],
birth_dt: Optional[datetime] = None) -> Dict[str, Any]:
out = {key: row.get(key) for key in ("sign", "lord", "years", "base_years", "dignity_adjustment", "adjustment_source")}
out = {key: row.get(key) for key in ("sign", "lord", "years", "base_years", "dignity_adjustment",
"adjustment_source", "cycle_index", "zero_duration")}
# 占星师 2026-11-08 验收清单 §7 反例:卡上要能说出「第几轮大运」、「哪一行是 0 年段」。
# 只在这两项有值时带上(小运行没有轮次,非 0 年段不重复写 false),其余字段不变(BUG-1812)。
if out.get("cycle_index") is None:
out.pop("cycle_index", None)
if not out.get("zero_duration"):
out.pop("zero_duration", None)
# Display ages stay at 0.01 year; dates come from the unrounded ages
# (原值:咨询层用取整后的年龄换日期,最多偏 1.8 天;三方对照 2026-11-07:产品侧实现缺陷)。
out["start_age"] = round(float(row["start_age"]), 2)
@@ -341,9 +348,17 @@ def rath_narayana_view(lagna_sign_idx: int, planet_lons: Dict[str, float], *,
if md is not None and ad is not None:
current: Dict[str, Any] = {"md": _period(md, birth_year, birth_datetime)}
rows = ad.get("rows") or []
running_ad = _running(rows, current_age)
if running_ad is not None:
current["ad"] = _period(running_ad, birth_year, birth_datetime)
if ad.get("status") == "selected":
current["ad_status"] = "selected"
running_ad = _running(rows, current_age)
if running_ad is not None:
current["ad"] = _period(running_ad, birth_year, birth_datetime)
else:
# 原值:小运未决时整段 current_dasha 不给 ad,卡上「没有 ad」与「没算」分不开。
# 新值:大运照常给,另带 ad_status / ad_reason,由卡上点名「子运未决」。
# 原因:占星师 2026-11-08 验收清单 §7 反例「小运未决但大运已定」:未决不得写成未计算(BUG-1812)。
current["ad_status"] = "blocked"
current["ad_reason"] = ad.get("reason") or "antardasha_not_selected"
if current_age is not None:
current["remaining_years"] = round(float(md["end_age"]) - float(current_age), 2)
view["current_dasha"] = current
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""Capture the seven counterexamples of 占星师 2026-11-08 验收清单 §7 as engine output.
One fixture per counterexample, written under
``frontend/tests/fixtures/counterexamples-20261109/<case>.json``. Every value in a
fixture comes from the real engine function the card's producer calls; nothing is
hand-written here. ``tests/test_counterexamples_engine_20261109.py`` re-runs the
same functions and compares its output with the file (a stale fixture fails), and
``frontend/tests/consult-counterexamples-card-20261109.test.ts`` injects the same
slices into a real golden workflow and reads the evidence card.
Only the public AA chart Barack Obama and synthetic inputs are used.
.venv\\Scripts\\python scripts/research/capture_counterexample_fixtures_20261109.py
"""
from __future__ import annotations
import copy
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "scripts"), str(ROOT / "scripts" / "research")]
import swisseph as swe # noqa: E402
from scripts import jyotish_engine as engine # noqa: E402
from scripts import narayana_legacy_label as label # noqa: E402
from scripts import narayana_rath as nr # noqa: E402
from scripts.chart_dasha import birth_moment # noqa: E402
from scripts.consultation_native_layers import build_career_analysis # noqa: E402
from scripts.functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets # noqa: E402
from scripts.jyotish_api_server import _consultation_dasha_sub_periods # noqa: E402
from scripts.jyotish_engine import compute_chart_data # noqa: E402
from scripts.main_yoga_detectors import neecha_bhanga_rows # noqa: E402
from scripts.narayana_rath import SIGNS # noqa: E402
OUT = ROOT / "frontend" / "tests" / "fixtures" / "counterexamples-20261109"
REFERENCE_DATE = "2026-11-09"
GRAHAS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
AYANAMSA = "raman"
# Public AA chart: Barack Obama, 1961-08-04 19:24 Honolulu (same input as tests/test_round6_r1_r2_narayana.py).
OBAMA = (1961, 8, 4, 19, 24, 21.3069, -157.8583, -10.0)
# Synthetic birthplace for the young birth-balance chart (tests/neutral_subject.py coordinates).
SYNTHETIC_PLACE = (39.9, 116.4, 8.0)
def _chart(birth: tuple) -> tuple[dict, int, datetime]:
y, mo, d, h, mi, lat, lon, tz = birth
chart, asc, *_ = compute_chart_data(y, mo, d, h, mi, lat, lon, tz,
node_mode="mean", ayanamsa_name=AYANAMSA)
return chart, int(asc), datetime(y, mo, d, h, mi)
def _lons(chart: dict) -> dict[str, float]:
return {name: float(chart["planets"][name]["degree_raw"]) for name in GRAHAS}
def _career_layer(chart: dict, base: dict) -> dict:
"""`build_career_analysis`'s functional layer — the second place the paksha is decided."""
analysis = build_career_analysis(engine, chart, {"functional_benefic_malefic": base}, {})
return analysis["functional_benefic_malefic"]
# ── (a) 缺月相 ────────────────────────────────────────────────────────────────
def case_missing_moon_paksha() -> dict:
chart, asc, _birth = _chart(OBAMA)
sign = SIGNS[asc]
with_lon = derive_functional_benefic_malefic(sign, moon_paksha=moon_paksha_from_planets(chart["planets"]))
bare = copy.deepcopy(chart)
# 合成输入:月亮保留星座与宫内度数(尊贵判定照常),但没有任何绝对经度,
# 于是 moon_paksha_from_planets 判不出盈亏 —— 正是「缺月相」那一刻的输入。
for key in ("lon", "degree_raw", "degree"):
(bare.get("planets", {}).get("Moon") or {}).pop(key, None)
without_lon = derive_functional_benefic_malefic(sign, moon_paksha=moon_paksha_from_planets(bare["planets"]))
return {
"ascendant": sign,
"blocked": {"base": without_lon, "career": _career_layer(bare, without_lon)},
"paksha_known": {"base": with_lon, "career": _career_layer(chart, with_lon)},
}
# ── (b) 低档强弱未决 ──────────────────────────────────────────────────────────
# Aries / Libra carry the same highest occupant tier (both empty of dignities): the
# seed comparison is `undecided` instead of a made-up winner (round-6 R2).
UNDECIDED_LONS = {"Sun": 185.0, "Moon": 29.5, "Mars": 190.0, "Mercury": 335.0, "Jupiter": 65.0,
"Venus": 10.0, "Saturn": 275.0, "Rahu": 85.0, "Ketu": 265.0}
def _shadbala_rows(birth: tuple, asc_sign: str) -> dict:
"""`shadbala.calc_shadbala` + `attach_parameter_sensitivity` + `shadbala_display.row_fields`.
The same three calls the API makes for `chart.shadbala`; only the kept fields differ.
"""
from shadbala import build_shadbala_context, calc_shadbala
from shadbala_display import row_fields
from shadbala_service.sensitivity import attach_parameter_sensitivity
y, mo, d, h, mi, lat, lon, tz = birth
chart, _asc, _birth = _chart(birth)
planets = copy.deepcopy(chart["planets"])
hour_decimal = float(h) + float(mi) / 60.0
jd_ut = swe.julday(y, mo, d, hour_decimal - float(tz))
result = calc_shadbala(
planets, asc_sign, hour_decimal, float(planets["Sun"]["degree_raw"]), float(planets["Moon"]["degree_raw"]),
context=build_shadbala_context(jd_ut, lat, lon, AYANAMSA, tz),
)
attach_parameter_sensitivity(result, planets=planets)
return {name: {"rupas": round(row["total_rupas"], 2), **row_fields(row)}
for name, row in result.get("planets", {}).items()}
def case_low_grade_undecided() -> dict:
comparison = nr.compare_signs(0, 6, UNDECIDED_LONS, rule3_block=True)
calc = nr.calc_narayana_rath(0, UNDECIDED_LONS)
product = label.product_narayana(
None, 0, UNDECIDED_LONS, current_age=30.0, birth_year=2000, birth_datetime=datetime(2000, 1, 1, 12, 0))
return {
"narayana": {
"comparison": comparison,
"calc": {key: calc.get(key) for key in
("status", "blocked_step", "blocked_sign", "reason", "seed_sign", "seed_basis", "mahadasha")},
"product": product,
},
"shadbala": _shadbala_rows(OBAMA, SIGNS[_chart(OBAMA)[1]]),
}
# ── (c) 小运未决、大运已定 ───────────────────────────────────────────────────
def _saturn_ketu_in_running_md(lons: dict[str, float], lagna: int, age: float) -> dict[str, float]:
"""Put Saturn and Ketu in the running MD sign (the engine's `saturn_ketu_priority_unresolved` trigger).
Moving them changes the strengths the sequence is built from, so the placement is
repeated until the running MD sign is the one both grahas sit in.
"""
current = dict(lons)
for _attempt in range(12):
rows = nr.calc_narayana_rath(lagna, current)["mahadasha"]
running = nr._running_age(rows, age) if rows else None
if running is None:
raise RuntimeError("no running Narayana MD at the reference age")
idx = SIGNS.index(running["sign"])
if nr.nd._get_planet_sign("Saturn", current) == idx and nr.nd._get_planet_sign("Ketu", current) == idx:
return current
current = {**current, "Saturn": idx * 30.0 + 10.0, "Ketu": idx * 30.0 + 20.0,
"Rahu": (idx * 30.0 + 200.0) % 360.0}
raise RuntimeError("Saturn / Ketu did not settle in the running MD sign")
def case_ad_blocked_md_known() -> dict:
chart, lagna, birth = _chart(OBAMA)
age = label.narayana_age(birth, datetime.fromisoformat(REFERENCE_DATE))
lons = _saturn_ketu_in_running_md(_lons(chart), lagna, age)
rows = nr.calc_narayana_rath(lagna, lons)["mahadasha"]
md_row = nr._running_age(rows, age)
ad = nr.antardasha(md_row, lons)
product = label.product_narayana(None, lagna, lons, current_age=age, birth_year=birth.year,
birth_datetime=birth)
# 卡上的 dasha_systems 层由 build_dasha_systems 产出;这里用同一张(合成的)盘跑它,
# 拿到真实的 narayana 行,而不是在 TS 里手拼。
from scripts.consultation_native_layers import build_dasha_systems
system_chart = {
"ascendant": {"sign": SIGNS[lagna], "lon": lagna * 30.0 + float(chart["ascendant"]["degree_in_sign"])},
"planets": {name: {"lon": value} for name, value in lons.items()},
}
layers = build_dasha_systems(
system_chart, {"narayana_dasha": product, "dasha_sub_periods": {}},
birth_payload={"year": birth.year, "month": birth.month, "day": birth.day,
"hour": birth.hour, "minute": birth.minute, "tz": -10.0},
reference=datetime.fromisoformat(REFERENCE_DATE),
)
return {
"age": round(age, 6),
"lons": lons,
"md": md_row,
"antardasha": {key: ad.get(key) for key in ("status", "reason", "comparison_status", "rows")},
"narayana": product,
"dasha_systems_narayana": next(row for row in layers["rows"] if row.get("system") == "narayana"),
}
# ── (d) 月亮自参照 ───────────────────────────────────────────────────────────
def case_moon_self_reference() -> dict:
from scripts.consultation_native_layers import build_yoga_conditions
from scripts.jyotish_api_server import _compact_yoga_entry
planets = {"Jupiter": {"sign": "Capricorn", "house": 6}, "Moon": {"sign": "Aries", "house": 9}}
compact = _compact_yoga_entry(neecha_bhanga_rows(planets)[0])
modules = {"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": [compact]}}}
layer = build_yoga_conditions({"planets": planets}, modules, {})
row = next(item for item in layer["rows"] if item["name"] == "Neecha Bhanga")
return {"row": row, "yoga_conditions_status": layer.get("status")}
# ── (e) 出生余额 ────────────────────────────────────────────────────────────
def _young_chart() -> dict:
"""A synthetic chart still inside its birth (balance) mahadasha at the reference date."""
lat, lon, tz = SYNTHETIC_PLACE
for day_offset in range(0, 60):
day = datetime(2020, 5, 1) + timedelta(days=day_offset)
chart, asc, birth = _chart((day.year, day.month, day.day, 10, 30, lat, lon, tz))
moon_lon = float(chart["planets"]["Moon"]["degree_raw"])
_timeline, elapsed, remaining, start_lord = birth_moment.mahadashas(birth, moon_lon)
if remaining > 9.0:
return {"chart": chart, "asc": asc, "birth": birth, "moon_lon": moon_lon,
"periods": birth_moment.compute_vimshottari_timeline(
birth_dt=birth, moon_lon=moon_lon,
current_date=datetime.fromisoformat(REFERENCE_DATE))["periods"],
"balance": {"lord": start_lord, "elapsed_years": elapsed, "remaining_years": remaining}}
raise RuntimeError("no synthetic birth kept a long-enough balance mahadasha")
def case_birth_balance() -> dict:
young = _young_chart()
payload = {
"birth": {"date": young["birth"].strftime("%Y-%m-%d"), "hour": young["birth"].hour,
"minute": young["birth"].minute, "second": 0},
"dasha": {"periods": young["periods"]},
"planets": {"Moon": {"lon": young["moon_lon"]}},
}
sub_periods = _consultation_dasha_sub_periods(payload, {"reference_date": REFERENCE_DATE})
return {
"birth": payload["birth"],
"moon_lon": young["moon_lon"],
"balance": young["balance"],
"dasha_periods": young["periods"],
"sub_periods": sub_periods,
}
# ── (f) 第二轮大运 ──────────────────────────────────────────────────────────
SECOND_CYCLE_AGE = 38.0
def case_second_cycle() -> dict:
chart, lagna, birth = _chart(OBAMA)
lons = _lons(chart)
rows = nr.calc_narayana_rath(lagna, lons)["mahadasha"]
running = nr._running_age(rows, SECOND_CYCLE_AGE)
product = label.product_narayana(None, lagna, lons, current_age=SECOND_CYCLE_AGE,
birth_year=birth.year, birth_datetime=birth)
moon_lon = lons["Moon"]
through = birth + timedelta(days=125 * 365.25)
timeline, *_ = birth_moment.mahadashas(birth, moon_lon, through=through)
current_vim = next(row for row in timeline if row["start"] <= through < row["end"])
return {
"age": SECOND_CYCLE_AGE,
"narayana": product,
"running_md": running,
"vimshottari": {
"through": through.strftime("%Y-%m-%d"),
"current": {"lord": current_vim["lord"], "cycle": current_vim.get("cycle"),
"start": current_vim["start"].strftime("%Y-%m-%d"),
"end": current_vim["end"].strftime("%Y-%m-%d"), "years": current_vim["years"]},
"first_cycle_years": round(sum(row["years"] for row in timeline[:9]), 2),
},
}
# ── (g) 0 年大运 ─────────────────────────────────────────────────────────────
def case_zero_duration() -> dict:
chart, lagna, birth = _chart(OBAMA)
lons = _lons(chart)
rows = nr.calc_narayana_rath(lagna, lons)["mahadasha"]
zero = next(row for row in rows if row["zero_duration"] and row["cycle_index"] == 1)
age = float(zero["start_age"])
product = label.product_narayana(None, lagna, lons, current_age=age, birth_year=birth.year,
birth_datetime=birth)
return {
"age": age,
"zero_row": zero,
"zero_rows": [{key: row.get(key) for key in ("sign", "start_age", "end_age", "years", "cycle_index")}
for row in rows if row["zero_duration"]],
"narayana": product,
"narayana_rows": [{key: row.get(key) for key in
("sign", "start_age", "end_age", "years", "cycle_index", "zero_duration")}
for row in rows],
}
CASES = {
"a_missing_moon_paksha": case_missing_moon_paksha,
"b_low_grade_undecided": case_low_grade_undecided,
"c_ad_blocked_md_known": case_ad_blocked_md_known,
"d_moon_self_reference": case_moon_self_reference,
"e_birth_balance": case_birth_balance,
"f_second_cycle": case_second_cycle,
"g_zero_duration": case_zero_duration,
}
def build_cases() -> dict[str, dict]:
"""Every case's engine output, exactly as the fixtures store it."""
out: dict[str, dict] = {}
for name, builder in CASES.items():
out[name] = {
"case": name,
"source": "scripts/research/capture_counterexample_fixtures_20261109.py",
"reference_date": REFERENCE_DATE,
"ayanamsa": AYANAMSA,
"engine": builder(),
}
return out
def normalise(value: Any) -> Any:
"""JSON round-trip so the file and a fresh run compare equal."""
return json.loads(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str))
def main() -> int:
OUT.mkdir(parents=True, exist_ok=True)
for name, payload in build_cases().items():
target = OUT / f"{name}.json"
target.write_text(json.dumps(normalise(payload), ensure_ascii=False, sort_keys=True, indent=1) + "\n",
encoding="utf-8")
print(f"wrote {target.relative_to(ROOT)} ({target.stat().st_size} bytes)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,217 @@
"""占星师 2026-11-08 验收清单 §7 的七个反例:引擎侧端到端断言(BUG-1812)。
每个反例一条测试,调用真实的引擎函数(不是包装脚本),断言状态、日期与「不计分 / 不增票」。
配套的数据卡断言在 `frontend/tests/consult-counterexamples-card-20261109.test.ts`,读同一份
fixture。fixture 由 `scripts/research/capture_counterexample_fixtures_20261109.py` 生成,
最后一条测试断言「引擎现在的输出 == fixture」,fixture 过期即失败。
只用公开名人盘(Barack Obama)或合成输入。
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
for entry in (str(ROOT), str(ROOT / "scripts"), str(ROOT / "scripts" / "research")):
if entry not in sys.path:
sys.path.insert(0, entry)
from capture_counterexample_fixtures_20261109 import ( # noqa: E402
CASES,
OUT,
REFERENCE_DATE,
SECOND_CYCLE_AGE,
build_cases,
normalise,
)
FIXTURES = {name: json.loads((OUT / f"{name}.json").read_text(encoding="utf-8")) for name in CASES}
def engine(case: str) -> dict:
return FIXTURES[case]["engine"]
def _moon(layer: dict) -> dict:
return layer["movable_lagna_moon"]
def test_fixture_is_fresh_for_every_counterexample() -> None:
"""一条覆盖七张 fixture:引擎输出必须与仓内文件逐字段相同。"""
fresh = normalise(build_cases())
for name in CASES:
assert fresh[name] == FIXTURES[name], (
f"{name}.json 与引擎当前输出不一致;重跑 "
"scripts/research/capture_counterexample_fixtures_20261109.py")
def test_a_missing_moon_paksha_blocks_only_the_moon_and_both_layers_agree() -> None:
"""(a) 缺月相:只有月亮 blocked,原因点名;基础层与事业层同源。"""
data = engine("a_missing_moon_paksha")
base, career = data["blocked"]["base"], data["blocked"]["career"]
for layer in (base, career):
moon = _moon(layer)
assert (moon["status"], moon["role"], moon["reason"]) == ("blocked", None, "moon_paksha_missing")
assert layer["status"] == "used"
# 只阻塞月亮:其他行星照常分类,月亮不进任何一档。
assert base["blocked_planets"] == ["Moon"]
classified = set(base["functional_benefics"]) | set(base["functional_malefics"]) | set(base["functional_neutrals"])
assert "Moon" not in classified
# 表 30 只列七颗星(Rahu / Ketu 不在功能吉凶表里),月亮之外六颗照常分类。
assert len(classified) == 6, sorted(classified)
# 盈亏已知时两层一致(修复前事业层是 blocked / moon_paksha_missing)。
known = data["paksha_known"]
assert _moon(known["base"])["status"] == _moon(known["career"])["status"] == "derived_from_table_30_note"
assert _moon(known["base"])["role"] == _moon(known["career"])["role"] == "neutral"
assert _moon(known["base"])["moon_paksha"] == _moon(known["career"])["moon_paksha"] == "waning"
def test_b_low_grade_comparison_undecided_names_the_step_and_scores_nothing() -> None:
"""(b) 低档强弱未决:Narayana 点名未定步骤、不计分;Shadbala 保留 parameter_sensitive。"""
data = engine("b_low_grade_undecided")
comparison = data["narayana"]["comparison"]
assert (comparison["status"], comparison["comparison_status"]) == ("undecided", "unresolved")
assert comparison["blocked_level"] == 3
assert comparison["reason"] == "rule3_highest_tier_below_own_sign_tied"
calc = data["narayana"]["calc"]
assert (calc["status"], calc["blocked_step"]) == ("blocked", "seed")
product = data["narayana"]["product"]
assert product["status"] == "blocked"
assert product["used_for_scoring"] is False
assert "current_dasha" not in product # 不用旧算法补出一个「已确认」的当前大运
method = product["method_status"]
assert (method["status"], method["chat_use"]) == ("blocked", "no_judgment")
assert method["unresolved_steps"] == ["seed"]
assert method["steps"]["seed"]["status"] == "blocked"
# Shadbala:翻盘的行星只能是 parameter_sensitive,不写成达标 / 不达标。
shadbala = data["shadbala"]
assert shadbala["Jupiter"]["minimum_check"] == "parameter_sensitive"
assert shadbala["Jupiter"]["minimum_open_questions"]
others = [name for name, row in shadbala.items() if name != "Jupiter"]
assert others and not any(row["minimum_check"] == "parameter_sensitive" for row in (shadbala[n] for n in others))
def test_c_ad_undecided_keeps_the_md_and_names_the_reason() -> None:
"""(c) 小运未决但大运已定:大运星座与日期保留,子运未决且原因被点名,不补旧 AD。"""
data = engine("c_ad_blocked_md_known")
md, ad = data["md"], data["antardasha"]
assert md["sign"] == "Taurus" and md["cycle_index"] == 2
assert (ad["status"], ad["reason"]) == ("blocked", "saturn_ketu_priority_unresolved")
assert ad["rows"] == []
product = data["narayana"]
current = product["current_dasha"]
assert current["md"]["sign"] == md["sign"]
assert current["md"]["cycle_index"] == md["cycle_index"] == 2
# 大运日期原样来自引擎行(未取整的 age 换成日期后与引擎一致)。
assert current["md"]["start_date"] and current["md"]["end_date"]
assert "ad" not in current # 没有由旧算法补出的小运
assert current["ad_status"] == "blocked"
assert current["ad_reason"] == ad["reason"]
# 未决层不计分:Narayana 整体仍是单体系候选,used_for_scoring 为 False。
assert product["used_for_scoring"] is False
assert product["method_status"]["chat_use"] == "single_system_candidate"
# 卡上的 dasha_systems 层同样只带未决的原因,不带一个补出来的小运。
row = data["dasha_systems_narayana"]
assert (row["md"]["sign"], row["md"]["cycle_index"]) == (md["sign"], md["cycle_index"])
assert (row["ad_status"], row["ad_reason"]) == ("blocked", ad["reason"])
assert "ad" not in row
def test_d_moon_self_reference_is_never_independent_confirmation() -> None:
"""(d) 月亮自参照:只标自参照,独立确认为 0,不当作独立一票。"""
row = engine("d_moon_self_reference")["row"]
assert row["name"] == "Neecha Bhanga" and row["status"] == "computed"
evidence = row["formation_evidence"]
assert evidence["self_reference_only"] is True
assert evidence["independent_condition_count"] == 0
moon = next(item for item in evidence["cancellation_reasons"] if item.get("self_reference"))
assert (moon["reference"], moon["relative_house"], moon["self_reference"]) == ("Moon", 1, True)
assert moon["independent_confirmation_allowed"] is False
non_self = [item for item in evidence["cancellation_reasons"] if not item.get("self_reference")]
assert len(non_self) == evidence["independent_condition_count"] == 0
def test_e_birth_balance_keeps_the_full_parent_start_beside_the_visible_one() -> None:
"""(e) 出生余额:子运边界不因裁剪而改变,父段起点与可见起点都留、且不同。"""
data = engine("e_birth_balance")
balance = data["balance"]
assert balance["remaining_years"] > 9, balance
sub = data["sub_periods"]
assert sub["status"] == "ready"
md = sub["current"]["mahadasha"]
assert md["lord"] == balance["lord"]
# 余额大运在出生前开始:卡上看到的是被出生裁出来的那一段。
assert md["start"] < data["birth"]["date"] < md["end"]
# 出生那一刻的小运被裁:父段起点(出生前)与可见起点(出生当天)同时留下、且不同。
clipped = next(row for row in sub["boundaries"] if row.get("parent_period_start"))
assert clipped["parent_period_start"] < clipped["visible_period_start"] == data["birth"]["date"]
# 同一条小运的完整父段边界与未裁剪版本逐日相同(边界不移动)。
from chart_dasha import birth_moment
full = {row["lord"]: row for row in birth_moment.antardashas({
"lord": md["lord"], "start": datetime.fromisoformat(md["start"]), "end": datetime.fromisoformat(md["end"])})}
assert full[clipped["lord"]]["start"].strftime("%Y-%m-%d") == clipped["parent_period_start"]
for row in sub["boundaries"]:
assert full[row["lord"]]["end"].strftime("%Y-%m-%d") == row["end"], row["lord"]
# 参考日所在的小运仍是引擎值,不是按出生比例重算的。
assert sub["current"]["antardasha"]["lord"] == next(
row["lord"] for row in full.values() if row["start"] <= datetime(2026, 11, 9) < row["end"])
def test_f_second_cycle_is_named_and_the_running_row_is_unique() -> None:
"""(f) 第二轮大运:当前段带 cycle_index 2、年数等于引擎值;Vimshottari 跨 120 年后同样进第二轮。"""
data = engine("f_second_cycle")
md = data["narayana"]["current_dasha"]["md"]
running = data["running_md"]
assert md["sign"] == running["sign"] == "Aquarius"
assert md["cycle_index"] == running["cycle_index"] == 2
assert md["years"] == running["years"] == 11
# 24 行里只有一行覆盖参考年龄;没有把第二轮当成「还没算」。
rows = data["narayana"]["mahadasha"]
live = [row for row in rows if row["start_age"] <= SECOND_CYCLE_AGE < row["end_age"]]
assert len(live) == 1 and live[0]["sign"] == md["sign"] and live[0]["cycle_index"] == 2
vim = data["vimshottari"]
assert vim["first_cycle_years"] == 120
assert vim["current"]["cycle"] == 2 and vim["current"]["lord"] == "Moon"
assert vim["current"]["years"] == 10
def test_g_zero_year_period_is_kept_in_the_data_and_never_current() -> None:
"""(g) 0 年大运:段本身留在数据里并带 zero_duration,当前大运绝不是 0 年段。"""
data = engine("g_zero_duration")
zero = data["zero_row"]
assert (zero["sign"], zero["years"], zero["cycle_index"]) == ("Sagittarius", 0, 1)
assert zero["zero_duration"] is True and zero["start_age"] == zero["end_age"]
# 半开区间 [start, end):0 年段占不到任何年龄,参考日落在它的起点也不是它。
product = data["narayana"]
assert product["current_dasha"]["md"]["sign"] == "Aquarius"
assert product["current_dasha"]["md"]["years"] == 11
assert product["current_dasha"]["md"]["start_age"] == data["age"]
signs = [row["sign"] for row in product["mahadasha"] if row["sign"] == "Sagittarius"]
assert signs, "0 年段必须留在 mahadasha 数据里"
kept = next(row for row in product["mahadasha"] if row["sign"] == "Sagittarius")
assert kept["years"] == 0 and kept["zero_duration"] is True
def test_fixtures_cover_all_seven_counterexamples() -> None:
assert sorted(CASES) == [
"a_missing_moon_paksha", "b_low_grade_undecided", "c_ad_blocked_md_known",
"d_moon_self_reference", "e_birth_balance", "f_second_cycle", "g_zero_duration",
]
for name in CASES:
payload = FIXTURES[name]
assert payload["reference_date"] == REFERENCE_DATE
assert payload["engine"]
@pytest.mark.parametrize("case", sorted(CASES))
def test_no_real_person_data_in_fixtures(case: str) -> None:
"""公开名人盘只以公开的出生日期出现;不得有姓名、邮箱、会话或用户标识。"""
text = (OUT / f"{case}.json").read_text(encoding="utf-8")
for marker in ("@", "password", "jwt", "cookie", "case_id"):
assert marker not in text.lower(), (case, marker)
@@ -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")