Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494c85e001 | ||
|
|
aa9767a5c2 |
+12
-27
@@ -24519,31 +24519,16 @@
|
||||
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;FIX-ondemand-transit-date-1(产品拍板 C 方案)
|
||||
- 修复版本:分支 codex/ondemand-transit-date-20261109
|
||||
|
||||
## BUG-1817 | 没有「一段时间内的行运变化」:换座、停滞、逆行往返、目标经度都算不出来
|
||||
## BUG-1833 | 出生时间只能校到唯一分钟:时间范围对当前问题已经够用时也停不下来
|
||||
|
||||
- 状态:fixed-pending-verify(分支 codex/ondemand-transit-window-20261109)
|
||||
- 首次发现 / 最近更新:2026-11-07(占星师 Q9)/ 2026-11-09(后端修复)
|
||||
- 影响面:新增 scripts/ondemand_calc/transit_window.py;scripts/ondemand_calc/registry.py 加两行注册。
|
||||
- 现象:只能问「某一天行运如何」,问「接下来多久换座 / 什么时候停滞 / 这次逆行什么时候往返 / 什么时候到某个度数」都得自己猜日期。
|
||||
- 根因:按需计算骨架里只有单日期工具;现成的 ephemeris_events 按 UT 正午逐日采样且不认 node_mode、没有罗睺计都;transit_trigger.find_exact_transit_date 只返回第一次到达、岁差写死;cmd_transit 的 --retrograde-calendar / --find-conjunction 依赖不存在的 transit_search.py。
|
||||
- 修复:新增 transit_window 工具(ingress / station / retrograde_loop / target_longitude),黄经与日速度来自 jyotish_engine._calc_sidereal_planets_for_jd,口径由调用方显式传;事件二分到 ≤1 分钟;逆行往返给两个停滞、retrograde_span、影子期进出,允许区间外 400 天搜索并标 outside_requested_window;目标经度按方向列出每一次到达;节点不报停滞(partial + gap);失败只留缺口不编日期。
|
||||
- 验证:tests/test_ondemand_transit_window_20261109.py(17 条)与 transit_date / contract / api growth 合计 47 条全过;2026 水星逆行两个停滞与直接 SwissEph 速度二分的差为 1.41 分钟。
|
||||
- 防复发:上述测试;合同 docstring 的注册步骤已含本工具。
|
||||
- 验收修正(Claude 2026-11-09,codex/accept-grok-batch2-1109):换座的 to_sign 原来把 JD 当黄经取星座(每次换座的新星座都是错的),direction 把 0° 白羊处的顺行换座判成 retrograde,两处都改为按粗扫步的黄经计算;逆行往返里两个停滞与影子期进出各自标 outside_requested_window;区间终点按终点那天的 UTC 偏移换算。补 3 条回归测试(共 20 条)。
|
||||
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;#173(共用骨架)
|
||||
- 修复版本:分支 codex/ondemand-transit-window-20261109
|
||||
|
||||
## BUG-1824 | 「解读依据」把候选格局显示成「成立」
|
||||
|
||||
- 状态:fixed-pending-verify(分支 codex/basis-candidate-status-20261109)
|
||||
- 首次发现 / 最近更新:2026-11-09(第七轮验收包,页面阶段)/ 2026-11-09(修复)
|
||||
- 影响面:frontend/src/lib/consultation-technique-evidence.ts 的 yogaLine;frontend/src/lib/consult-answer-basis.ts 的「格局」兜底文案;新增 frontend/src/lib/yoga-status-words.ts。
|
||||
- 现象:简明解读的「解读依据」里,「格局」一行写「成立:Sunaphaa Yoga、Vosi Yoga」,而卡上这些格局的状态是 candidate / computed,卡上的规则写明 none is confirmed。
|
||||
- 触发条件:任何带数据卡的简明解读回答。
|
||||
- 根因:yogaLine 只读领域段的 yogas.hits(检测器匹配到的规则名,BUG-1157),一律写成「成立:」;真正的状态在 card.base.natal.yogas_detail.rows[] 的 status / formation.status 里,从来没被这一行读过。
|
||||
- 修复:新增 yoga-status-words.ts(conditions_not_met → 不成立;candidate → 候选;computed → 未确认;其他 → 未标状态;结构观察不进这一行);yogaLine 改为按名字到 yogas_detail.rows 取状态词并按状态分组(候选:A、B;未确认:C),没有可列的写「本题没有检测到相关格局」;兜底文案由「盘上成立和落空的格局已核对」改为「盘上检测到的格局已列出各自状态」。行状态(已算 / 有条件)与数据卡事实记录不动。
|
||||
- 验证:frontend/tests/consult-basis-candidate-status-1824.test.ts(8 条:三种状态词、formed_under_source 不写成成立、结构观察不出现、未标状态、灰字与兜底都不含「成立:」和「已确认」、计算状态与解读状态分开、空 hits 文案、词表本身不提供「成立」);consult-technique-evidence-1793(2)、consult-answer-basis-receipt-20261108(4)、consult-answer-mode-ui-20261108(14)继续通过。
|
||||
- 防复发:上述测试;词表单独成文件,后续 #182 复用同一套词。
|
||||
- 验收修正(Claude 2026-11-09,codex/accept-grok-batch2-1109):占星师第七轮回复 §5 P0.2 给出了确切映射,初版词表(候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为候选)与之不同,验收时替换为原表:candidate / support_only_candidate → 命中候选规则,尚未完整确认;computed(未完成来源条件治理)→ 检测器检出,待核实;formed_under_source → 按所选来源的形成条件成立;强度和结果另评;conditions_not_met → 所选来源的必要条件未满足;blocked / not_available_yet / 卡上无状态行 / 状态值不在表内 → 当前无法确认,缺:<缺项>。只有 formation.status(完整条件检查)能给出「成立」或「必要条件未满足」;只在 hits 里出现的名字一律落到「当前无法确认」。灰字按「名字:状态文字」分组、组间用「。」,放不下的整项写「另 N 项未列」,状态文字不被 80 字截断。新增 5 行映射各一条与「只在 hits 里的格局不显示成立」一条回归测试(共 13 条)。#182(模型侧措辞)必须 import 同一个 yoga-status-words.ts 的 YOGA_STATUS_MAPPING / yogaStatus。
|
||||
- 关联记录:BUG-1793、BUG-1157、#182
|
||||
- 修复版本:分支 codex/basis-candidate-status-20261109
|
||||
- 状态:fixed-pending-verify(分支 codex/purpose-gate-contract-20261109;**仅合同部分,未接入**)
|
||||
- 首次发现 / 最近更新:2026-11-09(占星师提出,产品负责人同意)/ 2026-11-09(合同与测试)
|
||||
- 影响面:新增 scripts/rectification_purpose_gate.py 与 tests/test_rectification_purpose_gate.py;本单不改任何现有文件的行为。
|
||||
- 现象:校时流程只认「唯一分钟」这一个终点,缺少「这个时间范围对当前要回答的问题是否够用」的判定,因此区间已经稳定也继续细分。
|
||||
- 触发条件:任何出生时间校时。
|
||||
- 根因:产品里没有用途决策合同:没有把「层覆盖、扫描覆盖、分支状态、领域是否完整、输出是否随代表时刻改变」这些事实综合成一个可复用的判定,也没有明确「通过也不代表生辰、也不许改出生记录」。
|
||||
- 修复(第一步,仅合同):移植占星师的参考决策器为 assess_use(assessment, expected_binding),逐行等价:七项绑定必须完整且一致(不一致即 stale)、必填层必须非空且覆盖三态合法、scan_state / branch_state 枚举合法、两个布尔必须真布尔、两个 claim 列表必须无重复且不能交叉;material_change 为真即 needs_refinement;层全 executed_complete 且领域完整、扫描 policy_covered、分支 resolved/bounded_equivalent 且只有稳定输出时 scope_ready;有稳定或条件输出时 conditional;否则 insufficient_evidence。刻意没有区间宽度与分数差门槛。can_use_interval 只在 scope_ready 为真,can_modify_birth_record 与 representative_is_birth_truth 恒为假。
|
||||
- 验证:tests/test_rectification_purpose_gate.py 15 条全过(13 条移植 + 2 条新增:许可永不改出生记录、用合成示例驱动 status);与参考模块对 12 个合成场景逐条比较返回值 0 处不一致;新模块没有被任何现有代码 import。
|
||||
- 防复发:上述测试;后续单把 purpose_assessment 接进校时结果对象时,必须保留「不许改出生记录」「代表时刻不是生辰」两条恒为假。
|
||||
- 关联记录:占星师 2026-11-09 用途决策合同、docs/tasks/assets/purpose-gate-20261109/(README / PLAN / reference_gate / examples)、#184(校时可比性)
|
||||
- 修复版本:分支 codex/purpose-gate-contract-20261109
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# PROGRESS · 「解读依据」候选格局不再写成「成立」(#181,BUG-1824,2026-11-09)
|
||||
|
||||
- 分支:`codex/basis-candidate-status-20261109`(基线 `afac4ea9`)
|
||||
- 任务书:`docs/tasks/TASK-basis-candidate-status-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1824
|
||||
|
||||
## 改了什么
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `frontend/src/lib/yoga-status-words.ts`(新) | `YOGA_STATUS_WORDS` 与 `yogaStatusWord(row)`:`formation.status = conditions_not_met` → 不成立;`status = candidate` → 候选;`status = computed` → 未确认;其他 → 未标状态。另导出 `isStructuralObservation(row)` |
|
||||
| `frontend/src/lib/consultation-technique-evidence.ts` | 只改 `yogaLine` 与一行 import:仍取领域段 `yogas.hits` 的前 3 个名字,逐个到 `card.base.natal.yogas_detail.rows` 按 `name` 找状态词;结构观察跳过;按状态词分组(组按首次出现排、组内保持原顺序);找不到行 → 未标状态;没有可列的 → 「本题没有检测到相关格局」 |
|
||||
| `frontend/src/lib/consult-answer-basis.ts` | 「格局」的兜底文案改为「盘上检测到的格局已列出各自状态」,改动处写三栏注释(原因:BUG-1824:候选不得显示为成立) |
|
||||
|
||||
没动:数据卡(`yogas.hits` / `yogas_detail` 事实记录一字未改)、回执 schema、`display_status` 取值、页面组件、答题提示词、`references/**`、`scripts/**`。
|
||||
|
||||
## 状态词表
|
||||
|
||||
| 卡上的值 | 显示 |
|
||||
| --- | --- |
|
||||
| `formation.status = "conditions_not_met"` | 不成立 |
|
||||
| `status = "candidate"` | 候选 |
|
||||
| `status = "computed"` | 未确认 |
|
||||
| 其他 / 缺失 | 未标状态 |
|
||||
|
||||
`formed_under_source` 不单独显示为「成立」——卡上规则是 `none is confirmed`,按 `status` 显示。
|
||||
词表里没有「成立」「已确认」这两个词(「不成立」是否定式,是任务书要求的)。
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `node ... --test tests/consult-basis-candidate-status-1824.test.ts` | 8 过 / 0 败 |
|
||||
| `tests/consult-technique-evidence-1793.test.ts` | 2 过 / 0 败 |
|
||||
| `tests/consult-answer-basis-receipt-20261108.test.tsx` | 4 过 / 0 败 |
|
||||
| `tests/consult-answer-mode-ui-20261108.test.tsx` | 14 过 / 0 败 |
|
||||
| `tsc --noEmit -p frontend` | 0 错 |
|
||||
| `npm run lint` | 0 error |
|
||||
|
||||
新测试覆盖任务书的 7 条:三种状态词各对各;`formed_under_source` 显示候选、不写成立;结构观察不出现;卡上没有对应行的名字 → 未标状态;灰字与兜底都不含「成立:」「已确认」;`consultAnswerBasisRows` 走一遍时「格局」行 `status === "done"` 且 `result` 是分组灰字(计算状态与解读状态分开);空 `hits` → 「本题没有检测到相关格局」。既有断言没有改动,所以没有三栏注释。
|
||||
|
||||
## 说明
|
||||
|
||||
- 「格局」行要显示成 `已算`,测试里的审计行用 `interpretation_status: "supporting"`(引擎把 yoga 行标为 observation/supporting 时 `display_status` 才是 firm)。这是既有判定,本单没有改。
|
||||
- 页面组件 `consult-answer-basis.tsx` 不用改:它只渲染 `result` 与状态标签。
|
||||
@@ -1,45 +0,0 @@
|
||||
# PROGRESS · 按需计算(二)一段时间内的行运变化(#174,BUG-1817,2026-11-09)
|
||||
|
||||
- 分支:`codex/ondemand-transit-window-20261109`(基线 `afac4ea9`)
|
||||
- 任务书:`docs/tasks/TASK-ondemand-transit-window-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 新增 BUG-1817
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `scripts/ondemand_calc/transit_window.py`(新) | `TOOL = 'transit_window'`,`run(body)`。四类事件:换座 `ingress`、停滞 `station`、逆行往返 `retrograde_loop`、到达目标经度 `target_longitude` |
|
||||
| `scripts/ondemand_calc/registry.py` | 加了 `from . import transit_window` 与 `TOOLS['transit_window']` 两行(其余未动) |
|
||||
| `tests/test_ondemand_transit_window_20261109.py`(新) | 17 条:本单专有 5 条 + 通用 12 条 |
|
||||
| `docs/BUG_HISTORY.md` | 末尾追加 BUG-1817 |
|
||||
|
||||
口径与数值都照任务书:黄经与日速度全部来自 `jyotish_engine._calc_sidereal_planets_for_jd`(调用前先 `_configure_ephemeris_path()`),岁差/交点/位置口径由调用方传;`position_mode=mean` 返回 `blocked` + `position_mode_unsupported`;缺参数返回 `blocked` 信封并点名缺项(HTTP 仍是 200,按合同)。没有改合同、没有改 API 服务器、没有建前端目录。
|
||||
|
||||
## 实测(验收第 1 条的对照)
|
||||
|
||||
2026 年水星逆行(区间 2026-01-01 至 2026-03-31,lahiri / mean):
|
||||
|
||||
| 项 | 本工具 | 直接用 SwissEph 速度二分 | 差 |
|
||||
| --- | --- | --- | ---: |
|
||||
| 逆行停滞 | 2026-02-26T06:49Z | 2026-02-26T06:49Z | **1.41 分钟** |
|
||||
| 顺行停滞 | 2026-03-20T19:32Z | 2026-03-20T19:32Z | **1.41 分钟** |
|
||||
|
||||
逆行段:`retrograde_span = [314.265716, 328.340901]`,影子期 `shadow_entry 2026-02-11T22:11Z` 早于逆行停滞、`shadow_exit 2026-04-09T11:44Z` 晚于顺行停滞,符合要求。
|
||||
目标经度 320°(区间放宽到 2026-04-30 才够三次):2026-02-15T16:04Z 直行、2026-03-10T02:26Z 逆行、2026-04-01T14:43Z 直行。
|
||||
|
||||
收敛:每个事件二分到 ≤ 1/1440 天(1 分钟);粗扫月亮 6 小时、其余 1 天;逆行往返每侧最多 400 天,写在 `raw.precision`。
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py` | **47 过 / 0 败**(本单 17 条) |
|
||||
|
||||
覆盖本单专有 5 条:停滞对照 ≤ 2 分钟;目标经度三次方向 direct/retrograde/direct;影子期先后;lahiri 与 raman 换座时刻不同且回显;罗睺 mean 与 true 换座时刻不同;伦敦跨夏令时那天的偏移为 0/1。通用 12 条:缺项点名(start_date / end_date / 地点 / 时区 / 岁差 / events)、超 366 天与倒序日期、未知事件与未知行星、`position_mode=mean` 阻塞、`profile_source` 标记、指纹稳定与敏感、引擎失败不给日期、节点不报停滞(`partial` + gap)、`natal_point` 缺失阻塞、注册表分派。
|
||||
|
||||
## 偏离与取舍
|
||||
|
||||
- **目标经度测试的区间**:任务书示例区间 1–3 月只够两次到齐(第二次逆行的第三次要等 4 月顺行恢复),所以那条测试把 `end_date` 放到 2026-04-30,并在测试里注明原因。没有改实现。
|
||||
- **换座判定的实现**:按「星座编号变化 + 二分找第一次变化」实现,顺行与逆行(退回原星座)都成立;跨过 30° 边界的就是换座,不需要另外判断方向。
|
||||
- **`ayanamsa_degrees_at_start`**:由引擎同一次调用返回的 ayanamsa 值,6 位小数。
|
||||
- 遇到 `events` 里同时要 `station` 和 `retrograde_loop` 的节点星:`gaps` 记两条 `node_station_not_supported:<星>`(一次请求一条),状态 `partial`——任务书要求「不计算并写 gap」,按星记足够定位。
|
||||
@@ -0,0 +1,43 @@
|
||||
# PROGRESS · 出生时间按用途够用——第一步:用途决策合同(#186,BUG-1833,2026-11-09)
|
||||
|
||||
- 分支:`codex/purpose-gate-contract-20261109`(基线 `afac4ea9`)
|
||||
- 任务书:`docs/tasks/TASK-purpose-gate-contract-20261109.md`
|
||||
- 参考代码与规范:`docs/tasks/assets/purpose-gate-20261109/`
|
||||
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1833
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `scripts/rectification_purpose_gate.py`(新) | 移植 `assess_use(assessment, expected_binding)` 与它需要的常量、枚举、校验与结果构造 |
|
||||
| `tests/test_rectification_purpose_gate.py`(新) | 13 条参考测试 + 2 条新增,共 15 条 |
|
||||
| `docs/BUG_HISTORY.md` | 末尾追加 BUG-1833(写明只是合同、未接入、`scope_ready` 没有生产入口) |
|
||||
|
||||
**没有任何现有代码 import 新模块**(`grep -r rectification_purpose_gate scripts/` 无命中),本单不接入校时流程,也不改任何现有行为。
|
||||
|
||||
## 与参考代码的等价性
|
||||
|
||||
- 常量:`BINDING_KEYS` 七项一致;层覆盖三态、`scan_state` 三态、`branch_state` 四态、五种 `status` 全部照抄。
|
||||
- 判定顺序:绑定完整 → 绑定一致(不一致即 `stale`)→ 合同校验 → `material_change` → `scope_ready` 四个条件 → `conditional` → `insufficient_evidence`,与参考代码逐行一致。
|
||||
- **没有**区间宽度门槛、**没有**分数差门槛(参考代码刻意不加;`legacy_scores = [8, 8]`、`historical_confidence = low` 仍可 `scope_ready`)。
|
||||
- `can_modify_birth_record` 与 `representative_is_birth_truth` 恒为 `False`,`can_use_interval` 只在 `scope_ready` 为真。
|
||||
- 唯一的形式差异:把校验与结果构造拆成私有函数(`_validate` / `_result`)、常量化字符串;返回值字段名、顺序、取值完全相同。
|
||||
|
||||
**实测对照**:把参考模块与产品模块同时加载,对 12 个合成场景(ready / 两种采样态 / 三种分支态 / material_change / domain_complete=false / 全部 partial / 有 conditional / 无 stable / stale)逐条比较返回值——**12 个场景 0 处不一致**。
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_rectification_purpose_gate.py -q` | **15 过 / 0 败**(另 23 subtests 通过) |
|
||||
|
||||
新增的两条:
|
||||
|
||||
1. `test_permissions_never_allow_editing_the_birth_record`:五种状态(含 `stale`)下 `can_modify_birth_record` 与 `representative_is_birth_truth` 都必须 `is False`;
|
||||
2. `test_every_synthetic_example_matches_its_expected_status`:读 `examples.json`,用 `ready_interval` 作底、叠上每个示例的改动,`status` 与标注一致(`scope_ready` / `needs_refinement`)。
|
||||
|
||||
## 偏离与说明
|
||||
|
||||
- 测试从 `unittest` 风格原样移植(pytest 直接能跑),没有改成 pytest 函数式;这样与参考测试可以逐条对照。
|
||||
- `examples.json` 与参考测试直接从 `docs/tasks/assets/purpose-gate-20261109/` 读,没有复制进 `tests/fixtures/`:任务书只允许新建那两个文件,复制会多出一份可能走样的副本。
|
||||
- 本单没有任何「需拍板」项:任务书列的三件事都能照做。
|
||||
@@ -1,106 +0,0 @@
|
||||
# TASK:「解读依据」把候选格局显示成“成立”(#181,BUG-1824,2026-11-09)
|
||||
|
||||
- Gitea:#181
|
||||
- 分支:`codex/basis-candidate-status-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
|
||||
- Bug 编号:BUG-1824(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
- 范围:只改「解读依据」里「格局」这一行的灰字和兜底文案。
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
## 现象(第七轮验收包,页面阶段)
|
||||
|
||||
简明解读下的「解读依据」里,「格局」一行显示「成立:Sunaphaa Yoga、Vosi Yoga」「成立:Dharidhra Yoga…」。数据卡上这些格局的状态都是 `candidate`(或 `computed`),卡上的规则写明 `none is confirmed`。列表把“计算已执行”说成了“解读成立”。
|
||||
|
||||
## 根因(afac4ea9 实测,行号以符号为准)
|
||||
|
||||
- `frontend/src/lib/consultation-technique-evidence.ts` 的 `yogaLine(card)` 只读领域段的 `yogas.hits`(名字列表),一律写成 `成立:<名字>`;没有名字时写 `本题没有成立的格局`。
|
||||
- `hits` 是“检测器在本盘匹配到的规则名”(`consultation-evidence-card.ts` 约 :2401,BUG-1157),不是“成立”。
|
||||
- 每个格局真正的状态在同一张卡的 `card.base.natal.yogas_detail.rows[]` 里(`consultation-evidence-card.ts` 约 :2007–2028 从引擎层 `yoga_conditions` 原样拷贝):
|
||||
- `status`:`candidate` | `computed` | `structural_observation`(引擎 `scripts/consultation_native_layers.py` 的 `status_rule`);
|
||||
- `formation.status`:`formed_under_source` | `candidate` | `conditions_not_met`(`formation_rule`);
|
||||
- `claim_kind`:`structural_observation` 时不是命名格局。
|
||||
- `frontend/src/lib/consult-answer-basis.ts` 的 `BASIS_ENTRIES` 里「格局」的兜底文案是 `盘上成立和落空的格局已核对`,同样说过头。
|
||||
- 行状态(`已算` / `有条件`)来自 `display_status` / 两轴状态,是**计算**状态;这部分是对的,不改。
|
||||
|
||||
## 要做的
|
||||
|
||||
### 1. 新建状态词表 `frontend/src/lib/yoga-status-words.ts`
|
||||
|
||||
一个导出的常量和一个函数,供本单使用(#182 以后也会复用,所以单独成文件):
|
||||
|
||||
| 卡上的值 | 列表显示 |
|
||||
| --- | --- |
|
||||
| `formation.status = "conditions_not_met"` | 不成立 |
|
||||
| `status = "candidate"` | 候选 |
|
||||
| `status = "computed"` | 未确认 |
|
||||
| 其他 / 缺失 | 未标状态 |
|
||||
|
||||
- 判定顺序:先看 `formation.status === "conditions_not_met"`,否则看 `status`。
|
||||
- `formed_under_source` **不**单独显示为“成立”:卡上规则是 `none is confirmed`,照 `status` 显示(候选 / 未确认)。
|
||||
- 函数签名建议:`yogaStatusWord(row: Record<string, unknown>): "不成立" | "候选" | "未确认" | "未标状态"`。
|
||||
- 词表里**不得**出现“成立”“已确认”这类词。
|
||||
|
||||
### 2. 改 `yogaLine(card)`(`consultation-technique-evidence.ts`)
|
||||
|
||||
- 仍取领域段 `yogas.hits` 的名字(顺序不变,最多 3 个)。
|
||||
- 每个名字到 `card.base.natal.yogas_detail.rows` 里按 `name` 找对应行,用 `yogaStatusWord` 得到状态词。
|
||||
- `claim_kind === "structural_observation"` 或 `status === "structural_observation"` 的名字不进这一行(与 `annual-report-sections.ts` 把结构观察单列、不进「格局」的做法一致)。
|
||||
- 找不到对应行的名字显示“未标状态”。
|
||||
- 按状态词分组,组内保持原顺序,组按第一次出现的顺序排:`候选:A、B;未确认:C`。
|
||||
- 没有可列的名字时写 `本题没有检测到相关格局`。
|
||||
- `LINES` 表、其他技法的灰字、`techniqueDisplayStatus`、`withTechniqueEvidence` 的结构都不改。
|
||||
|
||||
### 3. 改兜底文案(`consult-answer-basis.ts`)
|
||||
|
||||
`BASIS_ENTRIES` 里「格局」的 `result` 由 `盘上成立和落空的格局已核对` 改为 `盘上检测到的格局已列出各自状态`。改动处写“原值 / 新值 / 原因”三栏注释,原因写 `BUG-1824:候选不得显示为成立`。文件头注释里对应描述同步改一句。其余条目不改。
|
||||
|
||||
### 4. 测试 `frontend/tests/consult-basis-candidate-status-1824.test.ts`
|
||||
|
||||
用合成卡(不写真人资料),至少覆盖:
|
||||
|
||||
1. 卡上状态分别为 `candidate`、`computed`、`formation.status = conditions_not_met` 的三个格局,`yogaLine` 产出 `候选:…;未确认:…;不成立:…`,三个词各对各;
|
||||
2. `formed_under_source` + `status = candidate` 显示“候选”;
|
||||
3. 结构观察不出现在这一行;
|
||||
4. 卡上没有对应行的名字显示“未标状态”;
|
||||
5. 生成的灰字和兜底文案里都不含“成立:”这个子串,也不含“已确认”;
|
||||
6. 经 `consultAnswerBasisRows` 走一遍:「格局」行的 `status` 仍是 `done`(已算),`result` 是第 1 条的灰字——计算状态和解读状态分开;
|
||||
7. 空 `hits` 时灰字为 `本题没有检测到相关格局`。
|
||||
|
||||
既有测试 `frontend/tests/consult-technique-evidence-1793.test.ts`、`consult-answer-basis-receipt-20261108.test.tsx`、`consult-answer-mode-ui-20261108.test.tsx` 必须照样通过;若其中有断言写了旧文案,改时写三栏注释,不得删断言。
|
||||
|
||||
### 5. 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md` 末尾追加 BUG-1824(状态、现象、触发条件、根因、修复、验证、防复发、关联 BUG-1793 / BUG-1157 / #182)。只在末尾追加,不用 sed 改整个文件。
|
||||
- `docs/tasks/PROGRESS-basis-candidate-status-20261109.md`。
|
||||
|
||||
## 允许改的文件(只有这些)
|
||||
|
||||
- 新建:`frontend/src/lib/yoga-status-words.ts`、`frontend/tests/consult-basis-candidate-status-1824.test.ts`、`docs/tasks/PROGRESS-basis-candidate-status-20261109.md`
|
||||
- 修改:`frontend/src/lib/consultation-technique-evidence.ts`(只改 `yogaLine` 和必要的 import)、`frontend/src/lib/consult-answer-basis.ts`(只改「格局」兜底文案与头注释一句)、`docs/BUG_HISTORY.md`(末尾追加)
|
||||
- 页面组件 `frontend/src/components/consult-answer-basis.tsx` 预计不用改;若确实要改,停下选“需拍板”。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
|
||||
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`、`frontend/src/mastra/product-voice.ts`:不改答题规则。
|
||||
- `frontend/src/lib/consultation-evidence-card.ts`:数据卡的事实记录一字不改(`yogas.hits`、`yogas_detail` 的字段和内容都不动;“把状态词写进卡和产品规则”归 #182)。该文件也是 #170 / #172 的范围。
|
||||
- `frontend/src/lib/consultation-answer-fact-guard.ts`:拦截检查归 #182 / #183。
|
||||
- `frontend/src/lib/consultation-agent-events.ts`(回执 schema,`display_status` 的取值不改)、`frontend/src/app/api/consult/route.ts`。
|
||||
- `frontend/src/mastra/consultation-tools.ts`(#171)、`frontend/src/mastra/consultation-workflow.ts` 与 `frontend/src/lib/consultation-route-service.ts`(#185)。
|
||||
- `scripts/**` 全部,特别是冻结文件:`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/consultation_native_layers.py`(引擎的状态值不改)。
|
||||
- 既有断言不得弱化。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
|
||||
- 在 `frontend` 下运行 `npm run lint`
|
||||
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs --test tests/consult-basis-candidate-status-1824.test.ts tests/consult-technique-evidence-1793.test.ts tests/consult-answer-basis-receipt-20261108.test.tsx tests/consult-answer-mode-ui-20261108.test.tsx`
|
||||
|
||||
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时跑。
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- 卡上出现上表以外的状态值,且不能归入“未标状态”时。
|
||||
- 要改数据卡、回执 schema、答题规则或页面组件才能做到时。
|
||||
- 任务书没写到的任何文案或取舍。
|
||||
@@ -1,157 +0,0 @@
|
||||
# TASK:按需计算——一段时间内的行运变化:换座、停滞、逆行往返、到达目标经度(#174,BUG-1817,2026-11-09)
|
||||
|
||||
- Gitea:#174
|
||||
- 分支:`codex/ondemand-transit-window-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
|
||||
- Bug 编号:BUG-1817(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
- 范围:**只做后端**。新增一个工具文件,在注册表里加一行。不做前端接入,不建 `frontend/src/mastra/ondemand-tools/`。
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
## 背景
|
||||
|
||||
占星师 2026-11-07 Q9:“一段时间内换位、逆行、精确经过:`transit --months`、`--retrograde-calendar`、`--find-conjunction`、`--target-longitude`。”
|
||||
2026-11-08 第五轮第 9 条:“行运窗口:支持换座、停滞、逆行往返和目标经度。”
|
||||
|
||||
#173 已在 staging 建好共用模块 `scripts/ondemand_calc/`。**开工前先读完**:
|
||||
|
||||
- `scripts/ondemand_calc/contract.py` 顶部 docstring(信封字段、错误规则、加工具的三步);
|
||||
- `scripts/ondemand_calc/registry.py`;
|
||||
- `scripts/ondemand_calc/transit_date.py`(第一个工具,本单照它的写法);
|
||||
- `docs/tasks/PROGRESS-ondemand-transit-date-20261109.md`「给 #174–#178 的注册步骤」;
|
||||
- `tests/test_ondemand_transit_date_20261109.py`(测试写法、星历路径配置)。
|
||||
|
||||
## 现状(afac4ea9 实测,行号以符号为准)
|
||||
|
||||
- 调用入口已经存在:`scripts/jyotish_api_server.py` 的 `/api/ondemand_calc` 薄路由 → `ondemand_calc.registry.ondemand_calc_api(body)` → `TOOLS[body["tool"]](body)`。**本单不需要、也不许改 API 服务器。**
|
||||
- 缺参数时合同的做法是**返回 `calculation_status = "blocked"` 的信封**(`contract.blocked_envelope`),HTTP 仍是 200,`gaps` 点名缺项。原任务书里“缺一项就 400”作废,以合同为准。
|
||||
- `contract.validate_common(body)` 只认单个 `date` 字段;本工具是区间,处理方法见下文“输入”。
|
||||
- `contract.resolve_profile`:岁差必填;`node_mode` / `position_mode` 缺省时用产品默认值 `mean` / `apparent`,并写 `profile_source = "product_default_explicit"`(显式传了写 `caller_supplied`)。**本单照用,不改。** 节点 / 位置口径何时从本命请求显式带过来,归 #185。
|
||||
- `position_mode = "mean"` 必须返回 `blocked`,`gaps` 写 `{"field": "position_mode", "reason": "position_mode_unsupported"}`,照 `transit_date.run` 的写法。
|
||||
- 计算用引擎函数 `jyotish_engine._calc_sidereal_planets_for_jd(jd, node_mode=..., include_ketu=True, ayanamsa_name=...)`,调用前先 `engine._configure_ephemeris_path()`,和 `transit_date.py` 完全一样。这样数值与指定日期行运一致,岁差和交点口径都真正生效。
|
||||
- **不要用**以下现成函数做最终时刻(可以在测试里当对照):
|
||||
- `scripts/ephemeris_events.py` 的 `build_ephemeris_events`:按 UT 正午逐日采样,`node_mode` 只接收不使用,没有罗睺 / 计都;
|
||||
- `scripts/transit_trigger.py` 的 `find_exact_transit_date`:只返回第一次到达,默认岁差是 lahiri;
|
||||
- `scripts/jyotish_engine.py` 的 `cmd_transit`、`--retrograde-calendar`、`--find-conjunction`:依赖的 `scripts/transit_search.py` 不存在。
|
||||
|
||||
## 要做的
|
||||
|
||||
### 文件
|
||||
|
||||
1. 新建 `scripts/ondemand_calc/transit_window.py`,`TOOL = 'transit_window'`,实现 `run(body: dict) -> dict`。
|
||||
2. 在 `scripts/ondemand_calc/registry.py`:
|
||||
- 顶部 `from . import transit_date` 旁边加一行 `from . import transit_window`;
|
||||
- `TOOLS` 里加一行 `'transit_window': transit_window.run,`,上方写一行注释列出必填参数(照 `transit_date` 那行注释的格式)。
|
||||
- registry.py 其他内容不改。
|
||||
3. 新测试 `tests/test_ondemand_transit_window_20261109.py`。
|
||||
4. `docs/tasks/PROGRESS-ondemand-transit-window-20261109.md`。
|
||||
5. `docs/BUG_HISTORY.md` 末尾追加 BUG-1817(只在文件末尾追加,不改其他条目)。
|
||||
|
||||
除上面 5 个文件外不改任何文件。
|
||||
|
||||
### 输入(`body`)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `tool` | 是 | `"transit_window"` |
|
||||
| `start_date`、`end_date` | 是 | `YYYY-MM-DD`,当地日期;`end_date >= start_date`;跨度 ≤ 366 天(本文件内常量 `MAX_WINDOW_DAYS = 366`,**不改** `contract.MAX_RANGE_DAYS`) |
|
||||
| `lat`、`lon`、`tz` 或 `timezone_id` | 是 | 与 `transit_date` 相同(可放在 `place` 里) |
|
||||
| `ayanamsa` | 是 | 与 `transit_date` 相同 |
|
||||
| `node_mode`、`position_mode` | 否 | 交给 `resolve_profile`,缺省用产品默认值并如实标记 |
|
||||
| `planets` | 否 | 九曜子集;不给时为九曜全集 |
|
||||
| `events` | 是 | `ingress` / `station` / `retrograde_loop` / `target_longitude` 的非空子集 |
|
||||
| `target` | `events` 含 `target_longitude` 时必填 | `{"planet": "...", "longitude": 0–360}` 或 `{"planet": "...", "natal_point": "<星名或 Ascendant>"}` |
|
||||
| `natal` | 用 `natal_point` 时必填 | `{"points": {"Moon": 123.456, ...}}`,恒星黄经,由调用方给出;本工具**不自己排本命盘** |
|
||||
|
||||
校验顺序与写法:
|
||||
|
||||
- 先自己校验 `start_date` / `end_date`(缺失、格式、先后、跨度),出错抛 `OnDemandInputError(..., field='start_date' 或 'end_date')`;
|
||||
- 再用 `contract.validate_common({**body, 'date': body['start_date']})` 校验地点和时区,用 `contract.resolve_profile` 取口径(`profile.tz` 因此是起始日的偏移);
|
||||
- 所有 `OnDemandInputError` 都转成 `contract.blocked_envelope(tool=TOOL, body=body, error=error)`;
|
||||
- `requested` 必须是调用方原样的 `body`(不得把上面临时加的 `date` 写进 `requested` 或指纹)。
|
||||
|
||||
### 计算
|
||||
|
||||
- 每个事件的时刻用二分法精确到 **≤ 1 分钟**(区间宽度 ≤ 1/1440 天)。
|
||||
- 粗扫步长:月亮 ≤ 6 小时,其余 ≤ 1 天;在 `raw.precision` 里写明步长和收敛阈值。
|
||||
- 当地时间:给了 `timezone_id` 时,**按事件当天**用 `contract.offset_hours(day, None, timezone_id)` 换算(夏令时生效);只给 `tz` 时用固定偏移。
|
||||
- **换座(ingress)**:恒星黄经跨过 30° 整数倍的时刻;逆行导致的退回原星座也是一次换座,标 `direction: "retrograde"`。罗睺 / 计都按 `profile.node_mode`(mean 或 true)计算,不得忽略。
|
||||
- **停滞(station)**:日速度(引擎返回的 `speed`)变号的时刻,标 `station_type: "retrograde"`(顺转逆)或 `"direct"`(逆转顺)。只对 Mercury、Venus、Mars、Jupiter、Saturn 计算;太阳、月亮没有停滞。罗睺 / 计都请求 `station` 或 `retrograde_loop` 时,不计算,在 `gaps` 写 `{"field": "planets", "reason": "node_station_not_supported:<星名>"}`,状态 `partial`(其他请求正常时)。
|
||||
- **逆行往返(retrograde_loop)**:每一段逆行给出:
|
||||
- `station_retrograde`、`station_direct` 两个停滞时刻及当时黄经;
|
||||
- `retrograde_span`:`[station_direct 黄经, station_retrograde 黄经]`;
|
||||
- `shadow_entry`:逆行前首次到达 `station_direct` 黄经的时刻;`shadow_exit`:顺行恢复后最后一次离开 `station_retrograde` 黄经的时刻。
|
||||
- 只要两个停滞中有一个落在请求区间内就列出这一段;为找齐停滞和影子期,可以向区间外搜索,但每侧最多 400 天;落在请求区间外的时刻标 `outside_requested_window: true`。400 天内找不到的那一项为 `null`,并在 `gaps` 写 `loop_incomplete:<星名>:<字段>`。
|
||||
- **到达目标经度(target_longitude)**:列出区间内**每一次**精确到达(逆行可能造成三次),每次标 `direction: "direct"` 或 `"retrograde"`。`natal_point` 在 `natal.points` 里找不到时 `blocked`,`gaps` 写 `{"field": "target.natal_point", "reason": "natal_point_missing:<名>"}`,不得用行运位置或别的点顶替。
|
||||
|
||||
### 输出
|
||||
|
||||
用 `contract.build_envelope`,信封字段与合同一致,不加顶层字段,`profile` 不加字段。`raw` 形状:
|
||||
|
||||
```
|
||||
{
|
||||
"start_date": "...", "end_date": "...",
|
||||
"ayanamsa_degrees_at_start": <float>,
|
||||
"precision": {"coarse_step_hours": {...}, "convergence_minutes": 1, "search_margin_days": 400},
|
||||
"events": [
|
||||
{"type": "ingress" | "station" | "target_longitude", "planet": "...",
|
||||
"utc": "YYYY-MM-DDTHH:MMZ", "local": "YYYY-MM-DDTHH:MM", "utc_offset_hours": <float>,
|
||||
"jd_ut": <float>, "longitude": <float, 6 位>, "sign": "...", "degree_in_sign": <float>,
|
||||
...该类型自己的字段(from_sign / to_sign / direction / station_type / target_longitude)}
|
||||
],
|
||||
"retrograde_loops": [ {...见上...} ]
|
||||
}
|
||||
```
|
||||
|
||||
- `events` 按 `jd_ut` 升序。
|
||||
- 计算失败(引擎抛错、星历不可用)时:`blocked`,`raw == {}`,`gaps` 写 `engine_failed:<异常类名>` 或 `ephemeris_unavailable`;信封中除 `requested` 外不得出现任何日期或时刻字符串(`computed_at_utc` 除外,与 `transit_date` 一致)。
|
||||
- 部分行星失败、其余成功:`partial`,成功的照列,失败的在 `gaps` 点名。
|
||||
|
||||
## 本单专有验收(写进新测试)
|
||||
|
||||
1. 2026 年水星的一次逆行(公开星历):
|
||||
- 两个停滞时刻与直接用 SwissEph 求速度过零的时刻相差 ≤ 2 分钟(测试里自己用 `swe.calc_ut` + `FLG_SIDEREAL | FLG_SPEED` 二分作对照);
|
||||
- 以逆行段中间的某个黄经为目标,三次到达都列出,方向依次为 direct / retrograde / direct;
|
||||
- `shadow_entry` 早于 `station_retrograde`,`shadow_exit` 晚于 `station_direct`。
|
||||
2. lahiri 与 raman 下,同一次换座的时刻不同,且各自 `profile.ayanamsa` 如实回显。
|
||||
3. 罗睺 / 计都在 `node_mode = mean` 与 `true` 下,同一次换座的时刻不同。
|
||||
4. 用 `timezone_id = "Europe/London"` 跨夏令时切换日(2026-03-29)的区间:切换前后事件的 `utc_offset_hours` 分别为 0 和 1。
|
||||
5. 与 `transit_date` 的一致性:在某个换座时刻,用 `transit_date` 算同一时刻(同口径),该行星黄经与 30° 边界相差 ≤ 0.01°。
|
||||
|
||||
## 通用验收(写进新测试)
|
||||
|
||||
1. 缺 `start_date` / `end_date` / 地点 / 时区 / 岁差 / `events` 中任一项,返回 `blocked` 且 `gaps[0].field` 点名缺项;跨度 > 366 天、`end_date < start_date` 同样 `blocked`。
|
||||
2. `position_mode = "mean"` 返回 `blocked` + `position_mode_unsupported`。
|
||||
3. 省略 `node_mode` / `position_mode` 时 `profile.profile_source == "product_default_explicit"`;都显式传时为 `caller_supplied`。
|
||||
4. 同一输入的 `input_fingerprint` 稳定;任一参数(日期、地点、岁差、交点、planets、events、target)改变后 fingerprint 改变。
|
||||
5. 引擎抛错(monkeypatch `_calc_sidereal_planets_for_jd`)时返回 `blocked`、`raw == {}`,除 `requested` 与 `computed_at_utc` 外信封里没有日期字符串。
|
||||
6. `ondemand_calc_api({"tool": "transit_window", ...})` 能分派到本工具;`transit_date` 原有测试不受影响。
|
||||
7. 测试只用公开日期和合成坐标(不写任何真人出生资料)。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
|
||||
- `frontend/**` 全部,特别是 `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`;本单不建 `frontend/src/mastra/ondemand-tools/`。
|
||||
- `scripts/ondemand_calc/contract.py`、`scripts/ondemand_calc/transit_date.py`、`scripts/ondemand_calc/muhurta_search.py`、`scripts/ondemand_calc/__init__.py`;`registry.py` 只许加上文那两行。合同不够用就停下选“需拍板”,不得改合同。
|
||||
- 冻结文件只许 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/ephemeris_events.py`、`scripts/transit_trigger.py`:不改现有行为;不补 `scripts/transit_search.py`。
|
||||
- 其他未完成单子占用的文件:`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)。
|
||||
- 其他按需工具(#175–#178)的文件名:`solar_return.py`、`varga.py`(在 ondemand_calc 内)、`dasha_date.py`、`prashna.py`,不要预建。
|
||||
- 既有测试的断言不得弱化;若必须改,写“原值 / 新值 / 原因”三栏注释,原因写 `占星师 Q9 / 第五轮 9:按需计算显式传口径`。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `.venv\Scripts\python -m pytest tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py -q`
|
||||
|
||||
Python 快速门、前端全量测试和 eslint 由 Claude 在验收时跑。
|
||||
|
||||
## 进度记录
|
||||
|
||||
`docs/tasks/PROGRESS-ondemand-transit-window-20261109.md`:做了什么、跑了哪些命令、通过/失败数、偏离与原因;附上验收第 1 条的实测时刻差(分钟)。
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- `contract.py` 的信封或校验表达不了本工具的输入输出,必须改合同时。
|
||||
- 要做到显式口径,就必须改冻结文件时。
|
||||
- 某颗星的停滞在 2026 年的测试区间内二分不能收敛到 ≤ 2 分钟(速度长期接近 0),需要决定精度口径时。
|
||||
- 任务书没写到的任何取舍。
|
||||
@@ -0,0 +1,49 @@
|
||||
# TASK:出生时间“按用途够用”——第一步:用途决策合同和测试(2026-11-09)
|
||||
|
||||
- 分支:`codex/purpose-gate-contract-20261109`(基于 staging `afac4ea9`)
|
||||
- Bug 编号:BUG-1833
|
||||
- 负责验收:Claude(jyotisha-7e)
|
||||
|
||||
## 背景
|
||||
|
||||
占星师在 2026-11-09 提出,产品负责人已同意:出生时间不一定要精确到唯一的分钟。如果时间范围对当前要回答的问题足够稳定,就停止校时,保留这个区间继续解盘;只有当某个敏感界限会改变当前答案时,才继续细分。
|
||||
|
||||
完整规范、实施计划和参考代码都放在本分支的 `docs/tasks/assets/purpose-gate-20261109/` 下:
|
||||
|
||||
- `README.md`:规范
|
||||
- `PLAN.md`:实施计划
|
||||
- `reference_gate.py`:参考决策器
|
||||
- `test_reference_gate.py`:13 个合同测试
|
||||
- `examples.json`:合成接口示例
|
||||
|
||||
本单只做计划中的 **Task 1 前半部分**:把决策合同和它的测试移植进产品。不接入现有校时流程,不改任何现有文件的行为。
|
||||
|
||||
## 要做的
|
||||
|
||||
1. 新建 `scripts/rectification_purpose_gate.py`,移植 `reference_gate.py` 中的 `assess_use`,以及它所需的常量和枚举。
|
||||
- 逻辑与参考代码一致,不加入区间宽度或分数差之类的门槛。参考代码故意没有这些门槛。
|
||||
- 函数签名为 `assess_use(assessment: dict, expected_binding: dict) -> dict`。
|
||||
2. 新建 `tests/test_rectification_purpose_gate.py`,移植全部 13 个测试,把 import 改到产品模块。另外新增两条测试:
|
||||
- 无论输入是什么,返回值的 `can_modify_birth_record` 都必须是 `False`,`representative_is_birth_truth` 也必须是 `False`;
|
||||
- 用 `examples.json` 里的每个合成示例调用 `assess_use`,返回的 `status` 与示例标注一致。
|
||||
3. 在 `docs/BUG_HISTORY.md` 末尾追加 BUG-1833。写明这只是合同部分,尚未接入,`scope_ready` 没有任何生产入口。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `scripts/active_rectification_events.py`:#184 正在改。把 `purpose_assessment` 接进结果对象的工作由后续单完成。
|
||||
- `scripts/chart_choice/**`、`scripts/active_rectification_event_engine.py`、`scripts/narayana_rath.py`:不在本单范围,另有分支在改。
|
||||
- 不改旧的 `can_apply`,不改出生记录,不加 API 或前端。
|
||||
- 不改 `references/**`、`frontend/src/lib/consult-preset-prompts.ts`、冻结文件。
|
||||
- 不得自行写“产品决定”或“裁定”;任务书没写到的取舍,一律选“需拍板”。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 15 个测试全部通过(13 个移植加 2 个新增)。
|
||||
2. `assess_use` 与参考代码逐行对照,逻辑等价。如有偏离,在 PROGRESS 里逐条写明原因。
|
||||
3. 新模块不被任何现有代码 import(本单不接入)。
|
||||
4. 写好 PROGRESS:`docs/tasks/PROGRESS-purpose-gate-contract-20261109.md`。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `.venv\Scripts\python -m pytest tests/test_rectification_purpose_gate.py -q`
|
||||
- 本单不改前端,无需跑 tsc 和 lint。
|
||||
@@ -0,0 +1,153 @@
|
||||
# 按用途接受出生时间区间 Implementation Plan
|
||||
|
||||
> For agentic workers: use `superpowers:executing-plans` to implement task by task in the WEBSITE repository. This delivery does not require creating subagents. Each task has its own test and review boundary.
|
||||
|
||||
**Goal:** 用户时间范围对当前解盘/推运用途已足够稳定时停止校时,并保留区间;只针对会改变当前答案的敏感界限继续核实。
|
||||
|
||||
**Architecture:** 复用现有候选扫描、原生引擎、共同覆盖及事实证据链;新增用途评估,不重写评分或大运。时间证据置信度与用途稳定性并列;区间使用与出生记录修改分离。
|
||||
|
||||
**Tech Stack:** 网站现有Python计算/API与TypeScript咨询/前端;参考决策器仅Python标准库,测试使用unittest。部署继续网站已有staging流程。
|
||||
|
||||
**Spec:** [完整接入规范](../../../handoff/2026-11-09/interval_rectification/README.md)。执行前读该规范与本计划;状态合同见同目录reference_gate.py、examples.json。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 网站基线 `c032823f9e256625a1b346a70172b8d304854311`;资料基线 `bef2161f945e49632f33992bccaafefa1e3fc7c5`。先查看网站当前状态,保留已有修改。
|
||||
- 不改变旧can_apply语义、原始出生记录或资料仓个人报告冻结链。
|
||||
- 不把分钟宽度、分数并列、缺失相同当用途通过/失败依据;不增加校时概率。
|
||||
- 所有事实来自已执行的原生计算;参考决策器不生产这些事实。
|
||||
- Dasha调用既有引擎,不重构顺序/日期;R2未决与相关完整调用要求保持。
|
||||
- 私人输入只在受控测试环境,公开回传用匿名合成或获准公开盘。
|
||||
- 下列新增模块路径是网站侧建议,若已有同职责模块则扩展该模块并在回执写真实映射。不要复制资料仓UI覆盖网站前端。
|
||||
|
||||
## Task 1:新增用途决策合同,保持旧门控
|
||||
|
||||
**Files:**
|
||||
- Create website `scripts/rectification_purpose_gate.py`:使用交接reference_gate.py中的`assess_use`合同。
|
||||
- Test website `tests/test_rectification_purpose_gate.py`:移植交接13个合同测试,改import到网站模块。
|
||||
- Modify website `scripts/active_rectification_events.py`:结果增加可空purpose_assessment;旧字段不重命名。
|
||||
|
||||
**Interfaces:** `assess_use(assessment: dict, expected_binding: dict) -> dict`,参数和返回枚举完整定义在参考代码。ValueError映射为网站既有请求/内部验证错误,权限保持false,不向模型返回可用状态。
|
||||
|
||||
- [ ] 先移入测试与合成fixtures,运行以下命令确认新模块未实现时失败。
|
||||
|
||||
```bash
|
||||
python3 -m pytest -q -o addopts='' tests/test_rectification_purpose_gate.py
|
||||
```
|
||||
|
||||
- [ ] 移植参考函数,执行同命令,预期13个测试通过。额外参数legacy_scores不参与用途门控。
|
||||
- [ ] 为旧接口加回归:无purpose输入时旧结果原样;有purpose时仍不改旧can_apply。
|
||||
|
||||
```python
|
||||
legacy = adjudicate_candidate_rows(rows, **existing_arguments)
|
||||
assert legacy['can_apply'] == legacy_baseline['can_apply']
|
||||
assert purpose_result['can_modify_birth_record'] is False
|
||||
```
|
||||
|
||||
这里`rows/existing_arguments/legacy_baseline`使用现有`test_rectification_comparability_bug1801.py`的同一fixture,不新造计分口径。
|
||||
- [ ] 单独提交合同及测试,暂不开scope_ready生产入口。
|
||||
|
||||
## Task 2:绑定用途、候选集合和真实计算结果
|
||||
|
||||
**Files:**
|
||||
- Create/extend website `scripts/rectification_purpose_service.py`。
|
||||
- Modify website `scripts/chart_choice/astrologer.py`、`scripts/active_rectification_event_engine.py`:提供原生结果引用及依赖,避免重复计算。
|
||||
- Test website `tests/test_rectification_purpose_binding.py`。
|
||||
|
||||
**Interfaces:** 建议新适配入口:
|
||||
|
||||
```python
|
||||
def build_scope_request(question: dict, original_input: dict, remaining_ranges: list,
|
||||
profiles: dict, evaluated_at: str) -> dict: ...
|
||||
def collect_scope_outputs(scope_request: dict, native_results: list) -> dict: ...
|
||||
```
|
||||
|
||||
这两个签名是待实施接口,不是交接包中已运行的函数。返回schema按规范第三、六节。build_scope_request负责规范化用途与hash;collect_scope_outputs只消费已有原生结果,不计算任何新Dasha。
|
||||
|
||||
- [ ] 测试固定同输入hash一致;换题、预测窗、输入修订、profile、engine、precision policy、候选集合任一项hash变化。
|
||||
- [ ] 对每次native调用记录candidate_id、branch_id、profile、method_id、status、原始结果ref和依赖。缺失字段保留blocked,不补0、不把not_applicable塞入required层。
|
||||
- [ ] 合并范围时仅合并真正相邻且边界归属一致的区间;时间点与分钟槽分开。没有历史排除依据的候选继续保留。
|
||||
- [ ] 测试用户报告0–1200秒但只提供0–60秒扫描时domain_complete=false;不能只评最高分范围通过全域。
|
||||
- [ ] 测试无事件但用户提供范围时可评估用途稳定性;历史证据仍not_evaluated,不要求先凑够事件。
|
||||
- [ ] 单独提交并回传绑定样例;对外只用合成ID和范围。
|
||||
|
||||
## Task 3:按用途扫描内部敏感界限和应期包络
|
||||
|
||||
**Files:**
|
||||
- Extend website `scripts/rectification_purpose_service.py`。
|
||||
- 复用网站已有候选差异模块;若没有适配入口,Create website `scripts/rectification_purpose_scan.py`。
|
||||
- Test website `tests/test_rectification_purpose_scan.py`。
|
||||
- 资料参照:`scripts/active_rectification_candidates.py`已有groups/discriminators,分钟采样不能冒充连续边界。
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
```python
|
||||
def scan_scope(scope_request: dict, compute_at, boundary_providers: dict) -> dict: ...
|
||||
```
|
||||
|
||||
`compute_at(candidate_at, branch_id)`必须回调网站原生链,返回Task2的单候选结果;boundary_providers按相关层返回已界定边界/误差带或“无法提供”,不能返回猜测的精确秒。scan_scope输出scan_state、per_layer_scan_receipts、output_envelopes、material_differences、stable/conditional事实ID。每个事实保留范围和依赖覆盖。
|
||||
|
||||
- [ ] 先用确定性假函数测试内部A→B→A;用它检验扫描器,不把它当占星oracle。
|
||||
|
||||
```python
|
||||
def synthetic_feature(offset_seconds):
|
||||
return 'B' if 25 <= offset_seconds < 35 else 'A'
|
||||
assert synthetic_feature(0) == synthetic_feature(60)
|
||||
assert synthetic_feature(30) != synthetic_feature(0)
|
||||
```
|
||||
|
||||
- [ ] 没有边界提供器且只有首尾样本时预期sampled_only;提供[25,35]界限后必须得到三段,不能合并首尾A穿过B。
|
||||
- [ ] 对实际所需层粗扫→按可用边界/局部误差加密→两侧原生重算→记录最大未查间隔。没有覆盖依据不返回policy_covered;计算预算耗尽返回incomplete。
|
||||
- [ ] 计算时间窗口端点min/max、并集、交集及每分支原值。用户问题日期边界、窗口存在性、支持方向、事实身份按冻结policy比较;禁止仅比较字符串摘要或主星名。
|
||||
- [ ] 测试同名AD但端点跨目标日期应material_change=true;相同blocked不生成stable事实;不相关D60改变仅记录不影响当前用途。
|
||||
- [ ] 分别用真实引擎验证至少一个稳定宽区间、一个已知分盘边界、一个应期日期敏感例。人工检查原始结果,不依赖模型解释评判是否稳定。
|
||||
- [ ] 通过后单独提交。开放policy_covered必须附实际层级扫描证据,不以参考合同测试通过代替。
|
||||
|
||||
## Task 4:接入API、模型和页面,新增“使用区间”动作
|
||||
|
||||
**Files:**
|
||||
- 网站现有校时API处理器(用`rg`定位真实路径并写入提交说明)。
|
||||
- `frontend/src/mastra/consultation-tools.ts`
|
||||
- `frontend/src/lib/consultation-evidence-card.ts`
|
||||
- `frontend/src/lib/consultation-technique-evidence.ts`
|
||||
- `frontend/src/lib/consult-answer-basis.ts`
|
||||
- Test `frontend/tests/rectification-purpose-interval.test.ts`、网站API对应测试文件。
|
||||
|
||||
**Interfaces:** API返回规范第六节对象;前端只消费服务器决策,不从宽度、confidence或can_apply推导新区间权限。示例UI判断:
|
||||
|
||||
```typescript
|
||||
const canUseInterval = result.purpose_assessment?.decision?.can_use_interval === true;
|
||||
const canShowLimited = result.purpose_assessment?.decision?.can_continue_analysis === true;
|
||||
// 新动作提交range_set_id与purpose binding;不提交代表分钟作为出生记录。
|
||||
```
|
||||
|
||||
服务器收到新动作后重新比对binding;客户端上传can_use_interval不授予权限。请求不匹配返回stale及重新评估指令。
|
||||
|
||||
- [ ] 写反例:旧can_apply=false而scope_ready=true时可继续区间分析;仍不能修改出生档案。
|
||||
- [ ] 写反例:旧confidence=high但needs_refinement时不能显示“已满足本次精度”。
|
||||
- [ ] 模型工具返回加入稳定/条件事实和分支,不只传代表盘;使用规范第六节可粘贴提示合同。
|
||||
- [ ] UI测试显示范围、用途、限制、稳定部分;不强制用户追求唯一分钟。出现实质分歧时只追问有区分能力的事件,不把方法未实现转成出生时间追问。
|
||||
- [ ] 将第七轮D9参照/Yoga状态修正沿用至区间事实,防止“稳定候选”被渲染为“已证实事件”。
|
||||
- [ ] 单独提交,功能开关默认关闭;先staging调试。
|
||||
|
||||
## Task 5:跨入口持久化、缓存和回归发布
|
||||
|
||||
**Files:**
|
||||
- 网站现有咨询/报告/日历/年运的请求、缓存键和报告序列化模块(由Task4定位并列明具体修改文件)。
|
||||
- Test website `tests/test_rectification_purpose_persistence.py`及对应浏览器E2E。
|
||||
|
||||
**Interfaces:** 持久化范围、scope、binding、decision版本、结果引用;代表时刻必须携带representative_is_birth_truth=false。用途评估元数据仅保留必要ID和证据引用,个人出生负载留原受控记录,不复制进新日志/profile元数据。
|
||||
|
||||
- [ ] 用同一合成请求经咨询→报告→日历→年运,对照range_set_id、scope、profile及raw payload指纹;不适用的入口明确拒绝区间或提示限制,不默默选中点。
|
||||
- [ ] 刷新页面、旧报告重开和导出后仍显示原范围、原用途版本;今天日期变化不复用旧评估。
|
||||
- [ ] 执行规范U01–U16;合同测试只是其中一层,必须补真实计算与浏览器证据。
|
||||
- [ ] 复跑既有`test_rectification_comparability_bug1801.py`、`test_rectification_api_can_apply_guard_bug1802.py`、`test_active_rectification_api.py`、`test_rectification_reseal_round7_bug1809.py`,失败和跳过逐项回传,不删旧断言来过门。
|
||||
- [ ] staging开启新通道,记录提交、镜像、开关和回执;关闭开关验证旧流程可用且原始范围未变。生产按网站既有流程发布,资料侧提交不是网站部署。
|
||||
|
||||
## 本交接包本地验证
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s handoff/2026-11-09/interval_rectification -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
这条命令验证参考状态合同,不验证网站实现。交接完成后网站开发人员可直接从Task1执行;无需再选择实施模式或等待资料侧批准普通编码步骤。
|
||||
@@ -0,0 +1,180 @@
|
||||
# 按用途接受出生时间区间:网站开发交接
|
||||
|
||||
日期:2026-11-09。给独立网站开发人员直接使用。适配基线:第七轮验收包网站 `c032823f9e256625a1b346a70172b8d304854311`;资料仓 `bef2161f945e49632f33992bccaafefa1e3fc7c5`。网站之后若已更新,先按函数与字段核对差异,不覆盖新代码。
|
||||
|
||||
**目标:出生时间只需精确到足以稳定回答当前问题的范围。区间宽度、是否唯一分钟、候选分数是否并列,都不能单独决定是否继续校时。**
|
||||
|
||||
交付内容:本规范;[分步实施计划](../../../docs/superpowers/plans/2026-11-09-purpose-scoped-rectification.md);[可运行决策合同](reference_gate.py);[合同反例测试](test_reference_gate.py);[合成接口示例](examples.json)。参考代码仅消费已经计算出的评估事实,不计算星盘、不扫描敏感界限、不替代网站引擎。网站敏感性生产器、API、前端尚需按计划实施。
|
||||
|
||||
本地交付检查:13项参考合同测试通过,文档相对链接可解析。解压包根目录运行 `python3 -m unittest discover -s handoff/2026-11-09/interval_rectification -p 'test_*.py' -v` 即可复验,不依赖完整资料仓。原始第七轮验收包未打入本交接;随附review_packet.py需要开发人员另行提供原包目录,不能把缺原包当复验通过。
|
||||
|
||||
## 一、产品决定及与第七轮的衔接
|
||||
|
||||
本规范更新第七轮交接中容易被理解为“必须找唯一分钟”的部分。12:00–12:01的8:8可以是一组足够使用的候选;甚至更宽范围也可接受。反过来,只有20秒也可能跨过关键分盘或应期边界,需要继续辨别。
|
||||
|
||||
R2的未决规则继续有效:不能把未知当相等,不能用缺失当0分。**但“不能确定唯一出生时间”不再阻止用户使用整个区间的稳定部分解盘。** Narayana相关子层缺失时保留其他已计算且稳定的结果;如本次确需该子层,只把对应判断列为不足,不假装全量完成。
|
||||
|
||||
独立保留两个状态:
|
||||
|
||||
| 维度 | 回答的问题 | 依据 |
|
||||
|---|---|---|
|
||||
| 时间范围的证据支持 | 原始出生记录与历史事件能否支持这个范围、排除哪些候选? | 已有来源记录、事件质量、共同覆盖、留出验证 |
|
||||
| 当前用途的结果稳定性 | 如果真实时间在剩余范围任一点,本次哪些判断会变、变化是否实质? | 相关引擎结果、敏感界限、预测窗变化、当前用途精度 |
|
||||
|
||||
低历史置信度不自动禁止稳定结果的条件分析;高历史分数不自动证明预测稳定。稳定性不能把用户原始范围缩小成“历史已证实区间”,也不能把合成可用率包装成校时准确率。
|
||||
|
||||
## 二、复用已有实现,不新造评分系统
|
||||
|
||||
| 已有位置或字段 | 本次怎么接 |
|
||||
|---|---|
|
||||
| 网站 `scripts/active_rectification_events.py::adjudicate_candidate_rows` | 保留共同覆盖比较、provisional/comparable leader、can_apply;在其旁增加用途评估结果,不修改历史评分阈值来实现区间可用 |
|
||||
| 网站 `scripts/chart_choice/astrologer.py::scan / score_minute`(由附件脚本确认) | 复用候选计算和事件明细;不要把score_minute当结果稳定性检测器 |
|
||||
| 网站 `scripts/active_rectification_event_engine.py`、`scripts/narayana_rath.py` | 从现有原生函数提取每候选输出、缺失和分支;不在聊天或新模块重写Dasha |
|
||||
| 网站 `frontend/src/lib/consultation-evidence-card.ts` | 增加区间、用途、稳定事实和条件事实;不只传代表时刻单盘 |
|
||||
| 网站 `frontend/src/lib/consultation-technique-evidence.ts`、`consult-answer-basis.ts` | 用同一评估对象呈现“区间足够/仅部分可用/需进一步区分”,不自行推导状态 |
|
||||
| 网站 `frontend/src/mastra/consultation-tools.ts` | 工具输入绑定区间与用途;工具返回包含分支和限制,校验旧结果失效 |
|
||||
| 资料仓 `scripts/active_rectification_candidates.py::build_candidate_difference_contract` | 已有分组、signature、discriminators、dimension_sensitivity,可作适配参照;不是完整连续区间稳定性引擎 |
|
||||
| 资料仓 `scripts/active_rectification_questions.py` 与现有候选选择器 | 复用敏感层和问答路由,优先询问能分开实质分歧区间的事件 |
|
||||
|
||||
网站真实API路由和页面文件需在其源码用 `rg 'can_apply|comparable_leader|winning_segment|representative_time'` 定位;本包没有网站完整源码,不虚构具体路由。网站可能已有候选分组实现,先对照再决定是否新增适配模块。
|
||||
|
||||
资料仓候选扫描当前有分钟采样、240分钟上限、固定分盘签名;`boundary_time`是新组首个采样点,不是已求解的精确边界。部分默认参数为Raman/true,网站当前为Raman/mean:调用必须显式传用户确认的全套参数,禁止复制默认值。资料侧旧评分还含宽度5/15分钟门槛;它只属于原有审批流程,不能挪作本次用途评估的硬门槛。
|
||||
|
||||
## 三、先冻结用途合同
|
||||
|
||||
每次评估建立 `purpose_scope`,至少包含:
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| question / domains / event_class | 原问题、主题;婚恋分开心动、成对、法律婚等目标 |
|
||||
| forecast_start / forecast_end / evaluated_at | 实际观察窗口、运行日期和时区;换日期必须重新评估 |
|
||||
| required_techniques / required_outputs | 由现有问题路由解析的全部关联可运行层及本次所需结果,不以“想通过”为由删技法 |
|
||||
| precision_policy_id / version | 输出到年、月、周、日或仅结构;窗口宽度和边界偏移的容许规则,固定在运行前 |
|
||||
| decision_predicates | 会改变回答的条件,如是否落入用户指定月、是否跨过事件窗口、关键论断方向是否改变 |
|
||||
| profile_fingerprint / engine_version | 岁差、交点、宫制、Dasha profile、年长、来源分支、引擎版本 |
|
||||
| candidate_set_fingerprint / input_revision | 全部仍合理的时间范围、范围来源、地点与时区解析版本、事件修订号 |
|
||||
|
||||
默认用户无需填一堆技术表单。系统从问题提取“关心什么、问多长时间、需要多细”,显示一句可更正的摘要;只有“这个月”和“具体哪天”这类会改变验收精度的歧义才追问。没有精度政策时先给条件结果,不能自设“差几天都无所谓”。
|
||||
|
||||
每个主题独立评估:同一出生区间可以“事业长期结构足够、婚恋月度窗口需细分”。不能因D60变化就阻塞所有主题;也不能只看D1同上升便说所有分盘和推运稳定。未纳入本次用途的层标not_applicable并说明原因,不删除原始数据。
|
||||
|
||||
## 四、从候选计算到用途稳定性
|
||||
|
||||
按以下顺序实现,始终调用原有计算链:
|
||||
|
||||
1. **保存原始范围与来源。** 同时保留用户报告范围、事件支持的候选集合、用于本次分析的集合。默认评估全部仍合理的候选,不能只拿当前分数最高的一段假装其他已排除。多个不连续范围保留为集合,不填补中间空隙。
|
||||
2. **获得相关输出。** 每个采样候选和每个必要的参数分支均保留输入指纹、计算状态、结构值、Dasha原始边界、窗口、证据ID和依赖。此前未执行的层必须明确缺失。
|
||||
3. **发现并细化界限。** 使用已有可定位的分盘、宫头、关系、度数阈值、Dasha及应期窗口敏感点;粗网格只做发现。界限附近加密、记录左右结果和未确定的边界带。只检查首尾或固定每分钟不足以排除内部A→B→A变化。
|
||||
4. **逐输出判定影响。** 原始结构变化必须保留;仅当该变化不会影响本次依赖它的判断时,才标“对本用途无实质影响”。由可追溯规则判定,不让模型比较两段相似文案代替数值和依赖比较。
|
||||
5. **输出稳定与差异两部分。** 只有覆盖全部剩余范围和相关分支的事实才进入 `stable_claim_ids`。有差异的判断保留分支条件及范围。分数相同、null相同、blocked相同、同一个模型给同一句话都不是稳定证据。
|
||||
6. **决定停止或追问。** 满足用途精度即可停止细分;发现会影响用途的界限才问针对性历史事件。缺算法、缺时区、网络或计算预算不足不是缺出生分钟,必须分开提示,不能反复逼用户回忆。
|
||||
|
||||
连续区间检验记录采用 `policy_covered / sampled_only / incomplete` 三态。`policy_covered`须逐层给出在声明分辨率下的扫描覆盖或界限界定依据、最大未检查间隔、边界误差与停止容差;不是要求所有技法有全局数学证明,也不是仅因“跑完循环”就通过。对无法排除的内部切换保留sampled_only;临界带宽大于用户用途允许值时不能提升为完整可用。
|
||||
|
||||
如果底层只接受分钟,本轮不得声称排除了分钟内部变化;精度要求超过引擎能力时报告限制。生日跨午夜、历史夏令时/重叠本地时刻、地点不确定都需转换成明确UTC区间或多分支;不在循环中猜时区。
|
||||
|
||||
### 应期变化怎样比较
|
||||
|
||||
原始窗口端点范围、窗口并集、交集、事件类、父段身份一起比较,不仅比较当前MD/AD名字。同名运的日期仍可能漂移。
|
||||
|
||||
- “问今年整体结构”与“问某月会不会发生”使用不同的已版本化policy;不能用一套固定分钟宽度。
|
||||
- 即使端点仅偏移一天,若跨过用户关心的月末或日期,也可构成实质差异;若仅需季度且所有相关判断与允许的窗口宽度均稳定,可保留区间。
|
||||
- 窗口并集只能写“各候选的可能范围”,不能写“整个范围一直有效”;交集为空不得生成共同确定窗口。
|
||||
- 起止、存在性、主星、方向、条件性等级任一实质变化均要保留;不把“多数候选一致”当全部稳定,不给均匀分钟权重冒充概率。
|
||||
- 不同原因区分 `birth_time_sensitive / method_unresolved / missing_input / calculation_failure / scan_incomplete`。R2规则未决不能靠把出生范围缩成一分钟消失。
|
||||
|
||||
## 五、状态与按钮:新增用途通道,不改旧can_apply含义
|
||||
|
||||
新增 `purpose_assessment`,其绑定字段必须由服务器重算,与当前请求逐项匹配。旧 `confidence`继续表示其既有候选证据评价;旧 `can_apply`不自动升级。**本次“使用区间继续分析”是新增动作,不是把出生记录改成代表分钟。**
|
||||
|
||||
| status | can_use_interval | can_continue_analysis | 用户动作 |
|
||||
|---|---:|---:|---|
|
||||
| scope_ready | true | true | 使用该区间继续;停止无收益的分钟细分 |
|
||||
| conditional | false | true | 继续看已核实稳定部分和明确条件结果;不声称全用途已稳定 |
|
||||
| needs_refinement | false | 取决于稳定/条件事实是否存在 | 回答针对界限的问题,或先看不受影响的部分 |
|
||||
| insufficient_evidence | false | false | 补必要输入、重试计算或缩小问题范围 |
|
||||
| stale | false | false | 按新用途/日期/参数/候选集合重新计算 |
|
||||
|
||||
`can_use_interval`表示当前请求全范围已按政策评估足够;不是允许修改出生档案。新通道的 `can_modify_birth_record`始终false。旧确认出生记录流程可独立保留,不由本功能触发。
|
||||
|
||||
代表时刻仅为现有单盘API兼容计算值。选择实际已计算、位于允许区间且不在未决边界带中的值;无必要不强取中心。API/缓存/报告同时携带区间与 `representative_is_birth_truth=false`。如果多个分支不等价,不能拿一个代表盘生成全体断言。
|
||||
|
||||
区间端点使用带时区的时间或服务器解析的UTC,明确闭/开边界。输入“12:00到12:01”是两个端点还是分钟槽由输入精度决定;不能把旧 `width_minutes=2`当120秒物理跨度。新字段用duration_seconds和sample_count分开保存。分段边界恰好落点的归属按计算函数规则记录,未决边界带单独保留。
|
||||
|
||||
## 六、返回对象和模型合同
|
||||
|
||||
`examples.json`给出合成接口示例;无真实出生数据。完整服务返回至少包含:
|
||||
|
||||
```text
|
||||
original_time_record(不修改)
|
||||
plausible_ranges[] + source/evidence_state
|
||||
purpose_scope + binding
|
||||
legacy_rectification_result(原有评分/共同覆盖/应用门控)
|
||||
purpose_assessment:
|
||||
required_layers + layer_coverage
|
||||
scan_state + per_layer_scan_receipts
|
||||
domain_complete + branch_state
|
||||
material_change + material_differences[]
|
||||
stable_claim_ids[] + conditional_claim_ids[]
|
||||
status + permissions + allowed_claim_ids[] + reason
|
||||
interval_results[]:
|
||||
range + boundary_policy + representative_at
|
||||
representative_is_birth_truth=false
|
||||
computed_payload_ref + output_envelopes + dependencies
|
||||
```
|
||||
|
||||
服务器必须先从真实结果构造评估事实,再调用决策合同;不能接受浏览器传来的 `domain_complete=true` 等字段授予可用状态。参考决策器只检查状态合同,不证明这些事实真实。
|
||||
|
||||
模型收到的规则可直接加入现有工具合同:
|
||||
|
||||
> 本次出生时间是区间。只使用allowed_claim_ids对应的证据。stable事实需在评估的全部候选及相关分支上成立;conditional事实必须带具体分支或缺项。代表时刻不是确认出生时刻。不得因为分数并列要求唯一分钟;只有material_differences会改变当前问题时才建议进一步校正。不得由缺失的技法推出结果。用途或预测日期变化后请求重新评估。
|
||||
|
||||
界面建议:
|
||||
|
||||
- scope_ready:“这个时间范围足够用于本次事业年度分析,无需继续细分。适用范围:……”。
|
||||
- conditional:“已有部分结论不受时间范围影响;以下小运窗口仍需区分或补算。”
|
||||
- needs_refinement:“范围内有一处变化会影响你问的月份。请核对下面这件已发生的事;暂不选定某一分钟。”
|
||||
- 不显示“校时准确率98%”“已校正为12:03”,除非另有完全独立且明确的依据;本功能不产生此类结论。
|
||||
|
||||
## 七、交付路线与停止条件
|
||||
|
||||
**阶段1:可解释的部分交付。** 在已有候选结果上增加区间用途对象、稳定/条件事实与新按钮;扫描尚未增强时如实sampled_only,不开放scope_ready。用户可以继续读取已核实的部分,不必等待C全部补齐。
|
||||
|
||||
**阶段2:用途敏感性扫描。** 在真实引擎上补相关界限、内部变化检测与窗口包络,达到policy_covered后才开放scope_ready。分数并列或区间超过15分钟不再单独阻塞此通道。
|
||||
|
||||
**阶段3:全链回归。** 咨询、报告、日历、年运、校时均保留同一范围与用途版本;前端刷新、重新打开、导出不能退回“唯一代表分钟”。旧报告不静默迁移,按原配置重开。
|
||||
|
||||
每阶段单独提交与部署到staging,保留功能开关 `purpose_scoped_rectification_v1`(建议新增名)。关闭功能仅撤回新通道,不删原始范围和证据,不改变旧can_apply。生产部署由网站现有流程决定。
|
||||
|
||||
性能采用缓存和分阶段计算:key包括输入修订、候选集合、用途、预测窗、精度政策、参数与算法版本;全量明细保留受控后端,模型拿相关包络和可追溯事实。预算耗尽返回incomplete,不以“超时即稳定”通过,不让缓存丢掉敏感层。
|
||||
|
||||
## 八、必须覆盖的真实验收场景
|
||||
|
||||
| 编号 | 场景 | 预期 |
|
||||
|---|---|---|
|
||||
| U01 | 20分钟范围,相关输出完整且对目标用途等价 | scope_ready;宽度不否决 |
|
||||
| U02 | 20秒范围,跨关键分盘且改变目标判断 | needs_refinement;窄不等于准确 |
|
||||
| U03 | 首尾相同,中间A→B→A | 检出内界限;未检查则sampled_only,禁止假通过 |
|
||||
| U04 | 8:8同分、多分钟但用途稳定 | scope_ready;不修改出生记录 |
|
||||
| U05 | 所有候选都缺Narayana_AD | 不以缺失相等判稳定;可交付其他有证据的稳定部分 |
|
||||
| U06 | AD缺失但MD有值;组合得分无法拆开 | 保留MD,排除共同覆盖不一致贡献,不填AD=0 |
|
||||
| U07 | D60变、当前用途经依赖审查不使用它 | 记录差异但不自动阻塞;换用途重算 |
|
||||
| U08 | 同名MD/AD,边界跨用户指定日期 | 仍判实质变化,不只比星名 |
|
||||
| U09 | 窗口并集很宽或交集为空 | 保留分支窗口;不生成共同确定日期 |
|
||||
| U10 | 旧结果换主题、日期、profile或候选集合 | stale,任何旧可用权限撤销直到重算 |
|
||||
| U11 | 两段不连续区间,各自稳定但结论不同 | 保留两个分支,不用中点、不合成连续范围 |
|
||||
| U12 | 跨午夜、DST歧义或分钟引擎被要求秒级 | 明确时间分支/能力边界,不猜解析或声称秒级稳定 |
|
||||
| U13 | 代表值单盘看似确定,区间实际有差异 | 模型/页面/导出保留差异,不丢区间 |
|
||||
| U14 | 低历史置信度但用户报告范围内结果稳定 | 条件于原范围使用;不升级真实出生时间置信度 |
|
||||
| U15 | R2方法未决、缩时也不能解决 | 标method_unresolved;不继续无效追问分钟 |
|
||||
| U16 | 扫描达到预算,尚有内部区间未覆盖 | incomplete,不授予scope_ready |
|
||||
|
||||
参考决策器测试覆盖其中的状态合同,**不等于上述实际引擎、浏览器和端到端测试已通过**。网站需补同名场景在真实调用链上的回执,特别是U03/U08/U12/U13。
|
||||
|
||||
验收指标改为:按用途可用区间率、敏感界限漏检率、错误宣布稳定数、过度要求继续校时数、稳定/条件事实保留率、重开与导出区间丢失数。历史真时刻覆盖率另报,按案例而非偏移次数统计;不把区间变宽换来的覆盖提高当校时提升。
|
||||
|
||||
## 九、开发人员回传格式
|
||||
|
||||
回传manifest列网站提交、资料提交、profile/policy/引擎版本、功能开关、构建及staging回执。每个U用例保留:输入区间来源→用途冻结→逐候选原始结果→敏感界限/误差带→输出包络与依赖→决策对象→模型实际输入→页面/导出。真实个人数据留在网站受控环境;对外用合成或公开获准案例。
|
||||
|
||||
本轮只有交接规范和可运行的状态参考合同,不修改网站服务器、不改变资料仓个人报告冻结导出合同。资料仓默认个人报告目前仍拒绝区间出生输入,本方案不能被用来绕过它;本次新增通道属于独立网站。
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"schema": "jyotish.purpose_interval_examples.v1",
|
||||
"boundary": "Synthetic contract examples, not computed charts or real personal records. Offsets are relative to a synthetic origin, not public API timestamps.",
|
||||
"ready_interval": {
|
||||
"range": {
|
||||
"start_offset_seconds": 0,
|
||||
"end_offset_seconds": 1200,
|
||||
"closed_start": true,
|
||||
"closed_end": true,
|
||||
"duration_seconds": 1200,
|
||||
"sample_count": 21,
|
||||
"representative_offset_seconds": 600,
|
||||
"representative_is_birth_truth": false
|
||||
},
|
||||
"assessment": {
|
||||
"binding": {
|
||||
"input_revision": "synthetic-input-v1",
|
||||
"candidate_set_fingerprint": "synthetic-range-0-1200",
|
||||
"purpose_fingerprint": "synthetic-career-yearly",
|
||||
"profile_fingerprint": "synthetic-explicit-profile",
|
||||
"engine_version": "synthetic-engine-v1",
|
||||
"precision_policy_version": "synthetic-purpose-policy-v1",
|
||||
"forecast_window_fingerprint": "synthetic-fixed-year-window"
|
||||
},
|
||||
"required_layers": ["D1", "D10", "Vimshottari", "Narayana_AD"],
|
||||
"layer_coverage": {
|
||||
"D1": "executed_complete",
|
||||
"D10": "executed_complete",
|
||||
"Vimshottari": "executed_complete",
|
||||
"Narayana_AD": "executed_complete"
|
||||
},
|
||||
"scan_state": "policy_covered",
|
||||
"domain_complete": true,
|
||||
"branch_state": "resolved",
|
||||
"material_change": false,
|
||||
"stable_claim_ids": ["synthetic:d1-common", "synthetic:career-window-common"],
|
||||
"conditional_claim_ids": []
|
||||
},
|
||||
"expected_status": "scope_ready",
|
||||
"note": "Layer list is deliberately abbreviated for a state-contract test; production must use the full relevant route. Scan coverage is stipulated by this fixture, not produced by the reference gate."
|
||||
},
|
||||
"sensitive_short_interval": {
|
||||
"range": {"start_offset_seconds": 0, "end_offset_seconds": 20, "duration_seconds": 20},
|
||||
"assessment_changes_from_ready": {
|
||||
"material_change": true,
|
||||
"stable_claim_ids": ["synthetic:d1-common"],
|
||||
"conditional_claim_ids": ["synthetic:before-boundary", "synthetic:after-boundary"]
|
||||
},
|
||||
"expected_status": "needs_refinement",
|
||||
"note": "Create a new candidate-set binding in a real request; this delta only demonstrates decision logic."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Purpose-use gate reference ONLY; never computes charts or grants record edits.
|
||||
|
||||
The server must derive all assessment fields from native computation evidence.
|
||||
Passing this gate cannot certify that a caller's supplied evidence is truthful.
|
||||
"""
|
||||
|
||||
BINDING_KEYS = (
|
||||
'input_revision', 'candidate_set_fingerprint', 'purpose_fingerprint',
|
||||
'profile_fingerprint', 'engine_version', 'precision_policy_version',
|
||||
'forecast_window_fingerprint',
|
||||
)
|
||||
|
||||
|
||||
def assess_use(assessment: dict, expected_binding: dict) -> dict:
|
||||
"""Return permissions for the evaluated scope, not birth-time confidence.
|
||||
|
||||
Deliberately has no interval-width or score-margin threshold.
|
||||
stable_claim_ids must cover the entire remaining candidate domain;
|
||||
conditional_claim_ids must point to explicit branch/limitation evidence.
|
||||
"""
|
||||
for binding in (assessment.get('binding'), expected_binding):
|
||||
if not isinstance(binding, dict) or any(
|
||||
not isinstance(binding.get(k), str) or not binding[k] for k in BINDING_KEYS
|
||||
):
|
||||
raise ValueError('complete server binding required')
|
||||
|
||||
def result(status, stable, conditional, reason):
|
||||
allowed = list(dict.fromkeys(stable + conditional))
|
||||
return {
|
||||
'schema': 'jyotish.purpose_use_decision.v1',
|
||||
'status': status,
|
||||
'can_use_interval': status == 'scope_ready',
|
||||
'can_continue_analysis': bool(allowed),
|
||||
'can_modify_birth_record': False,
|
||||
'representative_is_birth_truth': False,
|
||||
'stable_claim_ids': stable,
|
||||
'conditional_claim_ids': conditional,
|
||||
'allowed_claim_ids': allowed,
|
||||
'reason': reason,
|
||||
}
|
||||
|
||||
if any(assessment['binding'][k] != expected_binding[k] for k in BINDING_KEYS):
|
||||
return result('stale', [], [], 'binding_changed_recompute')
|
||||
|
||||
required = assessment.get('required_layers')
|
||||
coverage = assessment.get('layer_coverage')
|
||||
if not isinstance(required, list) or not required or any(
|
||||
not isinstance(x, str) or not x for x in required
|
||||
) or len(set(required)) != len(required) or not isinstance(coverage, dict):
|
||||
raise ValueError('explicit nonempty required layer set required')
|
||||
if any(coverage.get(layer) not in ('executed_complete', 'partial', 'blocked') for layer in required):
|
||||
raise ValueError('required layers cannot be absent or not_applicable')
|
||||
if assessment.get('scan_state') not in ('policy_covered', 'sampled_only', 'incomplete'):
|
||||
raise ValueError('invalid scan_state')
|
||||
if assessment.get('branch_state') not in ('resolved', 'bounded_equivalent', 'unresolved_material', 'not_evaluated'):
|
||||
raise ValueError('invalid branch_state')
|
||||
for flag in ('domain_complete', 'material_change'):
|
||||
if type(assessment.get(flag)) is not bool:
|
||||
raise ValueError(f'{flag} must be a boolean')
|
||||
for field in ('stable_claim_ids', 'conditional_claim_ids'):
|
||||
values = assessment.get(field)
|
||||
if not isinstance(values, list) or any(not isinstance(v, str) or not v for v in values):
|
||||
raise ValueError(f'invalid {field}')
|
||||
if len(set(values)) != len(values):
|
||||
raise ValueError(f'duplicate {field}')
|
||||
stable, conditional = assessment['stable_claim_ids'], assessment['conditional_claim_ids']
|
||||
if set(stable) & set(conditional):
|
||||
raise ValueError('one fact cannot be both stable and conditional')
|
||||
|
||||
if assessment['material_change']:
|
||||
return result('needs_refinement', stable, conditional, 'material_output_change')
|
||||
complete = all(coverage[layer] == 'executed_complete' for layer in required)
|
||||
if (complete and assessment['domain_complete']
|
||||
and assessment['scan_state'] == 'policy_covered'
|
||||
and assessment['branch_state'] in ('resolved', 'bounded_equivalent')
|
||||
and stable and not conditional):
|
||||
return result('scope_ready', stable, [], 'adequate_for_frozen_purpose')
|
||||
if stable or conditional:
|
||||
return result('conditional', stable, conditional, 'only_listed_claims_are_available')
|
||||
return result('insufficient_evidence', [], [], 'no_supported_outputs_for_purpose')
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Contract tests, not tests of a sensitivity scanner or the website runtime."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from reference_gate import assess_use, BINDING_KEYS
|
||||
|
||||
|
||||
class PurposeGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = json.loads(Path(__file__).with_name('examples.json').read_text())['ready_interval']
|
||||
self.a = copy.deepcopy(self.case['assessment'])
|
||||
self.binding = copy.deepcopy(self.a['binding'])
|
||||
|
||||
def test_wide_interval_can_be_used_without_confirming_birth(self):
|
||||
self.assertEqual(self.case['range']['duration_seconds'], 1200)
|
||||
r = assess_use(self.a, self.binding)
|
||||
self.assertEqual(r['status'], 'scope_ready')
|
||||
self.assertTrue(r['can_continue_analysis'])
|
||||
self.assertFalse(r['can_modify_birth_record'])
|
||||
|
||||
def test_short_interval_can_need_refinement(self):
|
||||
self.a['material_change'] = True
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'needs_refinement')
|
||||
|
||||
def test_equal_scores_do_not_block_scope_use(self):
|
||||
self.a['legacy_scores'] = [8, 8]
|
||||
self.a['historical_confidence'] = 'low'
|
||||
self.assertTrue(assess_use(self.a, self.binding)['can_use_interval'])
|
||||
|
||||
def test_every_binding_change_invalidates_permission(self):
|
||||
for key in BINDING_KEYS:
|
||||
with self.subTest(key=key):
|
||||
changed = dict(self.binding, **{key: 'different'})
|
||||
r = assess_use(self.a, changed)
|
||||
self.assertEqual(r['status'], 'stale')
|
||||
self.assertFalse(r['can_continue_analysis'])
|
||||
self.assertFalse(r['can_use_interval'])
|
||||
|
||||
def test_sampling_does_not_certify_whole_interval(self):
|
||||
for state in ('sampled_only', 'incomplete'):
|
||||
with self.subTest(state=state):
|
||||
self.a['scan_state'] = state
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'conditional')
|
||||
|
||||
def test_missing_layer_keeps_independent_stable_output(self):
|
||||
self.a['layer_coverage']['Narayana_AD'] = 'blocked'
|
||||
self.a['stable_claim_ids'] = ['synthetic:d1-common']
|
||||
r = assess_use(self.a, self.binding)
|
||||
self.assertEqual(r['status'], 'conditional')
|
||||
self.assertEqual(r['allowed_claim_ids'], ['synthetic:d1-common'])
|
||||
|
||||
def test_all_missing_is_not_stability(self):
|
||||
self.a['layer_coverage'] = {k: 'blocked' for k in self.a['required_layers']}
|
||||
self.a['stable_claim_ids'] = []
|
||||
r = assess_use(self.a, self.binding)
|
||||
self.assertEqual(r['status'], 'insufficient_evidence')
|
||||
self.assertFalse(r['can_continue_analysis'])
|
||||
|
||||
def test_unresolved_method_does_not_certify_scope(self):
|
||||
self.a['branch_state'] = 'unresolved_material'
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'conditional')
|
||||
|
||||
def test_unchecked_remaining_ranges_prevent_full_permission(self):
|
||||
self.a['domain_complete'] = False
|
||||
self.assertFalse(assess_use(self.a, self.binding)['can_use_interval'])
|
||||
|
||||
def test_bounded_equivalent_branches_can_be_used(self):
|
||||
self.a['branch_state'] = 'bounded_equivalent'
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'scope_ready')
|
||||
|
||||
def test_no_stable_outputs_cannot_pass_vacuously(self):
|
||||
self.a['stable_claim_ids'] = []
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'insufficient_evidence')
|
||||
|
||||
def test_conditions_remain_visible(self):
|
||||
self.a['conditional_claim_ids'] = ['synthetic:branch-a']
|
||||
r = assess_use(self.a, self.binding)
|
||||
self.assertEqual(r['status'], 'conditional')
|
||||
self.assertIn('synthetic:branch-a', r['conditional_claim_ids'])
|
||||
|
||||
def test_malformed_contracts_fail_closed(self):
|
||||
for changes in ({'required_layers': []}, {'material_change': 'false'},
|
||||
{'scan_state': 'done'}, {'binding': {}},
|
||||
{'conditional_claim_ids': self.a['stable_claim_ids']},
|
||||
{'layer_coverage': {'D1': 'not_applicable'}}):
|
||||
with self.subTest(changes=changes):
|
||||
with self.assertRaises(ValueError):
|
||||
assess_use(dict(self.a, **changes), self.binding)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -55,10 +55,7 @@ const BASIS_ENTRIES: readonly BasisEntry[] = [
|
||||
name: "功能吉凶",
|
||||
result: (profile) => `按 ${FUNCTIONAL_PROFILE_LABELS[profile]} 判断每颗星在这张盘里起的作用`,
|
||||
},
|
||||
// 原值:`盘上成立和落空的格局已核对`
|
||||
// 新值:`盘上检测到的格局已列出各自状态`
|
||||
// 原因:BUG-1824:候选不得显示为成立(卡上规则是 none is confirmed)
|
||||
{ match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上检测到的格局已列出各自状态" },
|
||||
{ match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上成立和落空的格局已核对" },
|
||||
{ match: /^Shadbala\b/i, name: "行星力量", result: () => "每颗星的强弱已算" },
|
||||
{ match: /^Ashtakavarga$/i, name: "宫位点数", result: () => "每个宫得到的点数已算" },
|
||||
{ match: /^Arudha\b/i, name: "Arudha 点位", result: () => "事业、婚姻相关的点位落在哪里" },
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
import type { TechniqueAuditRow } from "./consultation-agent-events.ts";
|
||||
import type { EvidenceCard } from "./consultation-evidence-card.ts";
|
||||
import { isStructuralObservation, yogaStatus } from "./yoga-status-words.ts";
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {});
|
||||
@@ -137,45 +136,11 @@ function savLine(card: EvidenceCard) {
|
||||
});
|
||||
}
|
||||
|
||||
// 原值:`成立:${hits}` / `本题没有成立的格局`
|
||||
// 新值:按卡上 yogas_detail 的状态,用占星师第七轮 §5 P0.2 的映射表(yoga-status-words.ts)分组写
|
||||
// 「名字、名字:状态文字」,组间用「。」;没有可列的写「本题没有检测到相关格局」
|
||||
// 原因:BUG-1824:候选不得显示为成立;hits 列表不能推断成立,状态文字不得被截断
|
||||
function yogaLine(card: EvidenceCard) {
|
||||
const detail = rec(rec(rec(card.base).natal).yogas_detail);
|
||||
const rows = Array.isArray(detail.rows) ? detail.rows.map(rec) : [];
|
||||
const byName = new Map<string, Rec>();
|
||||
for (const row of rows) {
|
||||
const name = str(row.name);
|
||||
if (name && !byName.has(name)) byName.set(name, row);
|
||||
}
|
||||
const notEvaluated = new Set(
|
||||
(Array.isArray(detail.not_evaluated) ? detail.not_evaluated.map(rec) : []).map((row) => str(row.name)).filter(Boolean),
|
||||
);
|
||||
return firstOf(card, (section) => {
|
||||
if (!("yogas" in section)) return undefined;
|
||||
const hits = list(rec(section.yogas).hits).slice(0, 3);
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const name of hits) {
|
||||
const row = byName.get(name);
|
||||
if (row && isStructuralObservation(row)) continue;
|
||||
const { text } = yogaStatus(row, { notEvaluated: notEvaluated.has(name) });
|
||||
const bucket = groups.get(text) ?? [];
|
||||
bucket.push(name);
|
||||
groups.set(text, bucket);
|
||||
}
|
||||
if (!groups.size) return "本题没有检测到相关格局";
|
||||
// 逐个名字往里放,放不下就写“另 N 项未列”,绝不把状态文字截半(截半会只剩“……成立”)。
|
||||
const entries = [...groups].flatMap(([text, names]) => names.map((name) => [text, name] as const));
|
||||
const render = (shown: number) => {
|
||||
const kept = new Map<string, string[]>();
|
||||
for (const [text, name] of entries.slice(0, shown)) kept.set(text, [...(kept.get(text) ?? []), name]);
|
||||
const body = [...kept].map(([text, names]) => `${names.join("、")}:${text}`).join("。");
|
||||
return shown < entries.length ? `${body}。另 ${entries.length - shown} 项未列` : body;
|
||||
};
|
||||
let shown = 1;
|
||||
while (shown < entries.length && render(shown + 1).length <= MAX) shown += 1;
|
||||
return render(shown);
|
||||
const hits = list(rec(section.yogas).hits);
|
||||
return hits.length ? `成立:${hits.slice(0, 3).join("、")}` : "本题没有成立的格局";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* BUG-1824 / 占星师第七轮回复 §5 P0.2(2026-11-09):Yoga 状态的唯一映射表。
|
||||
* 「解读依据」页面(#181)和答题模型的状态措辞(#182)必须 import 同一张表,
|
||||
* 不能一个把 candidate 升成“成立”、另一个把 computed 降成“非成立”。
|
||||
*
|
||||
* | 原始证据 | 推荐文字 |
|
||||
* |---|---|
|
||||
* | candidate / support_only_candidate | 命中候选规则,尚未完整确认 |
|
||||
* | computed,未完成来源条件治理 | 检测器检出,待核实 |
|
||||
* | formed_under_source | 按所选来源的形成条件成立;强度和结果另评 |
|
||||
* | conditions_not_met | 所选来源的必要条件未满足 |
|
||||
* | blocked / not_available_yet | 当前无法确认,列明缺项 |
|
||||
*
|
||||
* 规则(同一处原文):用 hits 列表不能推断“成立”;没有完整条件检查不能写“落空 / 非成立”。
|
||||
* 所以:只有 `formation.status` 这一完整条件检查的结果能给出“成立”或“必要条件未满足”;
|
||||
* 卡上找不到状态行、或状态值不在表内时,一律落到 blocked(当前无法确认),并写明缺什么。
|
||||
*
|
||||
* 验收时(Claude 2026-11-09)按占星师原表替换了 #181 初版词表
|
||||
* (初版:候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为候选)。
|
||||
*/
|
||||
|
||||
export type YogaStatusKey = "candidate" | "computed" | "formed_under_source" | "conditions_not_met" | "blocked";
|
||||
|
||||
/** 占星师原表,文字逐字照抄;`evidence` 是卡上可能出现的原始值。 */
|
||||
export const YOGA_STATUS_MAPPING: Readonly<Record<YogaStatusKey, { readonly evidence: readonly string[]; readonly text: string }>> = {
|
||||
candidate: { evidence: ["candidate", "support_only_candidate"], text: "命中候选规则,尚未完整确认" },
|
||||
computed: { evidence: ["computed"], text: "检测器检出,待核实" },
|
||||
formed_under_source: { evidence: ["formed_under_source"], text: "按所选来源的形成条件成立;强度和结果另评" },
|
||||
conditions_not_met: { evidence: ["conditions_not_met"], text: "所选来源的必要条件未满足" },
|
||||
blocked: { evidence: ["blocked", "not_available_yet"], text: "当前无法确认,列明缺项" },
|
||||
};
|
||||
|
||||
/** blocked 行实际显示的文字:把“列明缺项”换成具体缺项。 */
|
||||
export const YOGA_BLOCKED_PREFIX = "当前无法确认,缺:";
|
||||
|
||||
export const YOGA_GAP_NO_ROW = "卡上没有这条格局的状态记录";
|
||||
export const YOGA_GAP_NOT_EVALUATED = "引擎未能评估这条规则";
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {});
|
||||
const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
|
||||
|
||||
export type YogaStatus = { key: YogaStatusKey; text: string };
|
||||
|
||||
function blocked(gap: string): YogaStatus {
|
||||
return { key: "blocked", text: `${YOGA_BLOCKED_PREFIX}${gap}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个格局行(`card.base.natal.yogas_detail.rows[]`)的状态。`row` 缺失 = 只在 hits 里出现、卡上没有状态行。
|
||||
* `notEvaluated` 为 true 表示该名字在 `yogas_detail.not_evaluated` 里。
|
||||
*/
|
||||
export function yogaStatus(row: Rec | undefined, options: { notEvaluated?: boolean } = {}): YogaStatus {
|
||||
if (!row) return blocked(options.notEvaluated ? YOGA_GAP_NOT_EVALUATED : YOGA_GAP_NO_ROW);
|
||||
const formation = text(rec(row.formation).status);
|
||||
// 只有完整条件检查的结果才能说“成立”或“必要条件未满足”。
|
||||
if (formation === "formed_under_source") return { key: "formed_under_source", text: YOGA_STATUS_MAPPING.formed_under_source.text };
|
||||
if (formation === "conditions_not_met") return { key: "conditions_not_met", text: YOGA_STATUS_MAPPING.conditions_not_met.text };
|
||||
const status = text(row.status);
|
||||
const claim = text(row.claim_status);
|
||||
for (const key of ["candidate", "computed", "blocked"] as const) {
|
||||
if (status && YOGA_STATUS_MAPPING[key].evidence.includes(status)) {
|
||||
return key === "blocked" ? blocked(`状态为 ${status}`) : { key, text: YOGA_STATUS_MAPPING[key].text };
|
||||
}
|
||||
}
|
||||
if ((claim && YOGA_STATUS_MAPPING.candidate.evidence.includes(claim)) || formation === "candidate") {
|
||||
return { key: "candidate", text: YOGA_STATUS_MAPPING.candidate.text };
|
||||
}
|
||||
if (options.notEvaluated) return blocked(YOGA_GAP_NOT_EVALUATED);
|
||||
return blocked(status ? `状态值 ${status} 不在映射表内` : "卡上这条格局没有状态值");
|
||||
}
|
||||
|
||||
/** 卡上这一行是不是“结构观察”(不是命名格局),不进「格局」这一行。 */
|
||||
export function isStructuralObservation(row: Rec): boolean {
|
||||
return row.claim_kind === "structural_observation" || row.status === "structural_observation";
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* BUG-1824:「解读依据」的「格局」一行不得把候选格局写成「成立」。
|
||||
* 合成卡,不含任何真人资料。
|
||||
*
|
||||
* 验收(Claude 2026-11-09):状态文字改用占星师第七轮回复 §5 P0.2 的映射表。
|
||||
* 原值 / 新值 / 原因(适用于下面每条被改写的期望值):
|
||||
* 原值:候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为「候选」
|
||||
* 新值:命中候选规则,尚未完整确认 / 检测器检出,待核实 / 按所选来源的形成条件成立;强度和结果另评 /
|
||||
* 所选来源的必要条件未满足 / 当前无法确认,缺:…
|
||||
* 原因:占星师第七轮 §5 P0.2:模型和页面必须使用同一状态映射(#182 import 同一模块)
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TechniqueAuditRow } from "../src/lib/consultation-agent-events.ts";
|
||||
import { consultAnswerBasisRows } from "../src/lib/consult-answer-basis.ts";
|
||||
import type { EvidenceCard } from "../src/lib/consultation-evidence-card.ts";
|
||||
import { withTechniqueEvidence } from "../src/lib/consultation-technique-evidence.ts";
|
||||
import {
|
||||
YOGA_BLOCKED_PREFIX, YOGA_GAP_NO_ROW, YOGA_GAP_NOT_EVALUATED, YOGA_STATUS_MAPPING, yogaStatus,
|
||||
} from "../src/lib/yoga-status-words.ts";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const CANDIDATE = "命中候选规则,尚未完整确认";
|
||||
const COMPUTED = "检测器检出,待核实";
|
||||
const FORMED = "按所选来源的形成条件成立;强度和结果另评";
|
||||
const NOT_MET = "所选来源的必要条件未满足";
|
||||
|
||||
function cardWith(rows: Row[], hits: string[], extra: Row = {}): EvidenceCard {
|
||||
return {
|
||||
domains: ["career"],
|
||||
domain_sections: { career: { yogas: { hits } } },
|
||||
base: { natal: { yogas_detail: { rows, ...extra } } },
|
||||
} as unknown as EvidenceCard;
|
||||
}
|
||||
|
||||
function yogaResult(card: EvidenceCard): string | undefined {
|
||||
const audit: TechniqueAuditRow[] = [{
|
||||
technique: "Yogas",
|
||||
calculation_status: "executed",
|
||||
interpretation_status: "observation_only",
|
||||
} as unknown as TechniqueAuditRow];
|
||||
const enriched = withTechniqueEvidence(audit, card) ?? [];
|
||||
const rows = consultAnswerBasisRows(enriched);
|
||||
return rows.find((row) => row.name === "格局")?.result;
|
||||
}
|
||||
|
||||
test("the mapping table is the astrologer's round-7 §5 P0.2 table, word for word", () => {
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(YOGA_STATUS_MAPPING).map(([key, row]) => [key, [row.evidence, row.text]])),
|
||||
{
|
||||
candidate: [["candidate", "support_only_candidate"], CANDIDATE],
|
||||
computed: [["computed"], COMPUTED],
|
||||
formed_under_source: [["formed_under_source"], FORMED],
|
||||
conditions_not_met: [["conditions_not_met"], NOT_MET],
|
||||
blocked: [["blocked", "not_available_yet"], "当前无法确认,列明缺项"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("row 1: candidate and support_only_candidate read 命中候选规则,尚未完整确认", () => {
|
||||
assert.equal(yogaResult(cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga"])), `Sunaphaa Yoga:${CANDIDATE}`);
|
||||
assert.equal(yogaStatus({ name: "X", status: "support_only_candidate" }).text, CANDIDATE);
|
||||
assert.equal(yogaStatus({ name: "X", claim_status: "support_only_candidate" }).text, CANDIDATE);
|
||||
});
|
||||
|
||||
test("row 2: computed without a completed condition check reads 检测器检出,待核实", () => {
|
||||
assert.equal(yogaResult(cardWith([{ name: "Vosi Yoga", status: "computed" }], ["Vosi Yoga"])), `Vosi Yoga:${COMPUTED}`);
|
||||
assert.equal(yogaStatus({ name: "X", status: "computed", formation: { status: "candidate" } }).text, COMPUTED);
|
||||
});
|
||||
|
||||
test("row 3: formed_under_source reads the source-bound 成立 with strength and outcome left open", () => {
|
||||
const line = yogaResult(cardWith([
|
||||
{ name: "Amala Yoga", status: "candidate", formation: { status: "formed_under_source" } },
|
||||
], ["Amala Yoga"]));
|
||||
assert.equal(line, `Amala Yoga:${FORMED}`);
|
||||
});
|
||||
|
||||
test("row 4: conditions_not_met reads 所选来源的必要条件未满足, never 落空 / 非成立 / 不成立", () => {
|
||||
const line = yogaResult(cardWith([
|
||||
{ name: "Dharidhra Yoga", status: "candidate", formation: { status: "conditions_not_met" } },
|
||||
], ["Dharidhra Yoga"])) ?? "";
|
||||
assert.equal(line, `Dharidhra Yoga:${NOT_MET}`);
|
||||
assert.doesNotMatch(line, /落空|非成立|不成立/u);
|
||||
});
|
||||
|
||||
test("row 5: blocked / not_available_yet reads 当前无法确认 and names what is missing", () => {
|
||||
for (const status of ["blocked", "not_available_yet"]) {
|
||||
const result = yogaStatus({ name: "X", status });
|
||||
assert.equal(result.key, "blocked");
|
||||
assert.equal(result.text, `${YOGA_BLOCKED_PREFIX}状态为 ${status}`);
|
||||
}
|
||||
const line = yogaResult(cardWith([], ["Lakshmi Yoga"], { not_evaluated: [{ name: "Lakshmi Yoga", evaluation_status: "evaluation_failed" }] }));
|
||||
assert.equal(line, `Lakshmi Yoga:${YOGA_BLOCKED_PREFIX}${YOGA_GAP_NOT_EVALUATED}`);
|
||||
});
|
||||
|
||||
test("a yoga that appears only in the hits list never renders 成立", () => {
|
||||
const line = yogaResult(cardWith([], ["Gaja Kesari Yoga", "Budha-Aditya Yoga"])) ?? "";
|
||||
assert.equal(line, `Gaja Kesari Yoga、Budha-Aditya Yoga:${YOGA_BLOCKED_PREFIX}${YOGA_GAP_NO_ROW}`);
|
||||
assert.doesNotMatch(line, /成立/u);
|
||||
// 状态值不在表内、或没有状态值的行同样落到 blocked,不猜。
|
||||
assert.equal(yogaStatus({ name: "X", status: "confirmed" }).key, "blocked");
|
||||
assert.equal(yogaStatus({ name: "X" }).key, "blocked");
|
||||
});
|
||||
|
||||
test("mixed statuses group in first-seen order and the status text is never clipped", () => {
|
||||
const line = yogaResult(cardWith([
|
||||
{ name: "Sunaphaa Yoga", status: "candidate" },
|
||||
{ name: "Vosi Yoga", status: "computed" },
|
||||
{ name: "Gaja Kesari Yoga", status: "candidate", formation: { status: "formed_under_source" } },
|
||||
], ["Sunaphaa Yoga", "Vosi Yoga", "Gaja Kesari Yoga"])) ?? "";
|
||||
assert.ok(line.length <= 80, line);
|
||||
assert.doesNotMatch(line, /…/u);
|
||||
assert.ok(line.startsWith(`Sunaphaa Yoga:${CANDIDATE}。Vosi Yoga:${COMPUTED}`), line);
|
||||
// 放不下的整项挪到“另 N 项未列”,而不是把 FORMED 截成“……成立”。
|
||||
if (!line.includes(FORMED)) assert.match(line, /另 1 项未列$/u);
|
||||
assert.doesNotMatch(line.replace(FORMED, ""), /成立/u);
|
||||
});
|
||||
|
||||
test("structural observations stay out of the 格局 row", () => {
|
||||
const card = cardWith([
|
||||
{ name: "Neecha Bhanga witness", status: "structural_observation" },
|
||||
{ name: "Sunaphaa Yoga", status: "candidate" },
|
||||
], ["Neecha Bhanga witness", "Sunaphaa Yoga"]);
|
||||
assert.equal(yogaResult(card), `Sunaphaa Yoga:${CANDIDATE}`);
|
||||
});
|
||||
|
||||
test("neither the grey line nor the fallback copy claims 成立: or 已确认", () => {
|
||||
const card = cardWith([
|
||||
{ name: "Sunaphaa Yoga", status: "candidate" },
|
||||
{ name: "Vosi Yoga", status: "computed" },
|
||||
], ["Sunaphaa Yoga", "Vosi Yoga"]);
|
||||
const line = yogaResult(card) ?? "";
|
||||
const fallback = consultAnswerBasisRows([{
|
||||
technique: "Yogas",
|
||||
calculation_status: "executed",
|
||||
} as unknown as TechniqueAuditRow]).find((row) => row.name === "格局")?.result ?? "";
|
||||
for (const text of [line, fallback]) {
|
||||
assert.doesNotMatch(text, /成立:/u);
|
||||
assert.doesNotMatch(text, /已确认/u);
|
||||
}
|
||||
assert.equal(fallback, "盘上检测到的格局已列出各自状态");
|
||||
});
|
||||
|
||||
test("the 格局 row keeps its calculation status while the grey line carries the reading status", () => {
|
||||
const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga"]);
|
||||
const enriched = withTechniqueEvidence([{
|
||||
technique: "Yogas",
|
||||
calculation_status: "executed",
|
||||
interpretation_status: "supporting",
|
||||
} as unknown as TechniqueAuditRow], card) ?? [];
|
||||
const row = consultAnswerBasisRows(enriched).find((item) => item.name === "格局");
|
||||
assert.ok(row);
|
||||
assert.equal(row.status, "done");
|
||||
assert.equal(row.result, `Sunaphaa Yoga:${CANDIDATE}`);
|
||||
});
|
||||
|
||||
test("no hits at all says nothing was detected, not that nothing formed", () => {
|
||||
const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], []);
|
||||
assert.equal(yogaResult(card), "本题没有检测到相关格局");
|
||||
});
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from . import transit_date, transit_window
|
||||
from . import transit_date
|
||||
from .contract import OnDemandInputError, blocked_envelope
|
||||
|
||||
# 工具名 → run(body) -> envelope
|
||||
@@ -15,9 +15,6 @@ TOOLS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||
# 指定日期的行运:必填 date、地点(lat/lon)、时区(tz 或 timezone_id)、ayanamsa;
|
||||
# node_mode / position_mode 缺省用产品默认值(mean / apparent)。
|
||||
'transit_date': transit_date.run,
|
||||
# 一段时间的行运变化:必填 start_date、end_date、地点、时区、ayanamsa、events;
|
||||
# planets / target / natal 按事件类型选填。
|
||||
'transit_window': transit_window.run,
|
||||
}
|
||||
|
||||
__all__ = ['TOOLS', 'OnDemandInputError', 'ondemand_calc_api']
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
"""按需计算:一段时间内的行运变化(#174,BUG-1817)。
|
||||
|
||||
占星师 2026-11-07 Q9 / 2026-11-08 第五轮第 9 条:行运窗口要支持**换座、停滞、逆行往返、目标经度**。
|
||||
数值一律来自引擎的恒星黄经(`jyotish_engine._calc_sidereal_planets_for_jd`),岁差、交点、
|
||||
位置口径由调用方显式给出;本工具不排本命盘,`natal_point` 要调用方把恒星黄经传进来。
|
||||
|
||||
时刻用二分收敛到 ≤ 1 分钟。换座、停滞、目标经度的判定都只看恒星黄经与日速度,不另起算法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from . import contract
|
||||
from .contract import OnDemandInputError
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
TOOL = 'transit_window'
|
||||
MAX_WINDOW_DAYS = 366
|
||||
SEARCH_MARGIN_DAYS = 400
|
||||
CONVERGENCE_MINUTES = 1
|
||||
CONVERGENCE_DAYS = CONVERGENCE_MINUTES / 1440.0
|
||||
PLANETS = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu')
|
||||
STATION_PLANETS = ('Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn')
|
||||
EVENT_TYPES = ('ingress', 'station', 'retrograde_loop', 'target_longitude')
|
||||
COARSE_STEP_HOURS = {'Moon': 6.0, 'default': 24.0}
|
||||
|
||||
|
||||
def _engine():
|
||||
try:
|
||||
from scripts import jyotish_engine as engine
|
||||
except ModuleNotFoundError: # pragma: no cover - 直接跑 scripts/
|
||||
import jyotish_engine as engine # type: ignore[no-redef]
|
||||
return engine
|
||||
|
||||
|
||||
def _jd_from_datetime(moment: datetime) -> float:
|
||||
import swisseph as swe
|
||||
return swe.julday(
|
||||
moment.year, moment.month, moment.day,
|
||||
moment.hour + moment.minute / 60.0 + moment.second / 3600.0,
|
||||
)
|
||||
|
||||
|
||||
def _local_midnight_jd(day: datetime, tz: float) -> float:
|
||||
"""当地 00:00 对应的 JD(UT)。"""
|
||||
return _jd_from_datetime(datetime(day.year, day.month, day.day) - timedelta(hours=tz))
|
||||
|
||||
|
||||
class _Sky:
|
||||
"""一个区间内按需取恒星黄经与日速度,调用方给的口径固定不变。"""
|
||||
|
||||
def __init__(self, engine: Any, profile: Dict[str, Any], planets: Sequence[str]) -> None:
|
||||
self.engine = engine
|
||||
self.profile = profile
|
||||
self.planets = tuple(planets)
|
||||
if hasattr(engine, '_configure_ephemeris_path'):
|
||||
engine._configure_ephemeris_path() # noqa: SLF001
|
||||
self._cache: Dict[float, Dict[str, Dict[str, float]]] = {}
|
||||
|
||||
def at(self, jd: float) -> Dict[str, Dict[str, float]]:
|
||||
key = round(jd, 8)
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
data, _ayanamsa = self.engine._calc_sidereal_planets_for_jd( # noqa: SLF001
|
||||
jd,
|
||||
node_mode=self.profile['node_mode'],
|
||||
include_ketu=True,
|
||||
ayanamsa_name=self.profile['ayanamsa'],
|
||||
)
|
||||
snapshot: Dict[str, Dict[str, float]] = {}
|
||||
for name in self.planets:
|
||||
row = data.get(name)
|
||||
if not row:
|
||||
continue
|
||||
snapshot[name] = {
|
||||
'lon': float(row.get('degree_raw', row.get('degree', 0.0))),
|
||||
'speed': float(row.get('speed') or 0.0),
|
||||
'sign': str(row.get('sign') or ''),
|
||||
}
|
||||
self._cache[key] = snapshot
|
||||
return snapshot
|
||||
|
||||
def ayanamsa(self, jd: float) -> float:
|
||||
data, ayanamsa = self.engine._calc_sidereal_planets_for_jd( # noqa: SLF001
|
||||
jd,
|
||||
node_mode=self.profile['node_mode'],
|
||||
include_ketu=True,
|
||||
ayanamsa_name=self.profile['ayanamsa'],
|
||||
)
|
||||
del data
|
||||
return float(ayanamsa or 0.0)
|
||||
|
||||
def lon(self, planet: str, jd: float) -> Optional[float]:
|
||||
row = self.at(jd).get(planet)
|
||||
return None if row is None else row['lon']
|
||||
|
||||
|
||||
def _bisect(predicate: Callable[[float], bool], low: float, high: float,
|
||||
tolerance: float = CONVERGENCE_DAYS) -> float:
|
||||
"""在 [low, high] 上找 predicate 从 False 变 True 的位置。"""
|
||||
lo, hi = low, high
|
||||
while hi - lo > tolerance:
|
||||
middle = (lo + hi) / 2.0
|
||||
if predicate(middle):
|
||||
hi = middle
|
||||
else:
|
||||
lo = middle
|
||||
return (lo + hi) / 2.0
|
||||
|
||||
|
||||
def _signed_delta(value: float, reference: float) -> float:
|
||||
"""value - reference,按 0–360 取最短方向(正为顺行方向)。"""
|
||||
return ((value - reference + 180.0) % 360.0) - 180.0
|
||||
|
||||
|
||||
def _crossed(before: float, after: float, boundary: float) -> bool:
|
||||
"""从 before 到 after(正向时间)是否跨过 boundary(模 360)。顺行逆行都算。"""
|
||||
if before == after:
|
||||
return False
|
||||
delta = _signed_delta(after, before)
|
||||
if delta == 0.0:
|
||||
return False
|
||||
if delta > 0.0:
|
||||
return 0.0 < (boundary - before) % 360.0 <= delta
|
||||
return 0.0 < (before - boundary) % 360.0 <= -delta
|
||||
|
||||
|
||||
def _sign_boundaries(before: float, after: float) -> List[float]:
|
||||
"""一步之内跨过的所有 30° 整数倍,按行进方向排序。"""
|
||||
out: List[float] = []
|
||||
forward = (after - before) % 360.0
|
||||
if forward == 0.0:
|
||||
return out
|
||||
step = 30.0 if forward < 180.0 else -30.0
|
||||
boundary = ((before // 30.0) + (1 if step > 0 else 0)) * 30.0
|
||||
travelled = 0.0
|
||||
guard = 0
|
||||
while travelled < forward - 1e-9 and guard < 24:
|
||||
guard += 1
|
||||
if step > 0:
|
||||
distance = (boundary - before) % 360.0
|
||||
if distance > forward:
|
||||
break
|
||||
out.append(boundary % 360.0)
|
||||
travelled = distance
|
||||
boundary += 30.0
|
||||
else:
|
||||
distance = (before - boundary) % 360.0
|
||||
if distance > (before - after) % 360.0:
|
||||
break
|
||||
out.append(boundary % 360.0)
|
||||
travelled = distance
|
||||
boundary -= 30.0
|
||||
return out
|
||||
|
||||
|
||||
def _sign_name(engine: Any, longitude: float) -> str:
|
||||
signs = getattr(engine, 'SIGNS', None) or []
|
||||
if not signs:
|
||||
return ''
|
||||
return str(signs[int(longitude % 360.0 / 30.0) % 12])
|
||||
|
||||
|
||||
def _utc_stamp(jd: float) -> str:
|
||||
import swisseph as swe
|
||||
year, month, day, hour = swe.revjul(jd)
|
||||
minutes = int(round(hour * 60.0))
|
||||
return f'{year:04d}-{month:02d}-{day:02d}T{(minutes // 60) % 24:02d}:{minutes % 60:02d}Z'
|
||||
|
||||
|
||||
def _local_parts(jd: float, tz: float) -> Tuple[str, int, int]:
|
||||
"""当地日期与时分。"""
|
||||
import swisseph as swe
|
||||
year, month, day, hour = swe.revjul(jd + tz / 24.0)
|
||||
minutes = int(round(hour * 60.0))
|
||||
return f'{year:04d}-{month:02d}-{day:02d}', (minutes // 60) % 24, minutes % 60
|
||||
|
||||
|
||||
def _offset_for_jd(jd: float, tz_fixed: Optional[float], timezone_id: Optional[str]) -> float:
|
||||
if not timezone_id:
|
||||
return float(tz_fixed or 0.0)
|
||||
import swisseph as swe
|
||||
year, month, day, _hour = swe.revjul(jd + (tz_fixed or 0.0) / 24.0)
|
||||
return contract.offset_hours(datetime(year, month, day), None, timezone_id)
|
||||
|
||||
|
||||
def _event(engine: Any, sky: _Sky, planet: str, jd: float, kind: str,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> Dict[str, Any]:
|
||||
row = sky.at(jd).get(planet) or {}
|
||||
longitude = float(row.get('lon') or 0.0)
|
||||
offset = _offset_for_jd(jd, tz_fixed, timezone_id)
|
||||
date_text, hour, minute = _local_parts(jd, offset)
|
||||
return {
|
||||
'type': kind,
|
||||
'planet': planet,
|
||||
'utc': _utc_stamp(jd),
|
||||
'local': f'{date_text}T{hour:02d}:{minute:02d}',
|
||||
'utc_offset_hours': round(offset, 4),
|
||||
'jd_ut': round(jd, 8),
|
||||
'longitude': round(longitude, 6),
|
||||
'sign': _sign_name(engine, longitude),
|
||||
'degree_in_sign': round(longitude % 30.0, 4),
|
||||
}
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(longitude % 360.0 / 30.0) % 12
|
||||
|
||||
|
||||
def _ingress_events(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
step = COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) / 24.0
|
||||
events: List[Dict[str, Any]] = []
|
||||
jd = start_jd
|
||||
before = sky.lon(planet, jd)
|
||||
while jd < end_jd:
|
||||
nxt = min(jd + step, end_jd)
|
||||
after = sky.lon(planet, nxt)
|
||||
if before is None or after is None:
|
||||
before, jd = after, nxt
|
||||
continue
|
||||
if _sign_index(before) != _sign_index(after):
|
||||
start_sign = _sign_index(before)
|
||||
at = _bisect(lambda x: _sign_index(sky.lon(planet, x) or before) != start_sign, jd, nxt)
|
||||
event = _event(engine, sky, planet, at, 'ingress', tz_fixed, timezone_id)
|
||||
event['from_sign'] = _sign_name(engine, before)
|
||||
# 验收修正(Claude 2026-11-09):原来 to_sign 用 JD 当黄经取星座、direction 在 0° 白羊处判反;
|
||||
# 改为按粗扫步末端的黄经取星座,方向按最短角差的正负。
|
||||
event['to_sign'] = _sign_name(engine, after)
|
||||
event['direction'] = 'direct' if _signed_delta(after, before) > 0.0 else 'retrograde'
|
||||
events.append(event)
|
||||
before, jd = after, nxt
|
||||
return events
|
||||
|
||||
|
||||
def _station_events(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
step = COARSE_STEP_HOURS['default'] / 24.0
|
||||
events: List[Dict[str, Any]] = []
|
||||
jd = start_jd
|
||||
before_row = sky.at(jd).get(planet)
|
||||
while jd < end_jd:
|
||||
nxt = min(jd + step, end_jd)
|
||||
after_row = sky.at(nxt).get(planet)
|
||||
if not before_row or not after_row:
|
||||
before_row, jd = after_row, nxt
|
||||
continue
|
||||
before_speed = before_row['speed']
|
||||
after_speed = after_row['speed']
|
||||
if before_speed == 0.0 or (before_speed < 0) != (after_speed < 0):
|
||||
at = _bisect(lambda x: (sky.at(x).get(planet, {}).get('speed', 0.0) < 0) != (before_speed < 0), jd, nxt)
|
||||
event = _event(engine, sky, planet, at, 'station', tz_fixed, timezone_id)
|
||||
event['station_type'] = 'retrograde' if before_speed >= 0 else 'direct'
|
||||
event['speed'] = round(float(sky.at(at).get(planet, {}).get('speed', 0.0)), 6)
|
||||
events.append(event)
|
||||
before_row, jd = after_row, nxt
|
||||
return events
|
||||
|
||||
|
||||
def _target_events(engine: Any, sky: _Sky, planet: str, target: float, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
step = COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) / 24.0
|
||||
events: List[Dict[str, Any]] = []
|
||||
jd = start_jd
|
||||
before = sky.lon(planet, jd)
|
||||
while jd < end_jd:
|
||||
nxt = min(jd + step, end_jd)
|
||||
after = sky.lon(planet, nxt)
|
||||
if before is None or after is None:
|
||||
before, jd = after, nxt
|
||||
continue
|
||||
if before != after and _crossed(before, after, target):
|
||||
at = _bisect(lambda x: _crossed(before, sky.lon(planet, x) or before, target), jd, nxt)
|
||||
event = _event(engine, sky, planet, at, 'target_longitude', tz_fixed, timezone_id)
|
||||
event['target_longitude'] = round(float(target), 6)
|
||||
event['direction'] = 'direct' if (after - before) % 360.0 < 180.0 else 'retrograde'
|
||||
events.append(event)
|
||||
before, jd = after, nxt
|
||||
return events
|
||||
|
||||
|
||||
def _retrograde_loops(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str],
|
||||
gaps: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
||||
margin = SEARCH_MARGIN_DAYS
|
||||
stations = _station_events(engine, sky, planet, start_jd - margin, end_jd + margin, tz_fixed, timezone_id)
|
||||
loops: List[Dict[str, Any]] = []
|
||||
for index, station in enumerate(stations):
|
||||
if station.get('station_type') != 'retrograde':
|
||||
continue
|
||||
if index + 1 >= len(stations):
|
||||
continue
|
||||
direct = stations[index + 1]
|
||||
if direct.get('station_type') != 'direct':
|
||||
continue
|
||||
retro_lon = float(station['longitude'])
|
||||
direct_lon = float(direct['longitude'])
|
||||
entry = _find_crossing(engine, sky, planet, direct_lon, station['jd_ut'] - margin * 1.0,
|
||||
station['jd_ut'], tz_fixed, timezone_id, backwards=True)
|
||||
exit_ = _find_crossing(engine, sky, planet, retro_lon, direct['jd_ut'],
|
||||
direct['jd_ut'] + margin * 1.0, tz_fixed, timezone_id, backwards=False)
|
||||
inside = (start_jd <= station['jd_ut'] <= end_jd) or (start_jd <= direct['jd_ut'] <= end_jd)
|
||||
if not inside:
|
||||
continue
|
||||
for key, value in (('shadow_entry', entry), ('shadow_exit', exit_)):
|
||||
if value is None:
|
||||
gaps.append({'field': 'retrograde_loop', 'reason': f'loop_incomplete:{planet}:{key}'})
|
||||
loops.append({
|
||||
'planet': planet,
|
||||
'station_retrograde': station,
|
||||
'station_direct': direct,
|
||||
'retrograde_span': [round(direct_lon, 6), round(retro_lon, 6)],
|
||||
'shadow_entry': entry,
|
||||
'shadow_exit': exit_,
|
||||
'outside_requested_window': not (start_jd <= station['jd_ut'] <= end_jd),
|
||||
})
|
||||
return loops
|
||||
|
||||
|
||||
def _find_crossing(engine: Any, sky: _Sky, planet: str, longitude: float, low: float, high: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str],
|
||||
*, backwards: bool) -> Optional[Dict[str, Any]]:
|
||||
"""在 [low, high] 内找最后一次(backwards)或第一次(向前)到达 longitude 的时刻。"""
|
||||
if high <= low:
|
||||
return None
|
||||
step = COARSE_STEP_HOURS['default'] / 24.0
|
||||
found: Optional[float] = None
|
||||
jd = low
|
||||
before = sky.lon(planet, jd)
|
||||
while jd < high:
|
||||
nxt = min(jd + step, high)
|
||||
after = sky.lon(planet, nxt)
|
||||
if before is not None and after is not None and before != after and _crossed(before, after, longitude):
|
||||
at = _bisect(lambda x: _crossed(before, sky.lon(planet, x) or before, longitude), jd, nxt)
|
||||
# 向后找「最后一次」到达:从早到晚扫,保留最后一个;向前找「第一次」:命中即停。
|
||||
found = at
|
||||
if not backwards:
|
||||
break
|
||||
before, jd = after, nxt
|
||||
if found is None:
|
||||
return None
|
||||
return _event(engine, sky, planet, found, 'shadow_crossing', tz_fixed, timezone_id)
|
||||
|
||||
|
||||
def _validate(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(body, dict):
|
||||
raise OnDemandInputError('body must be an object')
|
||||
start = contract.parse_date(body.get('start_date'), 'start_date') if body.get('start_date') else None
|
||||
if start is None:
|
||||
raise OnDemandInputError('missing required field: start_date', 'start_date')
|
||||
end = contract.parse_date(body.get('end_date'), 'end_date') if body.get('end_date') else None
|
||||
if end is None:
|
||||
raise OnDemandInputError('missing required field: end_date', 'end_date')
|
||||
if end < start:
|
||||
raise OnDemandInputError('end_date must be on or after start_date', 'end_date')
|
||||
if (end - start).days + 1 > MAX_WINDOW_DAYS:
|
||||
raise OnDemandInputError(f'window must be at most {MAX_WINDOW_DAYS} days', 'end_date')
|
||||
events = body.get('events')
|
||||
if not isinstance(events, list) or not events:
|
||||
raise OnDemandInputError('missing required field: events', 'events')
|
||||
unknown = [item for item in events if item not in EVENT_TYPES]
|
||||
if unknown:
|
||||
raise OnDemandInputError(f'unknown event type: {unknown[0]}', 'events')
|
||||
planets = body.get('planets')
|
||||
if planets is None:
|
||||
planets = list(PLANETS)
|
||||
if not isinstance(planets, list) or not planets:
|
||||
raise OnDemandInputError('planets must be a non-empty list', 'planets')
|
||||
bad = [item for item in planets if item not in PLANETS]
|
||||
if bad:
|
||||
raise OnDemandInputError(f'unknown planet: {bad[0]}', 'planets')
|
||||
target = body.get('target')
|
||||
if 'target_longitude' in events:
|
||||
if not isinstance(target, dict):
|
||||
raise OnDemandInputError('missing required field: target', 'target')
|
||||
if str(target.get('planet')) not in PLANETS:
|
||||
raise OnDemandInputError('target.planet must be one of the nine planets', 'target')
|
||||
has_longitude = target.get('longitude') is not None
|
||||
has_point = target.get('natal_point') is not None
|
||||
if not has_longitude and not has_point:
|
||||
raise OnDemandInputError('target needs longitude or natal_point', 'target')
|
||||
if has_longitude:
|
||||
try:
|
||||
value = float(target['longitude'])
|
||||
except (TypeError, ValueError) as error:
|
||||
raise OnDemandInputError('target.longitude must be a number', 'target') from error
|
||||
if not 0.0 <= value < 360.0:
|
||||
raise OnDemandInputError('target.longitude must be 0–360', 'target')
|
||||
if has_point and not isinstance(body.get('natal'), dict):
|
||||
raise OnDemandInputError('target.natal_point needs natal.points', 'natal')
|
||||
return {
|
||||
'start': start,
|
||||
'end': end,
|
||||
'planets': list(dict.fromkeys(planets)),
|
||||
'events': list(dict.fromkeys(events)),
|
||||
'target': target,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_target(body: Dict[str, Any], inputs: Dict[str, Any]) -> Tuple[Optional[float], Optional[Dict[str, str]]]:
|
||||
target = inputs.get('target') or {}
|
||||
if 'target_longitude' not in inputs['events']:
|
||||
return None, None
|
||||
if target.get('longitude') is not None:
|
||||
return float(target['longitude']), None
|
||||
name = str(target.get('natal_point'))
|
||||
points = (body.get('natal') or {}).get('points') if isinstance(body.get('natal'), dict) else None
|
||||
if not isinstance(points, dict) or name not in points:
|
||||
return None, {'field': 'target.natal_point', 'reason': f'natal_point_missing:{name}'}
|
||||
return float(points[name]), None
|
||||
|
||||
|
||||
def run(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
inputs = _validate(body)
|
||||
common = contract.validate_common({**body, 'date': body['start_date']})
|
||||
profile = contract.resolve_profile({**body, 'date': body['start_date']}, common)
|
||||
target_longitude, target_gap = _resolve_target(body, inputs)
|
||||
except OnDemandInputError as error:
|
||||
return contract.blocked_envelope(tool=TOOL, body=body, error=error)
|
||||
|
||||
requested = dict(body)
|
||||
if target_gap is not None:
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[target_gap],
|
||||
)
|
||||
if profile['position_mode'] not in contract.SUPPORTED_POSITION_MODES:
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'position_mode', 'reason': 'position_mode_unsupported'}],
|
||||
)
|
||||
|
||||
tz_fixed = profile['tz'] if not profile.get('timezone_id') else None
|
||||
timezone_id = profile.get('timezone_id')
|
||||
try:
|
||||
engine = _engine()
|
||||
sky = _Sky(engine, profile, inputs['planets'])
|
||||
except Exception as error: # noqa: BLE001 - 星历不可用也只留缺口
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
|
||||
)
|
||||
start_jd = _local_midnight_jd(inputs['start'], profile['tz'])
|
||||
end_day = inputs['end'] + timedelta(days=1)
|
||||
# 验收修正(Claude 2026-11-09):区间终点按终点那天的偏移换算(原来用起始日偏移,跨夏令时差 1 小时)。
|
||||
end_offset = contract.offset_hours(end_day, None, timezone_id) if timezone_id else profile['tz']
|
||||
end_jd = _local_midnight_jd(end_day, end_offset)
|
||||
|
||||
gaps: List[Dict[str, str]] = []
|
||||
events: List[Dict[str, Any]] = []
|
||||
loops: List[Dict[str, Any]] = []
|
||||
try:
|
||||
for planet in inputs['planets']:
|
||||
if 'ingress' in inputs['events']:
|
||||
events.extend(_ingress_events(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id))
|
||||
if 'station' in inputs['events'] or 'retrograde_loop' in inputs['events']:
|
||||
if planet in ('Rahu', 'Ketu'):
|
||||
if 'station' in inputs['events'] or 'retrograde_loop' in inputs['events']:
|
||||
gaps.append({'field': 'planets', 'reason': f'node_station_not_supported:{planet}'})
|
||||
elif planet in STATION_PLANETS:
|
||||
if 'station' in inputs['events']:
|
||||
events.extend(_station_events(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id))
|
||||
if 'retrograde_loop' in inputs['events']:
|
||||
loops.extend(_retrograde_loops(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id, gaps))
|
||||
if 'target_longitude' in inputs['events'] and target_longitude is not None and planet == str((inputs['target'] or {}).get('planet')):
|
||||
events.extend(_target_events(engine, sky, planet, target_longitude, start_jd, end_jd, tz_fixed, timezone_id))
|
||||
except Exception as error: # noqa: BLE001 - 失败只留缺口,不编任何日期
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
|
||||
)
|
||||
|
||||
events.sort(key=lambda item: item['jd_ut'])
|
||||
for loop in loops:
|
||||
for key in ('station_retrograde', 'station_direct'):
|
||||
stamp = loop.get(key) or {}
|
||||
loop[f'{key}_outside_window'] = not (start_jd <= float(stamp.get('jd_ut', 0.0)) <= end_jd)
|
||||
# 验收修正(Claude 2026-11-09):任务书要求落在请求区间外的每个时刻自己标 outside_requested_window。
|
||||
for key in ('station_retrograde', 'station_direct', 'shadow_entry', 'shadow_exit'):
|
||||
stamp = loop.get(key)
|
||||
if isinstance(stamp, dict):
|
||||
stamp['outside_requested_window'] = not (start_jd <= float(stamp['jd_ut']) <= end_jd)
|
||||
raw = {
|
||||
'start_date': inputs['start'].strftime('%Y-%m-%d'),
|
||||
'end_date': inputs['end'].strftime('%Y-%m-%d'),
|
||||
'ayanamsa_degrees_at_start': round(sky.ayanamsa(start_jd), 6),
|
||||
'precision': {
|
||||
'coarse_step_hours': {planet: COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) for planet in inputs['planets']},
|
||||
'convergence_minutes': CONVERGENCE_MINUTES,
|
||||
'search_margin_days': SEARCH_MARGIN_DAYS,
|
||||
},
|
||||
'events': events,
|
||||
'retrograde_loops': loops,
|
||||
}
|
||||
status = 'partial' if gaps else 'executed'
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status=status, raw=raw, gaps=gaps,
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""出生时间「按用途够用」的用途决策合同(BUG-1833,#186 第一步)。
|
||||
|
||||
移植自占星师 2026-11-09 的参考决策器
|
||||
(``docs/tasks/assets/purpose-gate-20261109/reference_gate.py``),逐行等价:
|
||||
没有区间宽度门槛,也没有分数差门槛——这是参考代码刻意为之。
|
||||
|
||||
本模块只回答「这个已经算好的评估,可以在什么范围内被当作够用」,
|
||||
不做任何计算、不改出生记录、不证明调用方给的证据为真:
|
||||
|
||||
- ``can_use_interval`` 只有 ``scope_ready`` 才为真;
|
||||
- ``can_modify_birth_record`` 与 ``representative_is_birth_truth`` 永远为假。
|
||||
|
||||
**尚未接入**:本单只交付合同与测试,没有任何生产入口读它,
|
||||
``scope_ready`` 也不会影响任何现有流程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Final, List, Mapping, Sequence, Tuple
|
||||
|
||||
PURPOSE_USE_SCHEMA: Final[str] = 'jyotish.purpose_use_decision.v1'
|
||||
|
||||
BINDING_KEYS: Final[Tuple[str, ...]] = (
|
||||
'input_revision', 'candidate_set_fingerprint', 'purpose_fingerprint',
|
||||
'profile_fingerprint', 'engine_version', 'precision_policy_version',
|
||||
'forecast_window_fingerprint',
|
||||
)
|
||||
|
||||
LAYER_COVERAGE_STATES: Final[Tuple[str, ...]] = ('executed_complete', 'partial', 'blocked')
|
||||
SCAN_STATES: Final[Tuple[str, ...]] = ('policy_covered', 'sampled_only', 'incomplete')
|
||||
BRANCH_STATES: Final[Tuple[str, ...]] = (
|
||||
'resolved', 'bounded_equivalent', 'unresolved_material', 'not_evaluated',
|
||||
)
|
||||
|
||||
STATUS_SCOPE_READY: Final[str] = 'scope_ready'
|
||||
STATUS_CONDITIONAL: Final[str] = 'conditional'
|
||||
STATUS_NEEDS_REFINEMENT: Final[str] = 'needs_refinement'
|
||||
STATUS_INSUFFICIENT_EVIDENCE: Final[str] = 'insufficient_evidence'
|
||||
STATUS_STALE: Final[str] = 'stale'
|
||||
|
||||
|
||||
def _binding_is_complete(binding: Any) -> bool:
|
||||
return isinstance(binding, dict) and all(
|
||||
isinstance(binding.get(key), str) and binding[key] for key in BINDING_KEYS
|
||||
)
|
||||
|
||||
|
||||
def _claim_ids(assessment: Mapping[str, Any], field: str) -> List[str]:
|
||||
values = assessment.get(field)
|
||||
if not isinstance(values, list) or any(not isinstance(value, str) or not value for value in values):
|
||||
raise ValueError(f'invalid {field}')
|
||||
if len(set(values)) != len(values):
|
||||
raise ValueError(f'duplicate {field}')
|
||||
return list(values)
|
||||
|
||||
|
||||
def _validate(assessment: Mapping[str, Any], expected_binding: Mapping[str, Any]) -> None:
|
||||
"""合同校验:不合法一律 ValueError(fail closed)。"""
|
||||
for binding in (assessment.get('binding'), expected_binding):
|
||||
if not _binding_is_complete(binding):
|
||||
raise ValueError('complete server binding required')
|
||||
|
||||
required = assessment.get('required_layers')
|
||||
coverage = assessment.get('layer_coverage')
|
||||
if (not isinstance(required, list) or not required
|
||||
or any(not isinstance(layer, str) or not layer for layer in required)
|
||||
or len(set(required)) != len(required)
|
||||
or not isinstance(coverage, dict)):
|
||||
raise ValueError('explicit nonempty required layer set required')
|
||||
if any(coverage.get(layer) not in LAYER_COVERAGE_STATES for layer in required):
|
||||
raise ValueError('required layers cannot be absent or not_applicable')
|
||||
if assessment.get('scan_state') not in SCAN_STATES:
|
||||
raise ValueError('invalid scan_state')
|
||||
if assessment.get('branch_state') not in BRANCH_STATES:
|
||||
raise ValueError('invalid branch_state')
|
||||
for flag in ('domain_complete', 'material_change'):
|
||||
if type(assessment.get(flag)) is not bool: # noqa: E721 - 只认真正的 bool,不认 0/1
|
||||
raise ValueError(f'{flag} must be a boolean')
|
||||
_claim_ids(assessment, 'stable_claim_ids')
|
||||
_claim_ids(assessment, 'conditional_claim_ids')
|
||||
if set(assessment['stable_claim_ids']) & set(assessment['conditional_claim_ids']):
|
||||
raise ValueError('one fact cannot be both stable and conditional')
|
||||
|
||||
|
||||
def _result(status: str, stable: Sequence[str], conditional: Sequence[str], reason: str) -> Dict[str, Any]:
|
||||
allowed = list(dict.fromkeys(list(stable) + list(conditional)))
|
||||
return {
|
||||
'schema': PURPOSE_USE_SCHEMA,
|
||||
'status': status,
|
||||
'can_use_interval': status == STATUS_SCOPE_READY,
|
||||
'can_continue_analysis': bool(allowed),
|
||||
'can_modify_birth_record': False,
|
||||
'representative_is_birth_truth': False,
|
||||
'stable_claim_ids': list(stable),
|
||||
'conditional_claim_ids': list(conditional),
|
||||
'allowed_claim_ids': allowed,
|
||||
'reason': reason,
|
||||
}
|
||||
|
||||
|
||||
def assess_use(assessment: dict, expected_binding: dict) -> dict:
|
||||
"""Return permissions for the evaluated scope, not birth-time confidence.
|
||||
|
||||
Deliberately has no interval-width or score-margin threshold.
|
||||
stable_claim_ids must cover the entire remaining candidate domain;
|
||||
conditional_claim_ids must point to explicit branch/limitation evidence.
|
||||
"""
|
||||
_validate(assessment, expected_binding)
|
||||
|
||||
if any(assessment['binding'][key] != expected_binding[key] for key in BINDING_KEYS):
|
||||
return _result(STATUS_STALE, [], [], 'binding_changed_recompute')
|
||||
|
||||
required = assessment['required_layers']
|
||||
coverage = assessment['layer_coverage']
|
||||
stable = assessment['stable_claim_ids']
|
||||
conditional = assessment['conditional_claim_ids']
|
||||
|
||||
if assessment['material_change']:
|
||||
return _result(STATUS_NEEDS_REFINEMENT, stable, conditional, 'material_output_change')
|
||||
complete = all(coverage[layer] == 'executed_complete' for layer in required)
|
||||
if (complete and assessment['domain_complete']
|
||||
and assessment['scan_state'] == 'policy_covered'
|
||||
and assessment['branch_state'] in ('resolved', 'bounded_equivalent')
|
||||
and stable and not conditional):
|
||||
return _result(STATUS_SCOPE_READY, stable, [], 'adequate_for_frozen_purpose')
|
||||
if stable or conditional:
|
||||
return _result(STATUS_CONDITIONAL, stable, conditional, 'only_listed_claims_are_available')
|
||||
return _result(STATUS_INSUFFICIENT_EVIDENCE, [], [], 'no_supported_outputs_for_purpose')
|
||||
@@ -1,292 +0,0 @@
|
||||
"""按需计算(二):一段时间内的行运变化(#174,BUG-1817)。
|
||||
|
||||
换座 / 停滞 / 逆行往返 / 目标经度,时刻二分到 ≤1 分钟,口径显式。
|
||||
只用公开日期与合成坐标,不含任何个人资料。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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.ondemand_calc import ondemand_calc_api # noqa: E402
|
||||
from scripts.ondemand_calc import transit_window # noqa: E402
|
||||
|
||||
BASE = {
|
||||
'tool': 'transit_window',
|
||||
'start_date': '2026-01-01',
|
||||
'end_date': '2026-03-31',
|
||||
'lat': 0.0,
|
||||
'lon': 0.0,
|
||||
'tz': 0.0,
|
||||
'ayanamsa': 'lahiri',
|
||||
'node_mode': 'mean',
|
||||
'planets': ['Mercury'],
|
||||
'events': ['ingress', 'station', 'retrograde_loop'],
|
||||
}
|
||||
|
||||
|
||||
def _body(**overrides):
|
||||
body = dict(BASE)
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _jd(iso: str) -> float:
|
||||
import swisseph as swe
|
||||
moment = datetime.strptime(iso, '%Y-%m-%dT%H:%MZ')
|
||||
return swe.julday(moment.year, moment.month, moment.day, moment.hour + moment.minute / 60.0)
|
||||
|
||||
|
||||
def _reference_stations(planet: str, ayanamsa: str, start_iso: str, end_iso: str):
|
||||
"""独立对照:直接用 SwissEph 的恒星黄经速度二分过零点。"""
|
||||
import swisseph as swe
|
||||
from ayanamsa_utils import apply_ayanamsa, sidereal_flags
|
||||
|
||||
apply_ayanamsa(ayanamsa)
|
||||
flags = sidereal_flags(swe, ayanamsa) | getattr(swe, 'FLG_SPEED', 256)
|
||||
planet_id = {'Mercury': swe.MERCURY, 'Venus': swe.VENUS, 'Mars': swe.MARS}[planet]
|
||||
|
||||
def speed(jd: float) -> float:
|
||||
values, _ = swe.calc_ut(jd, planet_id, flags)
|
||||
return values[3]
|
||||
|
||||
stations = []
|
||||
jd = _jd(start_iso)
|
||||
end = _jd(end_iso)
|
||||
step = 0.5
|
||||
before = speed(jd)
|
||||
while jd < end:
|
||||
nxt = min(jd + step, end)
|
||||
after = speed(nxt)
|
||||
if (before < 0) != (after < 0):
|
||||
lo, hi = jd, nxt
|
||||
while hi - lo > 1.0 / 1440.0:
|
||||
middle = (lo + hi) / 2.0
|
||||
if (speed(middle) < 0) == (before < 0):
|
||||
lo = middle
|
||||
else:
|
||||
hi = middle
|
||||
stations.append((lo + hi) / 2.0)
|
||||
before, jd = after, nxt
|
||||
return stations
|
||||
|
||||
|
||||
def test_mercury_retrograde_stations_match_a_direct_swisseph_bisection() -> None:
|
||||
envelope = ondemand_calc_api(_body())
|
||||
assert envelope['calculation_status'] == 'executed'
|
||||
stations = [event for event in envelope['raw']['events'] if event['type'] == 'station']
|
||||
assert len(stations) == 2, stations
|
||||
reference = _reference_stations('Mercury', 'lahiri', '2026-02-01T00:00Z', '2026-04-15T00:00Z')
|
||||
assert len(reference) == 2
|
||||
for event, expected in zip(stations, reference):
|
||||
delta_minutes = abs(event['jd_ut'] - expected) * 24 * 60
|
||||
assert delta_minutes <= 2.0, (event['utc'], delta_minutes)
|
||||
|
||||
|
||||
def test_target_longitude_lists_all_three_passes_with_directions() -> None:
|
||||
# 三次到齐要看到 4 月:第二次逆行、第三次要等顺行恢复(2026-04-01)。
|
||||
envelope = ondemand_calc_api(_body(
|
||||
end_date='2026-04-30',
|
||||
events=['target_longitude'],
|
||||
target={'planet': 'Mercury', 'longitude': 320.0},
|
||||
))
|
||||
events = [event for event in envelope['raw']['events'] if event['type'] == 'target_longitude']
|
||||
assert len(events) == 3, events
|
||||
assert [event['direction'] for event in events] == ['direct', 'retrograde', 'direct']
|
||||
assert all(abs(event['longitude'] - 320.0) <= 0.01 for event in events)
|
||||
|
||||
|
||||
def test_retrograde_loop_shadow_ordering_and_span() -> None:
|
||||
envelope = ondemand_calc_api(_body())
|
||||
loops = envelope['raw']['retrograde_loops']
|
||||
assert len(loops) == 1
|
||||
loop = loops[0]
|
||||
assert loop['shadow_entry']['jd_ut'] < loop['station_retrograde']['jd_ut']
|
||||
assert loop['shadow_exit']['jd_ut'] > loop['station_direct']['jd_ut']
|
||||
low, high = loop['retrograde_span']
|
||||
assert low < high
|
||||
|
||||
|
||||
def test_ingress_times_differ_between_ayanamsas_and_echo_the_profile() -> None:
|
||||
lahiri = ondemand_calc_api(_body(events=['ingress']))
|
||||
raman = ondemand_calc_api(_body(events=['ingress'], ayanamsa='raman'))
|
||||
assert lahiri['profile']['ayanamsa'] == 'lahiri'
|
||||
assert raman['profile']['ayanamsa'] == 'raman'
|
||||
lahiri_times = [event['utc'] for event in lahiri['raw']['events'] if event['type'] == 'ingress']
|
||||
raman_times = [event['utc'] for event in raman['raw']['events'] if event['type'] == 'ingress']
|
||||
assert lahiri_times and raman_times
|
||||
assert lahiri_times[0] != raman_times[0]
|
||||
|
||||
|
||||
def test_nodes_follow_the_node_mode() -> None:
|
||||
# 罗睺约 19°/年,三个月里不会换座,所以用一整年。
|
||||
year = {'start_date': '2026-01-01', 'end_date': '2026-12-31'}
|
||||
mean = ondemand_calc_api(_body(planets=['Rahu'], events=['ingress'], node_mode='mean', **year))
|
||||
true = ondemand_calc_api(_body(planets=['Rahu'], events=['ingress'], node_mode='true', **year))
|
||||
mean_times = [event['utc'] for event in mean['raw']['events']]
|
||||
true_times = [event['utc'] for event in true['raw']['events']]
|
||||
assert mean_times and true_times
|
||||
assert mean_times[0] != true_times[0]
|
||||
|
||||
|
||||
def test_timezone_id_offset_follows_daylight_saving_inside_the_window() -> None:
|
||||
envelope = ondemand_calc_api({
|
||||
'tool': 'transit_window',
|
||||
'start_date': '2026-03-27',
|
||||
'end_date': '2026-03-30',
|
||||
'lat': 51.5074,
|
||||
'lon': -0.1278,
|
||||
'timezone_id': 'Europe/London',
|
||||
'ayanamsa': 'lahiri',
|
||||
'planets': ['Sun'],
|
||||
'events': ['ingress'],
|
||||
})
|
||||
assert envelope['calculation_status'] in {'executed', 'partial'}
|
||||
offsets = {event['utc_offset_hours'] for event in envelope['raw']['events']}
|
||||
assert offsets <= {0.0, 1.0}
|
||||
assert envelope['raw']['precision']['convergence_minutes'] == 1
|
||||
|
||||
|
||||
def test_longitude_at_an_ingress_agrees_with_the_single_date_tool() -> None:
|
||||
window = ondemand_calc_api(_body(events=['ingress']))
|
||||
ingress = next(event for event in window['raw']['events'] if event['type'] == 'ingress')
|
||||
single = ondemand_calc_api({
|
||||
'tool': 'transit_date',
|
||||
'date': ingress['utc'][:10],
|
||||
'time': ingress['utc'][11:16],
|
||||
'lat': 0.0, 'lon': 0.0, 'tz': 0.0,
|
||||
'ayanamsa': 'lahiri', 'node_mode': 'mean',
|
||||
})
|
||||
row = next(item for item in single['raw']['planets'] if item['planet'] == ingress['planet'])
|
||||
boundary = round(row['longitude'] / 30.0) * 30.0
|
||||
assert abs(row['longitude'] - boundary) <= 0.01
|
||||
|
||||
|
||||
def test_missing_inputs_are_blocked_and_named() -> None:
|
||||
cases = [
|
||||
({'start_date': None}, 'start_date'),
|
||||
({'end_date': None}, 'end_date'),
|
||||
({'lat': None}, 'lat'),
|
||||
({'tz': None}, 'tz'),
|
||||
({'ayanamsa': None}, 'ayanamsa'),
|
||||
({'events': None}, 'events'),
|
||||
]
|
||||
for change, field in cases:
|
||||
envelope = ondemand_calc_api(_body(**change))
|
||||
assert envelope['calculation_status'] == 'blocked', change
|
||||
assert envelope['raw'] == {}
|
||||
assert envelope['gaps'][0]['field'] == field, change
|
||||
|
||||
|
||||
def test_window_limits_and_unknown_values_are_blocked() -> None:
|
||||
too_long = ondemand_calc_api(_body(start_date='2026-01-01', end_date='2027-03-01'))
|
||||
assert too_long['calculation_status'] == 'blocked'
|
||||
assert too_long['gaps'][0]['field'] == 'end_date'
|
||||
reversed_ = ondemand_calc_api(_body(start_date='2026-03-01', end_date='2026-01-01'))
|
||||
assert reversed_['calculation_status'] == 'blocked'
|
||||
unknown_event = ondemand_calc_api(_body(events=['not_an_event']))
|
||||
assert unknown_event['calculation_status'] == 'blocked'
|
||||
assert unknown_event['gaps'][0]['field'] == 'events'
|
||||
unknown_planet = ondemand_calc_api(_body(planets=['NotAPlanet']))
|
||||
assert unknown_planet['calculation_status'] == 'blocked'
|
||||
assert unknown_planet['gaps'][0]['field'] == 'planets'
|
||||
|
||||
|
||||
def test_mean_position_mode_is_blocked_with_the_named_gap() -> None:
|
||||
envelope = ondemand_calc_api(_body(position_mode='mean'))
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['gaps'] == [{'field': 'position_mode', 'reason': 'position_mode_unsupported'}]
|
||||
|
||||
|
||||
def test_profile_source_marks_product_defaults() -> None:
|
||||
default = ondemand_calc_api(_body())
|
||||
assert default['profile']['profile_source'] == 'product_default_explicit'
|
||||
explicit = ondemand_calc_api(_body(node_mode='mean', position_mode='apparent'))
|
||||
assert explicit['profile']['profile_source'] == 'caller_supplied'
|
||||
|
||||
|
||||
def test_fingerprint_is_stable_and_sensitive() -> None:
|
||||
first = ondemand_calc_api(_body())
|
||||
assert ondemand_calc_api(_body())['input_fingerprint'] == first['input_fingerprint']
|
||||
seen = {first['input_fingerprint']}
|
||||
for change in (
|
||||
{'start_date': '2026-01-02'}, {'end_date': '2026-03-30'}, {'lat': 10.0}, {'ayanamsa': 'raman'},
|
||||
{'node_mode': 'true'}, {'planets': ['Venus']}, {'events': ['ingress']},
|
||||
):
|
||||
envelope = ondemand_calc_api(_body(**change))
|
||||
assert envelope['input_fingerprint'] not in seen, change
|
||||
seen.add(envelope['input_fingerprint'])
|
||||
|
||||
|
||||
def test_engine_failure_is_blocked_without_an_invented_date(monkeypatch) -> None:
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError('ephemeris broken')
|
||||
|
||||
monkeypatch.setattr(transit_window, '_Sky', lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError('broken')))
|
||||
envelope = ondemand_calc_api(_body())
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['raw'] == {}
|
||||
assert envelope['gaps'][0]['field'] == 'calculation'
|
||||
assert '2026-01-01' not in repr(envelope['raw'])
|
||||
|
||||
|
||||
def test_nodes_do_not_get_station_events() -> None:
|
||||
envelope = ondemand_calc_api(_body(planets=['Rahu'], events=['station']))
|
||||
assert envelope['calculation_status'] == 'partial'
|
||||
assert envelope['raw']['events'] == []
|
||||
assert any('node_station_not_supported' in gap['reason'] for gap in envelope['gaps'])
|
||||
|
||||
|
||||
def test_missing_natal_point_is_blocked_and_named() -> None:
|
||||
envelope = ondemand_calc_api(_body(
|
||||
events=['target_longitude'],
|
||||
target={'planet': 'Mercury', 'natal_point': 'Moon'},
|
||||
natal={'points': {'Sun': 10.0}},
|
||||
))
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['gaps'][0] == {'field': 'target.natal_point', 'reason': 'natal_point_missing:Moon'}
|
||||
|
||||
|
||||
def test_registry_dispatches_to_the_new_tool() -> None:
|
||||
envelope = ondemand_calc_api(_body(planets=['Sun'], events=['ingress'], start_date='2026-01-01', end_date='2026-01-05'))
|
||||
assert envelope['tool'] == 'transit_window'
|
||||
assert envelope['schema'] == 'jyotish.ondemand_calc.v1'
|
||||
|
||||
|
||||
# 验收补测(Claude 2026-11-09):换座的 to_sign / direction,以及区间外时刻的逐项标记。
|
||||
def test_ingress_names_the_new_sign_and_direction_across_zero_aries() -> None:
|
||||
result = transit_window.run(_body(start_date='2026-04-01', end_date='2026-04-30', planets=['Sun'], events=['ingress']))
|
||||
assert result['calculation_status'] == 'executed'
|
||||
[event] = result['raw']['events']
|
||||
assert (event['from_sign'], event['to_sign'], event['direction']) == ('Pisces', 'Aries', 'direct')
|
||||
|
||||
|
||||
def test_retrograde_ingress_back_into_the_previous_sign_is_named() -> None:
|
||||
result = transit_window.run(_body(start_date='2026-01-01', end_date='2026-12-31', events=['ingress']))
|
||||
events = result['raw']['events']
|
||||
assert events, result
|
||||
signs = list(__import__('jyotish_engine').SIGNS)
|
||||
for event in events:
|
||||
step = 1 if event['direction'] == 'direct' else -1
|
||||
assert signs.index(event['to_sign']) == (signs.index(event['from_sign']) + step) % 12, event
|
||||
assert any(event['direction'] == 'retrograde' for event in events)
|
||||
|
||||
|
||||
def test_each_time_outside_the_window_is_marked() -> None:
|
||||
result = transit_window.run(_body(start_date='2026-01-01', end_date='2026-06-30', events=['retrograde_loop']))
|
||||
loops = result['raw']['retrograde_loops']
|
||||
assert len(loops) == 2
|
||||
june = loops[1]
|
||||
assert june['station_retrograde']['outside_requested_window'] is False
|
||||
assert june['station_direct']['outside_requested_window'] is True
|
||||
assert june['shadow_exit']['outside_requested_window'] is True
|
||||
assert loops[0]['shadow_entry']['outside_requested_window'] is False
|
||||
@@ -0,0 +1,141 @@
|
||||
"""出生时间按用途够用:用途决策合同的 15 条测试(#186,BUG-1833)。
|
||||
|
||||
13 条移植自占星师的参考测试(`docs/tasks/assets/purpose-gate-20261109/test_reference_gate.py`),
|
||||
2 条新增(两条永远为假的许可、用合成示例驱动 status)。全部是合成数据,不含任何个人资料。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
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.rectification_purpose_gate import BINDING_KEYS, assess_use # noqa: E402
|
||||
|
||||
ASSETS = ROOT / 'docs' / 'tasks' / 'assets' / 'purpose-gate-20261109'
|
||||
EXAMPLES = json.loads((ASSETS / 'examples.json').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
class PurposeGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = EXAMPLES['ready_interval']
|
||||
self.a = copy.deepcopy(self.case['assessment'])
|
||||
self.binding = copy.deepcopy(self.a['binding'])
|
||||
|
||||
def test_wide_interval_can_be_used_without_confirming_birth(self):
|
||||
self.assertEqual(self.case['range']['duration_seconds'], 1200)
|
||||
result = assess_use(self.a, self.binding)
|
||||
self.assertEqual(result['status'], 'scope_ready')
|
||||
self.assertTrue(result['can_continue_analysis'])
|
||||
self.assertFalse(result['can_modify_birth_record'])
|
||||
|
||||
def test_short_interval_can_need_refinement(self):
|
||||
self.a['material_change'] = True
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'needs_refinement')
|
||||
|
||||
def test_equal_scores_do_not_block_scope_use(self):
|
||||
self.a['legacy_scores'] = [8, 8]
|
||||
self.a['historical_confidence'] = 'low'
|
||||
self.assertTrue(assess_use(self.a, self.binding)['can_use_interval'])
|
||||
|
||||
def test_every_binding_change_invalidates_permission(self):
|
||||
for key in BINDING_KEYS:
|
||||
with self.subTest(key=key):
|
||||
changed = dict(self.binding, **{key: 'different'})
|
||||
result = assess_use(self.a, changed)
|
||||
self.assertEqual(result['status'], 'stale')
|
||||
self.assertFalse(result['can_continue_analysis'])
|
||||
self.assertFalse(result['can_use_interval'])
|
||||
|
||||
def test_sampling_does_not_certify_whole_interval(self):
|
||||
for state in ('sampled_only', 'incomplete'):
|
||||
with self.subTest(state=state):
|
||||
self.a['scan_state'] = state
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'conditional')
|
||||
|
||||
def test_missing_layer_keeps_independent_stable_output(self):
|
||||
self.a['layer_coverage']['Narayana_AD'] = 'blocked'
|
||||
self.a['stable_claim_ids'] = ['synthetic:d1-common']
|
||||
result = assess_use(self.a, self.binding)
|
||||
self.assertEqual(result['status'], 'conditional')
|
||||
self.assertEqual(result['allowed_claim_ids'], ['synthetic:d1-common'])
|
||||
|
||||
def test_all_missing_is_not_stability(self):
|
||||
self.a['layer_coverage'] = {key: 'blocked' for key in self.a['required_layers']}
|
||||
self.a['stable_claim_ids'] = []
|
||||
result = assess_use(self.a, self.binding)
|
||||
self.assertEqual(result['status'], 'insufficient_evidence')
|
||||
self.assertFalse(result['can_continue_analysis'])
|
||||
|
||||
def test_unresolved_method_does_not_certify_scope(self):
|
||||
self.a['branch_state'] = 'unresolved_material'
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'conditional')
|
||||
|
||||
def test_unchecked_remaining_ranges_prevent_full_permission(self):
|
||||
self.a['domain_complete'] = False
|
||||
self.assertFalse(assess_use(self.a, self.binding)['can_use_interval'])
|
||||
|
||||
def test_bounded_equivalent_branches_can_be_used(self):
|
||||
self.a['branch_state'] = 'bounded_equivalent'
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'scope_ready')
|
||||
|
||||
def test_no_stable_outputs_cannot_pass_vacuously(self):
|
||||
self.a['stable_claim_ids'] = []
|
||||
self.assertEqual(assess_use(self.a, self.binding)['status'], 'insufficient_evidence')
|
||||
|
||||
def test_conditions_remain_visible(self):
|
||||
self.a['conditional_claim_ids'] = ['synthetic:branch-a']
|
||||
result = assess_use(self.a, self.binding)
|
||||
self.assertEqual(result['status'], 'conditional')
|
||||
self.assertIn('synthetic:branch-a', result['conditional_claim_ids'])
|
||||
|
||||
def test_malformed_contracts_fail_closed(self):
|
||||
for changes in ({'required_layers': []}, {'material_change': 'false'},
|
||||
{'scan_state': 'done'}, {'binding': {}},
|
||||
{'conditional_claim_ids': self.a['stable_claim_ids']},
|
||||
{'layer_coverage': {'D1': 'not_applicable'}}):
|
||||
with self.subTest(changes=changes):
|
||||
with self.assertRaises(ValueError):
|
||||
assess_use(dict(self.a, **changes), self.binding)
|
||||
|
||||
# ---- 本单新增的两条 ----
|
||||
|
||||
def test_permissions_never_allow_editing_the_birth_record(self):
|
||||
"""无论评估通过到什么程度,都不许改出生记录,也不许把代表时刻当生辰。"""
|
||||
scenarios = []
|
||||
for status_case in ('scope_ready', 'conditional', 'insufficient_evidence', 'needs_refinement', 'stale'):
|
||||
assessment = copy.deepcopy(self.a)
|
||||
if status_case == 'needs_refinement':
|
||||
assessment['material_change'] = True
|
||||
elif status_case == 'conditional':
|
||||
assessment['scan_state'] = 'sampled_only'
|
||||
elif status_case == 'insufficient_evidence':
|
||||
assessment['stable_claim_ids'] = []
|
||||
scenarios.append((status_case, assessment, self.binding))
|
||||
scenarios.append(('stale', copy.deepcopy(self.a), dict(self.binding, engine_version='other')))
|
||||
for name, assessment, binding in scenarios:
|
||||
with self.subTest(case=name):
|
||||
result = assess_use(assessment, binding)
|
||||
self.assertIs(result['can_modify_birth_record'], False)
|
||||
self.assertIs(result['representative_is_birth_truth'], False)
|
||||
|
||||
def test_every_synthetic_example_matches_its_expected_status(self):
|
||||
for name, example in EXAMPLES.items():
|
||||
if not isinstance(example, dict) or 'expected_status' not in example:
|
||||
continue
|
||||
with self.subTest(example=name):
|
||||
assessment = copy.deepcopy(EXAMPLES['ready_interval']['assessment'])
|
||||
assessment.update(copy.deepcopy(example.get('assessment_changes_from_ready') or {}))
|
||||
result = assess_use(assessment, copy.deepcopy(assessment['binding']))
|
||||
self.assertEqual(result['status'], example['expected_status'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user