Compare commits

...
Author SHA1 Message Date
jesseandClaude Opus 5.5 7721aa9a2c feat: rectification opening names the time source; unchanged range is said (issue #148)
Independent Staging Quality Gate / validate (push) Successful in 17m18s
Independent Staging Quality Gate / publish (push) Failing after 15m2s
Product decisions 2026-10-28 (issuecomment-36121):
- The opening's window sentence says where the reader's time came from
  (出生记录时间 / 大概时间 / 你填的时间 / 时间段), still one sentence. When the
  source is known the server writes the opening; unknown and legacy get none.
- When a turn recorded events, the rescore completed and it is not the
  delivery turn, an exactly unchanged range gets
  「这件事在现在的范围里每个时刻都对得上,分不出前后。」. It never invites
  more events (BUG-1084).
- Not done, by decision: per-layer Lagna/nakshatra/dasha narration
  (BUG-606/1253), precision-gated layers (scoring), voluntary/involuntary
  career exits, "what would narrow it further" (BUG-1084).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:28:50 +08:00
jesseandClaude Opus 5.5 8e932959b5 feat: rectification screen — timeline-only minute, board on demand, result card (issues #146, #147)
- The working minute is written once, on the timeline; the board's title row
  no longer repeats it.
- 「看盘面」 sits in the header at every width; the board is closed by default
  and opens as a right column on desktop (bottom sheet on phones).

- A distinct result (D4) leads with 最可能的出生时间 + minute + Lagna; the range
  drops to the next line. Indistinct results keep the range headline (D3).
- The most likely column adopts with 「采用 HH:MM 并打开星盘」 and goes to /chart
  after the server saved it; other columns stay adoptable as 「改用这个」 under
  还不能排除. No confidence label. Record-conflict wording unchanged.
- 「我说错了,改一条」: confirmed events (from the case read's existing evidence
  list) prefill the composer; nothing is sent by the pick. No 「再补一件事」:
  the card must not invite more events (BUG-1084, issue #148 decision).
- The segment card keeps "which chart" (BUG-1151): no minute lead, same
  adopt label, same actions row.

Docs: DESIGN §10/§11/§12, VOICE, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:23:04 +08:00
Jesse_ChenandClaude f80f3b75f8 Merge origin/staging into upstream sync 9 batch B (issue #159)
Independent Staging Quality Gate / validate (push) Successful in 18m34s
Independent Staging Quality Gate / publish (push) Successful in 5m14s
Co-Authored-By: Claude (agent loop) <noreply@anthropic.com>
2026-10-28 17:23:59 +08:00
jesse-uxandGrok (agent loop) cbe665107f test(freeze-guard): keep the sync8 freeze paths in the forbidden list (issue #159)
The sync9b edit replaced the two sync8 entries in
IDENTITY_FORBIDDEN_PREFIXES instead of appending after them, so the guard
covered two records instead of four while still passing. Restore the
sync8 pair under its own comment and keep the sync9b pair after it.

The freshness test passes again (9) and the frontend confirmation-gate
test for the moved sealed report passes (8).

Co-Authored-By: Grok (agent loop)
2026-10-28 17:01:31 +08:00
Jesse_ChenandClaude 8015e8efb7 docs(tasks): upstream sync 9 batch B first rejection (agent loop, issue #159)
The freshness test's IDENTITY_FORBIDDEN_PREFIXES list is append-only;
the branch replaced the two sync8 entries instead of adding the sync9b
ones after them, which weakens the identity guard. Restore them.

Co-Authored-By: Claude (agent loop) <noreply@anthropic.com>
2026-10-28 16:47:37 +08:00
jesse-uxandGrok (agent loop) 5c5f333d4d fix(varga): port the five sarpa ranges and the 22nd drekkana house (BUG-1494, BUG-1495, issue #159)
Upstream 50847e57, ported semantically:

- Sarpa Drekkana now accepts the five classical ranges (Cancer 2/3,
  Scorpio 1/2, Pisces 3) and partitions on the raw longitude instead of
  the rounded display degrees.
- The 22nd Drekkana is the eighth house of D3 (+7), not +21 zodiacal
  ten-degree divisions.

scripts/varga.py is part of the extended frozen identity, so the sealed
holdout and the reported-offset sweep were re-frozen under ERR-110:
20/20 sealed trials and 900/900 sweep trials match the sync8 records on
every compared field, and the metrics, summary and widening geometry are
unchanged.

Tests: the upstream varga/ sarpa files are added under
tests/test_upstream_sync9b_*.py; the stale +21 assertion and the sarpa
definition string are updated with the original/new/reason columns in
PROGRESS.

Co-Authored-By: Grok (agent loop)
2026-10-28 16:25:45 +08:00
Jesse_ChenandClaude 2243220841 docs(tasks): upstream sync 9 batch B brief (agent loop, issue #159)
Product chose A on #159: change the frozen varga.py for the five Sarpa
drekkanas and the 22nd drekkana, and refreeze only on identical 20/900
rectification scores. Narayana AD strength (B2) is deferred: our code
has no strength-profile family for it to propagate.

Co-Authored-By: Claude (agent loop) <noreply@anthropic.com>
2026-10-28 15:46:21 +08:00
48 changed files with 46139 additions and 213 deletions
+20
View File
@@ -1,5 +1,25 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 生时校正对话说得更清楚
- 开场那句会说明是按什么时间开始找的(出生记录、你填的大概时间,或你给的时间段)。
- 你说的一件事如果在现在的范围里哪个时刻都对得上、分不出前后,会直接说一句,不再什么都不提。
- 打分和出结果的规则不变。Skill 版本不变(issue #148)。
## 2026-10-28 — 生时校正的结果卡和盘面
- 结果分得出先后时,卡片最上面直接写最可能的出生时间和上升,按钮「采用 这个时间 并打开星盘」,采用后直接去星盘页;还不能排除的时间留在下面,仍然可以改用。
- 卡上可以点「我说错了,改一条」:选一条记下的事,输入框里先写好开头,补完发出就会改掉那一条。
- 代表时刻只在对话上方的时间轴写一次;当前盘面改成点「看盘面」再打开。
- Skill 版本不变(issue #146、#147)。
## 2026-10-28 — 两处分盘敏感点按来源修正
- Sarpa Drekkana 从三段补成书上的五段:巨蟹第 3 段、天蝎第 2 段以前漏掉,现在会标出来;正好落在 10 度边界上的盘不再划到上一段。
- 第 22 号 Drekkana 改为 D3 的第 8 宫(以前多移了 14 个星座)。这个点目前还没接进完整解读的敏感点表,所以报告上看不到差别。
- 生时校正 20 条密封案例与 900 个报时窗口,名次与分钟误差与上一份回放逐条相同。
- Skill 版本不变(BUG-1494、BUG-1495,issue #159)。
## 2026-10-28 — 生时校正有了自己的入口
- 侧栏「星盘」下面新增「生时校正」,打开一页就能看到现在的状态:没做过就开始,没做完就继续,做完了看结果或再校正一次。开始前先核对一遍出生资料。
+31
View File
@@ -20416,3 +20416,34 @@
- 关联:ERR-111(特征指纹随哈希种子变)、BUG-1482(#132 让模块能按名字被子进程找到)。
- 复发自:无
- 修复版本:分支 `codex/heavy-compute-processes-20261028`。
## BUG-1494 | Sarpa Drekkana 少两段,边界还按四舍五入后的度数判段
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #159,上游 `50847e57`)
- 影响面:完整解读里的 Sarpa Drekkana 标记。`scripts/varga.py`。
- 现象:只认 Cancer-2、Scorpio-1、Pisces-3 三段。Raman《Hindu Predictive Astrology》Drekkana 章是五段,Cancer-3 与 Scorpio-2 漏掉。判段还先把星座内度数四舍五入到 4 位再整除,边界附近会划到上一段。
- 触发条件:行星或上升落在被漏掉的两段,或经度正好在 10 度边界附近(如 100.0、220.0、360.0)。
- 根因:`SARPA_DREKKANA_SIGNS` 一个星座只记一个段号;`int(round(deg,4)/10)+1` 让 99.99999 与 100.0 之类的边界口径不稳。
- 修复:每星座改成段号元组(Cancer 2/3、Scorpio 1/2、Pisces 3),用原始经度 `int((lon % 30) / 10) + 1` 判段,`definition` 改成五段文字。生时校正不读这两个函数。
- 验证:`tests/test_upstream_sync9b_sarpa_precision.py`(6 条边界参数化)、`tests/test_upstream_sync9b_varga_bphs.py`(36 段全枚举与五段区间断言)、`tests/test_varga_bphs.py` 通过。密封校正 20 条与报时窗口 900 条与上一份记录逐条相同。
- 防复发:36 段全枚举 + 区间边界测试;判段只用原始经度,不再用显示用的四舍五入度数。
- 关联:BUG-1495。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync9b-20261028`。
## BUG-1495 | 第 22 号 Drekkana 按 21 个十度分段取,多移了 14 个星座
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #159,上游 `50847e57`)
- 影响面:`scripts/varga.py::calc_22nd_drekkana`。全仓只有单元测试直接调用它;完整解读的敏感点表目前没有生产者写 `drekkana_22nd`(`_calc_sensitive_points` 只产 bhrigu_bindu 与 sarpa_drekkana),所以这一项目前不会出现在报告里。这是既有接线缺口,本单不接线,也不把「注册了」写成「已计算」。
- 现象:结果取 D3 星座 +21。《Crux of Vedic Astrology》p.329:第 22 号 Drekkana 是 D3 的第 8 宫,即 +7。
- 触发条件:调用 `calc_22nd_drekkana`。
- 根因:把「在黄道上数 21 个十度分段」当成「在 D3 里移 21 个星座」。
- 修复:改 `+7`;`offset_from_lagna_drekkana` 仍是 22,输出字段名不变。
- 验证:`tests/test_upstream_sync9b_varga_bphs.py` 的固定反例(射手 D3 起,第 8 宫为巨蟹)与 36 段独立枚举(不调用生产偏移)通过;`tests/test_varga_bphs.py` 同步改成固定反例。密封 20 条 + 报时 900 条与上一份记录逐条相同。
- 防复发:独立枚举测试用「数满 22 个十度分段再映射 D3」的旁路口径,不复用生产公式。
- 关联:BUG-1494。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync9b-20261028`。
@@ -0,0 +1,118 @@
{
"record_version": "reported-offset-native-candidate-date-v3",
"frozen_at_utc": "2026-10-28T08:19:09.924155+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": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"implementation_sha256_prefix": "b44505aa0034e486",
"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": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"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": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"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": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "bc667c30c9562fb73fb0b7b4c22a5e377f9a531795141fc629b2cd3cf396773a",
"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": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"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": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"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
@@ -0,0 +1,116 @@
{
"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": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"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": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"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": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "bc667c30c9562fb73fb0b7b4c22a5e377f9a531795141fc629b2cd3cf396773a",
"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": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"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-10-28T08:19:06.008600+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": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"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": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"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,45 @@
# 22nd Drekkana: division count versus D3 house count
## Source
- `references/book_corpus/books/Crux of Vedic Astrology – Timing of Events (Sanjay Rath) (z-library.sk, 1lib.sk, z-lib.sk).md`
- Topic: 22nd Drekkana geometry. Corpus page 340, printed page 329.
- Lines 13809–13812: `house on the physical body. Incidentally, the 22nd`
`decanate is also the 8th house in the Drekkana`
`(D-3) chart while the 64th Navamsa is the 4th`
`house from the Moon in the Navamsa chart. The`
- Located by full-corpus search and read using `search_jyotish_books.py --read-unit 340`.
- This review verifies a traditional geometric definition, not health or lifespan prediction.
## Defect and correction
The producer added 21 to the D3 sign index. That confuses a count of zodiacal
ten-degree divisions with a count of signs in the divisional chart. Use D3
ascendant +7 modulo12, the eighth house inclusive.
The old test repeated the same +21 error. Replace it with a fixed Sagittarius
to Cancer counterexample and an independent 36-part zodiac enumeration. Across
each part, count 22 inclusively on the original zodiac, then apply the standard
Parashari D3 mapping to the resulting part. Test starts, interiors and ends.
The 64th Navamsa formula is unchanged: D9 Moon +63 modulo12 equals its fourth
house, as the same source states. `offset_from_lagna_drekkana=22` is an ordinal
label, not the number of D3 signs added.
No customer information is retained here. Recompute sensitive-point consumers
and use a new verified frozen pin before claiming the published report changed.
## Sarpa classification: missing two divisions
Raman, `Hindu Predictive Astrology -- Bangalore Venkat Raman, B V Raman -- 1981 -- IBH Prakashana -- isbn13 9788119394883 -- c9102b89e2f8f7c358a913f7d07d7c39 -- Anna’s Archive.md`:
- Corpus p.279, L10960–10962 explicitly counts five serpent divisions.
- Cancer second and third: p.280, L11013–11020.
- Scorpio first and second: pp.281–282, L11062–11074.
- Pisces third: p.283, L11129–11131.
- Search used `search_jyotish_books.py serpent --book <registered filename>`.
The old map accepted one division per sign and omitted Cancer-3 and Scorpio-2.
It now allows multiple divisions per sign. Independent tests cover all 36
divisions and their boundaries against the source's three degree intervals.
This classifies chart geometry; it does not establish a medical outcome.
@@ -0,0 +1,68 @@
# PROGRESS · 上游同步第九轮乙批(2026-10-28)
- 分支:`codex/upstream-sync9b-20261028`;基线 `origin/staging` `12eb7018`
- issue:#159(#141 乙批);产品 10-28 选 **A**:允许改冻结文件 `scripts/varga.py`
- 上游:`732642856/yinduzhanxing` main `50847e57`(10-16),按语义移植
- 结论:B1 两项完成;密封校正 20 条与报时窗口 900 条与上一份记录逐条相同,已按 ERR-110 另存新冻结;B2(Narayana 小运强度口径)按决策记录不做
## 做了什么
| 项 | 上游 | BUG | 改了什么 |
| --- | --- | --- | --- |
| T1 Sarpa 五段 + 原始经度判段 | `50847e57` | BUG-1494 | `SARPA_DREKKANA_SIGNS` 由单段号改段号元组;`calc_sarpa_drekkana` 去掉四舍五入、先取模;`definition` 改五段 |
| T2 第 22 号 Drekkana 改 D3 第 8 宫 | `50847e57` | BUG-1495 | `calc_22nd_drekkana` 由 `+21` 改 `+7`,字段名与序号 22 不变 |
| T4 上游配套文档 | `50847e57` | — | 原路径放入 `docs/research/sensitive_point_drekkana_count_review_2026_10_14.md` |
| T3 重新冻结 | — | — | 两份新冻结记录 + 两份回放报告(ERR-110),旧记录逐字节保留 |
生产文件只有 `scripts/varga.py` 一个;`git diff --stat` 见下。
## 与任务书的偏离
1. **红线 2 的「先不带 `--freeze` 跑」做不到**:`scripts/varga.py` 在冻结记录的扩展身份里,拿 sync8 的冻结记录回放会在打分前被身份闸门拦下(`frozen_record_mismatch:extended_identity`)。改成 ERR-110 的做法:仓库内建临时冻结记录 → 回放 → 与 sync8 逐条比 → 全部相同后才写正式记录。临时记录 `docs/research/.tmp_sweep_sync9b.freeze.json` 用完已删除,未提交。
2. **报时窗口脚本必须用仓库内路径**:报告里写 `freeze_record_path`(`relative_to(ROOT)`),所以正式记录直接放 `docs/research/`;比较轮的临时记录也在仓库内。
3. **改了前端一个测试文件**:`frontend/tests/rectification-confirmation-gate.test.ts` 写死当前密封报告路径,合同指针一挪就会红。只改这个路径与三栏注释,不动任何产品代码;与第八轮同做法的先例一致。
## 跑过的命令
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 上游两个测试文件 | `.venv\Scripts\python -m pytest tests/test_upstream_sync9b_varga_bphs.py tests/test_upstream_sync9b_sarpa_precision.py -o addopts="" -q` | 23 passed |
| 既有分盘测试一起 | 上一行再加 `tests/test_varga_bphs.py` | 39 passed |
| CLI 敏感点(任务书写 `-k "sarpa or drekkana or special"`) | `.venv\Scripts\python -m pytest tests/test_cli_smoke.py -k "sarpa or drekkana or special"` | 0 条被选中(35 deselected,退出码 5);`-k` 只匹配测试名,改用实际测试名 `-k sensitive_point_modules`:1 passed、34 deselected |
| 分盘 + CLI 敏感点合并 | 4 个文件/节点一起跑 | 40 passed |
| 密封校正 20 条 | `scripts/research/sealed_holdout_rerun.py`(新冻结记录) | `trial_count` 20;8 个字段(`true_rank`、`opaque_true_rank`、`minute_error`、`would_confirm`、`false_confirmation`、`insufficient_evidence_rejected`、`candidate_count`、`event_count`)不一致 0;`metrics` 相同;`changed_trial_count` 仍是 8 |
| 报时窗口 900 条 | `scripts/research/reported_offset_sweep.py --json`(新冻结记录) | `trial_count` 900;trial 全字段不一致 0;`summary` 与 `widening_geometry` 相同;`changed_trial_count` 仍是 581 |
| 冻结契约 | `tests/test_sealed_holdout_contract_freshness.py` | 9 passed |
| 确认门合同 | `tests/test_rectification_confirmation_and.py` | 10 passed |
| 其余分盘测试 | `pytest tests/test_varga*.py` | 53 passed、9 failed;9 条全在 `test_varga_vedastro_mode.py`(D2 起 Ascendant 星座不符)。在基线 `12eb7018` 的一次性工作树上同样 9 failed,与 Sarpa / 22nd 无关,不算本单 |
| 正式记录生成 | `scripts/research/*.py --freeze` 两份 + 回放两份 | `sealed_holdout_rerun_upstream_sync9b_2026_10_28.{freeze.json,json}`、`reported_offset_upstream_sync9b_2026_10_28.{freeze.json,json}` |
Python 快速门、前端 tsc / lint / build、`npm test`、`test:db` 没跑:没有改前端产品代码,没有动表。
## 断言三栏
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `tests/test_varga_bphs.py::test_22nd_drekkana_counts_forward_from_lagna_drekkana` | `expected = (drekkana_ref(asc_lon) + 21) % 12` | 固定 `3`(巨蟹),保留 `offset_from_lagna_drekkana == 22` | 旧断言把「+21」的错口径抄进了测试;换成书上的固定反例(射手 D3 起,第 8 宫为巨蟹) |
| `tests/test_cli_smoke.py::test_full_reading_exposes_sensitive_point_modules` | `definition == "Cancer-2, Scorpio-1, Pisces-3"` | `definition == "Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3"` | BUG-1494 改的就是这个定义字符串;仍逐字比对,没有放宽 |
| `tests/test_sealed_holdout_contract_freshness.py` 排除前缀 | 排除到 `upstream_sync8_2026_10_18` | 新增 `upstream_sync9b_2026_10_28` 两条,原有条目全部保留 | ERR-110 另存新冻结,旧记录留在清单外;第 1 次打回时补回被误删的 sync8 两条 |
| `frontend/tests/rectification-confirmation-gate.test.ts` 读取与断言路径 | `…upstream_sync8_2026_10_18.json` | `…upstream_sync9b_2026_10_28.json` | 合同当前指针移到新报告;注释补第 11 条三栏 |
| `scripts/research/sealed_holdout_rerun.py`、`reported_offset_sweep.py` 的 `FREEZE` / `REPORT` | `…upstream_sync8_2026_10_18` | `…upstream_sync9b_2026_10_28` | 同上,ERR-110 |
## 发现但没改
- 完整解读的敏感点表没有 `drekkana_22nd` 的生产者:`_calc_sensitive_points` 只产 `bhrigu_bindu` 与 `sarpa_drekkana`。所以第 22 号 Drekkana 修好后,报告里暂时看不到变化;这是既有接线缺口,本单不接线(也不把「有函数」写成「报告里已计算」)。
- `frontend/tests/fixtures/report-density-fictional-engine.json` 里还有两处旧 `definition` 字符串。它是前端 fixture,本单不改前端产品代码,留给前端轮次或验收方判断。
## 环境缺口
- Windows 跑 Python 测试要 `PYTHONUTF8=1`,否则子进程按 GBK 编码,遇到警告符号会 `UnicodeEncodeError`,看起来像引擎崩了。
- 前端测试没跑(任务书不要求,且本单没改前端产品代码)。
## 第 1 次打回(2026-10-28)
- 被验 SHA:`5c5f333d4d93fb691c10790988d3d4c986ac1bd3`;修复单 `docs/tasks/TASK-upstream-sync9b-fix-20261028.md`
- 错在哪:改 `IDENTITY_FORBIDDEN_PREFIXES` 时把第八轮的两条**替换**掉了,清单从 4 条变成 2 条;测试照样通过,但守门变弱(第八轮的冻结记录混进打分身份也不会再被发现)
- 改了什么:把第八轮两条加回原来的注释下面,第九轮乙批两条保留 —— 现在清单同时含 sync8 与 sync9b 共 4 条;本文件「断言三栏」对应行改成「新增两条、原有全部保留」
- 跑过:`tests/test_sealed_holdout_contract_freshness.py` 9 passed;`frontend/tests/rectification-confirmation-gate.test.ts` 8 passed(`node node_modules/tsx/dist/cli.mjs --test tests/rectification-confirmation-gate.test.ts`,工作树里 `frontend/node_modules` 已存在,没有重跑 `npm ci`)
- 没有重跑上游两个测试文件和校正 20 / 900:本次不改计算代码
+1
View File
@@ -544,3 +544,4 @@
| — | `PROGRESS-session-shell-116-20261028.md` | **#116 侧栏会话菜单搬进外壳**:每个页面都能「…」和拖动(直接执行,BUG-1467) | 已推 staging | `codex/session-shell-116-20261027` |
| `TASK-home-crash-saved-people-20261027.md` | `PROGRESS-home-crash-saved-people-20261027.md` | **#115 首页崩溃**:存过别人的账户一进首页就「页面出错了」——人物列表的资料缺两个字段,启程引导一读就报错;入口补齐默认值(BUG-1464) | 待验收 | `codex/home-crash-saved-people-20261027` |
| `TASK-upstream-sync9-20261028.md` | `PROGRESS-upstream-sync9-20261028.md` | **第 1 次打回(快速门 1 败:Darakaraka 旧测试锁错值,见 `TASK-upstream-sync9-fix-20261028.md`)** · **#141 上游同步第九轮**:甲批九项已做完并推 `codex/upstream-sync9-20261028`;校正 20 条和 900 条与上一份回放逐条相同;乙批仍等 #137 | 待验收 | `codex/upstream-sync9-20261028` |
| `TASK-upstream-sync9b-20261028.md` | `PROGRESS-upstream-sync9b-20261028.md` | **第 1 次打回(冻结契约排除清单删了第八轮两条,见 `TASK-upstream-sync9b-fix-20261028.md`)** · **#159 上游同步第九轮乙批(产品选 A)**:Sarpa Drekkana 补成五段、第 22 号 Drekkana 改为 D3 第 8 宫;改冻结文件 `varga.py`,校正 20 + 900 与上一份逐条相同后按 ERR-110 重新冻结。Narayana 小运强度口径(B2)不做,另问产品 | 待验收 | `codex/upstream-sync9b-20261028` |
@@ -0,0 +1,76 @@
# TASK · 上游同步第九轮乙批:Sarpa Drekkana 与第 22 号 Drekkana(2026-10-28)
- 日期:2026-10-28
- 来源:issue #159(#141 乙批)。产品 10-28 选 **A**:改冻结文件,生时校正 20 条 + 900 条分数逐条相同才重新冻结。
- 执行方:**Grok(Windows + Docker)**,分支 `codex/upstream-sync9b-20261028`,只推这个分支(本任务书就是该分支第一个提交)
- 验收:Claude(Linux)
- BUG 编号:开工时按 `docs/BUG_HISTORY.md` 全文件取最大号 +1(写作时 staging 最大号 BUG-1492;另一会话仍在占号,开工当时再核对)。本单预计 2 个号。
## 1. 基线
- 基线 commit:`origin/staging` `12eb7018`(#141 甲批与 #137 都已在其中)。
- 上游:`732642856/yinduzhanxing` main 的 `50847e57`(10-16)。按语义移植,不整文件覆盖。
## 2. 事故实证(上游测试放到 `12eb7018` 上跑)
| 项 | 上游测试结果 | 我方现状 | 来源 |
| --- | --- | --- | --- |
| B1a Sarpa Drekkana | `test_varga_bphs.py` 3 败 14 过;`test_sarpa_drekkana_precision.py` 4 败 2 过 | `scripts/varga.py::SARPA_DREKKANA_SIGNS` 只有 Cancer-2、Scorpio-1、Pisces-3 三段;`calc_sarpa_drekkana` 先把度数四舍五入到 4 位再算第几个 Drekkana,边界附近会划错 | Raman《Hindu Predictive Astrology》Drekkana 章:五段(Cancer-2、Cancer-3、Scorpio-1、Scorpio-2、Pisces-3) |
| B1b 第 22 号 Drekkana | 同上 | `scripts/varga.py::calc_22nd_drekkana` 取 D3 星座 +21 | 《Crux of Vedic Astrology》p.329:是 D3 的第 8 宫,即 +7。数 21 个十度分段 ≠ 在 D3 里移 21 个星座 |
下游:`jyotish_engine.py` 完整解读的特殊点(`calc_sarpa_drekkana`)→ 前端报告事实表 `report-fact-tables.ts` 的 Sarpa Drekkana 行;第 22 号 Drekkana 进完整解读的敏感点。
## 3. 根因
前八轮同步与 #141 甲批都没有取上游 main 10-16 的这一提交(#141 甲批因冻结文件把它留到乙批)。
## 4. 决策记录
1. **产品 10-28 在 #159 选 A**:允许改冻结文件 `scripts/varga.py`;改完跑生时校正 20 条 + 900 条,**逐条相同**才重新冻结为 `upstream_sync9b_2026_10_28`,沿用第八轮 `79f6ac52` 的做法。有任何一条不同:停,不重新冻结,回报「需拍板」。
2. **B2(Narayana 小运强度口径)本单不做。** 规划时核实:上游 `6859b263` 只是把已选的 `strength_profile` 往下传,但这个强度口径家族(`rath_*`、`pvr_*` 等)来自上游 09-27 的 `5b57949c`,我方从未移植;默认口径下结果不变。是否移植整套口径另由产品决定(已在 #159 说明)。
3. 用户可见变化已知并接受:部分盘 Sarpa Drekkana 标记会多出来(Cancer 第 3、Scorpio 第 2 段);第 22 号 Drekkana 的星座会变。
## 5. 硬红线
1. 只改 `scripts/varga.py` 里 `SARPA_DREKKANA_SIGNS`、`calc_sarpa_drekkana`、`calc_22nd_drekkana` 三处;`varga.py` 其他函数逐字节不变(PROGRESS 贴 `git diff --stat` 与函数级 diff 说明)。
2. 生时校正 20 条(`scripts/research/sealed_holdout_rerun.py`)+ 900 条(`scripts/research/reported_offset_sweep.py --json`):先不带 `--freeze` 跑出结果、与 `docs/research/*upstream_sync8_2026_10_18.json` 逐条比;**全部相同**后才按脚本自带的 `--freeze` 写新冻结记录(不覆盖旧记录)。
3. 不改其他冻结文件;不改 `.gitea/workflows/**`、数据库、前端。
4. 上游断言数值不得放宽;接口名不同只改调用方式。
5. 公开仓库:测试只用公开或虚构出生资料。
## 6. 任务分解
| 任务 | 内容 | 验收标准 |
| --- | --- | --- |
| T1 | Sarpa 五段 + 不经四舍五入判段(`50847e57`) | 上游两个测试文件中 Sarpa 相关全过;`definition` 字段改为五段 |
| T2 | 第 22 号 Drekkana +7(`50847e57`) | 上游相关断言过;完整解读里该敏感点的字段名不变 |
| T3 | 校正回放与重新冻结(红线 2) | 20/20、900/900 相同;新 `.freeze.json` 两份 + 回放 JSON 两份;`tests/test_sealed_holdout_contract_freshness.py` 全过 |
| T4 | 上游配套文档 `docs/research/sensitive_point_drekkana_count_review_2026_10_14.md` 原路径放入;BUG_HISTORY 2 条、CHANGELOG、PROGRESS、`docs/tasks/README.md` 状态板 | — |
## 7. 让步顺序
T1、T3、T4 必做;T2 若与我方敏感点结构冲突,回报原因、不硬改。
## 8. 验收命令
| 检查 | Grok 自跑 | Claude 验收 |
| --- | --- | --- |
| 上游两个测试文件(放 `tests/test_upstream_sync9b_*.py`) | 必跑 | 复验 |
| `tests/test_varga*.py`、`tests/test_cli_smoke.py -k "sarpa or drekkana or special"`、`tests/test_sealed_holdout_contract_freshness.py` | 必跑 | 复验 |
| 校正 20 + 900 回放 | 必跑 | 复验 |
| 前端 `report-fact-tables.test.ts` | 不跑(未改前端) | 跑 |
| Python 快速门 | 不跑 | 以 Claude 为准 |
## 9. 开工前置命令
```
git fetch origin
git worktree add .worktrees/upstream-sync9b-20261028 codex/upstream-sync9b-20261028
python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
grep -o 'BUG-[0-9]\+' docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
git fetch upstream main && git show 50847e57
```
## 10. 真机
`docs/testing/upstream-sync9b-20261028.md`。
@@ -0,0 +1,45 @@
# TASK · 上游同步第九轮乙批 · 第 1 次打回(2026-10-28)
- 原任务书:`docs/tasks/TASK-upstream-sync9b-20261028.md`(issue #159)
- 被验 SHA:`5c5f333d4d93fb691c10790988d3d4c986ac1bd3`(分支 `codex/upstream-sync9b-20261028`)
- 执行方:Grok,同一分支继续提交;不新开 BUG 号
## 1. 验收结果
| 检查 | 结果 |
| --- | --- |
| 回报 SHA 是分支最新提交,可快进到当前 `origin/staging` | 是 |
| `varga.py` 改动 | 与上游 `50847e57` 一致,只改了任务书点名的三处 |
| 上游测试 + `test_varga_bphs.py` + 冻结契约 + 敏感点冒烟(Linux) | 49 过 |
| 生时校正 20 条 + 900 条(Linux 复跑) | 与第八轮记录逐条相同 |
| 旧冻结记录 | 逐字节未动 |
| 两处偏离(临时记录回放、前端测试路径) | 认可,与第八轮做法一致 |
| 任务书里 `-k "sarpa or drekkana or special"` 选中 0 条 | 这是任务书写错了,你改用真实测试名是对的 |
## 2. 失败项:冻结契约测试的排除清单被删了两条
`tests/test_sealed_holdout_contract_freshness.py::IDENTITY_FORBIDDEN_PREFIXES` 这份清单历来是**追加**的(每次重新冻结都「再排除」新路径,旧路径留着)。本分支把第八轮的两条:
```
"docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18",
"docs/research/reported_offset_upstream_sync8_2026_10_18",
```
**替换**成了第九轮乙批的两条,第八轮那段注释还在、条目没了。这条测试守的是「冻结记录文件不能混进打分身份」,删掉以后第八轮的记录混进去也不会报错——测试仍然通过,但守门变弱了。
## 3. 要做的
1. 把上面两条第八轮路径加回去,放在原来那段注释下面;第九轮乙批两条保留在其后。即清单同时含第八轮与第九轮乙批共 4 条。
2. PROGRESS「断言三栏」这一行改写为「新增两条、原有全部保留」。
3. 跑 `tests/test_sealed_holdout_contract_freshness.py`,回报通过数。
4. 前端:在工作树 `frontend/` 里 `npm ci` 后跑 `npm test -- tests/rectification-confirmation-gate.test.ts`(或该仓等价的单文件命令),回报结果。
## 4. 验收命令
| 检查 | Grok 自跑 | Claude 验收 |
| --- | --- | --- |
| `tests/test_sealed_holdout_contract_freshness.py` | 必跑 | 复验 |
| `frontend/tests/rectification-confirmation-gate.test.ts` | 必跑 | 复验 |
| 上游两个测试文件、校正 20 + 900 | 不必重跑(本次不改代码) | 复验 |
完成后照旧回报新的 head SHA。
+9
View File
@@ -0,0 +1,9 @@
# 真机走查 · 上游同步第九轮乙批(issue #159)
用虚构或公开出生资料,在 staging 上看:
| # | 操作 | 应该看到 |
| --- | --- | --- |
| 1 | 生成一份完整报告,翻到特殊点 / 敏感点表 | Sarpa Drekkana 行正常显示;没有空白或「undefined」 |
| 2 | 同一张盘看第 22 号 Drekkana | 有星座,不报错 |
| 3 | 发起一次生时校正,走到出结果 | 和以前一样能走完(校正打分没有变) |
+12
View File
@@ -918,6 +918,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
**代表分钟只写在条上(2026-10-28,issue #146)。** 盘面标题行不再写时刻,「看盘面」按钮只写这三个字(读屏名称仍带时刻和上升)。下面 D10 那段里「条上与盘面标题行同一次调用」从此只剩条这一处。
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-chat-view.ts` 的 `deriveRectificationChatView` 算一次(容器 `rectification-agentic-chat.tsx` 每次渲染调用它一次;2026-09-26 代码拆分只搬位置)、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。
@@ -955,6 +957,14 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 11. 区间交付卡
**卡头与按钮(2026-10-28,issue #147,产品同意 https://git.copse.top/root/Jyotisha/issues/147#issuecomment-36056 四条)。** 下文 D3、D4 与「更像这个」按以下改:
- 区分得开(D4:前两列差 ≥ 5 个百分点)且有代表分钟时,卡头是 `.rectification-result-lead`:小字「最可能的出生时间」、30px 衬线时刻、「上升X」(有该分钟的宫位表才写);「目前范围 …(对照了 N 件经历)」降为下一行 `.rectification-range-delivery__range-line`。区分不开时 D3 原样:范围是 `<strong>` 卡头,加「区分不开」那句,不写最可能。
- 区分得开时最可能那列排第一,按钮是实心「采用 HH:MM 并打开星盘」,采用成功(服务端已保存)后去 `/chart`;其余列在小字「还不能排除」下面,按钮是描边「改用这个」,采用后不跳转。区分不开时每列仍是「更像这个」。出生记录冲突时一律「改用校正结果」,不跳转。
- 不显示把握(低 / 中 / 高):引擎的这一档没有校准,BUG-560 同理。
- 可以采用时,卡上多一个文字按钮「我说错了,改一条」,展开用户确认过的事件(与对话里的复述同一说法),点一条只在输入框里写好「我说错了:「…」应该是」,由用户补完发出,现有改记录工具来改;不自动发送、不另扣点。没有确认过的事件就不出这个按钮。不放「再补一件事」:训练门后补打字经历几乎缩不了范围,卡上不得邀请再补(BUG-1084,issue #148 产品 2026-10-28 定)。
- 盘型交付卡(「用哪张盘解读」)不加时刻卡头(BUG-1151:盘型产品不排分钟),按钮文案不变;平常采用后同样去 `/chart`,也有上面那一行。
交付对象是区间里并排的候选分钟,不是支持度数字,也不是逐行空差异。
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
@@ -973,6 +983,8 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
## 12. 校正右栏盘面
**默认关着(2026-10-28,issue #146)。** 所有宽度上,头部都有「看盘面」按钮(`.rectification-board-peek`),盘面默认不出。桌面点开后在右侧分出一栏(`minmax(18rem, 22.5rem)`,没有候选时 16–18rem),280ms 从右淡入,标题行右侧是关闭按钮;手机仍是底部抽屉。标题行不写时刻。
右栏回答「当前按哪一分钟在看盘」,不回答「选哪一分钟」。顶部不加任何新说明文字。
**本命宫位改北印星盘。** 用 `VedicChartSvg` 渲染代表分钟的 `house_table`。宫位表退到星盘下方的 `<details>`(读屏与复制仍可用)。换分钟时只高亮变化的宫(`changedHouses` / `lagnaChanged`),不做动画。星盘组件在校正面 `dynamic()` 分包里,不进首页首屏。
+12
View File
@@ -63,6 +63,18 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页不写副标题。按钮是「开始合盘」,上面不写价格。点了先确认「会用掉 N 点,余额 M 点。」,按钮是「取消 / 开始」。余额不够时说「需要 N 点,余额只有 M 点。」,主按钮是「去补点数」。N 从计价表读,M 从账户读。计价读不到时不弹确认,只说「计费配置不可用」。这两人已经有写好的合盘、且没有一份正在生成时,按钮下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页不出现两个人的选择框,也不出现「开始合盘」。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。
## 生时校正对话:开场说来源、事件没起作用时直说(2026-10-28,issue #148)
- **开场窗口句带上时间来源**,仍是一句:「我们来把你的出生时间缩小到更准的范围,按你填的大概时间,现在先在 HH:MM–HH:MM 之间找。」盘型产品:「……能不能选定用来解读的星盘,按你的出生记录时间,核对窗口是 HH:MM–HH:MM。」来源词只有四种:按你的出生记录时间 / 按你填的大概时间 / 按你填的时间(家人记得的准确时间)/ 按你给的时间段;不知道或旧资料不写。来源已知时开场由服务器写,模型那一版不用。不追问误差(不确定度只在资料里问一次)。
- **事件轮范围没动**:这一轮记下了事件、服务器重算过、又不是出结果的那一轮,范围前后完全一样时,正文后面接一句「这件事在现在的范围里每个时刻都对得上,分不出前后。」范围变了照旧写「范围从 … 变为 …」;没重新比较照旧写「这次没有重新比较……」。这句不邀请再补经历(BUG-1084)。
- 不做:逐层播报上升、月宿、大运变不变(BUG-606、BUG-1253);按精度限制能用哪些层(属于评分);追问离职是主动还是被动(引擎用不上);每轮写还缺什么、补哪类事能再收窄(BUG-1084)。
## 生时校正界面:收束卡与盘面(2026-10-28,issue #146、#147)
- 头部按钮「看盘面」;盘面标题「当前盘面」,不写时刻。
- 收束卡区分得开时:「最可能的出生时间」+ 时刻 + 「上升X」,下一行仍是「目前范围 …(对照了 N 件经历)」。最可能那列按钮「采用 HH:MM 并打开星盘」;其余列上方小字「还不能排除」,按钮「改用这个」。区分不开时不变(「更像这个」、「这几个时刻按现在的方法区分不开。」)。不写「把握」。
- 卡上一个文字按钮「我说错了,改一条」(不放「再补一件事」,BUG-1084);展开后一句「选一条要改的,输入框里会先写好开头:」,点一条后输入框写「我说错了:「事件」应该是」。
## 生时校正独立入口(2026-10-28,issue #145)
产品 2026-10-28 定:校正有自己的入口,不再从对话里开案例。下面这节「生时校正从对话进入」里的卡片文案和「明确要求则直接进入」由本节取代,其余(何时提议、做不了必须说一句、正文怎么提、回来)不变。
+94 -2
View File
@@ -2827,14 +2827,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
grid-template-columns: minmax(0, 1fr);
background: var(--color-canvas);
}
/* Issue #146: the board is closed by default; 「看盘面」 in the header opens it
as a right column on wide screens. */
.rectification-workspace.is-board-open:not(.is-compact) {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22.5rem);
}
/* No candidate yet: the board only carries the declared time, so it takes less
width until there is a house table to show. */
.rectification-workspace.is-board-empty {
.rectification-workspace.is-board-empty.is-board-open:not(.is-compact) {
grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem);
}
.rectification-workspace:not(.is-compact) .rectification-board {
animation: rectification-board-enter 280ms cubic-bezier(.22, 1, .36, 1) both;
}
@keyframes rectification-board-enter {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.rectification-workspace.is-compact {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
@@ -7201,3 +7213,83 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); }
.starter-rectify-link:hover {
background: var(--color-canvas-muted);
}
/* Result card (issue #147). The most likely minute leads only when the card
says the columns can be told apart (D4); the range drops to the line under
it. Candidates that cannot be ruled out keep their own adopt button. */
.rectification-result-lead {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-1) var(--space-3);
}
.rectification-result-lead__label {
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
}
.rectification-result-lead__time {
color: var(--color-ink);
font-family: var(--font-display);
font-size: 30px;
font-weight: 500;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.rectification-result-lead__lagna {
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.rectification-range-delivery__range-line {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
}
.rectification-range-delivery__others {
margin: 0 0 var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-canvas-muted);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.rectification-result-actions {
display: grid;
gap: var(--space-2);
}
.rectification-result-actions__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.rectification-result-actions__fix {
color: var(--color-action);
text-decoration: underline;
text-underline-offset: 3px;
}
.rectification-result-actions__list {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-lg);
background: var(--color-canvas-soft);
animation: rectification-result-list-enter 260ms cubic-bezier(.22, 1, .36, 1) both;
}
.rectification-result-actions__list p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.rectification-result-actions__items {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rectification-result-actions__items button {
white-space: normal;
text-align: start;
}
@keyframes rectification-result-list-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@@ -7,6 +7,8 @@ import { createPortal } from "react-dom";
import type { ChatMessage } from "@/lib/chat-message-view";
import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { useOpenChartAfterAdopt } from "@/hooks/use-open-chart-after-adopt";
import { fixEvidenceDraft } from "@/lib/rectification-fix-evidence";
import { livingDisplayFromReceipt } from "@/lib/rectification-agentic/core/candidate-window";
import {
RECTIFICATION_EMPTY_ACTION_LABEL,
@@ -170,7 +172,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
currentQuestionRef,
applyCaseSnapshot,
loadCaseSnapshot,
fixableEvidence,
} = useRectificationCaseSnapshot({ caseId, sessionId, initialSnapshot });
const { arm: armChartAfterAdopt, onSaved: savedThenChart } = useOpenChartAfterAdopt(onSaved);
const readonly = historyReadonly || Boolean(resultNotice);
const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
@@ -365,30 +369,33 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void send("opening", "");
}, [initialTurns.length, onOpeningConsumed, readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (candidateId: string) => runRectificationCandidateAccept({
caseId,
sessionId,
busy,
readonly,
candidateResult,
acceptingCandidateId,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setAcceptingCandidateId,
setCandidateResult,
setSavedTime,
setSavedStatus,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onSaved,
}, candidateId), [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
const acceptCandidate = useCallback(async (candidateId: string, openChart = false) => {
armChartAfterAdopt(openChart);
return runRectificationCandidateAccept({
caseId,
sessionId,
busy,
readonly,
candidateResult,
acceptingCandidateId,
keyCounter,
runAbort,
lastRunOutcome,
conversationAnchor,
setError,
setMessages,
setAcceptingCandidateId,
setCandidateResult,
setSavedTime,
setSavedStatus,
setPending,
beginLiveRun,
loadCaseSnapshot,
send,
onCompleted,
onSaved: savedThenChart,
}, candidateId);
}, [acceptingCandidateId, armChartAfterAdopt, savedThenChart, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, readonly, send, sessionId, setPending]);
async function copyMessage(message: RenderMessage) {
await copyRectificationMessage({ setCopiedMessageKey }, message);
@@ -533,7 +540,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
};
});
const boardPeek = compactBoard && !boardOpen ? (
// Issue #146: 「看盘面」 is in the header at every width. On a phone the open
// sheet covers it; on a desktop it stays as the toggle beside the open board.
const boardPeek = !compactBoard || !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
declaredTime={declaredTime}
@@ -598,8 +607,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
result={candidateResult}
acceptingCandidateId={acceptingCandidateId}
readonly={readonly || (busy && !acceptingCandidateId)}
onAccept={(candidateId) => void acceptCandidate(candidateId)}
onAccept={(candidateId, openChart) => void acceptCandidate(candidateId, openChart)}
adoptLocked={liveInterviewBlockingAdopt}
fixableEvidence={fixableEvidence}
onFixEvidence={(line) => {
setDraft(fixEvidenceDraft(line));
composer.current?.focus({ preventScroll: true });
}}
/>
{verifiedIdleCopy && (
<p className="rectification-pending-note" role="status">
+10 -10
View File
@@ -94,12 +94,9 @@ function RectificationBoardBody({
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}>当前盘面</h2>
{workingTime
? <span className="rectification-board__clock">{workingTime}</span>
: emptyCopy.clock
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
: null}
{compact && (
{/* Issue #146: the working minute is written once, on the timeline
above the conversation. The board no longer repeats it. */}
{(
<Button
className="rectification-board__close"
type="button"
@@ -288,7 +285,8 @@ export function RectificationBoard({
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
if (compact && !open) return null;
// Issue #146: closed by default at every width; 「看盘面」 opens it.
if (!open) return null;
const sheet = (
<aside
@@ -323,6 +321,9 @@ export function RectificationBoard({
);
}
/** Issue #146: the header button says what it does; the time lives on the timeline. */
export const RECTIFICATION_BOARD_OPEN_LABEL = "看盘面";
export function RectificationBoardPeek({
result,
declaredTime = null,
@@ -344,13 +345,12 @@ export function RectificationBoardPeek({
className="rectification-board-peek"
type="button"
variant="ghost"
aria-label={peek.detail ? `${peek.title},${peek.detail}` : peek.title}
aria-label={peek.detail ? `${RECTIFICATION_BOARD_OPEN_LABEL},${peek.detail}` : RECTIFICATION_BOARD_OPEN_LABEL}
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
<strong>{RECTIFICATION_BOARD_OPEN_LABEL}</strong>
</Button>
);
}
@@ -18,6 +18,12 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
import { RectificationSegmentDelivery } from "@/components/rectification-segment-delivery";
import { sessionOutcomeAllowsAdopt } from "@/lib/rectification-agentic/core/rectification-decision";
import { Button } from "@/components/ui/button";
import { RectificationResultActions, RectificationResultLead } from "@/components/rectification-result-parts";
import {
RECTIFICATION_OTHER_CANDIDATES_LABEL,
RECTIFICATION_SWITCH_TO_THIS_LABEL,
adoptAndOpenChartLabel,
} from "@/lib/rectification-fix-evidence";
import { deliveryWindowFromReceipt, protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
@@ -31,12 +37,17 @@ export function RectificationRangeDelivery({
readonly,
onAccept,
adoptLocked,
fixableEvidence = [],
onFixEvidence,
}: Readonly<{
result: RectificationCandidateResult;
acceptingCandidateId: string | null;
readonly: boolean;
onAccept: (candidateId: string) => void;
/** `openChart`: the most likely column's 「采用 HH:MM 并打开星盘」 (issue #147). */
onAccept: (candidateId: string, openChart?: boolean) => void;
adoptLocked?: boolean;
fixableEvidence?: readonly string[];
onFixEvidence?: (line: string) => void;
}>) {
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
const range = delivery?.range ?? result.credibleRange;
@@ -71,24 +82,48 @@ export function RectificationRangeDelivery({
// D4: per-column numbers only when the first column leads by ≥ 5 points.
const showPercents = rangeDeliveryShowsPercents(columns);
const indistinct = columns.length >= 2 && !showPercents;
// Issue #147: lead with the most likely minute only when D4 says the columns
// can be told apart; otherwise the range stays the headline (D3).
const primaryColumn = mostLikely && !indistinct
? columns.find((column) => column.candidate_id === delivery?.representative_candidate_id)
?? columns.find((column) => column.time === mostLikely)
?? null
: null;
const primaryLagna = primaryColumn
? result.houseTablesByTime[primaryColumn.time]?.lagna
?? (result.houseTable?.time === primaryColumn.time ? result.houseTable.lagna : null)
?? null
: null;
const orderedColumns = primaryColumn
? [primaryColumn, ...columns.filter((column) => column !== primaryColumn)]
: columns;
const showActions = adoptAllowed && !adoptLocked && !readonly;
if (delivery && (delivery.product_delivery || delivery.segment_summary)) {
return <RectificationSegmentDelivery summary={delivery.segment_summary ?? null} delivery={delivery} rangeLabel={rangeLabel}
selection={result.segmentSelection} resultId={result.resultId} busy={Boolean(busy)} accepting={Boolean(acceptingCandidateId)} onAccept={onAccept}
adoptLocked={adoptLocked} markdown={markdown} canAdopt={adoptAllowed}
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date} />;
candidateDate={result.candidates.find((candidate) => candidate.candidateId === delivery.representative_candidate_id)?.candidate_date}
fixableEvidence={fixableEvidence} onFixEvidence={onFixEvidence} readonly={readonly} />;
}
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely && !indistinct ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
{primaryColumn ? (
<>
<RectificationResultLead time={primaryColumn.time} lagna={primaryLagna} />
<p className="rectification-range-delivery__range-line">{title}</p>
</>
) : (
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely && !indistinct ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
)}
{vargaBlocked ? (
<p className="rectification-range-delivery__narrow">{windowMinutes === null ? "完整窗口范围暂不可用;暂不推荐分盘解读。" : "分盘解读需要更准的出生时间"}</p>
) : caption ? (
@@ -118,7 +153,8 @@ export function RectificationRangeDelivery({
</ul>
) : null}
<ul className="rectification-range-delivery__columns">
{columns.map((column) => {
{orderedColumns.map((column, index) => {
const isPrimary = column === primaryColumn;
const candidateDate = result.candidates.find((candidate) => candidate.candidateId === column.candidate_id)?.candidate_date;
const relativeDay = candidateDate && result.declaredBirthDate && candidateDate !== result.declaredBirthDate
? candidateDate < result.declaredBirthDate ? "(前一天)" : "(后一天)" : "";
@@ -128,8 +164,11 @@ export function RectificationRangeDelivery({
return (
<li
key={column.candidate_id}
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}`}
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}${isPrimary ? " is-primary" : ""}`}
>
{primaryColumn && index === 1 ? (
<p className="rectification-range-delivery__others">{RECTIFICATION_OTHER_CANDIDATES_LABEL}</p>
) : null}
<div className="rectification-range-delivery__time">{candidateDate ? `${candidateDate}${relativeDay} ${column.time}` : column.time}</div>
{adoptAllowed && !adoptLocked && candidateDate ? <p className="rectification-range-delivery__muted">采用后,排盘使用这一日期和时间;原填报日期保留。</p> : null}
{showPercents ? (
@@ -162,11 +201,11 @@ export function RectificationRangeDelivery({
<>
<Button
type="button"
variant="outline"
variant={isPrimary ? "default" : "outline"}
className="rectification-range-delivery__accept"
aria-pressed={adopted}
disabled={busy || adopted}
onClick={() => onAccept(column.candidate_id)}
onClick={() => onAccept(column.candidate_id, isPrimary && !recordConflict)}
>
{adopting
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
@@ -174,7 +213,11 @@ export function RectificationRangeDelivery({
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: recordConflict
? SWITCH_TO_RECTIFIED_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
: isPrimary
? adoptAndOpenChartLabel(column.time)
: primaryColumn
? RECTIFICATION_SWITCH_TO_THIS_LABEL
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
</Button>
{recordConflict && !adopted ? (
<p className="rectification-range-delivery__switch-note">
@@ -189,6 +232,13 @@ export function RectificationRangeDelivery({
);
})}
</ul>
{showActions ? (
<RectificationResultActions
evidence={fixableEvidence}
disabled={Boolean(busy)}
onFixEvidence={onFixEvidence}
/>
) : null}
{delivery?.more_label ? (
<p className="rectification-range-delivery__more">{delivery.more_label}</p>
) : null}
@@ -0,0 +1,80 @@
"use client";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { RECTIFICATION_FIX_COPY, RECTIFICATION_MOST_LIKELY_LABEL } from "@/lib/rectification-fix-evidence";
/**
* Issue #147: the result card leads with the most likely minute when the
* server says the candidates can be told apart (D4). The card decides that;
* this only draws it.
*/
export function RectificationResultLead({ time, lagna }: Readonly<{ time: string; lagna: string | null }>) {
return (
<div className="rectification-result-lead">
<span className="rectification-result-lead__label">{RECTIFICATION_MOST_LIKELY_LABEL}</span>
<strong className="rectification-result-lead__time">{time}</strong>
{lagna ? <span className="rectification-result-lead__lagna">上升{lagna}</span> : null}
</div>
);
}
/**
* 「我说错了,改一条」 lists the confirmed events and fills the composer with
* the chosen one. Nothing is sent here. There is deliberately no 「再补一件事」:
* typed events barely narrow the range after the training gate, so the card
* must not invite more (BUG-1084, issue #148 decision 2026-10-28).
*/
export function RectificationResultActions({
evidence,
disabled,
onFixEvidence,
}: Readonly<{
evidence: readonly string[];
disabled: boolean;
onFixEvidence?: (line: string) => void;
}>) {
const [fixOpen, setFixOpen] = useState(false);
const listId = useId();
if (!onFixEvidence || evidence.length === 0) return null;
return (
<div className="rectification-result-actions">
<div className="rectification-result-actions__row">
<Button
type="button"
variant="link"
className="rectification-result-actions__fix"
disabled={disabled}
aria-expanded={fixOpen}
aria-controls={listId}
onClick={() => setFixOpen((open) => !open)}
>
{RECTIFICATION_FIX_COPY.fixOne}
</Button>
</div>
{fixOpen ? (
<div id={listId} className="rectification-result-actions__list" role="group" aria-label={RECTIFICATION_FIX_COPY.fixOne}>
<p>{RECTIFICATION_FIX_COPY.pickHint}</p>
<div className="rectification-result-actions__items">
{evidence.map((line) => (
<Button
key={line}
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => {
setFixOpen(false);
onFixEvidence(line);
}}
>
{line}
</Button>
))}
</div>
</div>
) : null}
</div>
);
}
@@ -8,8 +8,10 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
import { protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
import { unknownProductDeliveryCopy } from "@/lib/rectification-agentic/v9/product-delivery";
import { Button } from "@/components/ui/button";
import { RectificationResultActions } from "@/components/rectification-result-parts";
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false }: Readonly<{
export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false,
fixableEvidence = [], onFixEvidence, readonly = false }: Readonly<{
summary: SegmentSummary | null;
delivery: RangeDeliveryProjection;
rangeLabel: string | null;
@@ -17,11 +19,15 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
resultId: string;
busy: boolean;
accepting: boolean;
onAccept: (candidateId: string) => void;
/** `openChart`: 「采用 HH:MM 并打开星盘」 (issue #147). */
onAccept: (candidateId: string, openChart?: boolean) => void;
adoptLocked?: boolean;
markdown?: string | null;
candidateDate?: string;
canAdopt?: boolean;
fixableEvidence?: readonly string[];
onFixEvidence?: (line: string) => void;
readonly?: boolean;
}>) {
const adoption = summary?.adoption_minute;
const candidateId = delivery.representative_candidate_id;
@@ -36,6 +42,10 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
const blocked = summary?.charts.some((chart) => chart.tier === "blocked") === true;
const reportBlocked = !summary || blocked;
const showAdopt = Boolean(canAdopt && adoption && candidateId && !adoptLocked);
// Issue #147: no minute lead here. The segment product answers "which chart",
// never "which minute" (BUG-1151, product 10-01 option A). A plain adopt still
// opens the chart afterwards; re-adopt and record-conflict adopts do not.
const plainAdopt = !(readopt && priorAdoption) && !delivery.record_conflict;
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正盘型">
<div className="rectification-candidates-heading"><strong>用哪张盘解读</strong></div>
@@ -58,8 +68,8 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
{showAdopt && adoption && candidateId ? (
<>
<p className="rectification-range-delivery__muted">采用后按 {adoption.date ? `${adoption.date} ` : ""}{adoption.time} 排盘;原填报资料保留。</p>
<Button type="button" variant="outline" className="rectification-range-delivery__accept" disabled={busy || adopted}
aria-pressed={adopted} onClick={() => onAccept(candidateId)}>
<Button type="button" variant={plainAdopt ? "default" : "outline"} className="rectification-range-delivery__accept" disabled={busy || adopted}
aria-pressed={adopted} onClick={() => onAccept(candidateId, plainAdopt)}>
{accepting ? RECTIFICATION_USER_COPY.rangeDeliveryAdopting : adopted ? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
: readopt && priorAdoption ? `改用 ${readopt}`
: delivery.record_conflict ? SWITCH_TO_RECTIFIED_LABEL : `改用 ${charts.map((chart) => chart.chart).join(" + ")} 盘解读`}
@@ -76,6 +86,9 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
) : adopted ? (
<p className="rectification-range-delivery__muted">{RECTIFICATION_USER_COPY.rangeDeliveryAdopted}</p>
) : null}
{showAdopt && !readonly ? (
<RectificationResultActions evidence={fixableEvidence} disabled={busy} onFixEvidence={onFixEvidence} />
) : null}
<p className="rectification-range-delivery__boundary">{delivery.boundary || REPRESENTATIVE_MINUTE_DISCLAIMER}</p>
<RectificationVerificationReport markdown={protectVargaReport(markdown === undefined ? delivery.verification_markdown : markdown, reportBlocked, delivery.product_delivery?.targets ?? summary?.targets,
!summary ? "完整窗口扫描暂不可用;不能提供分盘解读推荐。" : undefined)} />
@@ -0,0 +1,26 @@
"use client";
import { useCallback, useRef } from "react";
import { navigateInApp } from "@/lib/app-navigation";
/**
* 「采用 HH:MM 并打开星盘」 (issue #147). The adopt itself is the existing
* accept run; this only goes to /chart once the server has saved the time.
* Armed per click, so a failed adopt or a plain 「改用这个」 never navigates.
*/
export function useOpenChartAfterAdopt(
onSaved: ((time: string, status: "accepted" | "confirmed") => void) | undefined,
) {
const openAfterSave = useRef(false);
const arm = useCallback((open: boolean) => {
openAfterSave.current = open;
}, []);
const saved = useCallback((time: string, status: "accepted" | "confirmed") => {
onSaved?.(time, status);
if (!openAfterSave.current) return;
openAfterSave.current = false;
navigateInApp("/chart");
}, [onSaved]);
return { arm, onSaved: saved };
}
@@ -25,6 +25,7 @@ import {
type CurrentQuestionModel,
} from "@/lib/rectification-chat-snapshot";
import type { CandidateResult } from "@/lib/rectification-chat-snapshot";
import { fixableEvidenceLines } from "@/lib/rectification-fix-evidence";
/**
* Case snapshot sync for the rectification chat surface: the state that the
@@ -63,6 +64,8 @@ export function useRectificationCaseSnapshot({
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
const [questionRepairing, setQuestionRepairing] = useState(false);
const [snapshotFailure, setSnapshotFailure] = useState<RectificationSnapshotFailure | null>(null);
// Issue #147: the confirmed events 「我说错了,改一条」 lists.
const [fixableEvidence, setFixableEvidence] = useState<readonly string[]>(() => fixableEvidenceLines(initialSnapshot?.evidence));
const snapshotAbort = useRef<AbortController | null>(null);
const currentQuestionRef = useRef(currentQuestion);
@@ -93,6 +96,7 @@ export function useRectificationCaseSnapshot({
const snapshot = payload as RectificationCaseSnapshotPayload;
setResultNotice(snapshot.case?.result_notice ?? null);
setCanRecompare(snapshot.case?.result_identity?.can_recompare === true);
if (Array.isArray(snapshot.evidence)) setFixableEvidence(fixableEvidenceLines(snapshot.evidence));
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
@@ -174,6 +178,7 @@ export function useRectificationCaseSnapshot({
}, [applyCaseSnapshot, caseId, sessionId]);
return {
fixableEvidence,
resultNotice,
canRecompare,
savedTime,
@@ -119,12 +119,16 @@ export function openingSpokenBody(
range: readonly [string, string] | null,
productDelivery?: ProductDelivery | null,
facts?: OpeningBoundaryFacts | null,
source?: string | null,
): string {
const window = formatClockRange(range);
// Issue #148: the window sentence also says where the time came from. Still
// one sentence, so the opening stays three plain layers.
const from = window ? openingSourcePhrase(source) : null;
const head = productDelivery
? `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`
? `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,${from ? `${from},` : ""}核对窗口是 ${window}` : ""}。`
: window
? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。`
? `我们来把你的出生时间缩小到更准的范围,${from ? `${from},` : ""}现在先在 ${window} 之间找。`
: "我们来把你的出生时间缩小到更准的范围。";
const how = "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。";
const extra = openingBoundarySentence(facts);
@@ -269,6 +273,8 @@ export const RECTIFICATION_USER_COPY = {
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "这次比较没跑成,你说下一句的时候我会再试一次。",
rescoreSkipped: "这次没有重新比较,等下会再比一次。",
/** Issue #148: a recorded event that left the range exactly where it was. */
evidenceNoSplit: "这件事在现在的范围里每个时刻都对得上,分不出前后。",
/** BUG-1178: an event dated after today is not recorded or scored. */
futureDatedEvent: "有一件事的日期在今天之后,这件先不算;如果年份打错了,直接告诉我正确的年份就行。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
@@ -516,6 +522,39 @@ export function rangeChangedAfterEvidence(
return `范围从 ${fromText} 变为 ${toText}。`;
}
/**
* Issue #148: say plainly that the new event did not tell the candidates
* apart. Only when the server rescored and the range is unchanged; it never
* invites more events (BUG-1084).
*/
export function withEvidenceNoSplit(
body: string,
from: readonly [string, string] | null | undefined,
to: readonly [string, string] | null | undefined,
): string {
const fromText = formatClockRange(from);
const toText = formatClockRange(to);
if (!fromText || !toText || fromText !== toText) return body;
const notice = RECTIFICATION_USER_COPY.evidenceNoSplit;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
const prefix = /[。!?]$/.test(spoken) ? spoken : `${spoken}。`;
return `${prefix}${notice}`;
}
/**
* Issue #148: how the reader's own time was given, said once in the opening.
* Only for sources where the words are true; unknown or legacy get none.
*/
export function openingSourcePhrase(source: string | null | undefined): string | null {
if (source === "hospital_record") return "按你的出生记录时间";
if (source === "approximate") return "按你填的大概时间";
if (source === "family_exact") return "按你填的时间";
if (source === "period_only") return "按你给的时间段";
return null;
}
export function withRangeChangedAfterEvidence(
body: string,
from: readonly [string, string] | null | undefined,
@@ -25,6 +25,7 @@ import {
openingSpokenBody,
stripRangeProgressClaims,
type OpeningBoundaryFacts,
openingSourcePhrase,
} from "../user-copy";
import { resolveOpeningBoundaryFacts } from "../opening-boundary.ts";
import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../rectification-run-budget.ts";
@@ -53,6 +54,7 @@ import {
batchResultFromToolChunk,
batchHasFutureDatedItem,
batchRescoreFailed,
batchRescoredWithoutDelivery,
acceptedRecapLines,
composeHostFallbackNarration,
lastCompletedPublicTool,
@@ -640,8 +642,11 @@ export async function streamV9Attempt(
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
const productDelivery = productDeliveryForDomain(latestDossier.case.rectificationDomain);
const required = openingBoundarySentence(openingFacts);
if (required || !isAcceptableOpeningBody(answerText, openingRange, productDelivery, openingFacts)) {
answerText = openingSpokenBody(openingRange, productDelivery, openingFacts);
// Issue #148: a known source is a required clause of the window sentence,
// so the server writes the opening whenever it applies.
const sourceRequired = Boolean(openingRange && openingSourcePhrase(dossier.case.birthTimeSource));
if (required || sourceRequired || !isAcceptableOpeningBody(answerText, openingRange, productDelivery, openingFacts)) {
answerText = openingSpokenBody(openingRange, productDelivery, openingFacts, dossier.case.birthTimeSource);
}
}
const latestInference = previousInferenceFromReceipt(
@@ -656,6 +661,7 @@ export async function streamV9Attempt(
futureDated: batchHasFutureDatedItem(batchToolResult),
rangeBefore: rangeBeforeCompare,
rangeAfter: rangeAfterEvidence,
evidenceRescored: batchRescoredWithoutDelivery(batchToolResult),
};
// P3: the only clocks and percentages a model sentence may carry are this
// turn's server facts (range, representative minute, event fit rate).
@@ -12,6 +12,7 @@ import type { SpokenFactWhitelist } from "./spoken-grounding";
import {
RECTIFICATION_USER_COPY,
withCompareFailedRetryNotice,
withEvidenceNoSplit,
withRangeChangedAfterEvidence,
withRescoreSkippedNotice,
} from "../user-copy";
@@ -56,6 +57,8 @@ export type TurnServerFacts = Readonly<{
futureDated?: boolean;
rangeBefore: readonly [string, string] | null;
rangeAfter: readonly [string, string] | null;
/** Issue #148: events were recorded and rescored this turn (not the delivery turn). */
evidenceRescored?: boolean;
}>;
/** Join the server fact sentences after an already trimmed and whitelisted body. */
@@ -69,7 +72,10 @@ export function composeSpokenWithServerFacts(
const withFuture = (text: string) => !future || text.includes(future) ? text : text ? `${text}${/[。!?]$/.test(text) ? "" : "。"}${future}` : future;
if (facts.compareFailed) spoken = withCompareFailedRetryNotice(spoken);
if (facts.rescoreSkipped) return withFuture(withRescoreSkippedNotice(spoken));
return withFuture(withRangeChangedAfterEvidence(spoken, facts.rangeBefore, facts.rangeAfter));
const changed = withRangeChangedAfterEvidence(spoken, facts.rangeBefore, facts.rangeAfter);
return withFuture(facts.evidenceRescored === true
? withEvidenceNoSplit(changed, facts.rangeBefore, facts.rangeAfter)
: changed);
}
export const MAX_ATTEMPTS = 2;
@@ -128,6 +128,23 @@ export function batchHasFutureDatedItem(batchResult: unknown): boolean {
));
}
/**
* Issue #148: the batch recorded at least one event, the rescore ran to
* completion, and this is not the delivery turn. Only then may an unchanged
* range be spoken as 「分不出前后」.
*/
export function batchRescoredWithoutDelivery(batchResult: unknown): boolean {
if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false;
const row = batchResult as {
accepted_count?: unknown;
rescore?: { status?: unknown };
range_after_rescore?: { delivers_range_this_turn?: unknown } | null;
};
return typeof row.accepted_count === "number" && row.accepted_count > 0
&& row.rescore?.status === "completed"
&& row.range_after_rescore?.delivers_range_this_turn !== true;
}
export function batchRescoreFailed(batchResult: unknown): boolean {
if (!batchResult || typeof batchResult !== "object" || Array.isArray(batchResult)) return false;
const rescore = (batchResult as { rescore?: { status?: unknown } }).rescore;
+2 -3
View File
@@ -118,9 +118,8 @@ export function deriveRectificationChatView(input: RectificationChatViewInput) {
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
// The same call the board's title-row clock makes, on the same result, so
// the bar and `.rectification-board__clock` are one number by construction
// rather than by two code paths that happen to agree today.
// The one place the working minute is shown (issue #146 took it off the
// board's title row). The board's peek label reads the same call.
workingTime: workingRectificationTime(candidateResult),
workingAdopted: Boolean(candidateResult?.selectedTime),
segmentSummary: candidateResult?.rangeDelivery?.segment_summary,
@@ -0,0 +1,51 @@
/**
* 「我说错了,改一条」 on the result card (issue #147). The list is the case's
* confirmed events, worded with the same recap line the conversation already
* shows. Picking one only fills the composer; the user finishes the sentence
* and sends it, and the agent revises that event with its existing tool. No
* new route, nothing sent or charged by the pick itself.
*/
import { displayDateLabel, eventPhraseFromSummary } from "./rectification-agentic/v9/evidence-model.ts";
import { evidenceRecapLine } from "./rectification-agentic/v9/host-fallback.ts";
export const RECTIFICATION_FIX_COPY = {
fixOne: "我说错了,改一条",
pickHint: "选一条要改的,输入框里会先写好开头:",
} as const;
/** Confirmed events only, oldest first as stored, each once. */
export function fixableEvidenceLines(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const lines: string[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
if (row.status !== "confirmed") continue;
const summary = typeof row.summary === "string" ? row.summary : "";
if (!summary.trim()) continue;
const precision = typeof row.datePrecision === "string" ? row.datePrecision : "unknown";
const from = typeof row.occurredFrom === "string" ? row.occurredFrom : null;
const to = typeof row.occurredTo === "string" ? row.occurredTo : null;
const line = evidenceRecapLine({
display_date_label: displayDateLabel(precision, from, to),
event_phrase: eventPhraseFromSummary(summary),
}).trim();
if (line && !lines.includes(line)) lines.push(line);
}
return lines;
}
/** The composer's opening words; the user writes what it should be. */
export function fixEvidenceDraft(line: string): string {
return `我说错了:「${line}」应该是`;
}
/** The primary adopt label on the most likely column. */
export function adoptAndOpenChartLabel(time: string): string {
return `采用 ${time} 并打开星盘`;
}
export const RECTIFICATION_OTHER_CANDIDATES_LABEL = "还不能排除";
export const RECTIFICATION_SWITCH_TO_THIS_LABEL = "改用这个";
export const RECTIFICATION_MOST_LIKELY_LABEL = "最可能的出生时间";
@@ -112,6 +112,8 @@ export type RectificationCaseSnapshotPayload = Readonly<{
birth_time_source?: unknown;
}>;
turns?: unknown;
/** The case's evidence rows; the result card lists the confirmed ones (issue #147). */
evidence?: unknown;
}>;
/** `candidate_range` as `[start, end]`; null when absent or malformed. */
@@ -635,7 +635,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/);
assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);
assert.match(board, /<X aria-hidden="true"/);
assert.match(chat, /compactBoard && !boardOpen \? \(/);
// 原值:只在手机且盘面没打开时出「看盘面」入口(compactBoard && !boardOpen)。
// 新值:桌面上也出,手机上盘面打开时才收起。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.match(chat, /!compactBoard \|\| !boardOpen \? \(/);
assert.match(chat, /headerSlot && boardPeek \? createPortal\(boardPeek, headerSlot\)/);
assert.doesNotMatch(chat, /querySelector\(/);
assert.doesNotMatch(chat, /setHeaderSlot/);
@@ -111,8 +111,11 @@ const diagnosticReport = JSON.parse(readFileSync(
// 原值(10):docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json
// 新值(10):docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json
// 原因(10):sync8(BUG-1380~1390)改了冻结身份文件,输出不变,ERR-110 另存新记录。确认门行为与阈值不变。
// 原值(11):docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json
// 新值(11):docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json
// 原因(11):sync9b(BUG-1494~1495)改了冻结身份里的 scripts/varga.py,输出不变,ERR-110 另存新记录。确认门行为与阈值不变。
const currentTreeRerun = JSON.parse(readFileSync(
new URL("../../docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json", import.meta.url),
new URL("../../docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json", import.meta.url),
"utf8",
)) as {
frozen_record: { implementation_sha256: string };
@@ -260,9 +263,10 @@ test("sealed holdout aggregates match the v3 report and still block confirmation
// 原值(7):…upstream_sync6_2026_10_07.json / 新值(7):…ephemeris_nutation_2026_10_08.json / 原因(7):章动修正与 scoring-12 重新冻结(ERR-110,BUG-1275)。
// 原值(8):…ephemeris_nutation_2026_10_08.json / 新值(8):…scoring_perf_2026_10_13.json / 原因(8):BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录。
// 原值(9):…scoring_perf_2026_10_13.json / 新值(9):…upstream_sync8_2026_10_18.json / 原因(9):sync8 改了冻结身份文件,输出不变,ERR-110 另存新记录。
// 原值(10):…upstream_sync8_2026_10_18.json / 新值(10):…upstream_sync9b_2026_10_28.json / 原因(10):sync9b 改了冻结身份里的 varga.py,输出不变,ERR-110 另存新记录。
assert.equal(
productHoldout.current_tree_scorer.source_report,
"docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json",
);
assert.equal(currentTreeRerun.official_valid_independent_blind, false);
assert.equal(productHoldout.current_tree_scorer.matches_metrics_scorer, false);
@@ -553,7 +553,8 @@ test("the minute card hides the adopt button and the chart-clock line when adopt
const allowed = renderRange(cardResult("adopt_representative", true, minuteDelivery()));
assert.match(allowed, /<button/);
assert.match(allowed, /采用后,排盘使用这一日期和时间/);
assert.match(allowed, /更像这个/);
// 原值:/更像这个/。新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」。原因:issue #147。
assert.match(allowed, /更像这个|采用 \d{2}:\d{2} 并打开星盘/);
});
test("a refused adoption refreshes the snapshot and says so in Chinese", async () => {
@@ -734,3 +735,21 @@ test("answering widen A with a failed rescore never delivers or adopts on the ol
assert.notEqual(receipt.nextAction.type, "ready_to_adopt");
assert.notEqual(receipt.nextAction.type, "complete_with_range");
});
// Issue #147: the segment card answers "which chart" (BUG-1151), so it gets the
// actions row but never a minute lead, and keeps its own adopt label.
test("the segment card gets 改一条 but no minute lead and no 再补一件事", () => {
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: cardResult("adopt_representative", true, segmentDelivery()),
acceptingCandidateId: null,
readonly: false,
onAccept: () => {},
fixableEvidence: ["2016-09 升学"],
onFixEvidence() {},
}));
assert.match(html, /用哪张盘解读/);
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
assert.match(html, /我说错了,改一条/);
assert.doesNotMatch(html, /最可能|rectification-result-lead|并打开星盘/);
assert.match(html, /盘解读<\/button>/);
});
@@ -146,10 +146,17 @@ test("D4 threshold is 5 points between the first and second column", () => {
});
test("D3 range is the title and the representative minute is the subtitle", () => {
// 原值:前两列差距 ≥ 5 时仍以「目前范围 …」为 <strong> 卡头,「最可能 HH:MM」作副标题。
// 新值:区分得开时卡头改为「最可能的出生时间 HH:MM」,范围降为下一行;区分不开时仍以范围为卡头(D3 只在这时成立)。
// 原因:issue #147(产品 2026-10-28 同意:区分得开才以最可能时刻为卡头)。
const html = render([0.45, 0.3, 0.25]);
assert.match(html, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.match(html, /rectification-range-delivery__most-likely">最可能 05:07</);
assert.ok(html.indexOf("最可能 05:07") < html.indexOf("rectification-range-delivery__columns"));
assert.match(html, /rectification-result-lead__time">05:07</);
assert.match(html, /rectification-range-delivery__range-line">目前范围 05:00–05:15(对照了 2 件经历)</);
assert.ok(html.indexOf("rectification-result-lead") < html.indexOf("rectification-range-delivery__range-line"));
assert.ok(html.indexOf("rectification-range-delivery__range-line") < html.indexOf("rectification-range-delivery__columns"));
const flat = render([0.28, 0.26, 0.24]);
assert.match(flat, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.doesNotMatch(flat, /rectification-result-lead/);
assert.equal(rangeDeliveryMostLikely("05:07"), "最可能 05:07");
});
@@ -315,3 +322,30 @@ test("D2 a delivered card does not carry an unasked guided window as the next qu
const collecting = buildMethodFollowupPlan({ ...base, sessionOutcome: "collect_evidence" });
assert.doesNotMatch(collecting.next_followup?.collection_key ?? "", /^collect:guided:window:/);
});
// Issue #147: a distinct result leads with the most likely minute; its column
// adopts and opens the chart, the others stay adoptable as 「改用这个」.
test("a distinct result puts the most likely column first with 采用并打开星盘 and keeps the others adoptable", () => {
const calls: Array<[string, boolean | undefined]> = [];
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: result([0.45, 0.3, 0.25]),
acceptingCandidateId: null,
readonly: false,
onAccept: (candidateId: string, openChart?: boolean) => { calls.push([candidateId, openChart]); },
fixableEvidence: ["2016-09 升学"],
onFixEvidence() {},
}));
assert.match(html, /采用 05:07 并打开星盘/);
assert.equal([...html.matchAll(/改用这个/g)].length, 2);
assert.match(html, /还不能排除/);
assert.ok(html.indexOf("采用 05:07 并打开星盘") < html.indexOf("还不能排除"));
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: the card does not invite more events");
assert.match(html, /我说错了,改一条/);
assert.doesNotMatch(html, /把握/, "no confidence label (issue #147, BUG-560)");
});
test("an indistinct result keeps 更像这个 on every column and no lead", () => {
const html = render([0.28, 0.26, 0.24]);
assert.doesNotMatch(html, /并打开星盘|改用这个|还不能排除|rectification-result-lead/);
assert.match(html, /更像这个/);
});
@@ -58,7 +58,10 @@ test("the bar's working minute is the board clock's own value, not a second deri
// passes the result of that same call into the bar, so the two surfaces
// cannot drift apart — there is one expression, not two that agree today.
assert.match(board, /const workingTime = workingRectificationTime\(result\);/);
assert.match(board, /className="rectification-board__clock">\{workingTime\}/);
// 原值:盘面标题行渲染 rectification-board__clock,与时间轴同一个值。
// 新值:盘面不再写时刻,代表分钟只在时间轴上出现一次。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.doesNotMatch(board, /className="rectification-board__clock"/);
assert.match(chat, /workingTime: workingRectificationTime\(candidateResult\)/);
const result = candidateSnapshot();
@@ -395,7 +395,8 @@ test("range-delivery DOM is one button per row and adopted is aria-pressed", ()
assert.doesNotMatch(html, /说不好/);
assert.doesNotMatch(html, /排盘用/);
const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
assert.match(source, /onAccept\(column\.candidate_id\)/);
// 原值:onAccept(column.candidate_id)。新值:带第二个参数,最可能那列(且无记录冲突)采用后打开星盘。原因:issue #147。
assert.match(source, /onAccept\(column\.candidate_id, isPrimary && !recordConflict\)/);
assert.match(source, /aria-pressed=\{adopted\}/);
});
@@ -191,7 +191,13 @@ test("non-conflict states keep 更像这个 and show no switch note", () => {
[RECORD_OUTSIDE, "period_only"],
] as const) {
const markup = html(reported, source);
assert.ok(markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), `${reported}/${source}`);
// 原值:每列都是「更像这个」。
// 新值:区分得开时最可能那列是「采用 HH:MM 并打开星盘」、其余「改用这个」;区分不开时仍是「更像这个」。
// 原因:issue #147。无冲突时仍然不出现「改用校正结果」和换时间小字。
assert.ok(
markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis) || /采用 \d{2}:\d{2} 并打开星盘/.test(markup),
`${reported}/${source}`,
);
assert.equal(markup.includes(SWITCH_TO_RECTIFIED_LABEL), false, `${reported}/${source}`);
assert.equal(markup.includes("换成 04:53"), false, `${reported}/${source}`);
}
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RECTIFICATION_BOARD_OPEN_LABEL, RectificationBoard, RectificationBoardPeek } from "../src/components/rectification-board.tsx";
import { RectificationResultActions, RectificationResultLead } from "../src/components/rectification-result-parts.tsx";
import { diffRectificationBoard } from "../src/lib/rectification-board-model.ts";
import {
RECTIFICATION_FIX_COPY,
adoptAndOpenChartLabel,
fixEvidenceDraft,
fixableEvidenceLines,
} from "../src/lib/rectification-fix-evidence.ts";
// Issues #146 and #147: one working minute on the timeline, the board behind
// 「看盘面」, and the result card's lead and actions. Fictional data only.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
test("only confirmed events are listed to fix, once each, in the conversation's recap wording", () => {
const lines = fixableEvidenceLines([
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "confirmed", summary: "升学", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ status: "pending_confirmation", summary: "搬家", datePrecision: "month", occurredFrom: "2023-08-01", occurredTo: null },
{ status: "superseded", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
{ status: "confirmed", summary: "入职", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
{ status: "confirmed", summary: " ", datePrecision: "day", occurredFrom: "2024-04-07", occurredTo: null },
null,
"x",
]);
assert.equal(lines.length, 2);
assert.match(lines[0]!, /2016-09/);
assert.match(lines[1]!, /2024-04-07/);
assert.deepEqual(fixableEvidenceLines(undefined), []);
assert.equal(fixEvidenceDraft("2016-09 升学"), "我说错了:「2016-09 升学」应该是");
assert.equal(adoptAndOpenChartLabel("07:24"), "采用 07:24 并打开星盘");
});
test("the actions row offers only 改一条, and only with a callback and a confirmed event", () => {
assert.equal(renderToStaticMarkup(createElement(RectificationResultActions, { evidence: ["2016-09 升学"], disabled: false })), "");
const html = renderToStaticMarkup(createElement(RectificationResultActions, {
evidence: ["2016-09 升学"],
disabled: false,
onFixEvidence() {},
}));
assert.match(html, new RegExp(RECTIFICATION_FIX_COPY.fixOne));
assert.doesNotMatch(html, /再补一件事/, "BUG-1084: no invitation to add more events");
assert.match(html, /aria-expanded="false"/);
assert.doesNotMatch(html, /2016-09 升学/, "the event list opens on click");
const none = renderToStaticMarkup(createElement(RectificationResultActions, {
evidence: [],
disabled: false,
onFixEvidence() {},
}));
assert.equal(none, "", "nothing to fix without a confirmed event");
});
test("the lead shows the minute and the Lagna when known", () => {
assert.match(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: "巨蟹座" })), /最可能的出生时间[\s\S]*07:24[\s\S]*上升巨蟹座/);
assert.doesNotMatch(renderToStaticMarkup(createElement(RectificationResultLead, { time: "07:24", lagna: null })), /上升/);
});
test("the board is closed by default at every width and has no clock in its title row", () => {
const props = {
result: null,
declaredTime: "05:10",
savedStatus: null,
diff: diffRectificationBoard(null, null),
boardId: "board",
titleId: "title",
onClose() {},
};
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: false })), "");
assert.equal(renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: true, open: false })), "");
const open = renderToStaticMarkup(createElement(RectificationBoard, { ...props, compact: false, open: true }));
assert.match(open, /当前盘面/);
assert.doesNotMatch(open, /rectification-board__clock/);
assert.match(open, /aria-label="关闭盘面"/, "the desktop column closes too");
});
test("看盘面 says what it does; the time stays in its accessible name only", () => {
const html = renderToStaticMarkup(createElement(RectificationBoardPeek, {
result: null,
declaredTime: "05:10",
expanded: false,
boardId: "board",
onOpen() {},
}));
assert.equal(RECTIFICATION_BOARD_OPEN_LABEL, "看盘面");
assert.match(html, /<strong>看盘面<\/strong>/);
assert.doesNotMatch(html, /<span>/);
assert.match(html, /aria-label="看盘面,[^"]*05:10"/);
});
test("adopting from the most likely column opens the chart only after the server saved it", () => {
const hook = read("../src/hooks/use-open-chart-after-adopt.ts");
assert.match(hook, /onSaved\?\.\(time, status\);\s*if \(!openAfterSave\.current\) return;\s*openAfterSave\.current = false;\s*navigateInApp\("\/chart"\);/);
const chat = read("../src/components/rectification-agentic-chat.tsx");
assert.match(chat, /armChartAfterAdopt\(openChart\);/);
assert.match(chat, /onSaved: savedThenChart,/);
assert.match(chat, /onFixEvidence=\{\(line\) => \{\s*setDraft\(fixEvidenceDraft\(line\)\);/);
const segment = read("../src/components/rectification-segment-delivery.tsx");
assert.doesNotMatch(segment, /RectificationResultLead/, "the segment product never leads with a minute (BUG-1151)");
});
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
OPENING_BODY_JARGON,
RECTIFICATION_USER_COPY,
isAcceptableOpeningBody,
openingSourcePhrase,
openingSpokenBody,
withEvidenceNoSplit,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { composeSpokenWithServerFacts } from "../src/lib/rectification-agentic/v9/agent-run-support.ts";
import { batchRescoredWithoutDelivery } from "../src/lib/rectification-agentic/v9/host-fallback.ts";
import { productDeliveryForDomain } from "../src/lib/rectification-agentic/v9/product-delivery.ts";
// Issue #148 (product 2026-10-28): the opening's window sentence says where the
// reader's time came from, and an event that left the range unchanged is said
// to have not told the candidates apart. No invitation to add more (BUG-1084).
const RANGE = ["07:00", "08:00"] as const;
test("the opening names the time source inside the window sentence, only where the words are true", () => {
assert.equal(openingSourcePhrase("hospital_record"), "按你的出生记录时间");
assert.equal(openingSourcePhrase("approximate"), "按你填的大概时间");
assert.equal(openingSourcePhrase("family_exact"), "按你填的时间");
assert.equal(openingSourcePhrase("period_only"), "按你给的时间段");
for (const source of ["unknown", "legacy_import", "", null, undefined]) {
assert.equal(openingSourcePhrase(source), null, String(source));
}
const plain = openingSpokenBody(RANGE);
const sourced = openingSpokenBody(RANGE, null, null, "approximate");
assert.match(sourced, /我们来把你的出生时间缩小到更准的范围,按你填的大概时间,现在先在 07:00–08:00 之间找。/);
assert.equal(sourced.split("。").length, plain.split("。").length, "same sentence count: still three plain layers");
assert.equal(openingSpokenBody(RANGE, null, null, "unknown"), plain);
assert.equal(openingSpokenBody(null, null, null, "approximate"), openingSpokenBody(null), "no window, no source clause");
for (const word of OPENING_BODY_JARGON) assert.equal(sourced.includes(word), false, word);
assert.equal(isAcceptableOpeningBody(sourced, RANGE), true);
});
test("the segment product's opening carries the source before the check window", () => {
const product = productDeliveryForDomain("career");
const body = openingSpokenBody(RANGE, product, null, "hospital_record");
assert.match(body, /能不能选定用来解读的星盘,按你的出生记录时间,核对窗口是 07:00–08:00。/);
assert.equal(isAcceptableOpeningBody(body, RANGE, product), true);
});
test("an event that left the range unchanged gets one 分不出前后 sentence, and nothing else changes", () => {
const notice = RECTIFICATION_USER_COPY.evidenceNoSplit;
assert.doesNotMatch(notice, /再补|再说一件|补一件/, "BUG-1084: never invite more events");
const same = composeSpokenWithServerFacts("记下了。", { compareFailed: false, rescoreSkipped: false, rangeBefore: RANGE, rangeAfter: RANGE, evidenceRescored: true });
assert.equal(same, `记下了。${notice}`);
const moved = composeSpokenWithServerFacts("记下了。", { compareFailed: false, rescoreSkipped: false, rangeBefore: RANGE, rangeAfter: ["07:10", "07:50"], evidenceRescored: true });
assert.doesNotMatch(moved, new RegExp(notice));
assert.match(moved, /范围从 07:00–08:00 变为 07:10–07:50。/);
const noEvent = composeSpokenWithServerFacts("好。", { compareFailed: false, rescoreSkipped: false, rangeBefore: RANGE, rangeAfter: RANGE });
assert.equal(noEvent, "好。");
const skipped = composeSpokenWithServerFacts("记下了。", { compareFailed: false, rescoreSkipped: true, rangeBefore: RANGE, rangeAfter: RANGE, evidenceRescored: true });
assert.doesNotMatch(skipped, new RegExp(notice), "no rescore, no claim about the range");
assert.equal(withEvidenceNoSplit(`记下了。${notice}`, RANGE, RANGE), `记下了。${notice}`, "said once");
assert.equal(withEvidenceNoSplit("记下了。", null, RANGE), "记下了。");
});
test("only a completed rescore of accepted events outside the delivery turn counts", () => {
const ok = { accepted_count: 1, rescore: { status: "completed" }, range_after_rescore: { delivers_range_this_turn: false } };
assert.equal(batchRescoredWithoutDelivery(ok), true);
assert.equal(batchRescoredWithoutDelivery({ ...ok, accepted_count: 0 }), false);
assert.equal(batchRescoredWithoutDelivery({ ...ok, rescore: { status: "skipped" } }), false);
assert.equal(batchRescoredWithoutDelivery({ ...ok, rescore: { status: "failed" } }), false);
assert.equal(batchRescoredWithoutDelivery({ ...ok, range_after_rescore: { delivers_range_this_turn: true } }), false);
assert.equal(batchRescoredWithoutDelivery(null), false);
assert.equal(batchRescoredWithoutDelivery([]), false);
});
test("the attempt wires the source into the opening and the rescore flag into the server facts", () => {
const attempt = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run-attempt.ts", import.meta.url), "utf8");
assert.match(attempt, /const sourceRequired = Boolean\(openingRange && openingSourcePhrase\(dossier\.case\.birthTimeSource\)\);/);
assert.match(attempt, /openingSpokenBody\(openingRange, productDelivery, openingFacts, dossier\.case\.birthTimeSource\)/);
assert.match(attempt, /evidenceRescored: batchRescoredWithoutDelivery\(batchToolResult\),/);
});
@@ -226,7 +226,9 @@ test("the choice card confirms the tap and the board's first state shows the dec
assert.match(board, /rectificationBoardPeekCopy\(result, declaredTime, birthTimeSource\)/);
assert.doesNotMatch(board, /补充经历后,这里会显示当前本命宫位和换升时刻/);
assert.match(chat, /\$\{candidateResult \? "" : " is-board-empty"\}/);
assert.match(styles, /\.rectification-workspace\.is-board-empty \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
// The empty modifier is declared before `.is-compact`, so the single-column layout still wins.
assert.ok(styles.indexOf(".rectification-workspace.is-board-empty {") < styles.indexOf(".rectification-workspace.is-compact {"));
// 原值:.rectification-workspace.is-board-empty 常驻两栏(右栏 16–18rem)。
// 新值:只有盘面打开且不是手机布局时才分出右栏,空盘面仍用窄的那档。
// 原因:issue #146(看盘面按钮在所有宽度都有,盘面默认关着,代表分钟只在时间轴上写一次)。
assert.match(styles, /\.rectification-workspace\.is-board-empty\.is-board-open:not\(\.is-compact\) \{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\);/);
assert.match(styles, /\.rectification-workspace \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
});
+130 -115
View File
@@ -9,7 +9,7 @@
"dataset_path": "references/real_case_calibration/minute_rectification_holdout_v3.json",
"report_path": "references/real_case_calibration/minute_rectification_holdout_v3_report.json",
"source_audit_status": "corrected_known_date_errors",
"evaluated_on": "2026-10-18",
"evaluated_on": "2026-10-28",
"evaluated_on_scope": "latest_fixed_protocol_rerun_not_historical_runtime_metrics",
"historical_source_audit_status": "invalidated_after_replay",
"report_status": "invalidated_after_source_audit",
@@ -24,7 +24,7 @@
"evaluated_on": "2026-07-21"
},
"current_tree_scorer": {
"implementation_sha256": "bf8981b5c41702f9b333444deee2ab7378b9e98b111240e85ea62e6aae02fda8",
"implementation_sha256": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
@@ -52,7 +52,7 @@
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "c2c23bcbde5de58e519ae1d6545620edd0f738ce8dc2e872e659b598137a728f",
"production_scoring_sha256": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"research_files": [
"scripts/research/reported_offset_sweep.py",
"scripts/research/cluster_width_lib.py",
@@ -62,7 +62,7 @@
"scripts/minute_rectification_fact_blind_eval_v4.py",
"scripts/minute_rectification_holdout_validator.py"
],
"research_implementation_sha256": "9df579bdacd7dec43abd92c9abc1831771feb800fc29e6864dc315d083d52a03",
"research_implementation_sha256": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
@@ -90,17 +90,17 @@
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "e10537ca2caba3a4e6a8d2460265543b0cca55935afa8004108e45bef4bc3de7",
"scripts/research/sealed_holdout_rerun.py": "7945353014b93b6d7641f278779111d85b1773293781343b802a9b4b655ca4b5",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "c1f06967c3d4d214edadff139c24aa31e59fb628477174235abe14f799a99076"
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"matches_metrics_scorer": false,
"official_eval_implementation_hash_matches": false,
"official_eval_trial_count": 0,
"source_report": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"source_report": "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json",
"fixed_protocol_rerun_trial_count": 20,
"fixed_protocol_rerun_hash_matches": true,
"metrics": {
@@ -113,6 +113,106 @@
}
},
"current_tree_fixed_protocol_rerun": {
"evaluated_on": "2026-10-28",
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.freeze.json",
"implementation_sha256": "b44505aa0034e486fcfc29c8611a9559333a97c1005f92e6a5356bd855122205",
"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": "7a727418651321d1b9073588587050c4dc6292587748cf35734dd76cefb0effe",
"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": "6fa0b0c335ac0cdc25ee887d432ae2f1f6487c665aed9665ad5cc0bc6ef0283b",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "c524da3f54b2ed9c080b78b33854854a0ca3d5d79b7e536bb1c3bc2be3e02852",
"scripts/dasha_analyzer.py": "3949b96813217fe624899856afec9f0b787c4cb0fea3a9a0d961990dbd11943a",
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"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": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "bc667c30c9562fb73fb0b7b4c22a5e377f9a531795141fc629b2cd3cf396773a",
"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": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "8ecc381dfaa3c0e6bea058cfa44c606aabdb1086c137f16da82ee5a3bf5ccc91",
"scripts/research/sealed_holdout_rerun.py": "ebcdae760f2b76c81d5d0c8e2bf347cff4c81cca9074f27ae94b6734408ccd42",
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
"scripts/varga.py": "8ff96b9fc8346ca249abff6109434b6ed57fa150e1a9854d4f73bdf317568368"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"scorer_frozen_before_rerun": true,
"source_audit_status": "corrected_known_date_errors",
"trial_count": 20,
"official_valid_independent_blind": false,
"is_blind_evaluation": false,
"truth_hidden_from_ranker": true,
"results_previously_seen": true,
"must_not_claim_as_release_metrics": true,
"must_not_use_for_tuning": true,
"verified_minute_claim_allowed": false,
"metric_gates_passed": false,
"events_per_case": 3,
"boundary": "Previously exposed v3 shadow fixed-protocol rerun after upstream sync9b (ERR-110, BUG-1494 to BUG-1495). Trials and metrics match the 2026-10-18 upstream sync8 report. Versus the 2026-09-20 historical report, changed_trial_count stays 8. Not a release metric. Prior sync8 report and freeze preserved byte-for-byte.",
"report_sha256": "ef20e751a746a4ec3b368d6860123602172a9af9195a97ec5f4d3c2cdf4823ce",
"freeze_record_sha256": "32cad06f25c84891a24725110c9737489b8d79ed7c2d33ae959cb8eb16798dab",
"replay_started_at_utc": "2026-10-28T08:19:06.261911+00:00",
"replay_finished_at_utc": "2026-10-28T08:19:09.621910+00:00",
"previous_fixed_protocol_rerun": {
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.freeze.json",
"superseded_reason": "Upstream sync9b (BUG-1494 to BUG-1495) changed scripts/varga.py, which is part of the extended identity. Output is unchanged, so ERR-110 stored a new record. Prior sync8 report and freeze preserved byte-for-byte."
}
},
"previous_fixed_protocol_rerun": {
"evaluated_on": "2026-10-18",
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.freeze.json",
@@ -212,106 +312,6 @@
"superseded_reason": "Upstream sync8 (BUG-1380 to BUG-1390) changed frozen identity files. Output is unchanged, so ERR-110 stored a new record. Prior scoring_perf report and freeze preserved byte-for-byte."
}
},
"previous_fixed_protocol_rerun": {
"evaluated_on": "2026-10-03",
"report_path": "docs/research/sealed_holdout_rerun_astrologer_rulings_batch1_2026_10_03.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_astrologer_rulings_batch1_2026_10_03.freeze.json",
"implementation_sha256": "114c60c45fa35a77bb6bfe2c177bbcc90b2e0748e2a33e61975f7393802f96f2",
"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": "788745f7b7aba1d551a73a67a56daff3e730c1d408cd77dde155e410ad9d00bb",
"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": "fa74b52f52ca7beeb1c6c6647b11ef075c16bcb6fadb656196770d91004cd0ad",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "cdc0e68edc770a2a1810fbdb68cbfd46dcd50b267c739c496fcc078c5e26b5b4",
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
"scripts/ashtakavarga.py": "9bfc0b8a06af6122fa6c3881706e64f28b9ba58b53d98ff55957d4741d820f3f",
"scripts/dasha_analyzer.py": "5e53b1b4d4c7414c08dd0e7716f120a63c70ec3428ec93547f64d55c580da0e1",
"scripts/divisional_charts_extended.py": "73bd50a83ac444830940184d0988cea621855f91db76a3f1a232014b992eacea",
"scripts/domain_calculation_service.py": "9ee955874eea806c7b56f4246d90009c02ee8318b8f3dfe1d7132b1c0b4262e1",
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
"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": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
"scripts/narayana_dasha.py": "7ff2c3238cd113b11b815e14b41967bd8e486ff62e36650e55613b9c79d1bfe3",
"scripts/rectification/api_service.py": "e316ced0f3796d0378a0fe1aad950ee25922ae8afd53add578ed59ca8b2f0f79",
"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": "0afba494aa12899621173c3cc941c6c1d0be9cec0bdf4c4a1c9bcd004ab22de8",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "9bbadc246e6b8e94bb047522c3e5645613f508b3473ef40b38a207c77fdad58c",
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
"scripts/rectification/scoring_service.py": "4f13c6a78d2dda55454ac20e4091fce280e3626ecfe8a9d723217ef3ebc05d02",
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
"scripts/research/reported_offset_sweep.py": "f63c1c58d334e083577736397b4acc00757550fa3b863b02784c9d6f0f018c80",
"scripts/research/sealed_holdout_rerun.py": "1178ebfb9dd61d4aefe9961d83f19e7a943a4ca3de9fa2efffff6f8ba8c09161",
"scripts/shadbala.py": "e949ff1cc925ccbc9a25b68981091d1d621b80a5b3839a3b3ade655d082424d6",
"scripts/varga.py": "4331de5a25ea08729af91aae863943c4223183937f419d52dc63fd6fe7f27be6"
},
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
},
"scorer_frozen_before_rerun": true,
"source_audit_status": "corrected_known_date_errors",
"trial_count": 20,
"official_valid_independent_blind": false,
"is_blind_evaluation": false,
"truth_hidden_from_ranker": true,
"results_previously_seen": true,
"must_not_claim_as_release_metrics": true,
"must_not_use_for_tuning": true,
"verified_minute_claim_allowed": false,
"metric_gates_passed": false,
"events_per_case": 3,
"boundary": "Previously exposed v3 shadow fixed-protocol rerun after the 2026-10-03 astrologer rulings batch 1 re-freeze. Superseded by the 2026-10-07 upstream sync 6 re-freeze (ERR-110: scripts/varga.py, scripts/divisional_charts_extended.py, scripts/narayana_dasha.py and scripts/rectification/scoring_service.py bytes changed by the PVR 6.2 D5/D6/D8/D11 maps and the scoring-11 identity, BUG-1260/1264). Shadow trial fields versus the 2026-09-20 historical report are unchanged (changed_trial_count 0). Report and freeze preserved byte-for-byte.",
"report_sha256": "b39b53c1f2aa0ad6ac363b4c359116c30bc89b446cb670e6172c8cee5ccc5efd",
"freeze_record_sha256": "c1c11e7abc03b9174aeb1e0b280f550808f887818f578e5d10ad667e0da67e24",
"replay_started_at_utc": "2026-10-03T06:53:04.851491+00:00",
"replay_finished_at_utc": "2026-10-03T06:53:07.509845+00:00",
"previous_fixed_protocol_rerun": {
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync5_2026_10_02.json",
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync5_2026_10_02.freeze.json",
"superseded_reason": "Upstream sync round 6 (ERR-110). Prior sync5 report and freeze preserved byte-for-byte."
}
},
"current_tree_unfrozen_diagnostic": {
"is_blind_evaluation": false,
"results_already_seen": true,
@@ -330,6 +330,28 @@
"verified_minute_claim_allowed": false
},
"current_tree_reported_offset_replay": {
"report_path": "docs/research/reported_offset_upstream_sync9b_2026_10_28.json",
"report_sha256": "880bebc2e217100994a1442e47ead3f9d33e48aa3ac20f69d993f38def3ef25c",
"freeze_record_path": "docs/research/reported_offset_upstream_sync9b_2026_10_28.freeze.json",
"freeze_record_sha256": "385817bb27f04afeba230af5e547dd10dc8cf6db4b25c18bebe4f4e674cab972",
"scope": "reported_offset_sensitivity_not_product_accuracy",
"trial_count": 900,
"official_valid_independent_blind": false,
"official_blind_trial_count": 0,
"must_not_use_for_tuning": true,
"must_not_claim_as_release_metrics": true,
"replay_started_at_utc": "2026-10-28T08:19:10.206540+00:00",
"replay_finished_at_utc": "2026-10-28T08:23:34.348635+00:00",
"boundary": "Previously exposed reported-offset sensitivity after upstream sync9b (ERR-110, BUG-1494 to BUG-1495). Summary and trials match the 2026-10-18 upstream sync8 report. Versus the 2026-09-20 historical report, changed_trial_count stays 581 of 900. Not a release metric. No tuning. Prior sync8 report and freeze preserved byte-for-byte.",
"previous_reported_offset_replay": {
"report_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.json",
"report_sha256": "a51aad2e743dccba7726d1a0a5e44c5e431ff429fe1725c9be2f7fa5e9282da6",
"freeze_record_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.freeze.json",
"freeze_record_sha256": "117ab0bfa4a283e9d52073808f71dd8317f613c96b27ec271d887ef40087f4f5",
"superseded_reason": "Upstream sync9b (BUG-1494 to BUG-1495) changed scripts/varga.py, which is part of the extended identity. Output is unchanged, so ERR-110 stored a new record. Prior sync8 report and freeze preserved byte-for-byte."
}
},
"previous_reported_offset_replay": {
"report_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.json",
"report_sha256": "a51aad2e743dccba7726d1a0a5e44c5e431ff429fe1725c9be2f7fa5e9282da6",
"freeze_record_path": "docs/research/reported_offset_upstream_sync8_2026_10_18.freeze.json",
@@ -350,12 +372,5 @@
"freeze_record_sha256": "1560f24ffea19116c904272907ff9cce24904960406676f3aada3c8c174cf98a",
"superseded_reason": "Upstream sync8 (BUG-1380 to BUG-1390) changed frozen identity files. Output is unchanged, so ERR-110 stored a new record. Versus the 2026-09-20 historical report, changed_trial_count stays 581 of 900. Prior scoring_perf report and freeze preserved byte-for-byte."
}
},
"previous_reported_offset_replay": {
"report_path": "docs/research/reported_offset_astrologer_rulings_batch1_2026_10_03.json",
"report_sha256": "480eb771c2c7e5a300589561dc66be950a711ffa205a8fa370db6a11f1e04270",
"freeze_record_path": "docs/research/reported_offset_astrologer_rulings_batch1_2026_10_03.freeze.json",
"freeze_record_sha256": "6be6a997f610c341aecc1f782fa27a5b86b66d43d00fcfa10750c81189c34748",
"superseded_reason": "Upstream sync 6 (ERR-110: scripts/varga.py, scripts/divisional_charts_extended.py, scripts/narayana_dasha.py and scripts/rectification/scoring_service.py). 5 of 900 trials differ from the batch1 replay (delivery_width_minutes on 4, top_1_minute_error on 1). Versus the 2026-09-20 historical report, changed_trial_count is 235 (batch1 was 230). Prior report and freeze preserved byte-for-byte."
}
}
+5 -5
View File
@@ -30,11 +30,11 @@ from scripts.research.sealed_holdout_rerun import (
OFFSETS = (-30, -20, -15, -10, -8, -5, -3, 0, 3, 5, 8, 10, 15, 20, 30)
RADII = (15, 30, 60)
MINUTE_STEP = 1
# 原值: docs/research/reported_offset_scoring_perf_2026_10_13.{freeze.json,json}
# 新值: docs/research/reported_offset_upstream_sync8_2026_10_18.{freeze.json,json}
# 原因: sync8(BUG-1380~1389)改了 ashtakavarga / dasha_analyzer / narayana_dasha,输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/reported_offset_upstream_sync8_2026_10_18.freeze.json"
REPORT = ROOT / "docs/research/reported_offset_upstream_sync8_2026_10_18.json"
# 原值: docs/research/reported_offset_upstream_sync8_2026_10_18.{freeze.json,json}
# 新值: docs/research/reported_offset_upstream_sync9b_2026_10_28.{freeze.json,json}
# 原因: sync9b(BUG-1494~1495)改了 scripts/varga.py,它在扩展身份里;报时窗口输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/reported_offset_upstream_sync9b_2026_10_28.freeze.json"
REPORT = ROOT / "docs/research/reported_offset_upstream_sync9b_2026_10_28.json"
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
+5 -5
View File
@@ -27,11 +27,11 @@ from scripts.minute_rectification_feature_facts_v4 import build_feature_fact_row
from scripts.minute_rectification_holdout_validator import validate
DATASET = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
# 原值: docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.{freeze.json,json}
# 新值: docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.{freeze.json,json}
# 原因: sync8(BUG-1380~1389)改了 ashtakavarga / dasha_analyzer / narayana_dasha,输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.freeze.json"
REPORT = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json"
# 原值: docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.{freeze.json,json}
# 新值: docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.{freeze.json,json}
# 原因: sync9b(BUG-1494~1495)改了 scripts/varga.py,它在扩展身份里;切盘输出不变,ERR-110 另存新记录
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.freeze.json"
REPORT = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28.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 = [
+12 -8
View File
@@ -246,11 +246,12 @@ def calc_64th_navamsa(moon_lon: float) -> Dict:
def calc_22nd_drekkana(lagna_lon: float) -> Dict:
"""Compute the 22nd Drekkana from the Lagna's Drekkana position.
Traditional counting is inclusive, so the 22nd point is +21 signs from the
Lagna Drekkana anchor.
Crux of Vedic Astrology, printed p.329 (corpus page 340), identifies
this as the eighth house in D3. Counting 21 ten-degree divisions is
not the same as moving 21 signs in the D3 chart.
"""
base = calc_varga(lagna_lon, 3)
sign_idx = (base['sign_idx'] + 21) % 12
sign_idx = (base['sign_idx'] + 7) % 12
return {
'base_drekkana_sign_idx': base['sign_idx'],
'base_drekkana_sign': base['sign'],
@@ -279,16 +280,19 @@ def calc_bhrigu_bindu(moon_lon: float, rahu_lon: float) -> Dict:
}
SARPA_DREKKANA_SIGNS = {'Cancer': 2, 'Scorpio': 1, 'Pisces': 3}
# Raman, Hindu Predictive Astrology, Drekkanas chapter, corpus pp.279–283:
# five serpent divisions, including both Cancer-3 and Scorpio-2.
SARPA_DREKKANA_SIGNS = {'Cancer': (2, 3), 'Scorpio': (1, 2), 'Pisces': (3,)}
def calc_sarpa_drekkana(lon: float) -> Dict:
"""Classify Sarpa Drekkana using the classical water-sign sensitive drekkanas."""
lon = lon % 360
base = calc_varga(lon, 3)
sign = _sn(_si(lon))
degree_in_sign = round((lon % 30), 4)
drekkana_number = int(degree_in_sign / 10) + 1
expected = SARPA_DREKKANA_SIGNS.get(sign)
drekkana_number = int((lon % 30) / 10) + 1
expected = SARPA_DREKKANA_SIGNS.get(sign, ())
return {
'longitude': round(lon % 360, 4),
'sign': sign,
@@ -297,8 +301,8 @@ def calc_sarpa_drekkana(lon: float) -> Dict:
'drekkana_number': drekkana_number,
'd3_sign': base['sign'],
'd3_sign_idx': base['sign_idx'],
'is_sarpa_drekkana': bool(expected == drekkana_number),
'definition': 'Cancer-2, Scorpio-1, Pisces-3',
'is_sarpa_drekkana': drekkana_number in expected,
'definition': 'Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3',
}
def dignity(planet, sign_idx):
+1 -1
View File
@@ -701,7 +701,7 @@ def test_full_reading_exposes_sensitive_point_modules() -> None:
assert sensitive["bhrigu_bindu"]["sign"]
assert isinstance(sensitive["sarpa_drekkana"], dict)
for payload in sensitive["sarpa_drekkana"].values():
assert payload["definition"] == "Cancer-2, Scorpio-1, Pisces-3"
assert payload["definition"] == "Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3"
assert payload["is_sarpa_drekkana"] is True
@@ -53,6 +53,11 @@ IDENTITY_FORBIDDEN_PREFIXES = (
# 原因: sync8 改了冻结身份文件,输出不变,ERR-110 另存新记录
"docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18",
"docs/research/reported_offset_upstream_sync8_2026_10_18",
# 原值: 排除到 upstream_sync8_2026_10_18
# 新值: 再排除 upstream_sync9b_2026_10_28 两份冻结路径(追加,不替换上面的)
# 原因: sync9b 改了扩展身份里的 scripts/varga.py,回放输出不变,ERR-110 另存新记录
"docs/research/sealed_holdout_rerun_upstream_sync9b_2026_10_28",
"docs/research/reported_offset_upstream_sync9b_2026_10_28",
"docs/research/history/",
"artifacts/",
)
@@ -0,0 +1,16 @@
"""Classification must use raw longitude, never rounded display degrees."""
import pytest
from scripts.varga import calc_sarpa_drekkana
@pytest.mark.parametrize('longitude,part,is_sarpa', [
(99.99999, 1, False), (100.0, 2, True),
(219.99999, 1, True), (220.0, 2, True),
(359.99999, 3, True), (360.0, 1, False),
])
def test_sarpa_partition_uses_unrounded_longitude(longitude, part, is_sarpa):
row = calc_sarpa_drekkana(longitude)
assert row['drekkana_number'] == part
assert row['is_sarpa_drekkana'] is is_sarpa
assert 1 <= row['drekkana_number'] <= 3
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Regression tests for BPHS divisional chart mappings."""
from __future__ import annotations
import pytest
pytest.importorskip(
"hypothesis",
reason="hypothesis is not installed; see requirements-dev.txt. "
"The guard keeps this file collectable: without it the whole file "
"fails to collect and its tests vanish from the run instead of failing.",
)
from hypothesis import given
from hypothesis import strategies as st
from varga import (
SIGNS,
calc_22nd_drekkana,
calc_64th_navamsa,
calc_bhrigu_bindu,
calc_all_vargas,
calc_sarpa_drekkana,
calc_varga,
varga_map,
)
def navamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / (30 / 9))
if sign_index % 3 == 0:
start = sign_index
elif sign_index % 3 == 1:
start = (sign_index + 8) % 12
else:
start = (sign_index + 4) % 12
return (start + part_index) % 12
def dasamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / 3)
start = sign_index if sign_index % 2 == 0 else (sign_index + 8) % 12
return (start + part_index) % 12
def drekkana_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / 10)
return (sign_index + part_index * 4) % 12
def chaturthamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / (30 / 4))
return (sign_index + part_index * 3) % 12
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_navamsa_matches_bphs_reference(lon: float) -> None:
result = calc_varga(lon, 9)
assert result["sign_idx"] == navamsa_ref(lon)
assert result["sign"] == SIGNS[navamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_dasamsa_matches_bphs_reference(lon: float) -> None:
result = calc_varga(lon, 10)
assert result["sign_idx"] == dasamsa_ref(lon)
assert result["sign"] == SIGNS[dasamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_drekkana_uses_same_plus_four_plus_eight(lon: float) -> None:
result = calc_varga(lon, 3)
assert result["sign_idx"] == drekkana_ref(lon)
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_chaturthamsa_matches_pyjhora_parashara_reference(lon: float) -> None:
result = calc_varga(lon, 4)
assert result["sign_idx"] == chaturthamsa_ref(lon)
assert result["sign"] == SIGNS[chaturthamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
def test_chaturthamsa_reference_boundary_examples() -> None:
assert [varga_map(0, part, 4) for part in range(4)] == [0, 3, 6, 9]
assert [varga_map(8, part, 4) for part in range(4)] == [8, 11, 2, 5]
assert [varga_map(11, part, 4) for part in range(4)] == [11, 2, 5, 8]
def test_varga_map_boundary_examples() -> None:
assert varga_map(0, 0, 9) == 0 # Aries Navamsa starts Aries
assert varga_map(1, 0, 9) == 9 # Taurus Navamsa starts Capricorn (9th from sign)
assert varga_map(2, 0, 9) == 6 # Gemini Navamsa starts Libra (5th from sign)
assert varga_map(1, 0, 10) == 9 # Taurus Dasamsa starts Capricorn
assert varga_map(0, 2, 3) == 8 # Aries third Drekkana = Sagittarius
def test_rudramsa_d11_is_supported_by_native_varga_map() -> None:
assert varga_map(0, 0, 11) == 0
assert varga_map(0, 10, 11) == 10
# PVR 6.2.11: anti-zodiacal seed from Aries, zodiacal parts.
assert varga_map(1, 0, 11) == 11
assert varga_map(1, 10, 11) == 9
result = calc_varga(35.0, 11)
assert result["sign"] == "Aries"
assert 0 <= result["degree_in_sign"] < 30
def test_native_varga_map_supports_declared_high_value_vargas() -> None:
for div in (2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 24, 60, 81, 108, 144):
result = calc_varga(35.0, div)
assert result["sign"] in SIGNS
assert 0 <= result["degree_in_sign"] < 30
def test_default_varga_batch_includes_declared_vargas() -> None:
charts = calc_all_vargas({"Moon": 35.0}, 10.0)
for key in ("D5_Panchamsa", "D6_Shashthamsa", "D8_Ashtamsa", "D11_Rudramsa", "D81_Navamsa-Navamsa", "D108_Dwadasamsa-Navamsa", "D144_Dwadasamsa-Dwadasamsa"):
assert key in charts
assert charts["D11_Rudramsa"]["Moon"]["sign"] == "Aries"
def test_navamsa_matches_user_jhora_pdf_reference_chart() -> None:
"""Regression from private_chart_reference.pdf: JHora-style D9 table for public sample birth datetime San Francisco."""
expected = {
132.355025: "Cancer", # Ascendant 12 Leo 21'18.09"
3.5226611111111112: "Taurus",
311.78995555555554: "Capricorn",
91.33091388888889: "Cancer",
338.5488: "Virgo",
163.83150833333335: "Taurus",
340.5554638888889: "Libra",
304.3033805555556: "Scorpio",
231.04509444444443: "Capricorn",
51.045094444444445: "Cancer",
}
for longitude, expected_sign in expected.items():
assert calc_varga(longitude, 9)["sign"] == expected_sign
def test_64th_navamsa_counts_forward_from_moon_navamsa() -> None:
moon_lon = 42.3
result = calc_64th_navamsa(moon_lon)
expected_sign_idx = (navamsa_ref(moon_lon) + 63) % 12
assert result["sign_idx"] == expected_sign_idx
assert result["sign"] == SIGNS[expected_sign_idx]
assert result["offset_from_moon_navamsa"] == 64
def test_22nd_drekkana_counts_forward_from_lagna_drekkana() -> None:
asc_lon = 25.5
result = calc_22nd_drekkana(asc_lon)
# Sagittarius D3 rising: its eighth house is Cancer (Crux p.329).
expected_sign_idx = 3
assert result["sign_idx"] == expected_sign_idx
assert result["sign"] == SIGNS[expected_sign_idx]
assert result["offset_from_lagna_drekkana"] == 22
def test_22nd_drekkana_independent_zodiac_partition_count() -> None:
# Enumerate all 36 zodiacal ten-degree divisions, then count 22 inclusively.
# This oracle does not add an offset to the calculated D3 sign.
for division in range(36):
target = (division + 21) % 36
target_rasi, target_part = divmod(target, 3)
expected = (target_rasi + 4 * target_part) % 12
for within in (0.0, 0.001, 5.0, 9.999):
result = calc_22nd_drekkana(division * 10 + within)
assert result['sign_idx'] == expected
def test_bhrigu_bindu_uses_rahu_to_moon_arc_midpoint() -> None:
result = calc_bhrigu_bindu(270.5, 160.2333333333)
assert result["longitude"] == 215.3667
assert result["sign"] == "Scorpio"
assert result["degree_in_sign"] == 5.3667
def test_bhrigu_bindu_handles_wraparound_across_zero_aries() -> None:
result = calc_bhrigu_bindu(10.0, 350.0)
assert result["longitude"] == 0.0
assert result["sign"] == "Aries"
assert result["degree_in_sign"] == 0.0
def test_sarpa_drekkana_detects_classical_sensitive_ranges() -> None:
cancer_second = calc_sarpa_drekkana(105.0)
scorpio_first = calc_sarpa_drekkana(215.0)
pisces_third = calc_sarpa_drekkana(355.0)
safe_aries = calc_sarpa_drekkana(15.0)
assert cancer_second["is_sarpa_drekkana"] is True
assert cancer_second["sign"] == "Cancer"
assert cancer_second["drekkana_number"] == 2
assert scorpio_first["is_sarpa_drekkana"] is True
assert scorpio_first["sign"] == "Scorpio"
assert scorpio_first["drekkana_number"] == 1
assert pisces_third["is_sarpa_drekkana"] is True
assert pisces_third["sign"] == "Pisces"
assert pisces_third["drekkana_number"] == 3
assert safe_aries["is_sarpa_drekkana"] is False
def test_sarpa_contains_all_five_book_ranges_and_no_others() -> None:
# Zodiac degree intervals transcribed from Raman, not the production map.
intervals = ((100, 120), (210, 230), (350, 360))
for segment in range(36):
for offset in (0.0, 0.001, 5.0, 9.999):
longitude = segment * 10 + offset
expected = any(start <= longitude < end for start, end in intervals)
assert calc_sarpa_drekkana(longitude)['is_sarpa_drekkana'] == expected
+2 -1
View File
@@ -175,7 +175,8 @@ def test_64th_navamsa_counts_forward_from_moon_navamsa() -> None:
def test_22nd_drekkana_counts_forward_from_lagna_drekkana() -> None:
asc_lon = 25.5
result = calc_22nd_drekkana(asc_lon)
expected_sign_idx = (drekkana_ref(asc_lon) + 21) % 12
# Sagittarius D3 rising: its eighth house is Cancer (Crux p.329).
expected_sign_idx = 3
assert result["sign_idx"] == expected_sign_idx
assert result["sign"] == SIGNS[expected_sign_idx]
assert result["offset_from_lagna_drekkana"] == 22