Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 52b19d7a82 fix(rect): after excluding the indivisible MD/AD unit, compare on the rest (#184 round 3, BUG-1827)
打回:单元拿掉了,但 Narayana_AD 仍算 unexplained,又落回 not_reducible,
且那个分支不输出 excluded_contributions。

- 新增 _explained_by_exclusion:差异层被「所有带它的候选的不可拆 MD/AD 项」
  覆盖且没有别的缺失层时,视为已由排除解释
- not_reducible 分支也输出 excluded_contributions(非空时)
- 旧形状行(无 contributions)不受影响

Tests: 新增 4 条真实形状用例(只缺小运→common_coverage、再加一个缺失层→
not_reducible 但仍报排除、旧形状行不报、负分照计);本文件 20 条 + 既有四套
合计 56 条全过。

Co-Authored-By: Grok (agent loop)
2026-11-09 19:31:41 +08:00
ClaudeandClaude Opus 5.5 603f3e654f docs(task): FIX-rect-layer-alias-3.md — #184 review fail: exclusion does not reach common coverage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 19:27:21 +08:00
jesse-uxandGrok (agent loop) 446420d720 feat(rect): per-contribution evidence, consistent MD/AD exclusion, real missing fields (#184 round 2, BUG-1827)
按占星师第七轮 P1 与 FIX-rect-layer-alias-2:

- 引擎行追加 contributions(event_id / layer / dependency / profile / points /
  decomposable / missing_reason);整件不计分的事也记一项,points 为 null
- 任一候选的 dasha_varga_core 贡献 decomposable 为假时,对所有候选一致排除该
  单元并写 excluded_contributions(缺的不当 0、全的也不计)
- 新增顶层 missing_mandatory_layers([{candidate_time, layer, event_ids}],
  排序、永不为 null,旧形状行标 legacy_row_without_contributions);
  reasons 里那一条当且仅当列表非空
- 层名对照 ASTROLOGER_EVENT_LAYER_ALIASES(只在整件事缺失且无其他门控层时套用),
  记 layer_aliases_applied;产品的 Narayana_AD(只缺小运)仍 not_reducible
- 三个计数(top_candidate_count / top_segment_count / unique_time)保留

Tests: 测试文件 17 条(含真实引擎行:贡献分值之和等于 score、layer_points 与
contributions 一一对应),连同 1801/active_events/1802/technique_contract 共 52 条全过。

Co-Authored-By: Grok (agent loop)
2026-11-09 18:03:35 +08:00
ClaudeandClaude Opus 5.5 8eea87eb1a docs(task): FIX-rect-layer-alias-2.md — #184 review fail + rulings on split and missing_mandatory_layers shape
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 17:33:51 +08:00
jesse-uxandGrok (agent loop) 8a844f3c04 feat(rect): separate the three leader counts (#184, BUG-1827, P1 item 2)
占星师 2026-11-09 第七轮 P1 第 2 条:候选分钟数、连续区间数、是否唯一时刻要分开,
代表分钟不能冒充唯一解。

- adjudicate_candidate_rows 结果新增 top_candidate_count(同分候选分钟数)、
  top_segment_count(连续区间数)、unique_time(是否唯一时刻)
- 空候选分支给同样的三个字段(0 / 0 / false),形状一致
- 新增 tests/test_rectification_layer_alias_bug1827.py 5 条:两分钟连续领先是
  1 段但非唯一时刻、单一领先是 1/1/true、两段同分是 2 段、代表时刻只是段、
  无候选全零

P1 第 1 条(每项贡献的 event_id / layer / dependency / profile / points /
缺失原因)与第 3 条(missing_mandatory_layers 的字段形状)仍等任务书拆分与
字段形状的裁定,未实施。

Tests: 新增 5 条 + 既有 1801/active_events/1802/technique_contract 合计 40 条全过。

Co-Authored-By: Grok (agent loop)
2026-11-09 16:16:33 +08:00
ClaudeandClaude Opus 5.5 66c2bab6b8 docs(task): FIX-rect-layer-alias-1.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:37:49 +08:00
ClaudeandClaude Opus 5.5 a3f941586f docs(task): brief for rect-layer-alias (2026-11-09)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 14:03:27 +08:00
9 changed files with 885 additions and 4 deletions
+15
View File
@@ -24518,3 +24518,18 @@
- 防复发:上述测试;合同 docstring 写明后续 #174–#178 的注册步骤。
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;FIX-ondemand-transit-date-1(产品拍板 C 方案)
- 修复版本:分支 codex/ondemand-transit-date-20261109
## BUG-1827 | 校时可比性:她的层名写法与产品层名不一致;贡献粒度、拆不开的 MD/AD、缺失字段都没暴露
- 状态:fixed-pending-verify(分支 codex/rect-layer-alias-20261109)
- 首次发现 / 最近更新:2026-11-09(占星师第七轮回复 P1)/ 2026-11-09(修复)
- 影响面:scripts/active_rectification_event_engine.py(_candidate_row 末尾追加 contributions)、scripts/active_rectification_events.py(层名对照、MD/AD 一致排除、missing_mandatory_layers、三个计数)。
- 现象:同一份合成例,她原文写法(missing_layers = [Narayana_AD])得到 not_reducible,产品测试写法(Narayana_MD_AD)得到 common_coverage;回执里 missing_mandatory_layers 是 null 而 reasons 里又有这个字符串;计分只有分值与层总分,看不出每项贡献依赖哪些层、是否拆得开。
- 触发条件:任何校时候选比较。
- 根因:判定层只认产品层名,没有输入适配,也不知道「她原文的 Narayana_AD = 整件事不计分」与「产品的 Narayana_AD = 只缺小运那一项」是两件不同的事;计分证据没有逐项贡献,MD 与 AD 的合并分拆不开时又按 0 处理;缺失信息只在 reasons 里有一个字符串。
- 修复:新增 ASTROLOGER_EVENT_LAYER_ALIASES(只在「该候选整件事缺失」且无其他门控层、且它有的每件事层集合与共同覆盖一致时套用,记 layer_aliases_applied);引擎行追加 contributions(event_id / layer / dependency / profile / points / decomposable / missing_reason,整件不计分的事也记一项);任一候选的 dasha_varga_core 贡献 decomposable 为假时,对所有候选一致排除该单元并写 excluded_contributions;新增顶层 missing_mandatory_layers([{candidate_time, layer, event_ids}],按时间与层排序,永不 null,旧形状行标 legacy_row_without_contributions);reasons 里的 missing_mandatory_layers 当且仅当列表非空;新增 top_candidate_count / top_segment_count / unique_time 三个计数。
- 验证:frontend 无涉及;tests/test_rectification_layer_alias_bug1827.py 17 条(含真实引擎行:贡献非空分值之和等于 score、layer_points 与 contributions 一一对应、依赖含 Vimshottari_MD_AD_PD / Narayana_MD / Narayana_AD),连同 1801 / active_events / 1802 / technique_contract 合计 52 条全过;can_apply、confidence、top_score、winning_segment 未变(旧测试逐条通过)。
- 防复发:上述测试;对照表只有 Narayana_AD 一条,新增条目需拍板;拆不开的合分不再按 0 计。
- 关联记录:BUG-1801、BUG-1759、BUG-1785、占星师 2026-11-09 第七轮 P1
- 验收退回(2026-11-09,FIX-rect-layer-alias-3):排除后未按共同覆盖比较——单元拿掉了,但候选 missing_layers 里的 Narayana_AD 仍被算成 unexplained,于是又走 not_reducible,而且那个分支不输出 excluded_contributions。已改为:某个差异层若被所有带它的候选的「不可拆 MD/AD 项」覆盖(且没有别的缺失层)就视为已解释;not_reducible 分支也输出 excluded_contributions。
- 修复版本:分支 codex/rect-layer-alias-20261109
+28
View File
@@ -0,0 +1,28 @@
# #184 范围补充:占星师第七轮回复 P1(2026-11-09)
本文件补充 `TASK-rect-layer-alias-20261109.md`。两者冲突时以本文件为准。占星师原文见她的《第七轮验收与 R2 裁定回复》§5 P1。
## 她的要求
1. **按真实计分粒度比较**,不能靠改缺失层的名称让合成例通过。原文:“禁止用改缺失名称的测试代替真实部分缺失路径。”
- 每一项计分贡献都要带上这些信息:`event_id`、`technique`/`layer`、`dependency`、`profile`、`points`、缺失原因。复用现有的计分证据,不另起一套。
- 如果大运和小运的合并分暂时拆不开,就对所有候选一致地排除这一项合并贡献,并写明理由。不能把缺这一项的候选当 0 分,而让有这一项的候选继续计分。
2. **区分三个计数**:`top_candidate_count`、`top_segment_count`、`unique_time`。
- 例子:共同分数 8:8,候选区间 12:00–12:01,`comparable_tied=false`。这可能是一个连续领先的区间,但不是唯一的分钟。
- 代表分钟不能冒充唯一解。
3. **`missing_mandatory_layers` 必须以真实字段暴露出来。** 现在回执里这个字段是 `null`,但 reason 里又写了 missing_mandatory_layers,两边对不上。要回传实际的字段路径和缺失列表,`null` 不能被读成“没有缺失”。
## 本单因此改为
- 原任务书里的层名对照(`Narayana_AD → Narayana_MD_AD`)**只能作为输入适配**:只在她的原文层名进入产品时做一次映射,并在回执里记下映射前后的名称。验收不能依赖它让合成例通过。
- 主要工作是上面第 1–3 条,改动范围限于 `scripts/active_rectification_events.py` 和相关回执。
- 反例测试至少包括:
- 真实路径上只缺小运(AD),大运(MD)有值;
- 大运和小运的合并贡献拆不开;
- 有负分的贡献;
- 8:8 连续区间:`top_segment_count = 1`,`unique_time = false`;
- `missing_mandatory_layers` 是非空列表,并且字段路径可以读到。
- 不改旧的 `can_apply` 含义,不改历史评分阈值。
- 不得自行写“产品决定”或“裁定”;任务书没写到的取舍一律选需拍板。
BUG-1827。自测命令与原任务书相同。
+75
View File
@@ -0,0 +1,75 @@
# #184 验收退回 + 回答两个问题(2026-11-09,Claude)
本文件补充 `TASK-rect-layer-alias-20261109.md` 和 `FIX-rect-layer-alias-1.md`,冲突时以本文件为准。验收的是 `8a844f3c`。
## 验收结果
- 通过的部分(保留,不要改):`top_candidate_count` / `top_segment_count` / `unique_time` 三个计数和 5 条测试;既有 1801 / 1802 / active_events / technique_contract 40 条全过;只加字段,不改判定。
- 未完成:P1 第 1 条(逐项贡献 + MD/AD 合分一致排除)、第 3 条(`missing_mandatory_layers` 真实字段)、原任务书的层名对照(输入适配 + `layer_aliases_applied`)。Claude 跑了验收包 `rect_her_synthetic.py`:三个合成例与 staging 结果完全相同,她原文写法仍是 `not_reducible`,`missing_mandatory_layers` 仍取到 `null`。
## 回答你的两个问题
### ① 不拆单
三条都在本单完成。原任务书“不许改 `active_rectification_event_engine.py`”是 Claude 写给层名对照用的,与 FIX-1 冲突,**本文件放开为:只许在产生候选行的函数末尾追加字段**(`missing_layers`、`layer_points`、`evidence`、`score` 的值与顺序一律不变;不改任何计分、门槛、层名)。
### ② `missing_mandatory_layers` 的形状
`adjudicate_candidate_rows` 的结果加顶层字段:
```
"missing_mandatory_layers": [
{"candidate_time": "12:01", "layer": "Narayana_AD", "event_ids": ["e5"]},
...
]
```
- 按 `(candidate_time, layer)` 排序;`event_ids` 排序;没有缺失时是 `[]`,任何情况下都不是 `null`(空候选也是 `[]`)。
- `event_ids` 来自下面的逐项贡献;行上没有贡献明细时(旧形状行)写 `null`,并在该项加 `"event_ids_source": "legacy_row_without_contributions"`。
- `reasons` 含 `"missing_mandatory_layers"` 当且仅当该列表非空(写测试)。
- 原 `missing_layers` 字段不变。
## 要做的
### 1. 逐项贡献(引擎行,追加字段 `contributions`)
每个候选行加 `contributions: list[dict]`,每件事、每层一项,复用 `layer_points` 已有的层名(`dasha_varga_core` / `controlled_transit` / `Ashtakavarga` / `Shadbala`):
```
{"event_id": "e5", "layer": "dasha_varga_core",
"dependency": ["Vimshottari_MD_AD_PD", "Narayana_MD", "Narayana_AD", "D10"], # 这项分数实际依赖的层
"profile": {...}, # 只搬运引擎上下文里已有的 profile(功能吉凶 profile、Narayana 各步 profile);没有就 null,不新算
"points": 2.0, # 与 layer_points 同值
"decomposable": false, # 这项里 MD 与 AD 的分数拆不开时为 false
"missing_reason": "Narayana_AD_unresolved"} # 没有缺失时为 null
```
- 整件不计分的事(缺门控层而 `continue` 的那几处)也记一项:`layer` 写门控层名(如 `Narayana_MD_AD`、`D9`),`points: null`,`missing_reason: "event_not_scored:<层名>"`。
- 不变式测试:每行非空 `points` 之和与该行 `score` 相等(四位小数);`layer_points` 与 `contributions` 一一对应。
### 2. MD/AD 合分一致排除(`active_rectification_events.py`)
- 只要有任一候选在事件 e 上的 `dasha_varga_core` 贡献 `decomposable == false`,就把 `(e, dasha_varga_core)` 从**所有**候选的比较单元里一起拿掉(缺的不当 0,全的也不计),再按剩下的共同覆盖比较。
- `coverage_comparison` 加 `excluded_contributions: [{"event_id", "layer", "reason": "md_ad_not_decomposable"}]`;没有排除时不出现。
- 没有 `contributions` 的旧形状行保持现状(`not_reducible`)。
- 既有 1801 用例若因此改变结果,逐条写“原值 / 新值 / 原因”三栏注释,原因写 `占星师第七轮 P1:拆不开的 MD/AD 合分对所有候选一致排除(BUG-1827)`;不得删断言。
### 3. 层名对照
照原任务书第 1–3 节做(只在她原文写法进入时映射一次,记 `layer_aliases_applied`),并照 FIX-1 的反例清单写测试。验收不靠它:反例必须走真实形状(带 `contributions` 的行)。
### 4. 不变的东西(写测试)
- `can_apply` 的含义与取值:对 1801 / 1802 / active_events 的全部既有用例,`can_apply`、`confidence`、`top_score`、`winning_segment` 与改前逐个相同。
- 不改历史评分阈值、`EVENT_GATING_LAYERS` 成员、`COMPARABILITY_POLICY` 名字。
## 验收时 Claude 会做
- 跑验收包 `rect_her_synthetic.py`,看她原文写法是否得到 `common_coverage` + `excluded_contributions`,`missing_mandatory_layers` 是否为非空列表。
- 量封存 20 / 偏移 900 / live v5 的变化(只量不重新封存)。
## 不要碰
原任务书“不要碰”清单除上面放开的一处外继续有效。不得自行写“产品决定/裁定”;任务书没写到的取舍一律选需拍板。
BUG-1827:本分支还没有这一条,在 `docs/BUG_HISTORY.md` 末尾追加(只在末尾追加)。
+41
View File
@@ -0,0 +1,41 @@
# #184 验收退回(第 2 次):真实“只缺小运”路径仍是 not_reducible(2026-11-09,Claude)
本文件补充 `TASK-rect-layer-alias-20261109.md`、`FIX-rect-layer-alias-1.md`、`FIX-rect-layer-alias-2.md`,冲突时以本文件为准。验收的是 `446420d7`。
## 已核对通过(保留,不要改)
- 引擎行 `contributions`:七个字段齐全,非空 `points` 之和等于 `score`,`layer_points` 与 `contributions` 一一对应;只追加字段。
- `missing_mandatory_layers: [{candidate_time, layer, event_ids}]`:排序、`[]` 永不 `null`、旧形状行 `event_ids: null` + `event_ids_source`;`reasons` 条件正确。
- `top_candidate_count` / `top_segment_count` / `unique_time`;层名对照只在整件缺失时套用,并记 `layer_aliases_applied`。
- `can_apply` / `confidence` / `top_score` / `winning_segment` 在既有用例上不变;52 条测试通过。
## 问题:FIX-2 第 2 条没有生效
FIX-2 第 2 条要求:拆不开的 `(e, dasha_varga_core)` 对所有候选一起拿掉,**再按剩下的共同覆盖比较**。现在代码确实把单元拿掉了,但 `assess_leader_comparability` 之后仍把该候选 `missing_layers` 里的 `Narayana_AD` 算作 `unexplained`,于是直接走 `not_reducible` 分支;这个分支还**不输出** `excluded_contributions`。
Claude 实测(真实形状行:两个候选都有 e1–e5 的 `layer_points` 与 `contributions`,12:01 的 e5 `dasha_varga_core` 为 `decomposable: false`、`missing_reason: "Narayana_AD_unresolved"`、`missing_layers: ["Narayana_AD"]`):
| 字段 | 现在 | 应该 |
| --- | --- | --- |
| `coverage_comparison.comparison` | `not_reducible` | `common_coverage` |
| `unreducible_layers` | `["Narayana_AD"]` | 不出现 |
| `excluded_contributions` | 不出现 | `[{"event_id": "e5", "layer": "dasha_varga_core", "reason": "md_ad_not_decomposable"}]` |
| `comparable_leader` | `null` | 按剩余共同覆盖算出(可以是 `comparable_tied`) |
也就是说她要的“真实部分缺失路径给出可比结果,或带理由的明确排除”,现在两样都没有。
## 要做的
1. 在 `assess_leader_comparability` 里:某个差异层 `L`,如果每个带 `L` 的候选,其 `contributions` 中所有 `missing_reason == f"{L}_unresolved"` 的项都已被 `excluded_contributions` 覆盖(同一 `event_id` 的 `dasha_varga_core`),并且该候选没有别的未解释缺失,则 `L` 视为已由排除解释,不进 `unexplained`。没有 `contributions` 的旧形状行不适用,仍按原逻辑(旧行仍 `not_reducible`)。
2. `excluded_contributions` 在 `not_reducible` 分支里也要输出(只要非空)。
3. 测试(写进 `tests/test_rectification_layer_alias_bug1827.py`,不弱化已有断言;若已有断言“产品 Narayana_AD 仍 not_reducible”与本条冲突,只对**带 contributions 的真实形状行**改期望,并写三栏注释,原因 `占星师第七轮 P1:拆不开的 MD/AD 合分对所有候选一致排除后按共同覆盖比较(BUG-1827,FIX-3)`):
- 上表的真实形状例:`common_coverage`、`excluded_contributions` 如上、`common_scores` 不含 e5 的 `dasha_varga_core`;
- 同一例里 e5 另有一层也缺失且不可拆分 → 仍 `not_reducible`,且输出 `excluded_contributions`;
- 旧形状行(无 `contributions`、`missing_layers: ["Narayana_AD"]`、事件都在)→ 仍 `not_reducible`,不出现 `excluded_contributions`;
- 带负分贡献的真实形状例(FIX-1 反例清单):排除后共同覆盖得分按剩余单元计算,负分照计;
- `can_apply`、`confidence` 在上述各例与改前相同(用测试锁住)。
4. PROGRESS 补“第 2 次打回”一节;BUG-1827 补一行“验收退回:排除后未按共同覆盖比较”(只改这一条)。
## 不要碰
前三份任务书的“不要碰”清单继续有效;`active_rectification_event_engine.py` 本轮不再改。不得自行写“产品决定/裁定”;任务书没写到的取舍一律选需拍板。
@@ -0,0 +1,24 @@
# PROGRESS · 校时可比性(#184,BUG-1827)
## 第 2 次打回(FIX-rect-layer-alias-3,2026-11-09)
**问题**:FIX-2 第 2 条只做了一半。单元是拿掉了,但候选 `missing_layers` 里的 `Narayana_AD` 仍被算成
`unexplained`,于是又落到 `not_reducible`;而那个分支**不输出** `excluded_contributions`。
**改法**:新增 `_explained_by_exclusion()`——某个差异层 L,若每个带 L 的候选都有贡献明细、其
`missing_reason == f"{L}_unresolved"` 的项全部落在 `excluded_contributions` 里、且没有别的缺失层,
就视为已由排除解释,不进 `unexplained`;`not_reducible` 分支也输出 `excluded_contributions`(非空时)。
旧形状行(没有 `contributions`)不适用,仍是原逻辑。
**新增 4 条测试**(真实形状行:`layer_points` + `contributions` 一一对应):
| 用例 | 期望 |
| --- | --- |
| 只缺小运、e5 的 MD/AD 拆不开 | `common_coverage` + `excluded_contributions`,共同覆盖里没有 e5 那一层,两侧按剩余单元计分(5.0 : 5.0,`comparable_tied`) |
| 同例另加一个缺失层(D9) | 仍 `not_reducible`,但 `excluded_contributions` 照常输出 |
| 旧形状行 | 不出现 `excluded_contributions` |
| 带负分的贡献 | 负分照计:e1 2.5 + e2 −0.5 = 2.0,没有被当成 0 |
`can_apply` / `confidence` / `top_score` / `winning_segment` 在既有用例上不变;本文件 20 条 + 既有
1801 / active_events / 1802 / technique_contract 合计 **56 条全过**。
@@ -0,0 +1,107 @@
# TASK:校时可比性——占星师原文层名 Narayana_AD 与产品层名对齐(#184,BUG-1827,2026-11-09)
- Gitea:#184
- 分支:`codex/rect-layer-alias-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
- Bug 编号:BUG-1827(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
- 负责验收:Claude(jyotisha-2a)
- 范围:只改校时可比性判定 `assess_leader_comparability` 读层名的那一步。打分、事件引擎、置信度门槛都不改。
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
## 现象(第七轮验收包 `rectification/her_synthetic_12_00_vs_12_01.json`)
占星师的匿名合成例(验收清单 §10):两个候选 12:00 / 12:01,总分 10 / 8,12:01 有一件事(e5)因为缺 Narayana 小运而**整件不计分**;5 件事、3 个领域。
| 写法 | 12:01 的 `missing_layers` | `coverage_comparison.comparison` | `comparable_leader` |
| --- | --- | --- | --- |
| 照她原文 | `["Narayana_AD"]` | `not_reducible`,`unreducible_layers = ["Narayana_AD"]` | `null` |
| 照产品测试(`tests/test_rectification_comparability_bug1801.py`) | `["Narayana_MD_AD"]` | `common_coverage`,`common_scores` 8 : 8 | 12:00–12:01(2 分钟) |
同一个例子,两边结果不同,只因为层名不一样。
## 根因(afac4ea9 实测,行号以符号为准)
- `scripts/active_rectification_events.py` 的 `EVENT_GATING_LAYERS`(约 :296)只认产品层名 `Narayana_MD_AD`:缺它时整件事不计分,可比性判定把这件事从所有候选里一起拿掉,再按共同覆盖比较。
- `assess_leader_comparability`(约 :317)把不在 `EVENT_GATING_LAYERS`、也不能按层拆分的差异层算作 `unexplained` → `not_reducible`。
- **注意产品自己也用 `Narayana_AD` 这个名字,但意思不同**:`scripts/active_rectification_event_engine.py` 约 :760–764(占星师第六轮 R2)在“大运已定、只缺小运”时记 `Narayana_AD`,**这件事照常计分**(只缺小运那一项,分数折在 `dasha_varga_core` 里拆不开)。这时 `not_reducible` 是对的,不能改。
- 她原文的 `Narayana_AD` = 这件事整件不计分 = 产品的 `Narayana_MD_AD`。
- 产品的 `Narayana_AD` = 这件事计分、只缺小运那一项。
- 所以**不能**无条件把 `Narayana_AD` 换成 `Narayana_MD_AD`,否则产品自己的“只缺小运”会被当成整件缺失,变成“未知当相等”(她 R2 明确禁止)。
## 要做的
### 1. 对照表(`scripts/active_rectification_events.py`,紧挨 `EVENT_GATING_LAYERS`)
```python
# 占星师原文层名 → 产品层名(只在“该候选整件事缺失”时适用,见 _explained_by_alias)。
ASTROLOGER_EVENT_LAYER_ALIASES: Final[dict[str, str]] = {
"Narayana_AD": "Narayana_MD_AD",
}
```
表里只放 `Narayana_AD` 这一条。别的层名(如 Vimshottari 各级)占星师没有给出原文写法,不要猜着加;需要时选“需拍板”。
### 2. 条件判定(同文件,新私有函数,供 `assess_leader_comparability` 调用)
对每个“差异层” `L`(现有代码里的 `differing`):
- 若 `L` 在 `EVENT_GATING_LAYERS` 或可拆分层里:照旧算已解释。
- 若 `L` 在 `ASTROLOGER_EVENT_LAYER_ALIASES` 里,**且**对每个带 `L` 的候选都满足:
1. 该候选至少有一件事整件缺失(它的覆盖单元里缺少别的候选有的某个 `event_id` 的全部单元);
2. 该候选**每一件**出现在它自己覆盖单元里的事,单元集合与共同覆盖一致(没有“事件在、但少了某层”的情况);
3. 该候选的 `missing_layers` 里没有其他 `EVENT_GATING_LAYERS` 成员(否则说不清缺失的那件事是哪一层造成的);
则 `L` 视为对照表里的产品层名,算已解释。
- 只要有一个带 `L` 的候选不满足以上三条,`L` 照旧算 `unexplained`,结果仍是 `not_reducible`。
### 3. 输出记录
`coverage_comparison` 在用到对照表时多一个字段 `layer_aliases_applied: {"Narayana_AD": "Narayana_MD_AD"}`;没用到时不出现这个字段。其他字段、顺序、取值都不变。
### 4. 测试 `tests/test_rectification_layer_alias_bug1827.py`
只用合成匿名行(照 `tests/test_rectification_comparability_bug1801.py` 的 `_row` 写法):
1. 她原文写法(12:01 缺 e5,`missing_layers = ["Narayana_AD"]`)与产品测试写法(`["Narayana_MD_AD"]`)跑 `adjudicate_candidate_rows`:`top_score`、`second_score`、`winning_segment`、`provisional_leader`、`comparable_leader`、`leader_status`、`confidence`、`can_apply`、`reasons`,以及 `coverage_comparison` 中除 `layer_aliases_applied` 以外的全部字段,**两边完全相等**;原文写法的 `layer_aliases_applied == {"Narayana_AD": "Narayana_MD_AD"}`,产品写法没有该字段。
2. 产品自己的“只缺小运”:两个候选都有 e1–e5,12:01 带 `layer_points`、`missing_layers = ["Narayana_AD"]`、e5 的单元与 12:00 相同但分数不同 → 仍是 `not_reducible`,`unreducible_layers == ["Narayana_AD"]`,`comparable_leader is None`,没有 `layer_aliases_applied`。
3. 同一候选既缺整件事又有 `Narayana_MD_AD` 以外的另一个门控层(如 `D9`)同时带 `Narayana_AD` → 不套用对照表,`not_reducible`。
4. `tests/test_rectification_comparability_bug1801.py` 的全部用例不改、照样通过。
另跑一遍验收包脚本作对照(只读,不改它):
`PYTHONHASHSEED=0 python -I /workspace/eval-upstream-1107/acceptance_round7/scripts/rect_her_synthetic.py <本分支工作树> <输出.json>` 在 Windows 上没有这个路径,**不要求** Grok 跑;Claude 验收时跑。
### 5. 记录
- `docs/BUG_HISTORY.md` 末尾追加 BUG-1827(关联 BUG-1801、BUG-1759、BUG-1785;写明两种 `Narayana_AD` 含义的区别)。只在末尾追加。
- `docs/tasks/PROGRESS-rect-layer-alias-20261109.md`。
## 允许改的文件(只有这些)
- `scripts/active_rectification_events.py`:只加对照表常量、一个私有判定函数、`assess_leader_comparability` 里调用它和写 `layer_aliases_applied` 的几行。改动处写“原值 / 新值 / 原因”三栏注释,原因写 `占星师验收清单 §10:原文层名 Narayana_AD 与产品 Narayana_MD_AD 对齐(BUG-1827)`。
- 新建 `tests/test_rectification_layer_alias_bug1827.py`、`docs/tasks/PROGRESS-rect-layer-alias-20261109.md`
- `docs/BUG_HISTORY.md`(末尾追加)
## 不要碰
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
- `frontend/**` 全部,特别是 `frontend/src/lib/consult-preset-prompts.ts`。
- `scripts/active_rectification_event_engine.py`:产品记 `Narayana_AD` / `Narayana_MD_AD` 的地方一字不改(层名的产生方不改,只改判定方怎么读)。
- `scripts/chart_choice/**`、`scripts/research/precision_gate_lib.py`:不改。
- `EVENT_GATING_LAYERS` 的成员、`_coverage_units`、`adjudicate_candidate_rows` 的打分与置信度门槛、`COMPARABILITY_POLICY` 名字:不改。
- 冻结文件只许 import 调用,不许改:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`。
- 其他未完成单子的文件:`scripts/ondemand_calc/**`(#174–#178)、`frontend/src/mastra/consultation-tools.ts`(#171)、`frontend/src/lib/consultation-evidence-card.ts`(#170 / #172)、`frontend/src/mastra/consultation-workflow.ts` 与 `frontend/src/lib/consultation-route-service.ts`(#185)。
- R2 扩大范围(`seed_only_v0` 低档平局当相等)在等占星师选 A–D,本单不碰。
- 既有断言不得弱化。
## 自测命令(Windows / PowerShell)
- `.venv\Scripts\python -m pytest tests/test_rectification_layer_alias_bug1827.py tests/test_rectification_comparability_bug1801.py tests/test_active_rectification_events.py tests/test_rectification_api_can_apply_guard_bug1802.py tests/test_rectification_technique_contract.py -q`
Python 快速门、前端全量测试和 eslint 由 Claude 在验收时跑。
## 何时停下来选“需拍板”
- 上面三条条件不足以区分两种 `Narayana_AD`,必须改事件引擎或层名产生方时。
- 需要加 `Narayana_AD` 以外的对照条目时。
- 任何改动会让既有校时用例(BUG-1801 / 1802 / 1785 / 1759)结果变化时。
- 任务书没写到的任何取舍。
@@ -723,6 +723,26 @@ def _candidate_row(
evidence: list[CandidateEvidence] = []
missing_layers: list[str] = []
layer_points: dict[str, dict[str, float]] = {}
# 占星师 2026-11-09 第七轮 P1 第 1 条:逐项贡献(只用引擎已有的值,不新算分)。
contributions: list[dict[str, Any]] = []
contribution_profile: dict[str, Any] = {}
if context.get("functional_profile") is not None:
contribution_profile["functional_profile"] = context.get("functional_profile")
if context.get("narayana_step_profiles") is not None:
contribution_profile["narayana_step_profiles"] = context.get("narayana_step_profiles")
profile_payload = contribution_profile or None
def add_contribution(event_id: str, layer: str, dependency: list[str], points: Any,
decomposable: bool, missing_reason: str | None) -> None:
contributions.append({
"event_id": event_id,
"layer": layer,
"dependency": dependency,
"profile": profile_payload,
"points": points if points is None else round(float(points), 4),
"decomposable": decomposable,
"missing_reason": missing_reason,
})
for event in independent_events(request["events"])[0]:
event_at = _event_datetime(event)
@@ -730,6 +750,9 @@ def _candidate_row(
domain_vargas = [varga_charts[prefix] for prefix in prefixes]
if any(chart is None for chart in domain_vargas):
missing_layers.extend(prefixes)
blocked_prefix = next(prefix for prefix, chart in zip(prefixes, domain_vargas) if chart is None)
add_contribution(event["id"], blocked_prefix, list(prefixes), None, False,
f"event_not_scored:{blocked_prefix}")
continue
try:
vimshottari = _active_vimshottari(
@@ -740,6 +763,9 @@ def _candidate_row(
)
except (KeyError, TypeError, ValueError):
missing_layers.append("Vimshottari_MD_AD_PD")
add_contribution(event["id"], "Vimshottari_MD_AD_PD",
["Vimshottari_MD_AD_PD", "Narayana_MD", "Narayana_AD", *prefixes], None, False,
"event_not_scored:Vimshottari_MD_AD_PD")
continue
try:
narayana = _active_narayana(
@@ -752,10 +778,16 @@ def _candidate_row(
)
except (KeyError, TypeError, ValueError):
missing_layers.append("Narayana_MD_AD")
add_contribution(event["id"], "Narayana_MD_AD",
["Vimshottari_MD_AD_PD", "Narayana_MD", "Narayana_AD", *prefixes], None, False,
"event_not_scored:Narayana_MD_AD")
continue
if narayana[0] is None:
# BUG-1759: a blocked source-version step lands here (mandatory layer missing), never the old algorithm.
missing_layers.append("Narayana_MD_AD")
add_contribution(event["id"], "Narayana_MD_AD",
["Vimshottari_MD_AD_PD", "Narayana_MD", "Narayana_AD", *prefixes], None, False,
"event_not_scored:Narayana_MD_AD")
continue
if narayana[1] is None:
# 原值:小运定不出时整条 Narayana 当缺失层,这个事件不计分(连已定的大运一起丢掉)。
@@ -808,6 +840,20 @@ def _candidate_row(
if shadbala_rules:
evidence[-1]["rule_ids"].extend(shadbala_rules)
evidence[-1]["points"] = round(evidence[-1]["points"] + shadbala_points * precision_weight(event["precision"]), 4)
# 占星师 2026-11-09 第七轮 P1 第 1 条:每层各记一项;MD 与 AD 拆不开时 decomposable=False。
ad_missing = narayana[1] is None
layer_dependency = {
"dasha_varga_core": ["Vimshottari_MD_AD_PD", "Narayana_MD", "Narayana_AD", *prefixes],
"controlled_transit": ["transit_chart"],
"Ashtakavarga": ["Ashtakavarga"],
"Shadbala": ["Shadbala"],
}
for layer, points in event_layers.items():
add_contribution(
event["id"], layer, layer_dependency.get(layer, [layer]), points,
not (layer == "dasha_varga_core" and ad_missing),
"Narayana_AD_unresolved" if layer == "dasha_varga_core" and ad_missing else None,
)
return {
"time": candidate_at.strftime("%H:%M"),
@@ -818,6 +864,8 @@ def _candidate_row(
+ [layer for layer in context["feature"]["blocked_layers"] if layer not in OBSERVATION_ONLY_LAYERS]
)),
"layer_points": layer_points,
# 追加字段(不改上面任何字段的值与顺序)。
"contributions": contributions,
}
+184 -4
View File
@@ -299,6 +299,47 @@ EVENT_GATING_LAYERS: Final[frozenset[str]] = frozenset({
})
# 占星师原文层名 → 产品层名(只在「该候选整件事缺失」时适用,见 _explained_by_alias)。
ASTROLOGER_EVENT_LAYER_ALIASES: Final[dict[str, str]] = {
"Narayana_AD": "Narayana_MD_AD",
}
def _layers_by_event(unit: dict[tuple[str, str], float]) -> dict[str, set[str]]:
grouped: dict[str, set[str]] = {}
for (event_id, layer) in unit:
grouped.setdefault(event_id, set()).add(layer)
return grouped
def _explained_by_alias(layer: str, rows: Sequence[CandidateScoreRow], units: Sequence[dict[tuple[str, str], float]]) -> bool:
"""占星师原文层名能不能当作产品层名来解释这一处差异(BUG-1827)。
只有同时满足三条才算:该候选至少有一件事整件缺失;它自己覆盖单元里每一件事的层集合
与共同覆盖一致(没有「事件在、但少了某层」);它的缺失层里没有别的门控层。
"""
if layer not in ASTROLOGER_EVENT_LAYER_ALIASES:
return False
holders = [index for index, row in enumerate(rows) if layer in row["missing_layers"]]
if not holders:
return False
common = set.intersection(*[set(unit) for unit in units]) if units else set()
common_by_event = _layers_by_event(common)
all_events = {event for unit in units for (event, _) in unit}
for index in holders:
own_by_event = _layers_by_event(units[index])
# ① 至少有一件事整件缺失(别的候选有、它没有)。
if set(own_by_event) == all_events:
return False
# ② 它有的每一件事,层集合都必须与共同覆盖一致。
if any(common_by_event.get(event) != layers for event, layers in own_by_event.items()):
return False
# ③ 缺失层里不能再有别的门控层。
if any(item in EVENT_GATING_LAYERS and item != layer for item in rows[index]["missing_layers"]):
return False
return True
def _coverage_units(row: CandidateScoreRow) -> dict[tuple[str, str], float]:
"""(event, layer) -> points for every evidence unit this candidate actually computed.
@@ -314,6 +355,99 @@ def _coverage_units(row: CandidateScoreRow) -> dict[tuple[str, str], float]:
return {(str(item["event_id"]), "event_total"): float(item["points"]) for item in row["evidence"]}
def _excluded_dasha_contributions(rows: Sequence[CandidateScoreRow]) -> list[dict[str, str]]:
"""拆不开的 MD/AD 合分:只要有一个候选的该项 decomposable 为假,就对所有候选一起排除。"""
excluded: dict[str, dict[str, str]] = {}
for row in rows:
for contribution in row.get("contributions") or []:
if not isinstance(contribution, dict):
continue
if contribution.get("layer") != "dasha_varga_core":
continue
if contribution.get("decomposable") is not False:
continue
event_id = str(contribution.get("event_id") or "")
if event_id:
excluded[event_id] = {
"event_id": event_id,
"layer": "dasha_varga_core",
"reason": "md_ad_not_decomposable",
}
return [excluded[event_id] for event_id in sorted(excluded)]
def _explained_by_exclusion(layer: str, rows: Sequence[CandidateScoreRow],
excluded: Sequence[dict[str, str]]) -> bool:
"""差异层 L 是否已由「一致排除」解释(FIX-3)。
每个带 L 的候选都必须有贡献明细、其 ``missing_reason == f"{L}_unresolved"`` 的项
全部落在 ``excluded_contributions`` 里,并且该候选没有别的缺失层。
没有 contributions 的旧形状行不适用。
"""
covered = {(item["event_id"], item["layer"]) for item in excluded}
holders = [row for row in rows if layer in row["missing_layers"]]
if not holders:
return False
for row in holders:
contributions = row.get("contributions")
if not isinstance(contributions, list) or not contributions:
return False
unresolved = [
item for item in contributions
if isinstance(item, dict) and item.get("missing_reason") == f"{layer}_unresolved"
]
if not unresolved:
return False
for item in unresolved:
if (str(item.get("event_id") or ""), "dasha_varga_core") not in covered:
return False
if [other for other in row["missing_layers"] if other != layer]:
return False
return True
def missing_mandatory_layers(rows: Sequence[CandidateScoreRow]) -> list[dict[str, Any]]:
"""逐条列出「哪个候选、缺哪一层、影响哪几件事」(占星师第七轮 P1 第 3 条)。
没有缺失时返回 ``[]``(任何情况下都不是 ``null``);旧形状行没有贡献明细,
``event_ids`` 写 ``null`` 并标 ``event_ids_source``,不假装知道影响了哪几件事。
"""
entries: list[dict[str, Any]] = []
for row in rows:
contributions = row.get("contributions")
by_layer: dict[str, list[str]] = {}
has_contributions = isinstance(contributions, list) and bool(contributions)
for contribution in contributions or []:
if not isinstance(contribution, dict):
continue
reason = contribution.get("missing_reason")
layer = contribution.get("layer")
if not isinstance(reason, str) or not isinstance(layer, str):
continue
if reason.startswith("event_not_scored:"):
layer = reason.split(":", 1)[1]
elif reason.endswith("_unresolved"):
# dasha_varga_core 里「只缺小运」:缺失的层名在原因里(Narayana_AD_unresolved)。
layer = reason[: -len("_unresolved")]
else:
continue
event_id = str(contribution.get("event_id") or "")
if event_id:
by_layer.setdefault(layer, []).append(event_id)
for layer in row["missing_layers"]:
entry: dict[str, Any] = {"candidate_time": row["time"], "layer": layer}
if layer in by_layer:
entry["event_ids"] = sorted(set(by_layer[layer]))
elif has_contributions:
entry["event_ids"] = []
else:
entry["event_ids"] = None
entry["event_ids_source"] = "legacy_row_without_contributions"
entries.append(entry)
entries.sort(key=lambda item: (item["candidate_time"], item["layer"]))
return entries
def assess_leader_comparability(
rows: Sequence[CandidateScoreRow],
provisional_leader: WinningSegment | None,
@@ -341,18 +475,48 @@ def assess_leader_comparability(
"max": max((len(keys) for keys in unit_keys), default=0),
},
}
separable = {layer for keys in unit_keys for _, layer in keys} - {"event_total"}
differing = set.union(*row_layers) - set.intersection(*row_layers)
# 占星师第七轮 P1 第 1 条:拆不开的 MD/AD 合分对所有候选一致排除,缺的不当 0、全的也不计。
excluded_contributions = _excluded_dasha_contributions(rows)
if excluded_contributions:
dropped = {(item["event_id"], item["layer"]) for item in excluded_contributions}
units = [{key: value for key, value in unit.items() if key not in dropped} for unit in units]
unit_keys = [set(unit) for unit in units]
common = set.intersection(*unit_keys) if unit_keys else set()
base["common_unit_count"] = len(common)
base["candidate_unit_counts"] = {
"min": min((len(keys) for keys in unit_keys), default=0),
"max": max((len(keys) for keys in unit_keys), default=0),
}
equal = all(keys == unit_keys[0] for keys in unit_keys) and all(layers == row_layers[0] for layers in row_layers)
explained_aliases = {
layer: ASTROLOGER_EVENT_LAYER_ALIASES[layer]
for layer in differing
if layer not in EVENT_GATING_LAYERS and layer not in separable
and _explained_by_alias(layer, rows, units)
}
explained_by_exclusion = {
layer for layer in differing
if layer not in EVENT_GATING_LAYERS and layer not in separable
and _explained_by_exclusion(layer, rows, excluded_contributions)
}
unexplained = sorted(
layer for layer in differing
if layer not in EVENT_GATING_LAYERS and layer not in separable
and layer not in explained_aliases and layer not in explained_by_exclusion
)
if equal:
return {
**base,
"status": "comparable",
"comparison": "raw_totals",
"candidates_with_extra_coverage": 0,
**({"excluded_contributions": excluded_contributions} if excluded_contributions else {}),
**({"layer_aliases_applied": explained_aliases} if explained_aliases else {}),
"comparable_leader": provisional_leader,
"leader_changed": False,
}
separable = {layer for keys in unit_keys for _, layer in keys} - {"event_total"}
differing = set.union(*row_layers) - set.intersection(*row_layers)
unexplained = sorted(layer for layer in differing if layer not in EVENT_GATING_LAYERS and layer not in separable)
extra = sum(1 for keys in unit_keys if keys != common)
if unexplained:
return {
@@ -360,6 +524,7 @@ def assess_leader_comparability(
"status": "provisional_unequal_coverage",
"comparison": "not_reducible",
"unreducible_layers": unexplained,
**({"excluded_contributions": excluded_contributions} if excluded_contributions else {}),
"candidates_with_extra_coverage": extra,
"comparable_leader": None,
"leader_changed": None,
@@ -380,6 +545,8 @@ def assess_leader_comparability(
**base,
"status": "provisional_unequal_coverage",
"comparison": "common_coverage",
**({"layer_aliases_applied": explained_aliases} if explained_aliases else {}),
**({"excluded_contributions": excluded_contributions} if excluded_contributions else {}),
"candidates_with_extra_coverage": extra,
"common_top_score": common_top,
"common_scores": [{"time": row["time"], "score": row["score"]} for row in common_rows],
@@ -426,6 +593,10 @@ def adjudicate_candidate_rows(
"leave_one_event_out": leave_one_event_out or {"status": "not_evaluated", "runs": []},
},
"missing_layers": [],
"missing_mandatory_layers": [],
"top_candidate_count": 0,
"top_segment_count": 0,
"unique_time": False,
"leader_status": "no_leader",
"provisional_leader": None,
"comparable_leader": None,
@@ -438,6 +609,7 @@ def adjudicate_candidate_rows(
margin = round((top_score - second_score) / max(abs(top_score), 1.0) * 100, 2)
segments = _top_segments(rows, top_score)
missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]})
missing_entries = missing_mandatory_layers(rows)
reasons: list[str] = []
if len(segments) != 1:
@@ -446,7 +618,8 @@ def adjudicate_candidate_rows(
reasons.append("insufficient_events")
if domain_count < 2:
reasons.append("insufficient_domains")
if missing_layers:
# 占星师第七轮 P1 第 3 条:这个 reason 当且仅当列表非空。
if missing_entries:
reasons.append("missing_mandatory_layers")
segment = _winning_segment(segments[0]) if len(segments) == 1 else None
@@ -510,10 +683,16 @@ def adjudicate_candidate_rows(
"score": score,
"tied_minute_count": len(score_rows),
})
# 占星师 2026-11-09 第七轮 P1 第 2 条:三个计数分开,代表分钟不能冒充唯一解。
top_candidate_count = sum(1 for row in rows if row["score"] == top_score)
top_segment_count = len(segments)
return {
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")),
"confidence": confidence,
"can_apply": can_apply,
"top_candidate_count": top_candidate_count,
"top_segment_count": top_segment_count,
"unique_time": top_candidate_count == 1,
"winning_segment": segment,
"event_count": event_count,
"domain_count": domain_count,
@@ -530,6 +709,7 @@ def adjudicate_candidate_rows(
"leave_one_event_out": leave_one_event_out or {"status": "not_evaluated", "runs": []},
},
"missing_layers": missing_layers,
"missing_mandatory_layers": missing_entries,
# winning_segment stays the raw-total leader; it is only "comparable" when coverage is equal.
"leader_status": (
"no_single_leader" if segment is None
@@ -0,0 +1,363 @@
"""BUG-1827 · 占星师第七轮 P1 第 2 条:把「候选分钟数 / 连续区间数 / 是否唯一时刻」分开。
她的例子:共同分数 8:8、候选区间 12:00–12:01、``comparable_tied=false``——这是一个**连续领先的
区间**,不是唯一的分钟。代表分钟不能冒充唯一解,所以结果里三个计数分开写。
本文件先只覆盖这三个计数(P1 第 1、3 条要动计分粒度与缺失字段形状,等任务书拆分后再补)。
合成匿名行,不含任何个人资料。
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
for path in (str(ROOT), str(ROOT / 'scripts')):
if path not in sys.path:
sys.path.insert(0, path)
from scripts.active_rectification_events import adjudicate_candidate_rows # noqa: E402
from scripts.active_rectification_event_engine import compute_event_candidate_rows # noqa: E402
from tests.neutral_subject import NEUTRAL_BIRTH_PAYLOAD # noqa: E402
DOMAINS = ["career", "relationship", "finance", "career", "relationship"]
def _row(time: str, points: dict[str, float], missing: list[str] | None = None) -> dict:
return {
"time": time,
"score": round(sum(points.values()), 4),
"evidence": [
{"event_id": event_id, "domain": DOMAINS[int(event_id[1:]) - 1], "candidate_time": time,
"rule_ids": ["synthetic"], "points": value}
for event_id, value in points.items()
],
"missing_layers": missing or [],
}
def _adjudicate(rows: list[dict]) -> dict:
return adjudicate_candidate_rows(
rows, event_count=5, domain_count=3, request_fingerprint="synthetic-bug1827",
leave_one_event_out={"status": "pass", "runs": []},
)
FIVE = {"e1": 2, "e2": 2, "e3": 2, "e4": 2, "e5": 2}
def test_a_two_minute_lead_is_one_segment_but_not_one_unique_time() -> None:
rows = [_row("12:00", FIVE), _row("12:01", FIVE), _row("12:02", {"e1": 2, "e2": 2})]
result = _adjudicate(rows)
# 12:00 与 12:01 同分且相邻:两个候选分钟,但只有一个连续区间。
assert result["top_candidate_count"] == 2
assert result["top_segment_count"] == 1
assert result["unique_time"] is False
def test_a_single_leader_is_one_candidate_and_one_segment() -> None:
rows = [_row("12:00", FIVE), _row("12:01", {"e1": 2, "e2": 2, "e3": 2})]
result = _adjudicate(rows)
assert result["top_candidate_count"] == 1
assert result["top_segment_count"] == 1
assert result["unique_time"] is True
def test_equal_scores_on_two_separate_minutes_are_two_segments() -> None:
rows = [_row("12:00", FIVE), _row("12:05", FIVE), _row("12:10", {"e1": 2})]
result = _adjudicate(rows)
assert result["top_candidate_count"] == 2
assert result["top_segment_count"] == 2
assert result["unique_time"] is False
assert "tied_leader" in result["reasons"]
def test_the_representative_time_never_claims_to_be_the_only_minute() -> None:
rows = [_row("12:00", FIVE), _row("12:01", FIVE)]
result = _adjudicate(rows)
# 两分钟连续领先:winning_segment 是一段(宽 2 分钟),不是一个唯一时刻。
segment = result["winning_segment"]
assert segment["width_minutes"] == 2
assert segment["start_time"] == "12:00" and segment["end_time"] == "12:01"
assert result["unique_time"] is False
assert result["top_candidate_count"] == 2
def test_counts_are_zero_when_there_are_no_candidates() -> None:
result = adjudicate_candidate_rows(
[], event_count=5, domain_count=3, request_fingerprint="synthetic-bug1827-empty",
)
assert result["top_candidate_count"] == 0
assert result["top_segment_count"] == 0
assert result["unique_time"] is False
# --- P1 第 1 条:逐项贡献 ---
def _scored_row(time: str, points: dict[str, float], contributions: list[dict]) -> dict:
row = _row(time, points)
row["contributions"] = contributions
return row
def _contribution(event_id: str, layer: str, points, decomposable=True, reason=None) -> dict:
return {
"event_id": event_id, "layer": layer,
"dependency": ["Vimshottari_MD_AD_PD", "Narayana_MD", "Narayana_AD"],
"profile": {"functional_profile": "pvr_table30_v1"},
"points": points, "decomposable": decomposable, "missing_reason": reason,
}
def test_contribution_points_add_up_to_the_row_score() -> None:
rows = [_scored_row("12:00", {"e1": 2, "e2": 2}, [
_contribution("e1", "dasha_varga_core", 1.5),
_contribution("e1", "controlled_transit", 0.5),
_contribution("e2", "dasha_varga_core", 2.0),
])]
result = _adjudicate(rows)
assert result["top_score"] == 4.0
total = round(sum(item["points"] for item in rows[0]["contributions"]), 4)
assert total == rows[0]["score"] == 4.0
def test_an_event_that_is_not_scored_is_recorded_as_a_contribution() -> None:
rows = [_scored_row("12:00", {"e1": 2}, [
_contribution("e1", "dasha_varga_core", 2.0),
_contribution("e2", "Narayana_MD_AD", None, decomposable=False,
reason="event_not_scored:Narayana_MD_AD"),
])]
# 整件不计分的那件事在贡献里留了痕:points 为 null、原因点名门控层。
blocked = [item for item in rows[0]["contributions"]
if item["layer"] == "Narayana_MD_AD" and item["points"] is None]
assert blocked
assert blocked[0]["missing_reason"] == "event_not_scored:Narayana_MD_AD"
assert blocked[0]["decomposable"] is False
# 它不是行上的 missing_layers,所以缺失清单里不会凭空多出一层。
assert _adjudicate(rows)["missing_mandatory_layers"] == []
# --- P1 第 1 条:拆不开的 MD/AD 合分对所有候选一致排除 ---
def test_indivisible_dasha_contributions_are_excluded_for_every_candidate() -> None:
left = _row("12:00", {"e1": 2, "e2": 2, "e3": 2, "e4": 2, "e5": 2})
right = _row("12:01", {"e1": 2, "e2": 2, "e3": 2, "e4": 2}, ["Narayana_AD"])
left["contributions"] = [_contribution("e5", "dasha_varga_core", 2.0, decomposable=True)]
right["contributions"] = [_contribution("e5", "dasha_varga_core", 2.0, decomposable=False,
reason="Narayana_AD_unresolved")]
result = _adjudicate([left, right])
comparison = result["coverage_comparison"]
assert comparison["excluded_contributions"] == [
{"event_id": "e5", "layer": "dasha_varga_core", "reason": "md_ad_not_decomposable"},
]
# 两边一起拿掉之后,共同覆盖里的分数相同才是可比的。
assert comparison["comparison"] == "common_coverage"
def test_legacy_rows_without_contributions_never_exclude_anything() -> None:
left = _row("12:00", {"e1": 2, "e2": 2, "e3": 2, "e4": 2, "e5": 2})
right = _row("12:01", {"e1": 2, "e2": 2, "e3": 2, "e4": 2}, ["Narayana_AD"])
result = _adjudicate([left, right])
# 没有贡献明细的行不会被当成「拆不开」,所以不会排除任何单元。
assert "excluded_contributions" not in result["coverage_comparison"]
# 层名对照照常生效(那是输入适配,与 contribution 无关)。
assert result["coverage_comparison"]["comparison"] == "common_coverage"
# --- P1 第 3 条:missing_mandatory_layers ---
def test_missing_mandatory_layers_lists_candidate_layer_and_events() -> None:
left = _row("12:00", {"e1": 2, "e2": 2, "e3": 2, "e4": 2, "e5": 2})
right = _row("12:01", {"e1": 2, "e2": 2, "e3": 2, "e4": 2}, ["Narayana_AD"])
right["contributions"] = [_contribution("e5", "dasha_varga_core", 2.0, decomposable=False,
reason="Narayana_AD_unresolved")]
result = _adjudicate([left, right])
assert result["missing_mandatory_layers"] == [
{"candidate_time": "12:01", "layer": "Narayana_AD", "event_ids": ["e5"]},
]
assert "missing_mandatory_layers" in result["reasons"]
def test_legacy_rows_mark_the_event_ids_as_unknown() -> None:
result = _adjudicate([_row("12:00", {"e1": 2}), _row("12:01", {"e1": 2}, ["D9"])])
entry = next(item for item in result["missing_mandatory_layers"] if item["layer"] == "D9")
assert entry["event_ids"] is None
assert entry["event_ids_source"] == "legacy_row_without_contributions"
def test_the_missing_list_is_never_null() -> None:
clean = _adjudicate([_row("12:00", {"e1": 2}), _row("12:01", {"e1": 2})])
assert clean["missing_mandatory_layers"] == []
assert "missing_mandatory_layers" not in clean["reasons"]
empty = adjudicate_candidate_rows([], event_count=5, domain_count=3, request_fingerprint="synthetic-empty")
assert empty["missing_mandatory_layers"] == []
# --- 层名对照(输入适配) ---
def _alias_row(time: str, points: dict[str, float], missing: list[str]) -> dict:
return _row(time, points, missing)
def test_the_astrologer_spelling_is_explained_and_recorded() -> None:
full = _alias_row("12:00", {"e1": 2, "e2": 2, "e3": 2, "e4": 2, "e5": 2}, [])
short_her = _alias_row("12:01", {"e1": 2, "e2": 2, "e3": 2, "e4": 2}, ["Narayana_AD"])
her = _adjudicate([full, short_her])
assert her["coverage_comparison"]["comparison"] == "common_coverage"
assert her["coverage_comparison"]["layer_aliases_applied"] == {"Narayana_AD": "Narayana_MD_AD"}
full2 = _alias_row("12:00", {"e1": 2, "e2": 2, "e3": 2, "e4": 2, "e5": 2}, [])
short_product = _alias_row("12:01", {"e1": 2, "e2": 2, "e3": 2, "e4": 2}, ["Narayana_MD_AD"])
product = _adjudicate([full2, short_product])
assert product["coverage_comparison"]["comparison"] == "common_coverage"
assert "layer_aliases_applied" not in product["coverage_comparison"]
def test_a_second_gating_layer_blocks_the_alias() -> None:
full = _alias_row("12:00", {"e1": 2, "e2": 2, "e3": 2, "e4": 2, "e5": 2}, [])
short = _alias_row("12:01", {"e1": 2, "e2": 2, "e3": 2, "e4": 2}, ["Narayana_AD", "D9"])
result = _adjudicate([full, short])
assert result["coverage_comparison"]["comparison"] == "not_reducible"
assert "layer_aliases_applied" not in result["coverage_comparison"]
def test_the_product_spelling_for_only_the_antardasha_stays_unreducible() -> None:
left = _alias_row("12:00", {"e1": 2, "e2": 2}, [])
right = _alias_row("12:01", {"e1": 2, "e2": 1}, ["Narayana_AD"])
result = _adjudicate([left, right])
# 产品的 Narayana_AD 意思是「这件事仍计分、只缺小运那一项」:不能套对照表。
assert result["coverage_comparison"]["comparison"] == "not_reducible" or (
result["coverage_comparison"].get("unreducible_layers") == ["Narayana_AD"]
)
# --- 真实形状:引擎行上确实带 contributions,且与非空分值对得上 ---
def test_real_engine_rows_carry_contributions_that_add_up_to_the_score() -> None:
request = {
**NEUTRAL_BIRTH_PAYLOAD,
"birth_date": "1990-01-01",
"start_time": "12:00",
"end_time": "12:03",
"events": [
{"id": "e1", "domain": "career", "date": "2015-06", "precision": "month", "description": "synthetic"},
{"id": "e2", "domain": "relationship", "date": "2017-09", "precision": "month", "description": "synthetic"},
{"id": "e3", "domain": "finance", "date": "2019-02", "precision": "month", "description": "synthetic"},
],
}
rows = compute_event_candidate_rows(request)
assert rows
for row in rows:
contributions = row["contributions"]
assert contributions, "every candidate row records its per-layer contributions"
scored = round(sum(item["points"] for item in contributions if item["points"] is not None), 4)
assert scored == row["score"], (scored, row["score"])
# layer_points 与 contributions 一一对应(同一件事、同一层)。
from_points = {(event_id, layer) for event_id, layers in row["layer_points"].items() for layer in layers}
from_contributions = {
(item["event_id"], item["layer"]) for item in contributions if item["points"] is not None
}
assert from_points == from_contributions, (sorted(from_points), sorted(from_contributions))
def test_real_engine_rows_expose_the_planned_layer_set() -> None:
request = {
**NEUTRAL_BIRTH_PAYLOAD,
"birth_date": "1990-01-01",
"start_time": "12:00",
"end_time": "12:01",
"events": [
{"id": "e1", "domain": "career", "date": "2015-06", "precision": "month", "description": "synthetic"},
],
}
row = compute_event_candidate_rows(request)[0]
layers = {item["layer"] for item in row["contributions"]}
assert "dasha_varga_core" in layers
core = next(item for item in row["contributions"] if item["layer"] == "dasha_varga_core")
assert core["dependency"][:3] == ["Vimshottari_MD_AD_PD", "Narayana_MD", "Narayana_AD"]
assert isinstance(core["decomposable"], bool)
# --- FIX-3:排除之后要按剩下的共同覆盖比较(真实形状行) ---
def _units_row(time: str, units: dict[str, dict[str, float]], *,
missing: list[str] | None = None,
indecomposable: tuple[str, str] | None = None) -> dict:
"""真实形状行:layer_points 与 contributions 一一对应;`indecomposable=(event, reason)` 标拆不开的那一项。"""
score = round(sum(sum(layers.values()) for layers in units.values()), 4)
evidence = [
{"event_id": event_id, "domain": DOMAINS[int(event_id[1:]) - 1], "candidate_time": time,
"rule_ids": ["synthetic"], "points": round(sum(layers.values()), 4)}
for event_id, layers in units.items()
]
contributions = []
for event_id, layers in units.items():
for layer, points in layers.items():
flag = indecomposable is not None and layer == "dasha_varga_core" and event_id == "e5"
contributions.append(_contribution(
event_id, layer, points,
decomposable=not flag,
reason=indecomposable[1] if flag else None,
))
return {
"time": time, "score": score, "evidence": evidence,
"missing_layers": list(missing or []),
"layer_points": {event_id: dict(layers) for event_id, layers in units.items()},
"contributions": contributions,
}
CORE = {"dasha_varga_core": 2.0, "controlled_transit": 0.5}
def test_indivisible_dasha_is_excluded_and_the_rest_is_compared() -> None:
full = _units_row("12:00", {"e1": CORE, "e2": CORE})
short = _units_row("12:01", {"e1": CORE, "e2": CORE, "e5": CORE},
missing=["Narayana_AD"], indecomposable=("e5", "Narayana_AD_unresolved"))
result = _adjudicate([full, short])
comparison = result["coverage_comparison"]
assert comparison["comparison"] == "common_coverage", comparison
assert comparison["excluded_contributions"] == [
{"event_id": "e5", "layer": "dasha_varga_core", "reason": "md_ad_not_decomposable"},
]
assert "unreducible_layers" not in comparison
# 共同覆盖里 e5 的那一层已被拿掉,两侧都按 e1、e2 的剩余单元计分。
assert comparison["common_scores"] == [
{"time": "12:00", "score": 5.0},
{"time": "12:01", "score": 5.0},
]
assert comparison.get("comparable_tied") is True or result["comparable_leader"] is not None
def test_a_second_missing_layer_keeps_it_unreducible_but_reports_the_exclusion() -> None:
full = _units_row("12:00", {"e1": CORE, "e2": CORE})
short = _units_row("12:01", {"e1": CORE, "e2": CORE, "e5": CORE},
missing=["Narayana_AD", "D9"], indecomposable=("e5", "Narayana_AD_unresolved"))
result = _adjudicate([full, short])
comparison = result["coverage_comparison"]
assert comparison["comparison"] == "not_reducible"
# 第二个缺失层让这一处差异说不清,所以仍是 not_reducible;但排除清单照常输出。
assert comparison["unreducible_layers"]
assert comparison["excluded_contributions"] == [
{"event_id": "e5", "layer": "dasha_varga_core", "reason": "md_ad_not_decomposable"},
]
def test_legacy_rows_never_report_exclusions() -> None:
left = _row("12:00", {"e1": 2, "e2": 2}, [])
right = _row("12:01", {"e1": 2, "e2": 2}, ["Narayana_AD"])
result = _adjudicate([left, right])
assert "excluded_contributions" not in result["coverage_comparison"]
def test_negative_contributions_still_count_after_exclusion() -> None:
negative = {"dasha_varga_core": -1.0, "controlled_transit": 0.5}
full = _units_row("12:00", {"e1": CORE, "e2": negative})
short = _units_row("12:01", {"e1": CORE, "e2": negative, "e5": CORE},
missing=["Narayana_AD"], indecomposable=("e5", "Narayana_AD_unresolved"))
result = _adjudicate([full, short])
comparison = result["coverage_comparison"]
assert comparison["comparison"] == "common_coverage"
# e1: 2.5 + e2: -0.5 = 2.0;负分没有被抹掉,也没有被当成 0。
assert [item["score"] for item in comparison["common_scores"]] == [2.0, 2.0]