Compare commits

..
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 a2f5d8e67a docs(tasks): brief for #219 reading-claim-binding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXozsiu43ob7B8UZCmDM
2026-11-12 21:01:36 +08:00
46 changed files with 248 additions and 1679 deletions
-11
View File
@@ -1,16 +1,5 @@
# 印度占星 Skill 更新日志
## 2026-11-12 — 年运报告将 Narayana 列为条件参考
- 未决种子的具名分支独立列出,窗口来源数及把握等级依据独立证据。
- 本轮沿用当前 Skill 版本(BUG-1855)。
## 2026-11-12 — 个人报告的 Mudda 表、上升行和技法审计措辞
- Mudda 覆盖天数保留四位小数,说明引用报告内的完整时间线,中文后续年表沿用首年表头。
- 上升行的 R/C、速度、力量比和分主显示「不适用」或 `N/A`。技法审计显示逐星 Vimsopaka 分数,英文计算与解读状态使用完整短语。
- 本轮沿用当前 Skill 版本(BUG-1849)。
## 2026-11-12 — 个人报告的 Mudda 时间线与 Markdown 导出
- Mudda 表分别列出原始时间线、名义年和报告覆盖区间,期间按整秒显示。
-69
View File
@@ -25043,72 +25043,3 @@
- 防复发:同上。
- 关联记录:#192、BUG-1851、BUG-1852。
- 修复版本:`claude/report-reading-grade-20261112`。
## BUG-1849 | 个人报告 Mudda 表、技法审计和上升行暴露内部展示口径
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:中英文个人报告的 Mudda 区间与年度表、Strength Layers、行星表和技法审计。
- 现象:区间天数小数过长,说明出现内部字段名,部分表头和审计状态直接显示工程键,上升行不适用的栏位使用缺值占位。
- 触发条件:生成包含 Mudda 区间、技法审计及上升行的个人报告。
- 根因:报告组装直接使用 Mudda 区间原始精度和字段名,后续年表沿用内部列键,技法审计透出引擎状态和 Vimsopaka 等级。上升行使用通用缺值占位,英文普通报告投影只识别旧双轴状态格式。
- 修复:Mudda 区间按四位小数显示,引用上表的完整时间线,中文各年共用表头和枚举展示。审计显示逐星 Vimsopaka 分数和本地化的双轴短语。按 #204 评论 42117,上升行指定四格显示「不适用」或 `N/A`。英文普通报告投影识别新旧两种状态格式。
- 验证:两张公开盘的双语回归通过,覆盖 Mudda、上升行、Vimsopaka 与英文审计措辞。套件结果和基线失败见本单进度记录。
- 防复发:用公开报告回归新增显示规则,用既有审计和普通报告投影测试核对原证据附注及历史格式兼容。
- 关联记录:#204、BUG-1092、BUG-1840、BUG-1843、BUG-1844。
- 修复版本:`codex/report-wording-fixes-20261112`(待合入 staging)。
## BUG-1854 | 个人报告解读缺少三项逐节复查
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:新生成的中文个人报告「解读」。
- 现象:历史回放可能出现当前建议,双行运结果可能被用于未经检测的领域,瑜伽形成状态可能与现实结果核验混写。
- 触发条件:模型草稿包含上述说法并进入 `acceptReportReadingDraft`。
- 根因:这三项限制只在写作提示中声明,`checkSection` 缺少与事实包对照的复查。
- 修复:逐段检查历史回放的建议词、双行运已检测宫位和瑜伽形成状态。对应原因记录在 `ReadingRejection`,不通过的节沿用现有退回显示。
- 验证:新测试和任务书指定的既有测试共 35 项通过,核对每项触发只退回对应节。全量结果见本单进度。
- 防复发:合成段落覆盖书名和普通引语、第 7 宫与空宫位、候选与形成、同句与分句,以及其他节继续显示。
- 关联记录:#207、#192、BUG-1851、BUG-1852。
- 修复版本:`codex/reading-postcheck-gaps-20261112`。
## BUG-1855 | 年运把 Narayana 计入独立证据且隐藏未决候选
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:年运报告的本命阶段、转折窗口、证据文案及把握上限。
- 现象:Narayana 的转折点会增加独立来源数,同向阶段可能抬升把握。未决种子时年包只有阻塞状态及候选星座。
- 触发条件:年运包起运未决,或窗口和阶段引用 Narayana。
- 根因:年包只消费确定的 Rath 时间轴,窗口聚类和阶段判断把 Narayana 当成一票,前端用同向结果抬级,方法戳沿用旧常量。
- 修复:年包使用原生具名分支,范围内原始期间逐支保留。窗口及阶段将 Narayana 标为条件参考,取消同向抬级,方法戳取当前引擎值。
- 验证:合成盘和公开盘回归核对候选、方法戳、原始行及改前改后的独立来源数。完整检查见本单进度。
- 防复发:后端验证单来源不成窗、条件事件不能桥接窗口,前端验证同向不抬级和候选说明。
- 关联记录:#209、#190、BUG-1601、BUG-1650、BUG-1732、BUG-1830、BUG-1842。
- 修复版本:`codex/annual-narayana-conditional-20261112`。
## BUG-1858 | 报告用途稳定性由一分钟分盘边界检查推定
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:个人报告 `report_evidence_grade_v1` 每类结论的用途稳定性账本。
- 现象和触发:事实包报用途未评估,但一分钟内无分盘边界时账本填稳定。应期窗口无分盘依赖也被填稳定。
- 根因:`claim_grade` 从分盘边界检查推导用途稳定性。BUG-1851 的既有回归核对分级上限和分盘边界,还把边界值当作用途稳定性,未覆盖两者不一致。
- 修复:分别记录用途评估和一分钟边界检查,增加未评估披露,保留必需分盘贴边时的 D 上限。
- 验证:合成事实包复现原实现八项误报稳定。Claude 确认同步两条旧断言后,任务书三文件合跑 20 项通过。快速门有 3 项失败,均在 staging 定向复现。见 `docs/tasks/PROGRESS-purpose-stability-20261112.md`。
- 防复发:新增回归覆盖未评估、应期窗口、必需分盘贴边及缺失边界。保留旧测试的 D6/D30 上限检查,分别核对 D6 的边界状态和 D30 的披露。
- 关联记录:#215、BUG-1851、BUG-1848。
- 修复版本:`codex/purpose-stability-20261112`。
## BUG-1859 | 报告参与状态混用,Badhaka 宫位和宫主落点难以区分
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-12。
- 影响面:个人报告事实包的领域参与行及共享技法,咨询和报告共用的 Badhaka 记录。
- 现象:已算出的 Indu 等点只显示未路由;Badhaka 平铺字段容易把阻碍宫写成宫主星的落宫。
- 触发条件:报告事实包包含领域不采用的已算结果,或健康参与层读取 Badhaka。
- 根因:一个 `state` 同时表示执行与领域使用。BUG-1852 修了引用和描述,但没有分轴;BUG-1853 增加了计算,旧测试只验证平铺字段。
- 修复:为每行加计算和解读两轴,保留旧状态并分别汇总缺口。Badhaka 按原来源输出阻碍宫、宫内行星和宫主落点,沿用产品计算待审状态。
- 验证:十二上升合成盘、两张公开盘和精度阻塞回归均通过。任务及等级合同 42 项、消费者 10 项通过。详情见 `docs/tasks/PROGRESS-reading-status-axes-badhaka-20261112.md`。
- 防复发:检查已算未用、缺真实结果、不适用理由及阻碍原因。断言宫主不在阻碍宫时不列入其宫内行星,落在该宫时列入。
- 关联记录:#216、BUG-1848、BUG-1852、BUG-1853。
- 修复版本:`codex/reading-status-axes-badhaka-20261112`。
@@ -1,45 +0,0 @@
# 年运 Narayana 条件参考 · 2026-11-12
Gitea #209,BUG-1855。按 Claude 评论 42202 认领。任务书在分支首提交 `9e0abcaf5`,基线 `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`。
## 实施
- `package.py::_narayana.rath` 在主运未定时,以报告范围起点年龄和范围长度调用引擎的 `narayana_branches`。若引擎默认上限截断,按原始分支总数再取一次。年包逐支保留引擎 ID、原有名称或 ID 同名标签、父分支、原始 MD/AD 行和 `used_for_scoring: false`,仅过滤与报告范围没有交集或长度为零的行。保留 `alternative_seed_signs`。有候选时包层标为 `computed`,参考状态标 `parameter_sensitive`,不提供主时间轴行。
- `report_facts.py::_narayana_method` 读取引擎方法常量、`SOURCE` 和 `profile_config()`,未提供的字段为 `null`。`build_cards` 生成方法戳及条件参考区,按原标识保留未决分支。
- `report_facts.py::build_cards` 与 `_phases` 将本命 Narayana 的转折点及阶段资料标为条件参考。`_narayana_unresolved` 把具名候选标为未决。`_reading` 保留 Narayana 的主题和各领域倾向,阶段主判及支持、压力来源按 Vimshottari 取值。
- `report_facts.py::_windows` 在聚类前排除 Narayana。现有的至少两个独立来源规则处理单来源情形,未满足时不生成窗口。落在已成窗核心时段内且与窗口主题重叠的 Narayana 点单列条件参考,独立计数和窗界由其他来源生成。
- `annual-report-sections.ts::confidenceCap` 删除同向抬升,保留 C 上限。`annual-report-generate.ts` 在写作调用中补充条件参考规则和候选信息,在附录说明口径并逐支列出候选。审计表归 #210。
## 实测和文案
用同一张仓库公开名人盘跑改前与改后年运窗口。两边各 6 个窗口。改前独立来源数依时间顺序为 `3, 4, 3, 3, 3, 3`,其中 6 个窗口计入 Narayana。改后为 `2, 3, 2, 2, 2, 3`,没有窗口计入 Narayana。12 个 Narayana 转折点仍可供参考,1 个窗口附上同主题条件参考。
用户可见文案见年运附录。
- 「阶段以本命 Vimshottari 为主要依据。Narayana 是条件参考,不作为独立印证,采用 Sanjay Rath 版,旧算法供对照。」
- 「Narayana 有 N 个候选分支,各自列出,不合并。Narayana 是条件参考,不作为独立印证。」候选逐行写「Narayana 条件参考(不作为独立印证) 」及其名称或 ID。
- 「本命大运作背景,小运和细运分领域判断。Narayana 是条件参考,不作为独立印证,可说明主题是否被激活。」
- 「宫主只有掌管或落入某领域的宫才进入该领域,吉星凶星同一标准。功能吉凶说明倾向。」
- 「Varsha Narayana:移植上游 PVR 30.4 太阳角距版,从 Muntha 起,太阳每走 3° 为一名义年。星座强弱用 Rath 规则,与本命运限分开。条件参考,不作为独立印证。」
- 「每节先写盘面依据,再写解读。Narayana 是条件参考,不作为独立印证。把握上限为 C。A 需要你过往事件验证。」
- 方法差异一行写「Narayana 两种算法小运不同。Sanjay Rath 版作条件参考,旧算法供对照。」旧快照中的方向差异一行补「Narayana 是条件参考,不作为独立印证。」
## 既有断言变更
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `test_annual_report_facts.py` 的财富主题激活 | 只匹配 `via == dasha_sign` | 同时要求 `counts_as_support == false` | 验证保留主题、只作条件参考。 |
| 同文件的分领域阶段 | Narayana 单独给出压力时总标签 `split` | Vimshottari 判为 `partial/support`,该感情节主判为 `none`,Narayana 压力仍在原轨 | 阶段总评按主证据。 |
| 同文件的异向阶段 | 感情主判 `tracks_differ` | Vimshottari 主判 `support`,仍核对两轨为 `support` 和 `pressure` | 保留异向资料供对照。 |
## 验证
- Windows 开工预检整体退出码 1,远端核对与外部适配器诊断完成。缺历史 `.workbuddy` 镜像路径的碎片扫描断言在未改动的 staging 复现,错误台账已有同型记录。
- 新增 Python 回归及调整后的阶段定向测试共 5 项通过。合成盘使用原引擎计算,核对分支全量、同一报告范围的原始行,以及种子已决时的原时间轴行为。
- 前端年运定向套件 36 项通过。`tsc --noEmit -p .` 通过,ESLint 为 0 错误、62 警告。
- `pytest tests/ -k annual` 最终复跑,任务分支 161 项通过、3 项失败,staging 157 项通过、3 项失败。失败名称相同,分别是 `test_report_reader_main.py` 的年度日期、两套生产者值,以及 `test_timeline_boundaries_r3.py` 的年度连续性。
- `npm run build` 通过,`/` 为静态页面。首页引用 39 个资源,逐文件 gzip -9 后 JS 为 515,860 字节,CSS 为 90,886 字节,两项均与 staging 相同。
- Python 快速门为 1,463 项通过、3 项失败、2 项跳过,另有 29 个子测试通过。三项失败均在 staging 复现,位于 `test_upstream_technique_layers.py`,分别涉及上游固定值、Dasha 注册和 Kaala 对拍。
- 最终前端全量为 3,655 项,3,628 项通过、27 项失败、0 项取消(退出码 1)。staging 为 3,653 项,3,623 项通过、30 项失败、0 项取消。逐项比对保留全部原有测试并新增 2 项,任务分支的 27 项失败均出现在 staging。仅 staging 失败的 3 项为数据库集成用例。
- 浏览器中的真实模型写作留待独立验收。
- 仓库隐私检查 62 项通过。
@@ -1,27 +0,0 @@
# 报告用途稳定性 · 2026-11-12
Gitea #215,BUG-1858。首次按评论 42245 认领。评论 42285 确认两条断言可同步更新,认领评论 42293。任务书基线为 `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`。
## 已核实
- 用完整合成事实复现。所有分盘最近边界大于 60 分钟、事实包用途稳定性为 `not_evaluated` 时,旧账本将八类结论全标为 `stable`。八类上限仍为 C。
- 账本从事实包读取用途稳定性,默认 `not_evaluated`。事实包给出 `stable` 时,应期窗口仍记为 `not_evaluated`。分盘边界独立列在 `ledger.boundary_check`,包含规则、状态和原数值。必需 D6 距边界 0.25 分钟时上限仍为 D。
- 已检索 `frontend/src` 及后端消费者。前端等级判断读取 `evidence_grades.claims` 的 `cap`。未找到消费本单 `v:` 行的 `stable` 字面值的模块。
- 四项新增回归在本地候选上通过。第一项在原实现失败,实际为八项 `stable`。
- 拍板前合跑任务书指定的三个文件,19 项通过、1 项失败。原 staging 上两个既有文件 16 项均通过。候选失败是下表第一条。运行在第一条断言中止,第二条已用同一夹具独立核对。
## 既有断言变更
两条断言在 `tests/test_report_evidence_grade_20261112.py` 的同一个测试中。Claude 2026-11-12 答复确认可同步更新。
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `test_missing_required_layer_or_boundary_varga_gives_d` 的必需 D6 | `ledger.purpose_stability == "boundary_sensitive"` | `ledger.purpose_stability == "not_evaluated"`,在 `boundary_check.rows` 验证 D6 为 `boundary_sensitive`,保留上限 D 断言 | 任务书 #215 第 1、5 条;Claude 2026-11-12 答复。 |
| 同测试的仅核对 D30 | `must_disclose == ["v:D30"]` | `must_disclose` 同时包含 `v:D30` 与 `purpose_stability_not_evaluated`,保留上限 C 断言 | 任务书 #215 第 1、5 条;Claude 2026-11-12 答复。 |
## 最终验证
- 合入本分支的验证基线为 staging `2f374fff281622f342151a97fa6f6d758de74cc8`,保留了该分支上的 Bug 记录。
- 更新既有断言后,任务书指定三文件合跑 20 项通过,四项新增回归均通过。
- Python 快速门退出码 1,1,463 项通过、3 项失败、2 项跳过、29 项子测试通过。失败均在 `test_upstream_technique_layers.py`,在同一 staging 提交定向复跑重现。
- 仓库隐私检查 62 项通过。
@@ -1,22 +0,0 @@
# 个人报告解读复查 · 2026-11-12
Gitea #207,BUG-1854。按 Claude 评论 42178 认领,基线 `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`。任务书在分支首提交 `5183afcc2`。
## 实施
- `current_advice_in_replay` 只在 `current_advice_allowed === false` 时扫描「几条建议」「健康」「现在这几年」的每段文字。词表包括「你」「您」「建议」「应当」「应该」「应依」「最好」「不妨」「可以考虑」「宜」「不宜」「就医」「多休息」「留意」「注意」。用中文分词识别单字「宜」,避免把「宜昌」「适宜」判成建议。书名号内或由书名、篇名、技法等语境明示名称的引文,仅豁免「宜」「不宜」「留意」「注意」。普通引用的「宜早作」和书名后的「最好」仍会触发。
- `double_transit_scope` 识别 `Double Transit`、「双星过境」「双重行运」「双行运」。事业 10,财富 2、11,感情 7,健康 1、6、8、12,性格 1。总评、现在这几年、依据与边界免检。其余节须有一个本节宫位出现在 `transits.double_transit.checked_houses`,所以空或缺失时不能在这些节引用双行运。「几条建议」没有分配宫位。
- `yoga_status_mismatch` 用「。」「;」「!」「?」划句。同句点名已形成的瑜伽又写「未形成」「没有形成」「不成立」「未成立」「尚未成立」时退回。名字使用中文名或去掉 `Yoga` 后的英文主体。另一条规则识别同句内的「等现实」「要等现实事件」「需现实验证」「经现实核验才」与「成立」「形成」。结果核验与形成分句时可以并存。
`reading_facts.py::_yogas` 通过 `yoga_status_words.py::yoga_status` 写出的 `state` 有 `candidate`、`computed`、`formed_under_source`、`conditions_not_met`、`blocked`。形成状态为 `formed_under_source`,兼容任务书验收样例中的 `formed`。候选和已计算的状态均不当作已形成。
## 验证
- 新测试用合成段落验证节级退回原因,其余节均为 `ok`。覆盖明确引用名称、普通引语、地名、历史和在世标志、第 7 宫和空宫位、句界,以及候选瑜伽的谨慎说法。词表按机械规则检查,普通引用的历史指令仍会命中。
- 任务书指定的四个测试文件共 35 项通过。沿用既有断言。
- 将新增测试接到未修复的 staging 实现,6 项拦截回归失败,2 项允许场景通过。接回任务分支后 8 项均通过。
- `tsc --noEmit -p .` 通过,ESLint 为 0 错误、62 警告。
- 用仓库的 Node/tsx 全量文件清单和 4 个测试进程比对。staging 共 3,653 项,3,623 通过、30 失败、0 取消。任务分支共 3,661 项,3,633 通过、28 失败、0 取消。顶层 TAP 名称新增 8 项、缺失 0 项。两边有 27 个同名失败,另有 4 个数据库测试在其中一边的并行运行中失败。
- 分别串行复跑涉及两边差异的 4 个数据库测试文件。两边各 7 项通过,含任务分支并行时失败的并发档案修正迁移,以及 staging 并行时失败的管理员恢复和两项计费测试。
- `npm run build` 通过,`/` 为静态页面。首页引用的 39 个资源逐文件 gzip -9 后,JS 为 515,860 字节,CSS 为 90,886 字节,两项均与 staging 相同。
- 隐私检查通过。
@@ -1,29 +0,0 @@
# 个人报告参与状态与 Badhaka · 2026-11-12
Gitea #216,BUG-1859。计划评论 42250,认领评论 42345。任务书基线 `1ba0364a`,验证基线为同步后的 staging `c55338f35916587ec9007d0786f370e5c29d0aa9`。
## 两轴与消费位置
- 参与行复用 `technique_status_axes.technique_audit_axes` 规范状态词,保留原有 `state`。参与本领域的计算默认是 `executed + primary`。Badhaka 是专项答复 §4 指定的健康补充层,记 `supporting`。KP 已有条件参考规则,存在实际宫头或行星结果时记 `conditional`,只有方法标签时记 `not_computed + not_used`。
- 已算出的未路由值记 `executed + not_used`。缺结果且没有失败证据时记 `not_computed + not_used`,不附计算值。明确缺输入、调用失败和验证失败分别记录对应阻碍;精度保留层缺确认分钟,沿用原说明。显式不适用需带范围理由。汇总保留旧路由及精度分类,实际计算缺口分到各自状态。
- 全仓检索核对:`consult_card_domain_facts._badhaka` 把产品记录传入咨询和阅读,从 D1 原有行星状态读取尊贵度,缺失或无效上升分别标明原因。 `reading_facts.STRUCTURED_VALUES` 放行两段新记录、来源与 profile。前端 `computedPointsOf` 读取参与 ID、旧 `state` 和 `computed`,上限模块也读旧 `state`。星盘页身份标签来自独立的 `chart_view_points.functional_roles`,没有读取本轮旧字段。
- `badhaka_modality_v1` 沿用来源 §二及现有 `badhaka_house`。按落入目标星座记录 `badhaka_sthana.occupants`;`badhakesh` 记录宫主本身的落点和尊贵度。保留产品计算待审状态。
## 既有断言变更
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `test_badhaka_facts_20261112.py` 三种上升 | 比较顶层 `house/sign/lord` | 比较 `badhaka_sthana.house/sign` 与 `badhakesh.planet` | 迁移到任务书规定的宫位与宫主字段,预期值相同。 |
| 同测试宫主落点 | 比较 `lord_sign/lord_house` | 比较 `badhakesh.sign/house` | 保留对宫主落点的检查。 |
| 同测试宫内行星 | 比较顶层 `occupants` | 比较 `badhaka_sthana.occupants` | 保留对宫内行星的检查。 |
| `test_reading_facts_v3_20261112.py` 健康参与层宫位 | `value.house` 属于 7、9、11 | `value.badhaka_sthana.house` 属于 7、9、11 | 只更新读取位置。 |
| 同测试星座及宫主 | `value.sign` 和 `value.lord` 均有值 | `value.badhaka_sthana.sign` 和 `value.badhakesh.planet` 均有值 | 只更新读取位置。 |
| `test_report_reading_facts.py::test_withheld_varga_houses_stay_out_without_a_confirmed_minute` | D30 行精确等于四个旧字段 | 保留四个字段及精确相等,新增三个要求字段,值分别为 `blocked`、`not_used`、`missing_input` | 每条参与行都必须给两轴,缺确认分钟给具体阻碍。仍禁止附上计算值。 |
| `test_report_reading_facts.py::test_reading_facts_are_packet_leaves` | Python 默认带分隔空格的 JSON 长度小于 80,000 | 紧凑 JSON 长度小于 80,000 | 模型输入使用 `JSON.stringify(facts)`。测量实际的紧凑格式,保留阈值和内部字段禁入断言。 |
## 验证
- 任务文件和等级合同共 42 项通过。两张公开盘实测分别为第 7 宫双鱼、木星落第 10 宫,以及第 11 宫天蝎、火星落第 9 宫。第一张的宫内行星含月亮。两张盘均按 D1 核对宫主尊贵度,四个未用点均为已算未用。
- 报告与咨询消费者共 10 项通过。两条旧合同在 staging 原样通过。新事实用 Python 默认 JSON 计长为 80,710,实际模型使用的紧凑格式为 74,560,仍低于 80,000。
- Python 快速门退出码 1,1,463 项通过、3 项失败、2 项跳过、29 项子测试通过。三项失败均在同一 staging 提交的上游技法定向测试重现。
- 仓库隐私检查 62 项通过。
@@ -1,69 +0,0 @@
# 个人报告措辞修订 · 2026-11-12
Gitea #204,BUG-1849。按 Claude 评论 42071 认领。任务书位于任务分支首提交 `ca64e7b5c`,staging 基线为 `017e650fdafac4a841487dbba4aca803c761dad8`。
## 首轮待拍板
- 任务书第 6 项将上升行的 R/C、速度、力量比和分主列为不适用,并要求在 `—` 的含义与不适用冲突时停下来。
- `scripts/pl9_reader_export.py` 在原始位置表与主星和状态表生成上升行。`scripts/report_render/opening_guide.py::reading_guide` 把 `—` 定义为「缺数据或核对受限」,英文为 “missing data or a verification limit”。两种语言的个人报告均由 `scripts/pl9_full_data_export.py` 插入该说明。
- 若直接将这些格改为 `—`,读者会按现有说明理解为数据缺失或核对受限。请确认是否允许为上升行在报告说明中明确该符号的特例,或指定单独的不适用显示。
- `tests/test_report_opening_supplement.py` 的 3 项测试通过。已记录冲突,等待确认后实施并跑任务书要求的定向回归、报告套件与 Python 快速门禁。
## 续办口径
Claude 评论 42117 指定上升行的 R/C、速度、力量比和分主用「不适用」或 `N/A`。报告阅读说明保持原口径。
### 英文审计状态
| 中文 | 英文 |
| --- | --- |
| 计算已执行 | Calculation completed |
| 计算部分执行 | Calculation partially completed |
| 计算阻塞 | Calculation blocked |
| 计算不适用 | Calculation not applicable |
| 计算未完成 | Calculation not completed |
| 解读主要依据 | primary interpretive basis |
| 解读辅助依据 | supporting interpretive evidence |
| 解读有条件 | conditional interpretation |
| 解读仅观察 | observation only |
| 解读不作判断 | no interpretation |
| 解读不参与 | excluded from interpretation |
两轴仍以同一行展示。证据附注沿用原字段和值。
## 实施
- `scripts/pl9_reader_export.py` 统一覆盖区间天数为四位小数。中英文说明引用表格里的完整时间线,保留当前视图的日期语义。中文跨返照说明在报告层补句号。
- 同文件把中文首年和后续年度的 Mudda 表头与单元格展示统一。英文后续年度表保持原样。`scripts/pl9_full_data_export.py` 保留 Mudda 指定表头的「边界口径」。
- `scripts/report_render/audit_chapters.py` 从报告强度资料取逐星 `total_score`,按报告表的二十分口径与两位小数展示。保留 Shadbala 和 SAV。缺逐星值时省去 Vimsopaka 段。
- `scripts/pl9_reader_export.py` 仅调整两张表的上升行指定四格。`scripts/report_render/technique_status_axes.py` 按上表本地化英文审计状态。
- `frontend/src/lib/report-public-projection.ts` 在英文技法审计表的状态列识别新双轴短语,继续接受已保存报告里的旧格式。投影测试覆盖新旧两种格式和证据附注。
中文版后续年度其他表仍按现有键生成表头,留在本单范围之外。Patyayini 使用 `main/sub/boundary/boundary_status`,Saham 使用 `name/saham/sign/degree_in_sign`,Tajika Yoga 使用 `yoga/name/present/forming_planets`,年度行星扩展使用 `planet/sign/degree/longitude/nakshatra/pada`,年度锚点使用 `name/label/value/sign/degree/house`。实际列由每张表的非空数据决定。
## 既有断言调整
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `test_report_annual_layers_20261111.py` 的中英文覆盖行 | `coverage['days']` 原长小数 | 同一原值格式化为四位小数 | 保持时间边界和单位,核对新精度。 |
| 同文件的中英文视图说明 | 直接匹配引擎的 `periods_view.label/label_en` | 匹配各语言的完整可见句,引用上表完整时间线 | 锁住新的呈现,同时保留实际返照覆盖的语义。 |
| 同文件的跨返照说明 | 匹配未带句号的引擎原句 | 匹配同一时间边界与句号 | 增加中文句末核对。 |
| `test_report_narayana_shadbala_20261111.py` 的审计后缀 | 匹配原 SAV 和 Vimsopaka 等级整个后缀 | 匹配原 SAV 数值且禁止等级字段,新增公开盘测试逐项对照 Vimsopaka 分值 | 保留 SAV 与逐星核验强度。 |
| `test_report_status_words_20261111.py` 的英文条件行 | `calculation: executed; interpretation: conditional (parameter_sensitive)` | `Calculation completed, conditional interpretation (parameter_sensitive)` | 保留原计算、解读与证据附注,核对新显示。 |
| `test_report_audit_chapters_20261107.py` 的英文条件状态 | `conditional (parameter_sensitive)` | `conditional interpretation (parameter_sensitive)` | 同样核对条件解读及原证据附注。 |
| `test_report_wording_sanitizer_20261111.py` 的两条英文样例行 | `calculation: executed; interpretation: primary` | `Calculation completed, primary interpretive basis` | 保留两条过滤条件与其它单元格。 |
| `test_report_reader_main.py` 的审计差异提取 | 按旧英文双轴键和标签识别 Status 单元格 | 按新英文双轴标签识别同一单元格 | 维持现有报告版本比较范围。 |
## 验证
任务分支先同步至 staging `56bb1bed9`,再同步至 `1ba0364a7`。保留并行追加的 Bug 历史,本单使用任务书指定的 BUG-1849。
- 两次同步后均用真实引擎重生成两张公开盘的中英文完整报告。六个定向文件各 38 项通过,含七处展示回归、逐星分数缺失时的降级、原审计状态附注和普通报告投影。另跑最新 staging 带入的报告事实、Badhaka 和证据库相关检查,60 项通过、2 项跳过。
- 首次同步后用与 staging 相同的 Node/tsx 文件清单、限制为 4 个进程运行前端套件。3,652 项中 3,623 通过、29 失败、0 取消,3,632 个顶层 TAP 名称与基线一致。27 项与已验收的 #205 前端测试失败清单相同,另两项是咨询计时测试。同步至 `1ba0364a7` 后,查证计时和中途截断测试均在串行复核中通过。同步后的普通报告投影与报告解读前端定向测试共 35 项通过。
- 首次同步后的 `tsc --noEmit` 通过,`npm run lint` 为 0 错误、62 警告。
- 最新 staging 与任务分支的 `npm run build` 均通过,`/` 均为静态页面。两边首页引用的 39 个资源逐文件 gzip -9 后,JS 均为 515,860 字节,CSS 均为 90,886 字节。
- 报告套件分段比对 `56bb1bed9`,英文长测单独运行。除英文 12 盘和 200 盘扫描,任务分支 235 项中 227 通过、8 项与 staging 的失败项相同。旧英文状态断言补齐后,已在两次同步的定向报告套件复核通过。
- staging 已有的 8 个报告失败是 `test_report_chart_blank_columns.py` 的两项中文残留检查、`test_report_english_dictionary.py` 的英文配对检查、`test_report_ephemeris.py` 的月亮位置校验,以及 `test_report_reader_main.py` 的审计词、年度日期、两套生产者值和尊贵用词检查。
- Python 快速门 `python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime` 的 pytest 批次为 1,463 项通过、3 项失败、2 项跳过,另有 29 项子测试通过。英文 12 盘冒烟通过。三个失败均是 `test_upstream_technique_layers.py` 的 Kaala 对照问题,已在未修改报告代码的 `56bb1bed9` 基线复现。快速门退出码为 1。
- 200 盘英文长测已在同步后的任务分支单独启动,交接时仍在运行。独立验收需核对其最终结果。
- 隐私检查通过。Windows 门禁生成的五个临时验证文件只出现换行变化,核对后已恢复。
@@ -1,100 +0,0 @@
# TASK:年运报告的 Narayana 改为条件参考,种子未决时列出具名候选(2026-11-12)
- Gitea:#209
- 分支:`codex/annual-narayana-conditional-20261112`(基于 staging `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`)
- Bug 编号:BUG-1855。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
年运报告(产品里的「年运」功能,和个人报告是两套)在两处违反占星师对 Narayana 的裁定。
**她的裁定**
- Narayana 是有条件的参考,不能当作与 Vimshottari 并列的第二套独立证据。
- 出处:聊天裁定 2026-11-07 Q1;第五轮裁定 2026-11-08 第 1 条。
- 种子或分支未决时,按第七轮 R2 的 D 方案处理:保留所有具名候选和分支 id,各分支独立列出,不拼接,不参与打分。
- 出处:第七轮回复 2026-11-09。
- 个人报告已经按此改过,见 #190、`docs/tasks/PROGRESS-report-narayana-shadbala-20261111.md`。
**我们的现状**(行号以 staging 为准,大约位置)
1. `scripts/annual_service/package.py` 的 `rath()`(约 :1580–1587):
- 种子未决时,整层返回 `blocked`,只给出 `alternative_seed_signs`;
- 从不调用 `scripts/narayana_rath.py::narayana_branches`(:1748)。
2. `scripts/annual_service/report_facts.py`:
- Narayana 被当作窗口的独立系统计入:约 :645 的 `add(... "narayana" ...)`,以及 :85、:1245 一带;
- 方法戳 `_narayana_method()`(约 :170–188)是写死的 11-06 版本。
3. `frontend/src/lib/annual-report-sections.ts` 约 :546–556:Narayana 与 Vimshottari 方向一致时,把等级上限从 C 抬到 B。
## 要做的
1. **种子未决时列出具名候选。**
- `package.py` 在 `calc_narayana_rath` 没有给出 `mahadasha` 时,调用 `narayana_rath.narayana_branches(lagna, lons, current_age=<报告年起始的年龄>)`。
- 把每个分支的 id、名称、MD/AD 行(落在报告年覆盖范围内的)原样放进年包,每个分支标 `used_for_scoring: false`。
- 原来的 `alternative_seed_signs` 保留。
- 只调用 `narayana_rath.py`,不改它。
2. **不再当独立证据。**
- `report_facts.py` 里,Narayana 不参与窗口的“两套系统一致”计数,也不算独立来源。
- 窗口和阶段里仍可以写“Narayana 激活了哪个主题”,标为条件参考。具体在哪些函数改,自己定位,逐处列进 PROGRESS。
3. **去掉等级抬升。**
- `annual-report-sections.ts` 里“Vimshottari 与 Narayana 一致就到 B”的分支删掉,上限不因 Narayana 改变。
- 其他分级逻辑不动:分级规则等占星师答复后另开单。
4. **方法戳改为现算。**
- `_narayana_method()` 改为从 `narayana_rath` 模块现有的常量和函数读出 profile、种子规则和分支默认值,不再写死字符串。
- 读不到的字段写 `null`,不编。
5. **文案。**
- 给模型的证据和页面上,凡是写到 Narayana 的地方,标明“条件参考,不作为独立印证”。
- 种子未决时写“有 N 个候选分支,各自列出,不合并”。
- 措辞放在现有的文案常量里。新增的中文如果是给用户看的,在 PROGRESS 列出原文。
## 要改的文件
- `scripts/annual_service/package.py`:只改 `rath()` 一带。
- `scripts/annual_service/report_facts.py`
- `frontend/src/lib/annual-report-sections.ts`:只删 Narayana 抬级那一段。
- 必要时:`frontend/src/lib/annual-report-generate.ts` 里给模型的 Narayana 文案。
- 新建 `tests/test_annual_narayana_conditional_20261112.py`;如需要,新建一个前端测试文件。
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-annual-narayana-conditional-20261112.md`
## 不要碰
- 冻结文件:`scripts/narayana_*.py`(只许 import)、`scripts/jyotish_engine.py`、`scripts/jyotish_api_server.py`、`scripts/yoga_engine.py`、`scripts/consultation_native_layers.py`、`scripts/tajika_mudda.py`、`scripts/shadbala*.py`。
- 个人报告:`scripts/report_render/**`、`frontend/src/lib/personal-report*`。
- `references/**`。
- 年运报告的审计表 `_technique_audit`:另一张单在改,见 #210。
## 验收标准(写进新测试)
1. 一张种子未决的盘(自己找或合成)生成年包时,Narayana 层不是整层 `blocked`,而是带 ≥2 个具名分支,每个分支 `used_for_scoring == false`。
2. 一张种子已决的盘,原有行为和行不变。
3. 窗口打分时,同一窗口 Vimshottari 与 Narayana 方向一致,等级上限不变(不出现 B);系统计数里不含 Narayana。
4. `_narayana_method()` 的字段值等于 `narayana_rath` 模块的常量。用测试锁住,模块常量变化时测试会失败。
5. 现有年运测试全部通过:`tests/test_annual*`、`frontend/tests/annual-*`。
## 自测命令
```
python -m pytest -q tests/test_annual_narayana_conditional_20261112.py tests/ -k annual
cd frontend && npx tsx --test tests/annual-*.test.ts* && npx tsc --noEmit -p . && npx eslint .
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 进度记录
写在 `docs/tasks/PROGRESS-annual-narayana-conditional-20261112.md`,内容包括:
- 改动处的清单;
- 窗口计数改动前后的对比,用一张公开名人盘;
- 新增的用户可见文案;
- 测试结果,以及在 staging 上也复现的基线失败。
## 何时停下来选“需拍板”
- `narayana_branches` 的输出需要改冻结文件才能接上。
- 去掉 Narayana 计数后,某个窗口因为只剩一套系统而规则无法判断,而且现有代码没有给出单系统时的处理。
@@ -1,71 +0,0 @@
# TASK:上限账本的用途稳定性不再由一分钟规则冒充(2026-11-12)
- Gitea:#215
- 分支:`codex/purpose-stability-20261112`,基于 staging `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`
- Bug 编号:BUG-1858
- 验收:Claude(jyotisha-e6)
不得自行写「产品决定/裁定」。任务书没写到的取舍,一律选「需拍板」。
不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 里逐条写明原值、新值和原因。
占星师的引擎和资料(`scripts/jyotish_engine.py`、`scripts/yoga_engine.py`、`references/` 下她仓库已有的文件、`SKILL.md`)一律不改。产品差异只能放进单独的产品文件,并在 `references/product_runtime_contract.md` 公告(项目规则 2026-11-07)。
占星师回复原文:`docs/tasks/ref-astrologer-acceptance-reply-20261112.md`,由本分支第一个提交带入。下文的「她 §N」都指这份文件。
## 背景(她 §2 P0-1)
`scripts/report_render/evidence_grade.py` 为报告解读的每类结论算上限,账本里有一项 `purpose_stability`。问题在于两处结果对不上:
- 事实包里 `birth_precision.purpose_stability` 是 `not_evaluated`,因为按用途区间的合同还没接入。
- 账本里却写 `stable`。
原因是 `claim_grade` 用 `_varga_stability` 的结果推出 `purpose_stability`:分盘上升最近的换宫距离大于 `CONFIRMED_TOLERANCE_MINUTES = 1.0`,就算 `stable`。Jobs 样例的 8 项全是 `stable`;Obama 样例 7 项 `stable`,1 项 `boundary_sensitive`。
她的裁定:这个一分钟比较只是在 `birth_record_minute_v1` 规则下检查附近有没有边界,不能证明结论在真实出生区间内稳定。`timing_window` 更不能在没有精度依赖的情况下标成稳定。
## 要改的文件
- `scripts/report_render/evidence_grade.py`
- 新建 `tests/test_evidence_grade_purpose_stability_20261112.py`
- `tests/test_report_evidence_grade_20261112.py`:只在断言写的是旧的 `stable` 时改,并在 PROGRESS 逐条列出
- `docs/BUG_HISTORY.md`:只在末尾追加一条
- `docs/tasks/PROGRESS-purpose-stability-20261112.md`
## 要做的
1. 账本里的 `purpose_stability` 改成直接取事实包的 `birth_precision.purpose_stability`,取不到就填 `not_evaluated`。不能再由分盘边界推出来。
2. 分盘边界检查的结果单独记在账本新字段 `boundary_check` 里,形如 `{"policy": "birth_record_minute_v1", "rows": [...]}`。`rows` 保留现有的 `nearest_change_minutes`、`tolerance_minutes` 和状态(`clear` 表示一分钟内没有换宫,另有 `boundary_sensitive`、`not_evaluated`、`missing`)。
- `required` / `checked` 里 `v:` 依赖行的 `state`:`stable` 改名为 `clear_at_tolerance`。
- 其余状态不变。
- 前端读不读这个字符串,你要用 grep 核对 `frontend/src`。如果有读取,只能在 PROGRESS 报告,不许改前端,选需拍板。
3. 上限规则不变:
- 必需分盘落在边界内(`boundary_sensitive`),照旧给 D;
- `not_evaluated` 不会因此把结论降到 D(她:未评估不意味着整份报告一律 D)。
4. `timing_window` 的账本里,`purpose_stability` 在任何情况下都不能是 `stable`。
5. 新增一个 `must_disclose` 项 `purpose_stability_not_evaluated`:只要 `purpose_stability` 不是 `stable`,每类结论都带上这一项。
## 不要碰
- `frontend/**`、`references/**`、`scripts/report_render/reading_facts.py`、`scripts/report_render/birth_precision_margins.py`
- 冻结文件
## 验收标准(写进新测试)
1. 用合成 facts 测:`minute_confirmed=True`,所有分盘边界都大于 60 分钟,`birth_precision.purpose_stability="not_evaluated"`。此时 8 类结论的账本 `purpose_stability` 全是 `not_evaluated`,上限仍是 C。
2. 事实包里给出 `birth_precision.purpose_stability="stable"` 时,`timing_window` 仍不是 `stable`,其他类照抄 `stable`。
3. `v:D8` 落在 0.25 分钟内、并且是必需依赖时(自己造一个把 D8 设为必需的场景;做不到就用 D6),该类上限是 D。`boundary_check.rows` 里能看到 0.25。
4. 旧代码上,第 1 条必须失败。在 PROGRESS 里记下旧代码的结果。
5. `tests/test_report_evidence_grade_20261112.py` 和 `tests/test_reading_facts_v3_20261112.py` 全部通过。
## 自测命令(仓库根目录)
```
python3 -m pytest -q tests/test_evidence_grade_purpose_stability_20261112.py tests/test_report_evidence_grade_20261112.py tests/test_reading_facts_v3_20261112.py
python3 scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 何时停下来选「需拍板」
- 前端或别的模块依赖 `stable` 这个字面值。
- 只有改事实包结构才能完成。
@@ -0,0 +1,102 @@
# TASK:报告解读逐段绑定结论类型与依赖,摘要、建议、依据不再绕过分级(2026-11-12)
- Gitea:#219
- 分支:`codex/reading-claim-binding-20261112`,基于 staging `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`。
- 开工条件:#207、BUG-1858(用途稳定性)、BUG-1860(复查事实规则)、BUG-1861(运行时口径)都已合入 staging。开工前先 rebase 到最新的 staging。
- Bug 编号:BUG-1862
- 验收:Claude(jyotisha-e6)
不得自行写「产品决定/裁定」。任务书没写到的取舍,一律选「需拍板」。
不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 里逐条写明原值、新值和原因。
占星师的引擎和资料(`scripts/jyotish_engine.py`、`scripts/yoga_engine.py`、`references/` 下她仓库已有的文件、`SKILL.md`)一律不改。产品差异只能放进单独的产品文件,并在 `references/product_runtime_contract.md` 公告(项目规则 2026-11-07)。
占星师回复原文:`docs/tasks/ref-astrologer-acceptance-reply-20261112.md`,由本分支第一个提交带入。下文的「她 §N」都指这份文件。
本任务**允许**修改 `references/product_runtime_contract.md` 的 report-reading 块(`<!-- product-block:report-reading -->` 之内),以及 G 节公告区;其他段落不动。
## 背景(她 §2 P0-2,§3 末段)
现在的规则是:
- 只有六个分级节(character / career / wealth / love / health / now)的段落要带 `claim_type` 和 `grade`;
- overview / advice / basis 三节的段落可以不带。
样稿暴露的问题:
- 总评和建议里写了职业方向、能力和结果强弱,没有分级。
- A-2 事业第三段写了 2023–2041、2025–2028 等时间窗口,却只标为 `career_promise`,没有绑定 `timing_window`。
- B-1 健康节的 `health_obstruction` 段引用了 D8(约 0.25 分钟就会换宫),但 `health_obstruction` 的依赖只有 D1 和 Badhaka。这段等于借用了一个没纳入精度检查的证据。
她的要求:
- 每条实质判断都绑定结论类型、所用事实和依赖。
- 一段里混了几类结论的,要么拆开,要么取依赖的并集和最严格的上限。
- 摘要和建议只能引用已经分级的结论,并继承它们的等级。
- 纯事实描述、一般性操作说明,不必强行写成预测结论,但里面不能夹带新的推断。
- 段落引用到的层,就是这条判断的依赖,不能把无关技法变成所有句子的门槛。
## 要做的
1. **后端**(`scripts/report_render/evidence_grade.py`)
- `evidence_grades` 新增 `dependency_catalog`:每个依赖 id(如 `v:D8`、`p:health:D8`、`s:narayana`、`t:transits`)对应:
- 当前状态(复用 `_evaluate`);
- 正文里会出现的别名词表,例如 `v:D8` → `["D8"]`,`s:narayana` → `["Narayana", "纳拉亚纳"]`,`s:vimshottari` → `["Vimshottari", "大运", "小运", "次小运"]`,`t:transits` → `["过境", "行运"]`,`p:health:Badhaka` → `["Badhaka", "阻碍宫", "阻碍宫主"]`。
- 目录覆盖 `CLAIM_TYPES` 里出现过的全部依赖,外加各领域的分盘(D1–D60 中事实包实际给出的)。词表写进 PROGRESS。
- 不认识的层不进目录,也不编别名。
2. **草稿格式**(`frontend/src/lib/personal-report-reading-contract.ts`)
- 所有九节的每一段都必须二选一:
- (a) `claims: [claim_type, ...]`(至少一个)+ `grade` + `refs: [依赖 id, ...]`;
- (b) `kind: "fact"`:纯事实或操作说明,不带等级。
- 存储版本号加一。旧版本已存的解读按旧逻辑显示,不迁移。
3. **复查**(`frontend/src/lib/personal-report-reading.ts`),新增以下退回原因:
- `claim_binding_missing`:段落两样都不是。
- `grade_above_cap`(扩展现有规则):段落 grade 必须不高于它 `claims` 里**每一类**的上限,即取最严格的那个。
- `ref_outside_claims`:`refs` 里的 id 不在所列 `claims` 各类 `required` 与 `checked` 的并集里。
- `undeclared_dependency`:正文出现了 `dependency_catalog` 中某个 id 的别名,但该 id 不在 `refs` 里。
- `ref_unstable`:`refs` 里有 `boundary_sensitive` / 缺失类状态的依赖,而段落 grade 不是 D。依赖不管是 `required` 还是 `checked`,只要被引用就算。
- `timing_unbound`:正文出现四位年份、「月」与数字连写、或大运 / 小运 / 次小运 / 窗口,而 `claims` 里没有 `timing_window`。
- `fact_with_inference`:`kind: "fact"` 段出现「意味着」「显示出」「适合」「容易」「有利」「不利」「倾向」「说明你」等推断词。词表写进 PROGRESS,并用现有样稿测误伤。
- overview / advice 两节的 (a) 型段落,`claims` 只能引用本稿六个分级节里已经通过的段落用过的结论类型,否则记 `claim_not_established`。
4. **提示**:产品合同的 report-reading 块改写段落格式说明。写清以下几点:
- 两种段落形态;
- `refs` 必须覆盖正文点名的每一层;
- 带时间窗口的段落必须加上 `timing_window`;
- 混合段落取最严格的上限;
- 摘要和建议只引用已分级的结论。
在 G 节公告区写明原值、新值和原因。
5. **显示**
- (a) 型段落照旧显示等级行。多类结论时,显示最严格的等级,后面附「(综合 N 类结论)」。
- (b) 型段落不显示等级行。
- MD 导出同步。
## 不要碰
- `scripts/report_render/reading_facts.py`(只读它)
- 聊天
- 冻结文件
- 现有检查的逻辑(只扩展 `grade_above_cap`)
## 验收标准(新测试,全部用合成文字和合成 facts)
逐条覆盖她 §6 第 3 条里与本任务相关的回归:
1. 摘要段没有绑定 → 退回;摘要引用了本稿没有分级过的结论类型 → 退回。
2. 事业段写「2025年至2028年是窗口」,只标 `career_promise` → `timing_unbound`;补上 `timing_window` 并带上相应的 `refs` → 通过,等级取两者中更严格的。
3. 健康段正文提到 D8,`claims=["health_obstruction"]` → `ref_outside_claims` 或 `undeclared_dependency`;改成 `["health_obstruction","health_pressure"]`,`refs` 带上 `v:D8`,而 D8 在 0.25 分钟边界上 → grade 不是 D 时退回 `ref_unstable`,是 D 时通过。
4. `kind: "fact"` 段写「水星落第五宫」→ 通过;写「水星落第五宫,意味着适合写作」→ 退回。
5. 旧存储版本的解读仍能渲染,现有测试全部通过。
## 自测命令
```
python3 -m pytest -q tests/test_report_evidence_grade_20261112.py tests/test_evidence_grade_purpose_stability_20261112.py
python3 scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
cd frontend && npx tsx --test tests/report-reading-*.test.ts* tests/report-md-export-reading-20261111.test.ts && npx tsc --noEmit -p . && npx eslint .
```
## 何时停下来选「需拍板」
- 别名词表在正常写法里大量误伤(例如「大运」几乎每段都有),以致无法写出能通过的稿子。这时列出误伤的例子停下,不得自行放宽规则。
- 需要改事实包结构。
@@ -1,107 +0,0 @@
# TASK:报告解读复查补三项机械检查(2026-11-12)
- Gitea:#207
- 分支:`codex/reading-postcheck-gaps-20261112`(基于 staging `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`)
- Bug 编号:BUG-1854。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
个人报告的「解读」由模型写,写完后由 `frontend/src/lib/personal-report-reading.ts::acceptReportReadingDraft` 逐节复查。
- 复查不通过的那一节,页面上显示「这一节没有通过数据核对,暂不显示。」,其他节照常显示。
- 每一节由 `checkSection` 检查,退回原因记在 `ReadingRejection` 类型里。
- 模型的输入是事实包 `report-reading-facts-v3`,由 `scripts/report_render/reading_facts.py` 生成。
占星师 2026-11-12 审核了一份样稿(她的仓库 `8aaf6099`,`handoff/2026-11-12/preset_q3/README.md` 第六、七节),列出了一批必须覆盖的反例。其中三条目前只在提示词里要求,复查不拦。#192 的验收材料把这三条标成了「部分(只靠提示)」:
1. **已故人物不给当前建议。** 事实包里 `report_mode = "historical_replay"`、`current_advice_allowed = false` 时,正文不应写对当事人现在的建议。一份样稿在健康节末尾写了「若有实际问题应依现实就医评估」。
2. **Double Transit 不推到别的领域。** `transits.double_transit.checked_houses` 只列了被检测的宫位,例如 `[7]`。样稿曾把第 7 宫的检测结果写进健康和整体应期。
3. **「形成」和「未验证」不混用。** 样稿曾写「Yoga 成立条件要等现实事件核验」,把“没有经过现实验证”写成了“没有形成”。
## 要改的文件
- `frontend/src/lib/personal-report-reading.ts`:只改复查部分,即 `checkSection`、`ReadingRejection`,以及新加的辅助函数。
- 新建 `frontend/tests/report-reading-postcheck-gaps-20261112.test.ts`
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-reading-postcheck-gaps-20261112.md`
## 要做的
每一条新检查不通过时,只退那一节,原因写进 `ReadingRejection`。
### 1. `current_advice_in_replay`
只在事实包 `current_advice_allowed === false` 时启用。
- 「几条建议」「健康」「现在这几年」三节里,任何一段命中下面任一条,就退回这一节:
- 第二人称「你」「您」;
- 建议性措辞:「建议」「应当」「应该」「应依」「最好」「不妨」「可以考虑」「宜」「不宜」「就医」「多休息」「留意」「注意」。
- 例外:「宜」「不宜」「注意」「留意」出现在引号内的书名、技法名里时不算。如果没法可靠识别这种情况,就在 PROGRESS 列出误伤的例子,选需拍板。
- `current_advice_allowed` 为 `true` 或缺失时,这条检查不运行。
### 2. `double_transit_scope`
- 先列出各节对应的宫位:
- 事业:10;
- 财富:2、11;
- 感情:7;
- 健康:1、6、8、12;
- 性格:1。
- 某一节的段落提到「Double Transit」「双星过境」「双重行运」「双行运」中任何一个,而这一节的宫位和 `checked_houses` 没有交集,就退回这一节。
- 总评、现在这几年、依据与边界三节不在上面的列表里,不做这项检查。
- `checked_houses` 为空或缺失时,凡是提到双行运又不在这三节的段落,一律退回。
### 3. `yoga_status_mismatch`
事实包 `yogas[]` 每一行有 `name`、`name_cn` 和 `state`。`state` 取 `candidate`、`formed` 等值,具体有哪些自己核对,列进 PROGRESS。
满足以下任一条件,就退回那一节:
1. 段落里出现某一行的 `name_cn`,或者 `name` 去掉 `Yoga` 后的主体词,同一句里又出现「未形成」「没有形成」「不成立」「未成立」「尚未成立」,而这一行的 `state` 是形成类的状态。
2. 段落里出现「等现实」「要等现实事件」「需现实验证」「经现实核验才」中任何一个,同一句里又出现「成立」或「形成」。
「句」以「。」「;」「!」「?」分割。
## 不要碰
- `frontend/src/lib/personal-report-reading.ts` 里的现有检查和 `writeReportReading`,只新增代码。
- `references/**`,包括 `references/product_runtime_contract.md`:提示词不改。
- `scripts/**`、冻结文件和其他前端文件。
## 验收标准(写进新测试)
1. 三条检查各有一个能触发的合成段落和一个不触发的合成段落。
- 第 1 条还要测:同一段文字在 `current_advice_allowed: true` 时不触发。
- 第 2 条还要测:`checked_houses: [7]` 时,感情节可以提到双行运,健康节提到就退回。
- 第 3 条还要测:`state` 为 `formed` 的瑜伽被写成「不成立」时退回;`candidate` 的瑜伽写成「候选、尚未确认」时不退回。
2. 每条检查只退回所在的那一节,其他节保持 `status: "ok"`。
3. 现有的 `frontend/tests/report-reading-20261027.test.tsx`、`report-md-export-reading-20261111.test.ts`、`report-no-web-wording-20261109.test.ts` 全部通过。
4. 测试里的段落全部用合成文字,不使用真实模型的原文。
## 自测命令(在 frontend 目录)
```
npx tsx --test tests/report-reading-postcheck-gaps-20261112.test.ts tests/report-reading-20261027.test.tsx tests/report-md-export-reading-20261111.test.ts tests/report-no-web-wording-20261109.test.ts
npx tsc --noEmit -p .
npx eslint .
```
## 进度记录
写在 `docs/tasks/PROGRESS-reading-postcheck-gaps-20261112.md`,内容包括:
- 每条检查的规则和词表;
- `yogas[].state` 实际有哪些取值;
- 误伤风险,以及你测过的边界例子;
- 测试结果。
## 何时停下来选“需拍板”
- 第 1 条的词表误伤正常的历史回放写法,而且不放宽就无法避免。
- `yogas[].state` 里分不清哪些是形成类的状态。
- 需要改提示词、事实包或冻结文件才能完成。
@@ -1,85 +0,0 @@
# TASK:事实包参与层改用两轴状态,Badhaka 区分宫与宫主并补十二上升测试(2026-11-12)
- Gitea:#216
- 分支:`codex/reading-status-axes-badhaka-20261112`,基于 staging `1ba0364a7a8c0f964c3e21c21cb304aaa1899375`
- Bug 编号:BUG-1859
- 验收:Claude(jyotisha-e6)
不得自行写「产品决定/裁定」。任务书没写到的取舍,一律选「需拍板」。
不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 里逐条写明原值、新值和原因。
占星师的引擎和资料(`scripts/jyotish_engine.py`、`scripts/yoga_engine.py`、`references/` 下她仓库已有的文件、`SKILL.md`)一律不改。产品差异只能放进单独的产品文件,并在 `references/product_runtime_contract.md` 公告(项目规则 2026-11-07)。
占星师回复原文:`docs/tasks/ref-astrologer-acceptance-reply-20261112.md`,由本分支第一个提交带入。下文的「她 §N」都指这份文件。
## 背景
### 两轴状态(她 §4「状态与可见审计」)
`scripts/report_render/reading_facts.py` 给每个领域的参与层写一个单轴 `state`,取值有 `executed`、`not_routed`、`not_in_domain_route`、`not_computed`、`blocked` 等。
- 已经算出、只是本领域不用它下结论的层(Indu、Gulika 等),现在的状态是 `not_in_domain_route`,读起来像是没执行。
- 她要求拆成两个轴:`execution_status`(`executed` / `blocked` / `not_applicable` / `not_computed`)和 `interpretation_usage`(`primary` / `supporting` / `conditional` / `not_used`)。
- 已算出但没用的层,必须是 `execution_status=executed`、`interpretation_usage=not_used`。
- `blocked` 必须写明具体阻碍:缺输入、调用失败或验证失败。
- `not_applicable` 必须写明范围或适用条件的理由。
仓库里已经有词表 `scripts/report_render/technique_status_axes.py`(`CALCULATION_LABELS` / `INTERPRETATION_LABELS`),要复用它,不要另起一套。
### Badhaka(她 §3、§4)
产品模块 `scripts/report_render/badhaka_facts.py` 返回的记录用 `house`、`sign`、`lord` 等字段,没有区分「阻碍宫(Badhaka sthana)」和「阻碍宫主(Badhakesh)」。样稿因此写成「阻碍星落在第七宫」。
她要求下一包附上:实现、profile、十二上升单测,以及星与宫区分的测试。
## 要改的文件
- `scripts/report_render/reading_facts.py`:只改参与层行的构造,以及汇总 `blocked_states` 的部分
- `scripts/report_render/badhaka_facts.py`
- 新建 `tests/test_reading_status_axes_20261112.py`
- 新建 `tests/test_badhaka_twelve_ascendants_20261112.py`
- `tests/test_badhaka_facts_20261112.py`、`tests/test_reading_facts_v3_20261112.py`:只在字段改名处改,并逐条列进 PROGRESS
- `docs/BUG_HISTORY.md`:末尾追加一条
- `docs/tasks/PROGRESS-reading-status-axes-badhaka-20261112.md`
## 要做的
1. 参与层每一行加两个字段:`execution_status` 和 `interpretation_usage`。
- 原有的 `state` 字段保留、取值不变。前端 `computedPointsOf` 和 `evidence_grade.py` 都读它。
- 映射规则:
- `executed` 且本领域在用 → `executed` + `primary`(或按现有数据写 `supporting`,规则写进 PROGRESS)
- `not_in_domain_route` 且有值 → `executed` + `not_used`
- `not_routed` 且有值 → `executed` + `not_used`;没有值 → `not_computed` + `not_used`
- `not_computed` → `not_computed` + `not_used`
- `blocked` 类 → `blocked`,同时带 `blocked_reason`(`missing_input` / `call_failed` / `validation_failed` 之一,按实际原因写)
- 任何一行不得出现 `execution_status=not_computed` 却又附有计算值的情况。
2. `badhaka_facts()` 的记录改用这些字段名:
- `badhaka_sthana`:`{house, sign, occupants}`
- `badhakesh`:`{planet, sign, house, dignity}`
- `ascendant_sign`、`ascendant_modality`、`source`、`status` 保留
- 加 `profile: "badhaka_modality_v1"`,说明「动 → 11、固 → 9、双 → 7,按 badhaka-obstacle-planet-guide.md §二」
- 旧字段名不再输出。`reading_facts.STRUCTURED_VALUES["Badhaka"]` 和 `consult_card_domain_facts.py` 里引用它的地方同步改。改之前先 grep 全仓,列进 PROGRESS。
3. 十二上升测试:每个上升都用合成行星位置,断言 Badhaka 宫的宫位、星座、宫主。另外断言宫内行星只列落在该星座里的星,宫主自己不算进去(除非宫主恰好落在宫内)。
4. 区分测试:造一个宫主不在 Badhaka 宫内的例子,断言 `badhakesh.house` 不等于 `badhaka_sthana.house`,而且 `badhaka_sthana.occupants` 里不含宫主。
## 不要碰
- `frontend/**`
- `references/**`
- `scripts/yoga_engine.py`、`scripts/jyotish_engine.py`(只能调用 `badhaka_house`)
- `scripts/report_render/evidence_grade.py`
## 验收标准
1. 用 Jobs(1955-02-24 19:15,UTC−8,Raman 岁差,平均交点)和 Obama 两张公开盘生成事实包,健康领域 Badhaka 的结果是:
- Jobs:Badhaka 宫为第 7 宫双鱼,宫内有月亮;Badhakesh 为木星,在第 10 宫。
- Obama:Badhaka 宫为第 11 宫天蝎;Badhakesh 为火星,在第 9 宫。
生成方法参考现有的 `tests/test_reading_facts_v3_20261112.py`。
2. 两张盘的 Indu、Gulika、Mandi、Pranapada 行都是 `executed` + `not_used`。
3. 新测试、改过的旧测试和 Python 快速质量门全部通过。
## 何时停下来选「需拍板」
- 某个领域的「在用 / 辅助」无法从现有数据判断。
- 改字段名会影响 `frontend/` 下你不许改的代码。
@@ -1,104 +0,0 @@
# TASK:个人报告几处露出的原始字段和格式问题(2026-11-12)
- Gitea:#204
- 分支:`codex/report-wording-fixes-20261112`(基于 staging `017e650fdafac4a841487dbba4aca803c761dad8`)
- Bug 编号:BUG-1849。开工时核对 `docs/BUG_HISTORY.md` 末尾;若已被占用,顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-de)
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因,并在 PROGRESS 里逐条列出。
## 背景
2026-11-12,Claude 用 `tests/public_chart_reports.py` 的第一张公开名人盘渲染了中英文个人报告(`public_chart_reports(PUBLIC_REPORT_CASES[0])`),看到下面几处问题。它们都在报告渲染层,引擎的值本身没有错。
| # | 版本 | 现在显示 | 位置 |
|---|---|---|---|
| 1 | 中、英 | Mudda 区间小表「本报告覆盖区间」一行写 `365.2555787037037 天` | `scripts/pl9_reader_export.py::_mudda_coverage_lines`,`coverage.get('days')` |
| 2 | 中 | 区间表下方一句:`报告覆盖区间视图(本次真实返照 → 下一次真实返照);完整原始时间线见 raw_periods` | 同一函数,`periods_view.label`。英文版用的是 `label_en` |
| 3 | 中 | 跨返照那句 `……(原始计算延伸至2027-02-27T20:28:43)` 句末没有句号 | 同一函数,`MUDDA_CROSSING_TEXT` |
| 4 | 中 | 第二年及以后的年度表里,Mudda 表头是英文键:`balance_profile \| lord \| start \| end \| boundary_status` | `pl9_reader_export.py` 渲染 `annual_pack` 的 `table_key == 'mudda_rows'` 那段(约 :3240) |
| 5 | 中 | 「Strength Layers」审计行结尾是 `Vimsopaka status=良好。` | 技法审计表 Strength Layers 一行的组装处,自己定位 |
| 6 | 中、英 | 行星表和主星表里,**上升**一行的「R/C」「速度」「力量比」「分主」是 `未列` / `not listed` | 行星表、行星主星与状态表的组装处,自己定位 |
| 7 | 英 | 技法审计表的 Status 列写成 `calculation: executed; interpretation: primary` | 英文版审计表。中文版同一格写的是「计算已执行,解读主要依据」 |
## 要做的
1. **天数**:保留 4 位小数,中英文都改,例如 `365.2556 天` / `365.2556 days`。表里其他数值不动。
2. **`raw_periods`**:用户能看到的文字里不出现字段名。
- 中文改为:`报告覆盖区间视图(本次真实返照 → 下一次真实返照);完整原始时间线见上表「原始完整时间线」一行`;
- 英文改为与之对应的一句。
- 在报告层替换,不改引擎和 `scripts/tajika_mudda.py`。
3. **句号**:中文跨返照句末补「。」。在报告层补,`consultation_native_layers.py` 是冻结文件,不改。
4. **英文表头**:中文版第二年及以后的 Mudda 表,表头改用第一年那张表的中文表头:`余额口径 | 主星 | 开始 | 结束 | 边界口径`。
- 单元格里的枚举值,用第一年那张表现成的映射显示。
- 英文版不变。
- 别的 `annual_pack` 表如果在中文版里也露出英文键,**列进 PROGRESS,本单不改**。
5. **Vimsopaka**:去掉 `status=良好` 这类等级词。
- 改为写出每颗行星的 Vimsopaka 原始值,取值与报告 Vimsopaka 表相同、单位相同;
- 如果拿不到原始值,就整段去掉,不写任何等级词。
- 英文版同样处理。
6. **上升行**:上升点没有逆行、速度、力量比和分主。
- 这几格改为 `—`,中英文相同,只改上升一行;
- 表格说明里已经写了 `—` 表示“缺数据或核验限制”。若这个含义和“不适用”冲突,就选需拍板,不要另造新词。
7. **英文 Status 列**:按中文版的映射,一一对应写英文短语。
- 先在 PROGRESS 列出中文全部取值和你拟的英文译法,再改代码;
- 只改展示文字,不改状态值本身。
## 要改的文件
- `scripts/pl9_reader_export.py`
- 技法审计表与行星表的组装文件(自己定位,在 PROGRESS 写明)
- 新建 `tests/test_report_wording_fixes_20261112.py`
- `docs/BUG_HISTORY.md`:只在末尾追加一条。
- `docs/tasks/PROGRESS-report-wording-fixes-20261112.md`
## 不要碰
- 冻结文件:
- `scripts/jyotish_engine.py`
- `scripts/jyotish_api_server.py`
- `scripts/yoga_engine.py`
- `scripts/consultation_native_layers.py`
- `scripts/tajika_mudda.py`
- `scripts/shadbala*.py`
- `scripts/narayana_*.py`
- `scripts/report_render/reading_facts.py`、`frontend/src/lib/personal-report-reading*.ts`、`frontend/src/mastra/personal-report-reading.ts`:Claude 在 #192 里改。
- `references/**`
## 验收标准(写进新测试)
用两张公开名人盘渲染中英文报告,断言下面 7 条:
1. 不再出现 `\d+\.\d{5,} 天` 和 `\d+\.\d{5,} days`。
2. 用户可见文字里没有 `raw_periods`。
3. 跨返照句以「。」结尾。
4. 中文版里没有 `| balance_profile | lord |` 这样的表头。
5. 没有 `Vimsopaka status=`。
6. 上升行的 R/C 和速度两格是 `—`。
7. 英文版没有 `calculation: `、`; interpretation: ` 这种写法。
另外,`tests/test_report_*` 里现有的全部测试都要通过。
## 自测命令(在仓库根目录)
```
python -m pytest -q tests/test_report_wording_fixes_20261112.py
python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
## 进度记录
写在 `docs/tasks/PROGRESS-report-wording-fixes-20261112.md`,内容包括:
- 每一处改在哪里;
- 第 7 条的中英对照表;
- 第 4 条发现的其他英文键表;
- 测试结果,以及在 staging 上也复现的基线失败。
## 何时停下来选“需拍板”
- 某一处只能改冻结文件才能修好。
- 第 6 条里,`—` 的现有含义和“不适用”冲突。
- 第 5 条拿不到 Vimsopaka 原始值,而去掉整段会让这一行变空。
-2
View File
@@ -649,7 +649,6 @@ Action results and errors never take page layout. They are toasts from `src/lib/
- **Export content:** 中文报告保存了解读时,导出列表在摘要之后列出「解读」,整篇和自选的默认正文均包含它。文件沿用页面的节标题、段落与「计算依据」标签。没有解读的报告沿用原正文。 Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`. Chinese chart house lines use `SIGN_ZH`(白羊、金牛……,不带「座」). English chart house lines keep the English sign name.
- **Structure:** inside the app shell, like every other secondary page — `/reports/[reportId]` was the last standalone full-screen route, and opening a report used to drop the reader out of the app entirely. 「个人报告」 sits in the 46px header; below it the reader owns its own scroll boundary, carrying the sticky screen chrome (back and the labelled 「导出」 button) and then the longform Markdown body beside a persistent TOC rail. Every phase — loading, generating, timed-out, and each error — renders inside the same shell, so the way back is always the rail. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”.
- **Technique audit status (BUG-1849):** newly generated English reports use readable calculation and interpretation phrases together in the Status cell, with the evidence qualifier when present. The reader also renders the earlier status format from saved reports.
- **TOC levels (2026-09-29):** chapters (`##`) are `--color-ink` at weight 600 with `--space-3` above each new chapter; sections (`###`) are indented `--space-5` and stay `--color-ink-secondary`. The two levels read apart without relying on the current-item pill.
- **中文 / English (2026-09-29):** reports generated since the English edition carry both editions; the action bar shows segmented tabs (`.report-language-switch`, `aria-label="报告语言"`, 中文 / English, the same raised slot as the chart-type strip) before 「导出」. The selected segment keeps a neutral background; its text is `--color-action` and a 1px inset edge is `--color-action-strong`, matched on `[data-active]` or `[aria-selected="true"]`. Reports without an English edition show no switch at all rather than a disabled one. The edition lives in the URL (`?lang=en`, read by the server page, written with `history.replaceState`), so refresh and shared links keep it. Switching remounts the article, TOC and fact tables for that edition and sets `lang` on the reader body; chrome (header, rail, 到底部) stays Chinese. Charts draw the same glyphs; only the legend reads `Su Mo …` / 「bar = retrograde」.
- **导出框里的英文版提示 (2026-10-25, K5 / D8):** the caption and the 「切到 English」 band are gone. A report without an English edition still raises one info toast (id `report-export-english`) when the sheet opens. Switching the sheet's language still resets the checklist to that edition's blocks; the file name still gains `-EN`.
@@ -1391,7 +1390,6 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
## 年运报告与开始遮罩(2026-11-01,issue #153 / #154)
- **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。
- **入口**:「我的报告」最上面两个横条并排(手机上下排,2026-11-03 方案 A,见 Personal report centre):「年运」横条是 `Sun` 图标方块、标题、一行「这一年每段怎么安排,几个要留意的时间点」,右边描边按钮「打开年运」(手机「打开」),直接打开 `/reports/annual`。没有单独的入口页。个人报告横条上不写份数。
- **地址**:`/reports/annual`(`?report=<id>` 打开某一份),放在 `/reports` 下,侧栏仍亮「我的报告」。沿用二级页外壳,标题「年运」,标题旁是人物切换。正文一栏 `min(720px, 100%)` 居中。
- **遮罩**:报告的章节骨架在后面虚着(灰标题 + 两条微光条),上面一层 `--color-canvas` 72% 半透明 + 6px 模糊,正中一张卡片(`min(520px, 100%)`、圆角 20px、细边、`--shadow-elevated`,超出时卡内滚动):范围小字「YYYY-MM-DD 到 YYYY-MM-DD」,衬线标题「某人的这一年」;三个胶囊切换「生日年度 / 公历年 / 未来十二个月」(`ToggleGroup`,选中描边 `--color-action-strong`、文字 `--color-action`);三条注意事项(主色勾);出生时间没确认时一行浅底提示,本人和别人都带「先做生时校正」链接(#160 起可以给档案里的其他人做校正);「补充现在的情况(可以不填)」折叠,展开后是关系状态、工作 / 学习、最关心的(最多三项)、已有的计划(一行输入)、不想看的;居中实心按钮「开始写年运」(最小 220×52,手机占满宽),下面一行「本次消耗 N 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。
+10 -26
View File
@@ -116,19 +116,6 @@ const YEAR_KEY_CN: Readonly<Record<string, string>> = {
Lagna: "年盘上升", year_lord: "年主", Muntha: "Muntha", mudda_start: "Mudda 起运星", status: "年盘",
};
const NARAYANA_REFERENCE = "Narayana 是条件参考,不作为独立印证";
const NARAYANA_WRITING_RULE = `${NARAYANA_REFERENCE}。提到 Narayana 时,写明这是条件参考。方向一致只表示方向相同,不抬高把握。未决分支分开列,不合并,不作结论。`;
function narayanaCandidateLines(facts: unknown): string[] {
const reference = record(record(record(facts).cards).conditional_narayana);
const branches = rows(reference.branches);
if (!branches.length) return [];
return [
`Narayana 有 ${branches.length} 个候选分支,各自列出,不合并。${NARAYANA_REFERENCE}。`,
...branches.map((branch) => `Narayana 条件参考(不作为独立印证) ${str(branch.name) || str(branch.branch_id)}`),
];
}
function structureName(key: string): string {
const year = /^Y(\d{4}):(.+)$/.exec(key);
if (year) return `${year[1]} 年${YEAR_KEY_CN[year[2]] ?? year[2]}`;
@@ -144,10 +131,10 @@ function conflictLine(row: Row): string {
if (row.code === "birth_range_sampled") return `${label}:误差范围内每 ${String(row.resolution_minutes)} 分钟取样、变化处逐分钟定位;没变化的只能说「已检查样本一致」,不是整段都核实过`;
if (row.code === "annual_chart_birth_range_not_checked") return `${label}:年盘上升和 Mudda / Patyayini 的日期按所用出生时间算,没有在误差两端重算`;
if (row.code === "phase_split") return `${label}:${str(row.phase_id)} 这一段各领域方向不同,分开写,不合成一个总评`;
if (row.code === "tracks_differ") return `${label}:${str(row.phase_id)} Vimshottari 和 Narayana 对同一领域方向相反。${NARAYANA_REFERENCE}。`;
if (row.code === "tracks_differ") return `${label}:${str(row.phase_id)} Vimshottari 和 Narayana 对同一领域方向相反,两边都写`;
if (row.code === "birth_time_not_confirmed") return `${label}:出生时间还没确认,上升、细分盘和大运边界都会跟着动,窗口已放宽`;
if (row.code === "phase_counterweight") return `${label}:${str(row.phase_id)} 这一段支持和压力都在,按加权后的主调写,反方向的一面照实写`;
if (row.code === "narayana_profiles_differ") return `${label}:${str(row.phase_id)} Narayana 两种算法小运不同。Sanjay Rath 版作条件参考,旧算法供对照。`;
if (row.code === "narayana_profiles_differ") return `${label}:${str(row.phase_id)} Narayana 两种算法小运不同,只按 Sanjay Rath 版计入`;
if (row.code === "block_blocked") return `${label}:${str(row.path)} 没有算出来(${str(row.reason)})`;
return `${label}:${str(row.code)}`;
}
@@ -202,20 +189,20 @@ export function buildAppendix(facts: unknown, range: AnnualSnapshot["range"], bi
id: "method",
title: "这份报告的口径",
lines: [
`阶段以本命 Vimshottari 为主要依据。${NARAYANA_REFERENCE},采用 Sanjay Rath 版,旧算法供对照。`,
...narayanaCandidateLines(facts),
// BUG-1601 (#166 Q9): Rath is the Narayana skeleton; legacy is comparison only.
"阶段骨架看本命大运(Vimshottari)和 Narayana;Narayana 用 Sanjay Rath 版,旧算法只作对照,不算第二份支持",
// BUG-1650 (astrologer 2026-11-06): no weighted sum, per domain and per track.
`本命大运作背景,小运和细运分领域判断。${NARAYANA_REFERENCE},可说明主题是否被激活。`,
"宫主只有掌管或落入某领域的宫才进入该领域,吉星凶星同一标准。功能吉凶说明倾向。",
"本命大运作背景;小运、细运和 Narayana 小运分领域、分体系判断,再对照两套体系;不加权求和,正负不互相抵消",
"宫主只有掌管或落入某领域的宫才进入该领域,吉星凶星同一标准;功能吉凶只是倾向,不是结果;Narayana 运所在星座只表示主题被激活",
"只判断了部分领域时只写这些领域,不概括整段;和前一段相同的写「延续前一阶段的主题」;「平稳」暂不使用",
"宫主的功能吉凶、所管宫、所落宫、尊贵、燃烧分别记录;Maraka 单独记录,不作为负分;罗睺计都参考定位星,保留自身位置",
"各领域方向一致才有主调,不一致就分领域写,看不出就写看不出;基调只决定建议的方向,正文不写顺紧总评",
"年内展开看 Mudda 和 Patyayini,不参与本命运限判断;年盘分四层记:事项关联、Tajika 联系(九种结构判断,来源未核定,只列联系、不作因素)、阻碍、五分力(偏弱不等于压力);只列支持与压力因素,不下成败结论",
// 产品 2026-11-07 占星师方案:年盘完整层。
"年盘另看:Deeptamsa 内的 Tajika 相位、五分力与 Harsha、年盘 Shadbala(年盘时刻重算,与本命分开)、Saham(PVR 表 74,按回归时刻昼夜取式,参数敏感)",
"Varsha Narayana:移植上游 PVR 30.4 太阳角距版,从 Muntha 起,太阳每走 3° 为一名义年。星座强弱用 Rath 规则,与本命运限分开。条件参考,不作为独立印证。",
"Varsha Narayana:移植上游 PVR 30.4 太阳角距版,从 Muntha 起,太阳每走 3° 为一名义年,星座强弱用本产品 Narayana(Rath)规则;参数敏感,与本命 Narayana 分开",
"行运另看:Sade Sati 阶段(土星在本命月亮第 12、1、2 座)、慢星所在星座的本命 Ashtakavarga(BAV / SAV)、木星土星的 Vedha(只查慢星,参数敏感)",
`每节先写盘面依据,再写解读。${NARAYANA_REFERENCE}。把握上限为 C。A 需要你过往事件验证。`,
"写法:每节先写盘面依据再写解读;本命两轨(Vimshottari、Narayana)分开判断,不一致两边都写;把握按 A/B/C 标,A 需要你过往事件验证,这份报告最高 B",
"行运只算木星、土星、罗睺计都的换宫、停滞和精确经过本命点,只收与本命或报告领域有关的;木星、土星按 Parashari 星座级相照(木星 5、7、9,土星 3、7、10,同座另记)关联各领域的本命星,相照只是关联,不代表好坏;不用统一容许度;天象日期不等于事情发生的日子",
`转折窗口:${String(config.window_cluster_days ?? 21)} 天内、指向同一领域、至少 ${String(config.window_min_independent_systems ?? 2)} 个互相独立的来源;罗睺计都算一条轴,Mudda 和 Patyayini 算一层,同一颗慢星的几次经过算一个来源;这些是展示参数,不是统计验证`,
"精确相照时刻(木星 120°/180°/240°、土星 60°/180°/270°,及同座)单独列表:只记达到角度的时刻,同一次顺逆往返算一个序列,不设影响范围,也不进转折窗口",
@@ -345,14 +332,11 @@ export async function writeAnnualReport(input: Readonly<{
input.signal.addEventListener("abort", onAbort, { once: true });
try {
const written = await input.write({
system: `${systemPrompt()}\n${NARAYANA_WRITING_RULE}`,
system: systemPrompt(),
user: userPrompt({
name: input.subject.name,
entry,
evidence: {
...sectionEvidence(input.facts, entry, input.situation),
Narayana参考: [NARAYANA_REFERENCE, ...narayanaCandidateLines(input.facts)],
},
evidence: sectionEvidence(input.facts, entry, input.situation),
failures,
written: writtenSoFar(),
}),
+12 -5
View File
@@ -539,12 +539,19 @@ function transitEvidence(facts: unknown): Row {
/**
* The highest confidence a section may claim (ai-reading-workflow-prompt [A]/[B]/[C]):
* A needs the user's own past events. The current annual report has no rule
* for raising the cap to B using a conditional reference.
* A needs the user's own past events, which this report never has; B needs
* independent systems agreeing (a window from two sources, and both natal tracks
* pointing the same way in at least one phase); otherwise C.
*/
export function confidenceCap(facts: unknown, domain?: AnnualDomain): "B" | "C";
export function confidenceCap(): "B" | "C" {
return "C";
export function confidenceCap(facts: unknown, domain?: AnnualDomain): "B" | "C" {
const all = rows(cards(facts).phases);
const windows = rows(cards(facts).windows);
const domainWindows = domain ? windows.filter((window) => Array.isArray(window.theme_domains) && (window.theme_domains as string[]).includes(domain)) : windows;
const agree = all.some((phase) => (domain ? [domain] : [...ANNUAL_DOMAINS]).some((key) => {
const tracks = record(record(record(record(phase.lean).domains)[key]).tracks);
return str(tracks.vimshottari) !== "none" && str(tracks.vimshottari) === str(tracks.narayana);
}));
return domainWindows.length > 0 && agree ? "B" : "C";
}
function pointsOf(facts: unknown): Map<string, Row> {
+1 -70
View File
@@ -84,9 +84,6 @@ export type ReadingRejection =
| "false_no_blocker"
| "dignity_mismatch"
| "false_missing_layer"
| "current_advice_in_replay"
| "double_transit_scope"
| "yoga_status_mismatch"
| "all_sections_withheld";
type Claims = Readonly<Record<string, { cap?: unknown; sections?: unknown; required?: unknown }>>;
@@ -157,68 +154,6 @@ function falseMissingLayer(text: string, computed: Set<string>): boolean {
return false;
}
const REPLAY_ADVICE_SECTIONS = new Set(["advice", "health", "now"]);
const REPLAY_ADVICE = /你|您|建议|应当|应该|应依|最好|不妨|可以考虑|不宜|就医|多休息|留意|注意/;
const TITLE_ADVICE_WORDS = /不宜|宜|注意|留意/g;
const QUOTED_NAME = /《[^《》\n]+》|〈[^〈〉\n]+〉|「[^「」\n]+」|『[^『』\n]+』|“[^“”\n]+”|‘[^‘’\n]+’|"[^"\n]+"/g;
const NAME_INTRO = /(?:书名|篇名|著作|技法(?:名|名称)?)[:: \t]*$/;
const NAME_SUFFIX = /^[ \t]*(?:一书|这本书|(?:这[一项门])?技法)/;
const CHINESE_WORDS = new Intl.Segmenter("zh", { granularity: "word" });
function currentAdviceInReplay(text: string, section: string, facts: Record<string, unknown> | null): boolean {
if (facts?.current_advice_allowed !== false || !REPLAY_ADVICE_SECTIONS.has(section)) return false;
const prose = text.replace(QUOTED_NAME, (quote: string, start: number) => {
const title = quote.startsWith("《") || quote.startsWith("〈") ||
NAME_INTRO.test(text.slice(0, start)) || NAME_SUFFIX.test(text.slice(start + quote.length));
return title ? quote.replace(TITLE_ADVICE_WORDS, " ") : quote;
});
if (REPLAY_ADVICE.test(prose)) return true;
// Match the word 宜 without treating 宜昌 or 适宜 as advice.
if (prose.includes("宜")) {
for (const { segment } of CHINESE_WORDS.segment(prose)) {
if (segment === "宜") return true;
}
}
return false;
}
const DOUBLE_TRANSIT = /\bDouble\s+Transit\b|双星过境|双重行运|双行运/i;
const TRANSIT_SCOPE_EXEMPT = new Set(["overview", "now", "basis"]);
const SECTION_HOUSES: Readonly<Record<string, readonly number[]>> = {
career: [10], wealth: [2, 11], love: [7], health: [1, 6, 8, 12], character: [1],
};
function doubleTransitOutOfScope(text: string, section: string, facts: Record<string, unknown> | null): boolean {
if (TRANSIT_SCOPE_EXEMPT.has(section) || !DOUBLE_TRANSIT.test(text)) return false;
const transits = facts?.transits as { double_transit?: { checked_houses?: unknown } } | undefined;
const checked = transits?.double_transit?.checked_houses;
return !Array.isArray(checked) || !SECTION_HOUSES[section]?.some((house) => checked.includes(house));
}
const YOGA_FORMATION_NEGATED = /未形成|没有形成|不成立|未成立|尚未成立/;
const REAL_WORLD_FORMATION_CONDITION = /等现实|要等现实事件|需现实验证|经现实核验才/;
const YOGA_FORMATION = /成立|形成/;
function mentionsYoga(sentence: string, yoga: { name?: unknown; name_cn?: unknown }): boolean {
const chinese = typeof yoga.name_cn === "string" ? yoga.name_cn.trim() : "";
if (chinese && sentence.includes(chinese)) return true;
if (typeof yoga.name !== "string") return false;
const english = yoga.name.replace(/\bYoga\b/gi, " ").trim().replace(/\s+/g, " ");
if (!english) return false;
const escaped = english.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const name = new RegExp(`(?<![\\p{Script=Latin}\\p{N}])${escaped}(?![\\p{Script=Latin}\\p{N}])`, "iu");
return name.test(sentence.replace(/\s+/g, " "));
}
function yogaStatusMismatch(text: string, facts: Record<string, unknown> | null): boolean {
const yogas = facts?.yogas;
return text.split(/[。;!?]/).some((sentence) => {
if (REAL_WORLD_FORMATION_CONDITION.test(sentence) && YOGA_FORMATION.test(sentence)) return true;
return YOGA_FORMATION_NEGATED.test(sentence) && Array.isArray(yogas) && yogas.some((yoga) =>
(yoga?.state === "formed_under_source" || yoga?.state === "formed") && mentionsYoga(sentence, yoga));
});
}
/** A planet name followed closely by a dignity word the facts never give that planet. */
function dignityMismatch(text: string, dignities: Map<string, Set<string>>): boolean {
if (dignities.size === 0) return false;
@@ -261,7 +196,6 @@ function checkSection(
claims: Claims,
dignities: Map<string, Set<string>>,
computed: Set<string>,
facts: Record<string, unknown> | null,
): { section: CheckedSection; chars: number } {
const withheld = (reason: ReadingRejection) => ({
section: { id: section.id, title, status: "withheld" as const, reason },
@@ -280,9 +214,6 @@ function checkSection(
if (BANNED_PHRASES.some((phrase) => text.includes(phrase)) || CERTAINTY.test(text)) return withheld("banned_phrase");
if (dignityMismatch(text, dignities)) return withheld("dignity_mismatch");
if (falseMissingLayer(text, computed)) return withheld("false_missing_layer");
if (currentAdviceInReplay(text, section.id, facts)) return withheld("current_advice_in_replay");
if (doubleTransitOutOfScope(text, section.id, facts)) return withheld("double_transit_scope");
if (yogaStatusMismatch(text, facts)) return withheld("yoga_status_mismatch");
disclaimers += text.match(DISCLAIMER)?.length ?? 0;
chars += text.length;
if (!graded) {
@@ -330,7 +261,7 @@ export function acceptReportReadingDraft(value: unknown, facts: Record<string, u
const dignities = dignitiesOf(facts);
const computed = computedPointsOf(facts);
const checked = sections.map((section, index) =>
checkSection(section, REPORT_READING_SECTIONS[index]!.title, claims, dignities, computed, facts));
checkSection(section, REPORT_READING_SECTIONS[index]!.title, claims, dignities, computed));
const withheld = checked.filter(({ section }) => section.status === "withheld").length;
if (withheld === checked.length) return { ok: false, reason: "all_sections_withheld" };
const total = checked.reduce((sum, { chars }) => sum + chars, 0);
+1 -3
View File
@@ -378,9 +378,7 @@ type StatusCellPredicate = (value: string) => boolean;
const STATUS_CELL = /^(\| (?:\\.|[^|\\\r\n])+ \| )([^|\r\n]+?)(?= \|)/;
const AUDIT_EVIDENCE = "parameter_sensitive|profile_sensitive|single_system_inference|unclosed_divisional_chart|user_history_verification_required";
const LEGACY_EN_AUDIT_AXES = "calculation: (?:executed|partial|blocked|not_applicable|not_run); interpretation: (?:primary|supporting|conditional|observation_only|blocked|not_used)";
const EN_AUDIT_AXES = "Calculation (?:completed|partially completed|blocked|not applicable|not completed), (?:primary interpretive basis|supporting interpretive evidence|conditional interpretation|observation only|no interpretation|excluded from interpretation)";
const EN_AUDIT_STATUS = new RegExp(`^(?:${EN_AUDIT_AXES}|${LEGACY_EN_AUDIT_AXES})(?: \\((?:${AUDIT_EVIDENCE})\\))?$`);
const EN_AUDIT_STATUS = new RegExp(`^calculation: (?:executed|partial|blocked|not_applicable|not_run); interpretation: (?:primary|supporting|conditional|observation_only|blocked|not_used)(?: \\((?:${AUDIT_EVIDENCE})\\))?$`);
const ZH_AUDIT_STATUS = new RegExp(`^计算(?:已执行|部分执行|阻塞|不适用|未完成),解读(?:主要依据|辅助依据|有条件|仅观察|不作判断|不参与)(?:((?:${AUDIT_EVIDENCE})))?$`);
function publicStatusCell(table: string, scope: ProjectionScope): StatusCellPredicate | undefined {
@@ -1,33 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildAppendix, rangeView } from "../src/lib/annual-report-generate";
import { confidenceCap } from "../src/lib/annual-report-sections";
const captured = JSON.parse(readFileSync(new URL("./fixtures/annual-report-facts.json", import.meta.url), "utf8")).facts;
const seed = JSON.parse(readFileSync(new URL("./fixtures/narayana-branches-20261109/seed.json", import.meta.url), "utf8"));
test("the annual window cannot raise confidence for matching Vimshottari and Narayana directions", () => {
assert.ok(captured.cards.windows.some((window: { theme_domains: string[] }) => window.theme_domains.includes("relationship")));
assert.ok(captured.cards.phases.some((phase: { lean: { domains: { relationship: { tracks: Record<string, string> } } } }) => {
const tracks = phase.lean.domains.relationship.tracks;
return tracks.vimshottari === "support" && tracks.narayana === "support";
}));
assert.equal(confidenceCap(captured, "relationship"), "C");
});
test("the annual appendix describes candidate branches separately as conditional references", () => {
const facts = structuredClone(captured);
const branches = seed.product.branches.slice(0, 2) as Array<{ branch_id: string }>;
facts.cards.conditional_narayana = { branches };
const appendix = buildAppendix(facts, rangeView(facts, "birthday_year", "annual:birthday_year:2026"), {
known: true, beforeMinutes: 0, afterMinutes: 0, source: "family_exact", rectification: "none",
});
const method = appendix.find((entry) => entry.id === "method");
assert.ok(method);
assert.ok(method.lines.some((line) => line.includes("Narayana 有 2 个候选分支,各自列出,不合并")));
for (const branch of branches) {
assert.ok(method.lines.some((line) => line.includes(branch.branch_id) && line.includes("不作为独立印证")));
}
});
@@ -1,121 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
acceptReportReadingDraft,
REPORT_READING_SECTIONS,
type ReadingRejection,
type ReadingSkillMeta,
} from "../src/lib/personal-report-reading";
import { EVIDENCE_GRADE_CONTRACT } from "../src/lib/personal-report-reading-contract";
const SKILL: ReadingSkillMeta = {
name: "jyotish-vedic-astrology", version: "test",
loaded: [{ path: "SKILL.md", sha256: "0".repeat(64) }],
};
const NEUTRAL = "这里仅复述一个虚构场景中的既有记录。叙述把记载的顺序和可核对的线索分开,也说明当时的材料有些空白。相关细节均用于这个例子的文本核对。".repeat(2);
const CLAIMS = {
character: "character_structure", career: "career_promise", wealth: "wealth_earning",
love: "relationship_structure", health: "health_pressure", now: "timing_window",
} as const;
const GRADES = {
contract: EVIDENCE_GRADE_CONTRACT,
claims: Object.fromEntries(Object.entries(CLAIMS).map(([section, claim]) => [claim, { cap: "C", sections: [section] }])),
};
type Section = (typeof REPORT_READING_SECTIONS)[number]["id"];
function expectPostcheck(
facts: Record<string, unknown>,
examples: Partial<Record<Section, string>>,
rejected?: readonly [Section, ReadingRejection],
): void {
const draft = {
sections: REPORT_READING_SECTIONS.map(({ id }) => {
const claim = id in CLAIMS ? CLAIMS[id as keyof typeof CLAIMS] : null;
return {
id,
paragraphs: [NEUTRAL, examples[id] ?? NEUTRAL].map((text) => ({
text, claim_type: claim, grade: claim ? "C" : null,
})),
};
}),
};
const verdict = acceptReportReadingDraft(draft, { ...facts, evidence_grades: GRADES }, SKILL);
assert.ok(verdict.ok && verdict.reading.version === 3, JSON.stringify(verdict));
assert.equal(verdict.withheld, rejected ? 1 : 0);
for (const section of verdict.reading.sections) {
if (section.id === rejected?.[0]) {
assert.equal(section.status, "withheld", section.id);
assert.equal(section.reason, rejected[1], section.id);
} else {
assert.equal(section.status, "ok", section.id);
}
}
}
test("the three historical replay sections withhold direct address or current advice", () => {
const replay = { report_mode: "historical_replay", current_advice_allowed: false };
for (const section of ["advice", "health", "now"] as const) {
expectPostcheck(replay, { [section]: "这里建议先放慢节奏。" }, [section, "current_advice_in_replay"]);
}
for (const phrase of ["你", "您", "建议", "应当", "应该", "应依", "最好", "不妨", "可以考虑", "宜", "不宜", "就医", "多休息", "留意", "注意"]) {
expectPostcheck(replay, { health: `本节使用该措辞:${phrase}。` }, ["health", "current_advice_in_replay"]);
}
expectPostcheck(replay, { career: "这里以第二人称转述这段记录:你核对了这一点。" });
});
test("the same text passes when current advice is allowed or the flag is absent", () => {
const example = { health: "这里建议先放慢节奏。" };
expectPostcheck({ current_advice_allowed: true }, example);
expectPostcheck({}, example);
expectPostcheck({ current_advice_allowed: false }, { health: "当年途经宜昌,留下的文字只有一条纪事。" });
});
test("title quoting exempts only the four title words and ordinary quotes still count", () => {
const replay = { current_advice_allowed: false };
expectPostcheck(replay, { health: "这里引用《宜忌》与〈注意〉,并抄录技法「不宜」和“留意”这项技法的名称。" });
expectPostcheck(replay, { health: "记录按「注意」技法归类。" });
expectPostcheck(replay, { health: "记录曾出现「宜早作」这句话。" }, ["health", "current_advice_in_replay"]);
expectPostcheck(replay, { health: "历史笔记提到《建议录》。" }, ["health", "current_advice_in_replay"]);
expectPostcheck(replay, { health: "文中引用《宜忌》。另外,最好先核对。" }, ["health", "current_advice_in_replay"]);
});
test("a checked seventh house covers relationships and withholds Double Transit in health", () => {
const facts = { transits: { double_transit: { checked_houses: [7] } } };
expectPostcheck(facts, { love: "双行运仅讨论第七宫。", health: "健康这一段引用 Double Transit。" }, ["health", "double_transit_scope"]);
});
test("Double Transit accepts an intersecting house for each mapped section", () => {
for (const [section, house] of [["career", 10], ["wealth", 2], ["wealth", 11], ["character", 1], ["health", 1], ["health", 6], ["health", 8], ["health", 12]] as const) {
expectPostcheck({ transits: { double_transit: { checked_houses: [house, 7] } } }, { [section]: "这里核对双星过境。" });
}
});
test("missing and empty checked houses withhold other sections and preserve the three exemptions", () => {
const phrase = "这里提到双重行运。";
for (const facts of [{}, { transits: { double_transit: { checked_houses: [] } } }]) {
expectPostcheck(facts, { overview: phrase, now: phrase, basis: phrase, career: phrase }, ["career", "double_transit_scope"]);
expectPostcheck(facts, { advice: phrase }, ["advice", "double_transit_scope"]);
}
});
test("a formed Yoga cannot be negated in the sentence that names it", () => {
const row = { name: "Dhana Yoga", name_cn: "财富瑜伽" };
for (const state of ["formed", "formed_under_source"]) {
const facts = { yogas: [{ ...row, state }] };
expectPostcheck(facts, { wealth: "财富瑜伽不成立。" }, ["wealth", "yoga_status_mismatch"]);
expectPostcheck(facts, { wealth: "这里的 dhana 尚未成立。" }, ["wealth", "yoga_status_mismatch"]);
expectPostcheck(facts, { wealth: "财富瑜伽成立。另一项组合未形成。" });
expectPostcheck(facts, { wealth: "另一项称为 MahaDhana 的组合未形成。" });
}
expectPostcheck({ yogas: [{ ...row, state: "candidate" }] }, { wealth: "财富瑜伽是候选、尚未确认。" });
});
test("reality checks can qualify outcomes but cannot be required for Yoga formation in the same sentence", () => {
for (const condition of ["等现实", "要等现实事件", "需现实验证", "经现实核验才"]) {
expectPostcheck({}, { basis: `瑜伽是否成立,${condition}核对。` }, ["basis", "yoga_status_mismatch"]);
}
for (const punctuation of ["。", ";", "!", "?"]) {
expectPostcheck({}, { basis: `瑜伽按这组条件形成${punctuation}实际结果需现实验证。` });
}
});
+2 -19
View File
@@ -1581,27 +1581,10 @@ def _narayana(ctx: _Context, natal: dict, range_start: datetime, range_end: date
nr = _module("narayana_rath")
result = nr.calc_narayana_rath(lagna % 12, lons, cycle_count=2)
if not result.get("mahadasha"):
start_age = (range_start - ctx.birth_utc).total_seconds() / 86400.0 / NARAYANA_AGE_YEAR_DAYS
end_age = (range_end - ctx.birth_utc).total_seconds() / 86400.0 / NARAYANA_AGE_YEAR_DAYS
window = {"current_age": start_age, "window_years": end_age - start_age}
candidates = nr.narayana_branches(lagna % 12, lons, **window)
if candidates["emitted_count"] < candidates["branch_count"]:
candidates = nr.narayana_branches(lagna % 12, lons, **window, cap=candidates["branch_count"])
branches = []
for branch in candidates["branches"]:
rows = {key: [row for row in branch.get(key, [])
if row["start_age"] < end_age and row["end_age"] > start_age
and row["end_age"] > row["start_age"]]
for key in ("md_rows", "ad_rows")}
branches.append({**branch, **rows, "name": branch.get("name") or branch["branch_id"],
"used_for_scoring": False})
return block("computed" if branches else "blocked", "narayana_rath.calc_narayana_rath",
return block("blocked", "narayana_rath.calc_narayana_rath",
reason=str(result.get("reason") or result.get("status") or "seed_undecided"),
producer_status=result.get("status"),
alternative_seed_signs=sorted((result.get("alternatives") or {}).keys()),
branch_schema=candidates["schema"], branches=branches, branch_count=len(branches),
reference_status="parameter_sensitive" if branches else "blocked",
used_by_product_timeline=False, used_for_scoring=False)
alternative_seed_signs=sorted((result.get("alternatives") or {}).keys()))
md_rows = _age_rows(result["mahadasha"], to_utc, zone, range_start, range_end, level="MD")
zero_rows = [{"sign": md.get("sign"), "cycle_index": md.get("cycle_index"), "start_age": md.get("start_age")}
for md in result["mahadasha"] if md.get("years") == 0]
+30 -54
View File
@@ -168,32 +168,27 @@ def _domains_for(houses: list[int]) -> list[str]:
def _narayana_method() -> dict[str, Any]:
constants = {
"profile": "PROFILE", "duration_profile": "DURATION_PROFILE",
"role_profile": "ROLE_PROFILE", "ad_start": "AD_START_PROFILE", "branch_schema": "BRANCH_SCHEMA",
"branch_cap": "BRANCH_ENUMERATION_CAP", "branch_window_years": "BRANCH_WINDOW_YEARS",
"unresolved": "UNRESOLVED_METHOD_NOTES", "unresolved_effect": "UNRESOLVED_EFFECT",
}
profiles = {
"start_sign": "seed", "dual_lord_rule": "dual_lord", "ad_rule": "ad_direction",
"years_profile": "years", "strength_scope": "strength_scope", "saturn_ketu": "saturn_ketu",
"start_profile": "start", "clock": "clock", "algorithm_version": "algorithm_version",
}
try:
rath = ap._module("narayana_rath")
except Exception: # noqa: BLE001 - the stamp is descriptive; the block reports failures
return {**dict.fromkeys((*constants, *profiles, "md_direction")), "status": "module_unavailable"}
get_config = getattr(rath, "profile_config", None)
config = get_config() if callable(get_config) else getattr(rath, "DEFAULT_STEP_PROFILES", {})
return {"profile": "sanjay_rath", "status": "module_unavailable"}
return {
**{key: getattr(rath, attr, None) for key, attr in constants.items()},
**{key: config.get(attr) for key, attr in profiles.items()},
"md_direction": getattr(rath, "SOURCE", {}).get("md_order"),
"profile": getattr(rath, "PROFILE", "sanjay_rath"),
"start_sign": "stronger_of_lagna_and_7th_seven_levels",
"md_direction": "table_10_saturn_ketu_exceptions",
"duration_profile": getattr(rath, "DURATION_PROFILE", None),
"dual_lord_rule": getattr(rath, "DUAL_LORD_PROFILE", None),
"role_profile": getattr(rath, "ROLE_PROFILE", None),
"ad_rule": "arambha_rasi_twelve_equal_parts_table_13",
# 第二轮第 7 条:p.66 不是「七层全平后用 AK」的第八条通用规则,原文回到规则 (5)(6) 的比较,待核。
"unresolved": ["rath_p66_indira: returns to rules (5)/(6) and the Chara Karaka comparison; pending review, not a general AK rule",
"AD direction: J.S. 1.1.34 / 2.4.31-32 with the p.50 Parashara reconciliation, reviewed together; not applied",
"same-sign dual lords with equal true longitude: undecided (display-precision ties excluded)"],
"unresolved_effect": "a phase whose Narayana period depends on an unresolved branch gives no direction",
}
CONFIG["narayana_method"] = _narayana_method()
NARAYANA_CONDITIONAL_NOTE = "条件参考,不作为独立印证"
# ── birth range ────────────────────────────────────────────────────────────
@@ -649,8 +644,7 @@ def build_cards(package: dict, natal: dict, samples: list[dict] | None = None) -
for row in skeleton_rows:
add(_jd(row["start"]), row["start"], "narayana", f"{str(row.get('level')).lower()}_change",
[sign_house(row.get("sign"))], sign=row.get("sign"), lord=row.get("lord"), level=row.get("level"),
profile=CONFIG["narayana_skeleton_profile"], basis="natal_lagna_house",
counts_as_support=False, note=NARAYANA_CONDITIONAL_NOTE)
profile=CONFIG["narayana_skeleton_profile"], basis="natal_lagna_house")
transits = package.get("slow_transits") or {}
context = package.get("transit_context") if isinstance(package.get("transit_context"), dict) else {}
av_rows = {(row.get("body"), _jd(row.get("at"))): row for row in _rows(context.get("ashtakavarga"))}
@@ -686,13 +680,6 @@ def build_cards(package: dict, natal: dict, samples: list[dict] | None = None) -
domains = _domain_cards(natal, charts, phases, windows, events)
conflicts = _conflicts(package, phases, natal, events)
config = {key: list(value) if isinstance(value, tuple) else value for key, value in CONFIG.items()}
config["narayana_method"] = _narayana_method()
rath = narayana.get("rath") or {}
conditional_narayana = {
"status": rath.get("reference_status", rath.get("status")),
"note": NARAYANA_CONDITIONAL_NOTE, "counts_as_support": False,
"branches": rath.get("branches", []), "branch_count": rath.get("branch_count", 0),
}
return ap.block(
"computed", "annual_service.report_facts.build_cards",
config=config,
@@ -701,7 +688,6 @@ def build_cards(package: dict, natal: dict, samples: list[dict] | None = None) -
sky_events_not_personal=dropped_sky, transit_relations_at_start=in_force,
exact_contacts=_exact_contacts(transits, natal),
windows=windows, year_overview=year_overview, domains=domains, conflicts=conflicts,
conditional_narayana=conditional_narayana,
transit_context={
"status": context.get("status") or "blocked",
"sade_sati": _rows(context.get("sade_sati")),
@@ -948,27 +934,28 @@ def _reading(lords: dict[str, Any], natal: dict, functional: dict, narayana_unre
entry = {"planet": item["planet"], "level": key, "houses": touched}
if item["role"] == "support":
sup = True
if track != "narayana":
support.append(entry)
contributed.add(item["planet"])
support.append(entry)
contributed.add(item["planet"])
elif item["role"] == "pressure":
pre = True
if track != "narayana":
pressure.append(entry)
contributed.add(item["planet"])
pressure.append(entry)
contributed.add(item["planet"])
tracks[track] = "conflict" if sup and pre else "support" if sup else "pressure" if pre else "none"
verdict = tracks["vimshottari"]
background = [{"level": key, "planet": item["planet"], "role": item["role"],
**({"counts_as_support": False, "note": NARAYANA_CONDITIONAL_NOTE}
if key.startswith("narayana_") else {})}
active = [verdict for verdict in tracks.values() if verdict not in ("none", "unresolved")]
if not active:
verdict = "none"
elif len(set(active)) == 1:
verdict = active[0]
elif "conflict" in active:
verdict = "conflict"
else:
verdict = "tracks_differ"
background = [{"level": key, "planet": item["planet"], "role": item["role"]}
for item in items for key in CONFIG["lean_background"]
if key in item["levels"] and set(item["natal_houses"]) & wanted]
activated = [{**row, "counts_as_support": False, "note": NARAYANA_CONDITIONAL_NOTE}
if row["level"].startswith("narayana_") else row
for row in activations if set(row["houses"]) & wanted]
domains[domain] = {
"verdict": verdict, "direction_basis": "functional_tendency_only", "tracks": tracks,
"activated": activated,
"activated": [row for row in activations if set(row["houses"]) & wanted],
"support": support, "pressure": pressure, "background": background,
}
judged = {domain: card["verdict"] for domain, card in domains.items() if card["verdict"] != "none"}
@@ -1043,7 +1030,6 @@ def _phases(package, charts, vim_rows, skeleton_rows, reference_rows, events, fu
"year_chart": chart["target_year"] if chart else None,
"vimshottari": {"md": (md or {}).get("lord"), "ad": (ad or {}).get("lord"), "pd": (pd or {}).get("lord")},
"narayana": {"profile": CONFIG["narayana_skeleton_profile"],
"counts_as_support": False, "note": NARAYANA_CONDITIONAL_NOTE,
"md": {"sign": (nmd or {}).get("sign"), "lord": (nmd or {}).get("lord"),
"house": sign_house((nmd or {}).get("sign"))},
"ad": {"sign": (nad or {}).get("sign"), "lord": (nad or {}).get("lord"),
@@ -1101,8 +1087,6 @@ def _sade_sati_at(package: dict, jd: float) -> dict[str, Any]:
def _narayana_unresolved(package: dict, md_row: dict | None) -> bool:
"""The Rath period rests on a branch the astrologer has not settled (second round, item 7)."""
rath = (package["natal_periods"].get("narayana") or {}).get("rath") or {}
if rath.get("reference_status") == "parameter_sensitive":
return True
seed = str(rath.get("seed_basis") or "")
if any(flag in seed for flag in ("book_example", "undecided", "rath_p66")):
return True
@@ -1250,10 +1234,8 @@ def _clusters(events: list[dict]) -> list[list[dict]]:
def _windows(events: list[dict], phases: list[dict], range_lo: float, range_hi: float, obs_jd: float) -> list[dict]:
conditional_events = [event for event in events if event["system"] == "narayana"]
pool = [event for event in events
if event["system"] != "narayana"
and not (event["system"] == "tithi_pravesha" and not CONFIG["tithi_pravesha_in_windows"])]
if not (event["system"] == "tithi_pravesha" and not CONFIG["tithi_pravesha_in_windows"])]
# 原值:21 天内的事件不论主题都聚成一组;新值:只把指向同一领域的事件聚在一起
# 原因:占星师 2026-11-06 第 7 条「日期接近但指向不同领域,也不算互相印证」(BUG-1650)
by_ids: dict[tuple[str, ...], dict[str, Any]] = {}
@@ -1300,11 +1282,6 @@ def _windows(events: list[dict], phases: list[dict], range_lo: float, range_hi:
for index, entry in enumerate(picked, start=1):
cluster, systems = entry["cluster"], entry["systems"]
lo, hi = cluster[0]["jd"], cluster[-1]["jd"]
conditional = [{"turning_point_id": event["id"], "system": event["system"],
"domains": sorted(set(event["domains"]) & set(entry["themes"])),
"counts_as_support": False, "note": NARAYANA_CONDITIONAL_NOTE}
for event in conditional_events if lo <= event["jd"] <= hi
and set(event["domains"]) & set(entry["themes"])]
zone = _zone_from(cluster[0]["instant"])
shifts = [event["birth_shift_days"] for event in cluster if event.get("birth_shift_days")]
before = -min((s[0] for s in shifts), default=0.0)
@@ -1342,7 +1319,6 @@ def _windows(events: list[dict], phases: list[dict], range_lo: float, range_hi:
"independent_systems": systems,
"independent_count": len(systems),
"turning_point_ids": [event["id"] for event in cluster],
"conditional_references": conditional,
"theme_domains": entry["themes"],
"vimshottari_levels": sorted({str(e.get("level")) for e in cluster if e["system"] == "vimshottari"}),
"phase_id": phase["id"] if phase else None,
+3 -7
View File
@@ -175,8 +175,7 @@ def _planet_base(view: dict, name: str) -> str | None:
def _badhaka(view: dict) -> dict[str, Any]:
"""BUG-1853: product computation from D1 positions (report_render/badhaka_facts.py).
The product record separates the obstructing house from its lord's placement
and keeps its pending-review status.
Her engine has no natal Badhaka module; the record says it is pending her review.
"""
from report_render.badhaka_facts import badhaka_facts
@@ -185,13 +184,10 @@ def _badhaka(view: dict) -> dict[str, Any]:
for name in PLANETS:
base = _planet_base(view, name)
if base:
placement = dig(view, base)
planets[name] = {"sign": placement.get("sign"), "house": placement.get("house"),
"dignity": placement.get("status") or placement.get("dignity")}
planets[name] = dig(view, base)
record = badhaka_facts(asc, planets)
if record is None:
return {**_gap("core_chart.houses.house_1.cusp_sign"),
"blocked_reason": "validation_failed" if asc else "missing_input"}
return _gap("core_chart.houses.house_1.cusp_sign")
return {"status": "value", "path": "product:report_render.badhaka_facts", "value": record, "check": "product_computation"}
+1 -1
View File
@@ -150,6 +150,7 @@ def _sanitize_pl9_ai_density_markdown(
"moon_in_varshaphala": "年盘月亮",
"without_dasha_balance": "不计余额",
"balance 口径": "起始依据",
"边界口径": "日期说明",
"Progression (local ": "Progression (",
"p152 - birth_place_eight_year_overview": "",
"p153 - local_eight_year_overview": "",
@@ -216,7 +217,6 @@ def _sanitize_pl9_ai_density_markdown(
}
for raw, public in replacements.items():
text = text.replace(raw, public)
text = re.sub(r"(?<!\| 余额口径 \| 主星 \| 开始 \| 结束 \| )边界口径", "日期说明", text)
for raw, public in (
("functional_benefic", "功能吉星"),
("functional_malefic", "功能凶星"),
+14 -34
View File
@@ -745,10 +745,6 @@ def _mudda_coverage_lines(annual: dict, language: str) -> list[str]:
raw_end = mudda_report_instant(raw[-1].get("end"))
coverage_start = mudda_report_instant(coverage.get("start"))
coverage_end = mudda_report_instant(coverage.get("end"))
try:
coverage_days = f"{float(coverage.get('days')):.4f}"
except (TypeError, ValueError):
coverage_days = "—"
if zh:
try:
from tajika_mudda import NOMINAL_YEAR_DAYS_NOTE
@@ -758,7 +754,7 @@ def _mudda_coverage_lines(annual: dict, language: str) -> list[str]:
rows = [
f"| 原始完整时间线 | {raw_start} | {raw_end} | 完整期间 |",
f"| 名义年 | | | {nominal_days} 天。{NOMINAL_YEAR_DAYS_NOTE} |",
f"| 本报告覆盖区间 | {coverage_start} | {coverage_end} | {coverage_days} 天 |",
f"| 本报告覆盖区间 | {coverage_start} | {coverage_end} | {coverage.get('days')} 天 |",
]
else:
header = "| Interval | Start | End | Notes |"
@@ -766,7 +762,7 @@ def _mudda_coverage_lines(annual: dict, language: str) -> list[str]:
f"| Full raw timeline | {raw_start} | {raw_end} | Full periods |",
f"| Nominal year | | | {nominal_days} days. The interval between actual solar returns is about "
"365.2535–365.2608 days. The nominal year ends about 302–930 seconds earlier. |",
f"| Report coverage | {coverage_start} | {coverage_end} | {coverage_days} days |",
f"| Report coverage | {coverage_start} | {coverage_end} | {coverage.get('days')} days |",
]
lines = [header, "|---|---|---|---|", *rows, ""]
if coverage.get("basis") == "actual_solar_return_to_next_actual_solar_return":
@@ -783,16 +779,13 @@ def _mudda_coverage_lines(annual: dict, language: str) -> list[str]:
from consultation_native_layers import MUDDA_CROSSING_TEXT
except ImportError: # pragma: no cover - package import
from scripts.consultation_native_layers import MUDDA_CROSSING_TEXT
lines.extend([MUDDA_CROSSING_TEXT.format(end=end).rstrip("。") + "。", ""])
lines.extend([MUDDA_CROSSING_TEXT.format(end=end), ""])
else:
lines.extend([f"The period continues under the original method to {end}. Read the part after the return with the next annual chart.", ""])
view = mudda.get("periods_view")
label = view.get("label" if zh else "label_en") if isinstance(view, dict) else None
if label:
if zh:
label = label.replace(" raw_periods", "raw_periods")
timeline = "上表「原始完整时间线」一行" if zh else 'the “Full raw timeline” row above'
lines.extend([label.replace("raw_periods", timeline), ""])
lines.extend([label, ""])
return lines
@@ -1051,15 +1044,6 @@ def render_pl9_parity_markdown(packet: dict) -> str:
text = str(value or '').strip()
return mapping.get(text, text or '-')
def _mudda_row(row: dict) -> str:
return (
f"| {_cell(row.get('balance_profile'))} | {_cell(_token(row.get('lord')))} | "
f"{_cell(mudda_report_instant(row.get('start')))} | {_cell(mudda_report_instant(row.get('end')))} | "
f"{_public_status_note(row.get('boundary_status'))} |"
)
mudda_header_zh = '| 余额口径 | 主星 | 开始 | 结束 | 边界口径 |'
def _public_report_text(value) -> str:
text = str(value or '').strip()
if _lang == 'en':
@@ -1222,13 +1206,12 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'|--------|-----|------|--------|-----------|-------|-----------|------|-------|',
])
ascendant = _dict(core_chart.get('ascendant'))
asc_not_applicable = 'N/A' if _lang == 'en' else '不适用'
if ascendant:
info = _ascendant_report_fields(ascendant)
asc_label = 'Ascendant' if _lang == 'en' else '上升'
lines.append(
f"| {_cell(asc_label)} | {asc_not_applicable} | {_cell(_token(info['sign']))} | {_cell(_degree(info['degree']))} | "
f"{_cell(_degree(info['longitude']))} | 1 | {_cell(info['nakshatra'])} | {_cell(info['pada'])} | {asc_not_applicable} |"
f"| {_cell(asc_label)} | - | {_cell(_token(info['sign']))} | {_cell(_degree(info['degree']))} | "
f"{_cell(_degree(info['longitude']))} | 1 | {_cell(info['nakshatra'])} | {_cell(info['pada'])} | - |"
)
def _motion_flags(row: dict) -> str:
# BUG-1674: R = retrograde, C = combust, both when both; a direct,
@@ -1262,7 +1245,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
asc_label = 'Ascendant' if _lang == 'en' else '上升'
lines.append(
f"| {_cell(asc_label)} | {_cell(_token(info['sign_lord']))} | {_cell(_token(info['nakshatra_lord']))} | "
f"{asc_not_applicable} | - | {_cell('/')} | {asc_not_applicable} |"
f"- | - | {_cell('/')} | - |"
)
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
row = _dict(planets.get(planet))
@@ -2680,13 +2663,16 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'### Mudda 年度大运',
'',
*_mudda_coverage_lines(annual, _lang),
mudda_header_zh,
'| 余额口径 | 主星 | 开始 | 结束 | 边界口径 |',
'|----------|------|------|------|----------|',
])
for row in mudda_rows:
if not isinstance(row, dict):
continue
lines.append(_mudda_row(row))
lines.append(
f"| {_cell(row.get('balance_profile'))} | {_cell(_token(row.get('lord')))} | "
f"{_cell(mudda_report_instant(row.get('start')))} | {_cell(mudda_report_instant(row.get('end')))} | {_public_status_note(row.get('boundary_status'))} |"
)
lines.append('')
readings = [str(row.get('reading')).strip() for row in mudda_rows if isinstance(row, dict) and str(row.get('reading') or '').strip()]
if readings:
@@ -3243,10 +3229,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
if title in rendered_titles and table_key == 'pyjhora_tajika_yoga_rows':
continue
rendered_titles.add(title)
localized_mudda = table_key == 'mudda_rows' and _lang == 'zh'
keys = preferred_keys if localized_mudda else [
key for key in preferred_keys if any(row.get(key) not in (None, '') for row in rows)
]
keys = [key for key in preferred_keys if any(row.get(key) not in (None, '') for row in rows)]
if not keys:
keys = [
key for key in rows[0]
@@ -3259,13 +3242,10 @@ def render_pl9_parity_markdown(packet: dict) -> str:
f'#### {title}',
'',
*(_mudda_coverage_lines(annual_pack, _lang) if table_key == 'mudda_rows' else []),
mudda_header_zh if localized_mudda else '| ' + ' | '.join(_cell(key) for key in keys) + ' |',
'| ' + ' | '.join(_cell(key) for key in keys) + ' |',
'|' + '|'.join('---' for _key in keys) + '|',
])
for row in rows:
if localized_mudda:
lines.append(_mudda_row(row))
continue
if table_key == 'mudda_rows':
row = {**row, **{key: mudda_report_instant(row.get(key)) for key in ('start', 'end')}}
lines.append('| ' + ' | '.join(_cell(row.get(key)) for key in keys) + ' |')
+16 -22
View File
@@ -224,7 +224,7 @@ def _dual_dasha_note(timing: dict, language: str) -> str:
return (" · " if en else ";").join(lines)
def _strength_layers_note(planets: dict, vimsopaka: dict, note: Any, language: str) -> str:
def _strength_layers_note(planets: dict, note: Any, language: str) -> str:
en = language == "en"
sensitive, determinate = [], []
for planet, row in planets.items():
@@ -249,29 +249,25 @@ def _strength_layers_note(planets: dict, vimsopaka: dict, note: Any, language: s
summary = ("Shadbala: " if en else "Shadbala:") + (" · " if en else ";").join(parts)
else:
summary = "Shadbala: no per-planet minimum results." if en else "Shadbala:缺少逐星达标结果。"
details = [summary.rstrip("。.")]
sav_prefix = "Ashtakavarga SAV="
sav = str(note or "").partition(sav_prefix)[2].partition("; Vimsopaka status=")[0].rstrip("。")
if sav:
details.append(sav_prefix + sav)
scores = _dict(vimsopaka.get("planets")) or vimsopaka
values = [
f"{_planet(planet, language)} {score:.2f}"
for planet in PLANETS
if isinstance(score := _dict(scores.get(planet)).get("total_score"), (int, float))
]
if values:
prefix = "Vimsopaka (out of 20): " if en else "Vimsopaka(满分 20):"
details.append(prefix + (", " if en else "、").join(values))
return (" · " if en else ";").join(details)
original = str(note or "")
start = original.find("Ashtakavarga SAV=")
if start == -1:
return summary
suffix = original[start:]
if en:
suffix = suffix.rstrip("。")
if HAN.search(suffix):
sav, _, vimsopaka = suffix.partition("; Vimsopaka status=")
labels = {"优秀": "excellent", "良好": "good", "一般": "average", "偏弱": "below average"}
suffix = sav + "; Vimsopaka status=" + labels.get(vimsopaka, "see Chinese edition")
return summary + ("; " if en else ";") + suffix
def _audit_rows(packet: dict, language: str) -> list[tuple[str, str, str, str]]:
from .technique_status_axes import functional_profile_text, technique_audit_axes, technique_audit_text
worksheets = _dict(packet.get("worksheets"))
strengths = _dict(worksheets.get("strengths_and_scores"))
functional = _dict(strengths.get("functional_benefic_malefic"))
functional = _dict(_dict(worksheets.get("strengths_and_scores")).get("functional_benefic_malefic"))
profile_note = functional_profile_text(functional.get("functional_profile"), language)
timing = _dict(worksheets.get("timing_and_predictive_systems"))
table = packet.get("technique_audit_table") if isinstance(packet.get("technique_audit_table"), list) else []
@@ -291,10 +287,8 @@ def _audit_rows(packet: dict, language: str) -> list[tuple[str, str, str, str]]:
elif name == "Vimshottari + Narayana Cross-check":
note = _dual_dasha_note(timing, language)
elif name == "Strength Layers":
shadbala = _dict(strengths.get("shadbala"))
note = _strength_layers_note(
_dict(shadbala.get("planets")), _dict(strengths.get("vimsopaka")), row.get("note"), language,
)
shadbala = _dict(_dict(worksheets.get("strengths_and_scores")).get("shadbala"))
note = _strength_layers_note(_dict(shadbala.get("planets")), row.get("note"), language)
else:
note = _clean(row.get("note"), language)
axes = technique_audit_axes(packet, row)
+8 -12
View File
@@ -15,8 +15,6 @@ from yoga_engine import badhaka_house
SOURCE = "references/badhaka-obstacle-planet-guide.md §二"
STATUS = "product_computation_pending_astrologer_review"
PROFILE = "badhaka_modality_v1"
PROFILE_RULE = "动 → 11、固 → 9、双 → 7,按 badhaka-obstacle-planet-guide.md §二"
SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio",
"Sagittarius", "Capricorn", "Aquarius", "Pisces")
LORDS = ("Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", "Venus", "Mars",
@@ -25,7 +23,7 @@ MODALITY = {11: "movable", 9: "fixed", 7: "dual"}
def badhaka_facts(asc_sign: Any, planets: dict[str, dict]) -> dict[str, Any] | None:
"""Return the obstructing house and its lord's placement separately.
"""Badhaka house, sign, lord and the lord's placement, plus occupants.
``planets`` maps a planet name to its D1 record (``sign``, ``house``, optional ``dignity``).
Returns None when the ascendant sign is unknown.
@@ -42,15 +40,13 @@ def badhaka_facts(asc_sign: Any, planets: dict[str, dict]) -> dict[str, Any] | N
return {
"ascendant_sign": asc_sign,
"ascendant_modality": MODALITY[house],
"badhaka_sthana": {"house": house, "sign": sign, "occupants": occupants},
"badhakesh": {
"planet": lord,
"sign": lord_data.get("sign"),
"house": lord_data.get("house"),
"dignity": lord_data.get("dignity"),
},
"house": house,
"sign": sign,
"lord": lord,
"lord_sign": lord_data.get("sign"),
"lord_house": lord_data.get("house"),
"lord_dignity": lord_data.get("dignity"),
"occupants": occupants,
"source": SOURCE,
"status": STATUS,
"profile": PROFILE,
"profile_rule": PROFILE_RULE,
}
+14 -20
View File
@@ -33,7 +33,7 @@ GRADE_CONTRACT = "report_evidence_grade_v1"
GRADE_ORDER = ("A", "B", "C", "D")
# Product policy, versioned: how far the true birth instant may sit from the
# recorded one. A minute-confirmed record is taken as +/- 1 minute; an
# unconfirmed one has no stated tolerance, so the local boundary is not checked.
# unconfirmed one has no stated tolerance, so varga stability is not evaluated.
PRECISION_POLICY = "birth_record_minute_v1"
CONFIRMED_TOLERANCE_MINUTES = 1.0
@@ -42,7 +42,7 @@ MISSING_STATES = {"blocked", "withheld", "missing", "calculation_failed", "missi
# Dependency kinds:
# ("p", domain, id) domain participation row
# ("s", id) shared technique row
# ("v", code) varga ascendant boundary relative to the birth-time tolerance
# ("v", code) varga ascendant stable within the birth-time tolerance
# ("t", key) timing / transit / annual block present
# Each claim type: route (her router section), section ids it may appear in,
# required deps (missing -> D) and checked deps (missing -> disclosed only).
@@ -124,7 +124,7 @@ def _shared(facts: dict, item_id: str) -> str:
return "missing"
def _varga_boundary(facts: dict, code: str) -> tuple[str, dict[str, Any]]:
def _varga_stability(facts: dict, code: str) -> tuple[str, dict[str, Any]]:
precision = facts.get("birth_precision") or {}
margin = (precision.get("ascendant_margins") or {}).get(code)
if not facts.get("minute_confirmed"):
@@ -134,7 +134,7 @@ def _varga_boundary(facts: dict, code: str) -> tuple[str, dict[str, Any]]:
nearest = min(float(margin.get("before_minutes", 0)), float(margin.get("after_minutes", 0)))
detail = {"policy": PRECISION_POLICY, "nearest_change_minutes": nearest,
"tolerance_minutes": CONFIRMED_TOLERANCE_MINUTES}
return ("clear_at_tolerance" if nearest > CONFIRMED_TOLERANCE_MINUTES else "boundary_sensitive"), detail
return ("stable" if nearest > CONFIRMED_TOLERANCE_MINUTES else "boundary_sensitive"), detail
def _block_state(facts: dict, key: str) -> str:
@@ -168,7 +168,7 @@ def _evaluate(facts: dict, dep: tuple, sensitive: set[str]) -> dict[str, Any]:
if dep[1] in sensitive and state == "executed":
state = "parameter_sensitive"
elif kind == "v":
state, detail = _varga_boundary(facts, dep[1])
state, detail = _varga_stability(facts, dep[1])
else:
state = _block_state(facts, dep[1])
return {"id": _dep_id(dep), "state": state, **({"detail": detail} if detail else {})}
@@ -189,20 +189,15 @@ def claim_grade(facts: dict, claim_type: str) -> dict[str, Any]:
required = [_evaluate(facts, dep, sensitive) for dep in spec["required"]]
checked = [_evaluate(facts, dep, sensitive) for dep in spec["checked"]]
missing_required = [row["id"] for row in required if _is_missing(row)]
must_disclose = [row["id"] for row in checked if _is_missing(row)]
missing_checked = [row["id"] for row in checked if _is_missing(row)]
sensitive_rows = [row["id"] for row in required + checked if row["state"] == "parameter_sensitive"]
boundary_rows = []
for row in required + checked:
if row["id"].startswith("v:"):
details = {key: value for key, value in row["detail"].items() if key != "policy"}
state = "clear" if row["state"] == "clear_at_tolerance" else row["state"]
boundary_rows.append({"id": row["id"], "state": state, **details})
precision = facts.get("birth_precision") or {}
purpose = precision.get("purpose_stability") or "not_evaluated"
if claim_type == "timing_window" and purpose == "stable":
stability = [row for row in required + checked if row["id"].startswith("v:")]
if any(row["state"] == "boundary_sensitive" for row in stability):
purpose = "boundary_sensitive"
elif any(row["state"] == "not_evaluated" for row in stability):
purpose = "not_evaluated"
if purpose != "stable":
must_disclose.append("purpose_stability_not_evaluated")
else:
purpose = "stable"
cap = "D" if missing_required else "C"
reasons = (
[f"required dependency unavailable: {item}" for item in missing_required]
@@ -215,15 +210,14 @@ def claim_grade(facts: dict, claim_type: str) -> dict[str, Any]:
"reasons": reasons,
"required": required,
"checked": checked,
"must_disclose": must_disclose,
"must_disclose": missing_checked,
"parameter_sensitive": sensitive_rows,
# Six axes (§5.2) plus the local boundary check.
# Six axes (§5.2), raw enums; a missing value never defaults to good.
"ledger": {
"case_fit": "none",
"method_maturity": "source_bounded" if not missing_required else "dependency_missing",
"independent_agreement": "not_assessed",
"purpose_stability": purpose,
"boundary_check": {"policy": PRECISION_POLICY, "rows": boundary_rows},
"external_calculation_check": "not_done",
"contradiction": [f"{row.get('planet')}: natural {row.get('natural')} / functional {row.get('functional')}"
for row in _contradictions(facts)],
+5 -43
View File
@@ -24,7 +24,6 @@ from typing import Any
from .birth_precision_margins import ascendant_margins
from .evidence_grade import evidence_grades
from .parity_chapters import _VIMSOPAKA_DIGNITY
from .technique_status_axes import technique_audit_axes
try:
from scripts.consult_card_domain_facts import (
@@ -116,13 +115,7 @@ PARTICIPATION_REASON_CODES = {
("general", "D2"): "not_routed",
}
STRUCTURED_VALUES = {
"Badhaka": ("ascendant_sign", "ascendant_modality", "badhaka_sthana", "badhakesh", "source", "status",
"profile", "profile_rule"),
}
PARTICIPATION_BLOCKED_REASONS = {
"missing_input": "missing_input", "error": "call_failed",
"calculation_failed": "call_failed", "call_failed": "call_failed",
"validation_failed": "validation_failed",
"Badhaka": ("ascendant_modality", "house", "sign", "lord", "lord_sign", "lord_house", "lord_dignity", "occupants", "status"),
}
UNUSED_POINT_PATHS = {
"Indu": f"{LAGNAS}.Indu_Lagna.sign",
@@ -415,31 +408,6 @@ def _unused_value(view: dict, item_id: str, minute_confirmed: bool) -> str | Non
return None
def _participation_axes(row: dict[str, Any], item: dict[str, Any], view: dict) -> dict[str, Any]:
state, item_id = row["state"], row["id"]
if item_id == "kp" and item.get("check") == "label":
# The participation label describes the method, even when no KP results were supplied.
if not (dig(view, f"{KP}.planets") or dig(view, f"{KP}.houses")):
return {"execution_status": "not_computed", "interpretation_usage": "not_used"}
if state == "executed" or "value" in row:
usage = "not_used"
if state == "executed":
usage = {"Badhaka": "supporting", "kp": "conditional"}.get(item_id, "primary")
axes = technique_audit_axes({}, {"calculation_status": "executed", "interpretation_status": usage})
return {"execution_status": axes["calculation_status"], "interpretation_usage": axes["interpretation_status"]}
if item.get("status") == "not_applicable" and isinstance(item.get("reason"), str) and item["reason"].strip():
return {"execution_status": "not_applicable", "interpretation_usage": "not_used", "reason": item["reason"]}
raw_reason = item.get("blocked_reason") or item.get("reason_code") or item.get("status")
blocked_reason = (
"missing_input" if state == "withheld"
else PARTICIPATION_BLOCKED_REASONS.get(raw_reason)
or PARTICIPATION_BLOCKED_REASONS.get(row.get("reason_code"))
)
if blocked_reason:
return {"execution_status": "blocked", "interpretation_usage": "not_used", "blocked_reason": blocked_reason}
return {"execution_status": "not_computed", "interpretation_usage": "not_used"}
def _participation(rows: list[dict[str, Any]], domain: str, view: dict, minute_confirmed: bool) -> list[dict[str, Any]]:
"""Each technique's state for the domain; small values ride along."""
out = []
@@ -450,7 +418,7 @@ def _participation(rows: list[dict[str, Any]], domain: str, view: dict, minute_c
item_id = item["id"]
reason = None
if status == "blocked":
reason = PARTICIPATION_REASON_CODES.get((domain, item_id), "not_computed")
reason = PARTICIPATION_REASON_CODES[domain, item_id]
elif status != "value" and status != "withheld":
reason = "not_computed" # A packet leaf is absent. The card does not report an engine exception.
state = {"value": "executed", "withheld": "withheld"}.get(status, "blocked")
@@ -471,9 +439,6 @@ def _participation(rows: list[dict[str, Any]], domain: str, view: dict, minute_c
row["reason"] = PRECISION_SENTENCE
if isinstance(item.get("citation"), str):
row["source"] = item["citation"]
row.update(_participation_axes(row, item, view))
if row["execution_status"] == "not_computed":
row.pop("value", None)
out.append(row)
return out
@@ -783,15 +748,12 @@ def _blocked_states(
) -> dict[str, Any]:
labels: dict[str, list[str]] = {
"blocked": [], "not_routed": [], "not_in_domain_route": [],
"parameter_sensitive": [], "not_applicable": [], "withheld": [], "not_computed": [],
"parameter_sensitive": [], "not_applicable": [], "withheld": [],
}
for name, row in domains.items():
for item in [*(row.get("participation") or []), *(shared if name == "general" else [])]:
identity = f"{name}:{item['id']}"
if item["state"] in UNUSED_REASONS or item["state"] == "withheld":
labels[item["state"]].append(identity)
if item["execution_status"] in ("blocked", "not_applicable", "not_computed"):
labels[item["execution_status"]].append(identity)
if item["state"] in labels:
labels[item["state"]].append(f"{name}:{item['id']}")
for family in families:
status = str(family.get("status") or "")
if status in BLOCKED_STATES:
+2 -17
View File
@@ -9,21 +9,6 @@ INTERPRETATION_LABELS = {
"primary": "主要依据", "supporting": "辅助依据", "conditional": "有条件",
"observation_only": "仅观察", "blocked": "不作判断", "not_used": "不参与",
}
CALCULATION_LABELS_EN = {
"executed": "Calculation completed",
"partial": "Calculation partially completed",
"blocked": "Calculation blocked",
"not_applicable": "Calculation not applicable",
"not_run": "Calculation not completed",
}
INTERPRETATION_LABELS_EN = {
"primary": "primary interpretive basis",
"supporting": "supporting interpretive evidence",
"conditional": "conditional interpretation",
"observation_only": "observation only",
"blocked": "no interpretation",
"not_used": "excluded from interpretation",
}
FUNCTIONAL_PROFILE_LABELS = {"pvr_table30_v1": "PVR 表 30", "bphs_exception_profile": "BPHS 例外表"}
CONDITIONAL_EVIDENCE = {
"parameter_sensitive", "profile_sensitive", "single_system_inference",
@@ -118,9 +103,9 @@ def technique_audit_text(row, axes, language):
evidence = axes.get("interpretation_evidence")
unfinished = _key(row.get("status")) in ("not_run", "not run", "未完成") or _key(row.get("calculation_status")) in ("not_run", "未完成")
if language == "en":
calc = CALCULATION_LABELS_EN["not_run" if unfinished else calculation]
calc = "not_run" if unfinished else calculation
suffix = f" ({evidence})" if evidence else ""
return f"{calc}, {INTERPRETATION_LABELS_EN[interpretation]}{suffix}"
return f"calculation: {calc}; interpretation: {interpretation}{suffix}"
calc = "未完成" if unfinished else CALCULATION_LABELS[calculation]
suffix = f"({evidence})" if evidence else ""
return f"计算{calc},解读{INTERPRETATION_LABELS[interpretation]}{suffix}"
@@ -1,119 +0,0 @@
"""Annual Narayana uses real engine branches but keeps them out of scoring."""
from datetime import datetime, timedelta, timezone
import random
from annual_service import api, package, report_facts
import narayana_rath as nr
from scripts.research.capture_narayana_branch_fixtures_20261109 import CASES, SEED
from tests.test_annual_report_facts import _body
def _unresolved_birth() -> dict:
# Replay the repository's pinned synthetic seed fixture with its location.
rng = random.Random(SEED)
lo, hi = datetime(1940, 1, 1), datetime(2010, 12, 31, 23, 59)
minutes = int((hi - lo).total_seconds() // 60)
for _ in range(CASES["seed"] + 1):
birth = lo + timedelta(minutes=rng.randrange(minutes))
lat, lon = rng.uniform(-55, 65), rng.uniform(-180, 180)
return _body(year=birth.year, month=birth.month, day=birth.day,
hour=birth.hour, minute=birth.minute, lat=lat, lon=lon, tz=round(lon / 15),
timezone_id=None, report_range={"kind": "calendar_year", "year": 2026})
def _age(at: datetime, ctx: package._Context) -> float:
return (at - ctx.birth_utc).total_seconds() / 86400 / package.NARAYANA_AGE_YEAR_DAYS
def test_unresolved_narayana_package_preserves_each_named_branch():
body = _unresolved_birth()
ctx = package._Context(api._request(body))
natal = package._natal_chart(ctx)
lagna, lons = natal["lagna_sign_idx"], natal["narayana_input_longitudes"]
assert nr.seed_comparison(lagna, lons)["status"] == "undecided"
native = nr.calc_narayana_rath(lagna, lons, cycle_count=2)
assert not native.get("mahadasha")
annual = api.compute_annual_package(body)["package"]
rath = annual["natal_periods"]["narayana"]["rath"]
assert rath["status"] == "computed" and rath["reference_status"] == "parameter_sensitive"
assert report_facts._narayana_unresolved(annual, None)
assert rath["used_by_product_timeline"] is False and rath["used_for_scoring"] is False
assert rath["alternative_seed_signs"] == sorted((native.get("alternatives") or {}).keys())
start, end = [datetime.fromisoformat(annual["report_range"][key]["utc"])
for key in ("start", "end")]
lo, hi = _age(start, ctx), _age(end, ctx)
original = nr.narayana_branches(lagna, lons, current_age=lo, window_years=hi - lo)
original = nr.narayana_branches(lagna, lons, current_age=lo, window_years=hi - lo,
cap=original["branch_count"])
branches = rath["branches"]
assert len(branches) == rath["branch_count"] == original["branch_count"] >= 2
assert len({branch["branch_id"] for branch in branches}) == len(branches)
assert {b["seed_sign"] for b in branches if b["kind"] == "timeline"} == {
nr.SIGNS[lagna], nr.SIGNS[(lagna + 6) % 12],
}
for branch, expected in zip(branches, original["branches"], strict=True):
assert branch["branch_id"] == expected["branch_id"] and branch["name"]
assert branch["used_for_scoring"] is False
for key in ("md_rows", "ad_rows"):
assert branch[key] == [row for row in expected.get(key, [])
if row["start_age"] < hi and lo < row["end_age"]
and row["start_age"] < row["end_age"]]
def test_resolved_seed_preserves_the_existing_md_and_ad_timeline():
ctx = package._Context(api._request(_body()))
natal = package._natal_chart(ctx)
lagna, lons = natal["lagna_sign_idx"], natal["narayana_input_longitudes"]
native = nr.calc_narayana_rath(lagna, lons, cycle_count=2)
assert native["seed_sign"] and native["mahadasha"]
start, end = datetime(2026, 1, 1, tzinfo=timezone.utc), datetime(2027, 1, 1, tzinfo=timezone.utc)
to_utc = package._age_mapper(ctx)
expected_md = package._age_rows(native["mahadasha"], to_utc, ctx.zone, start, end, level="MD")
expected_ad = []
for md in native["mahadasha"]:
if md["start_age"] < _age(end, ctx) and md["end_age"] > _age(start, ctx):
ad = nr.antardasha(md, lons)
if ad["status"] == "selected":
expected_ad.extend({**row, "parent_md": md["sign"]} for row in
package._age_rows(ad["rows"], to_utc, ctx.zone, start, end, level="AD"))
rath = package._narayana(ctx, natal, start, end)["rath"]
assert rath["status"] == "computed" and rath["used_by_product_timeline"] is True
assert "branches" not in rath and rath["seed_sign"] == native["seed_sign"]
assert expected_md and rath["rows"] == expected_md + expected_ad
def _turning_point(system: str, day: int) -> dict:
at = package.instant(datetime(2026, 1, day, tzinfo=timezone.utc), timezone.utc)
return {"id": system, "system": system, "jd": at["jd_ut"], "instant": at,
"domains": ["career"], "kind": "ad_change", "level": "AD"}
def test_windows_count_independent_evidence_without_using_narayana_to_link_dates():
vim, nar, transit = [_turning_point(system, day) for system, day in
(("vimshottari", 1), ("narayana", 12), ("transit_saturn", 20))]
bounds = vim["jd"], transit["jd"] + 15, vim["jd"]
assert report_facts._windows([vim, nar], [], *bounds) == []
[window] = report_facts._windows([vim, nar, transit], [], *bounds)
assert window["independent_systems"] == ["transit_saturn", "vimshottari"]
assert window["independent_count"] == 2 and window["turning_point_ids"] == ["vimshottari", "transit_saturn"]
assert window["conditional_references"] == [{"turning_point_id": "narayana", "system": "narayana",
"domains": ["career"], "counts_as_support": False,
"note": report_facts.NARAYANA_CONDITIONAL_NOTE}]
march = _turning_point("transit_jupiter", 31)
assert report_facts._windows([vim, nar, march], [], *bounds) == []
def test_narayana_method_stamp_reads_the_live_engine_defaults():
method = report_facts._narayana_method()
assert method["profile"] == nr.PROFILE
assert method["start_sign"] == nr.SEED_PROFILE
assert method["md_direction"] == nr.SOURCE["md_order"]
assert method["duration_profile"] == nr.DURATION_PROFILE
assert method["ad_start"] == nr.AD_START_PROFILE
assert method["ad_rule"] == nr.AD_DIRECTION_PROFILE
assert method["dual_lord_rule"] == nr.DUAL_LORD_PROFILE
assert method["algorithm_version"] == nr.ALGORITHM_VERSION
assert method["branch_cap"] == nr.BRANCH_ENUMERATION_CAP
assert method["branch_window_years"] == nr.BRANCH_WINDOW_YEARS
assert method["unresolved"] is None
+3 -7
View File
@@ -316,11 +316,9 @@ def test_reading_keeps_domains_apart_and_drops_the_tie_rule() -> None:
assert reading["domains"]["career"]["tracks"]["narayana"] == "none"
# The Narayana sign's own house (2nd: wealth) is an activation, not a vote for Saturn.
wealth = reading["domains"]["wealth"]
assert any(row["via"] == "dasha_sign" and row["counts_as_support"] is False for row in wealth["activated"])
assert any(row["via"] == "dasha_sign" for row in wealth["activated"])
assert wealth["tracks"]["narayana"] == "none"
# #209 原值 split;新值仅 Vimshottari 有方向的领域为 partial。Narayana 压力仍单列。
assert reading["label"] == "partial" and reading["partial_direction"] == "support"
assert reading["domains"]["relationship"]["verdict"] == "none"
assert reading["label"] == "split", "domains that differ say so; no tie rule"
# The same planet in two tracks is listed once with both levels and never counted twice.
once = read({"vimshottari_ad": "Mars", "vimshottari_pd": None, "narayana_ad": {"lord": "Mars", "sign": "Aries", "house": 10}}, natal, FUNCTIONAL)
assert [item["levels"] for item in once["lords"]] == [["vimshottari_ad", "narayana_ad"]]
@@ -330,9 +328,7 @@ def test_reading_keeps_domains_apart_and_drops_the_tie_rule() -> None:
# Opposite tracks on one domain are kept side by side.
differ = read({"vimshottari_ad": "Mars", "vimshottari_pd": None, "narayana_ad": {"lord": "Saturn", "sign": "Capricorn", "house": 7}},
natal, FUNCTIONAL)
# #209 原值 tracks_differ;新值主判沿用 Vimshottari,条件参考仍保留反向倾向。
assert differ["domains"]["relationship"]["verdict"] == "support"
assert differ["domains"]["relationship"]["tracks"] == {"vimshottari": "support", "narayana": "pressure"}
assert differ["domains"]["relationship"]["verdict"] == "tracks_differ"
# An unresolved Narayana branch gives no direction.
held = read({"vimshottari_ad": None, "vimshottari_pd": None, "narayana_ad": {"lord": "Saturn", "sign": "Capricorn", "house": 7}},
natal, FUNCTIONAL, narayana_unresolved=True)
+3 -4
View File
@@ -9,10 +9,9 @@ def test_source_table_for_cancer_leo_gemini():
("Gemini", 7, "Sagittarius", "Jupiter")):
planets = {lord: {"sign": "Libra", "house": 2, "dignity": "Neutral"}, "Ketu": {"sign": sign, "house": house}}
record = badhaka_facts(asc, planets)
sthana, badhakesh = record["badhaka_sthana"], record["badhakesh"]
assert (sthana["house"], sthana["sign"], badhakesh["planet"]) == (house, sign, lord)
assert (badhakesh["sign"], badhakesh["house"]) == ("Libra", 2)
assert sthana["occupants"] == ["Ketu"]
assert (record["house"], record["sign"], record["lord"]) == (house, sign, lord)
assert (record["lord_sign"], record["lord_house"]) == ("Libra", 2)
assert record["occupants"] == ["Ketu"]
assert record["status"] == STATUS
@@ -1,43 +0,0 @@
"""The source table's house and lord, with separate D1 placement facts."""
import pytest
from scripts.report_render.badhaka_facts import badhaka_facts
@pytest.mark.parametrize(("ascendant", "house", "sign", "lord"), [
("Aries", 11, "Aquarius", "Saturn"),
("Taurus", 9, "Capricorn", "Saturn"),
("Gemini", 7, "Sagittarius", "Jupiter"),
("Cancer", 11, "Taurus", "Venus"),
("Leo", 9, "Aries", "Mars"),
("Virgo", 7, "Pisces", "Jupiter"),
("Libra", 11, "Leo", "Sun"),
("Scorpio", 9, "Cancer", "Moon"),
("Sagittarius", 7, "Gemini", "Mercury"),
("Capricorn", 11, "Scorpio", "Mars"),
("Aquarius", 9, "Libra", "Venus"),
("Pisces", 7, "Virgo", "Mercury"),
])
def test_badhaka_house_and_lord_from_source_table(ascendant, house, sign, lord):
assert ascendant != sign
planets = {
lord: {"sign": ascendant, "house": 1, "dignity": "Neutral"},
"Ketu": {"sign": sign, "house": house},
"Rahu": {"sign": ascendant, "house": house},
}
record = badhaka_facts(ascendant, planets)
assert record["badhaka_sthana"] == {"house": house, "sign": sign, "occupants": ["Ketu"]}
assert record["badhakesh"] == {"planet": lord, "sign": ascendant, "house": 1, "dignity": "Neutral"}
assert record["badhakesh"]["house"] != record["badhaka_sthana"]["house"]
assert record["badhakesh"]["planet"] not in record["badhaka_sthana"]["occupants"]
assert record["profile"] == "badhaka_modality_v1"
assert record["source"].endswith("badhaka-obstacle-planet-guide.md §二")
assert record["status"] == "product_computation_pending_astrologer_review"
assert {"house", "sign", "lord", "lord_house", "occupants"}.isdisjoint(record)
def test_badhakesh_is_an_occupant_when_it_is_in_the_obstructing_sign():
record = badhaka_facts("Gemini", {"Jupiter": {"sign": "Sagittarius", "house": 7}})
assert record["badhaka_sthana"]["occupants"] == ["Jupiter"]
assert record["badhakesh"]["house"] == record["badhaka_sthana"]["house"] == 7
@@ -1,70 +0,0 @@
"""Keep the product's minute boundary check separate from purpose stability."""
from scripts.report_render.evidence_grade import CLAIM_TYPES, claim_grade, evidence_grades
from tests.test_report_evidence_grade_20261112 import _facts
def _complete_facts(purpose_stability):
facts = _facts()
for row in facts["domains"]["health"]["participation"]:
if row["id"] == "Badhaka":
row["state"] = "executed"
precision = facts["birth_precision"]
precision["purpose_stability"] = purpose_stability
for margin in precision["ascendant_margins"].values():
margin.update(before_minutes=90.0, after_minutes=120.0)
return facts
def test_clear_minute_boundary_does_not_establish_purpose_stability():
claims = evidence_grades(_complete_facts("not_evaluated"))["claims"]
assert len(claims) == 8
assert {name: claim["ledger"]["purpose_stability"] for name, claim in claims.items()} == {
name: "not_evaluated" for name in CLAIM_TYPES
}
for claim in claims.values():
assert claim["cap"] == "C"
assert "purpose_stability_not_evaluated" in claim["must_disclose"]
character = claims["character_structure"]
assert character["required"][0]["state"] == "clear_at_tolerance"
assert next(row for row in character["checked"] if row["id"] == "v:D9")["state"] == "clear_at_tolerance"
assert character["ledger"]["boundary_check"]["policy"] == "birth_record_minute_v1"
assert character["ledger"]["boundary_check"]["rows"][0] == {
"id": "v:D1", "state": "clear", "nearest_change_minutes": 90.0, "tolerance_minutes": 1.0,
}
def test_explicit_stability_does_not_make_timing_stable():
claims = evidence_grades(_complete_facts("stable"))["claims"]
for name, claim in claims.items():
purpose = "not_evaluated" if name == "timing_window" else "stable"
assert claim["ledger"]["purpose_stability"] == purpose
assert ("purpose_stability_not_evaluated" in claim["must_disclose"]) == (purpose != "stable")
assert claims["timing_window"]["ledger"]["boundary_check"]["rows"] == []
def test_required_boundary_still_caps_the_claim_at_d():
facts = _complete_facts("not_evaluated")
facts["birth_precision"]["ascendant_margins"]["D6"]["before_minutes"] = 0.25
claim = claim_grade(facts, "health_pressure")
assert claim["cap"] == "D"
assert claim["ledger"]["purpose_stability"] == "not_evaluated"
row = next(row for row in claim["ledger"]["boundary_check"]["rows"] if row["id"] == "v:D6")
assert row == {
"id": "v:D6", "state": "boundary_sensitive", "nearest_change_minutes": 0.25, "tolerance_minutes": 1.0,
}
def test_missing_purpose_defaults_to_unassessed_independently_of_boundary_status():
facts = _complete_facts(None)
del facts["birth_precision"]["ascendant_margins"]["D9"]
claim = claim_grade(facts, "character_structure")
assert claim["cap"] == "C"
assert claim["ledger"]["purpose_stability"] == "not_evaluated"
assert claim["must_disclose"] == ["v:D9", "purpose_stability_not_evaluated"]
assert claim["ledger"]["boundary_check"]["rows"][-1]["state"] == "missing"
facts["minute_confirmed"] = False
claim = claim_grade(facts, "character_structure")
assert claim["cap"] == "C"
assert {row["state"] for row in claim["ledger"]["boundary_check"]["rows"]} == {"not_evaluated"}
+2 -2
View File
@@ -80,8 +80,8 @@ def test_public_report_v3_facts(public_report):
# BUG-1853: Badhaka is computed from the ascendant and reaches the health facts with its positions.
badhaka = next(row for row in facts["domains"]["health"]["participation"] if row["id"] == "Badhaka")
assert badhaka["state"] == "executed"
assert badhaka["value"]["badhaka_sthana"]["house"] in (7, 9, 11)
assert badhaka["value"]["badhaka_sthana"]["sign"] and badhaka["value"]["badhakesh"]["planet"]
assert badhaka["value"]["house"] in (7, 9, 11)
assert badhaka["value"]["sign"] and badhaka["value"]["lord"]
# Product computation, not her engine (owner rule 2026-11-07): the record says so.
assert badhaka["value"]["status"] == "product_computation_pending_astrologer_review"
assert dig(packet, "worksheets.advanced_systems.badhaka") is None
-118
View File
@@ -1,118 +0,0 @@
"""Separate participation from computation in the report's facts."""
import pytest
from scripts.consult_card_domain_facts import _badhaka
from scripts.report_render import reading_facts as rf
from scripts.report_render.technique_status_axes import CALCULATION_LABELS, INTERPRETATION_LABELS
from tests.test_reading_facts_v3_20261112 import _reading_facts, _reading_request, public_report
def test_unused_results_and_absent_results_keep_distinct_axes():
card = [{"id": "Indu", "status": "blocked"}]
missing, = rf._participation(card, "wealth", {}, True)
assert (missing["state"], missing["execution_status"], missing["interpretation_usage"]) == (
"not_in_domain_route", "not_computed", "not_used",
)
assert "value" not in missing and "blocked_reason" not in missing
view = {"worksheets": {"divisional_and_special_charts": {
"special_lagnas": {"Indu_Lagna": {"sign": "Leo"}},
"varga_full": {"D10_Dasamsa": {"Ascendant": {"sign": "Leo"}}},
}}}
unused, = rf._participation(card, "wealth", view, True)
not_routed, = rf._participation([{"id": "D10", "status": "blocked"}], "general", view, True)
assert unused["state"] == "not_in_domain_route"
assert not_routed["state"] == "not_routed"
for row in (unused, not_routed):
assert (row["execution_status"], row["interpretation_usage"]) == ("executed", "not_used")
assert row["value"] == "Leo" and row["computed"] is True
@pytest.mark.parametrize(("item", "execution", "reason"), [
({"status": "withheld"}, "blocked", "missing_input"),
({"status": "error"}, "blocked", "call_failed"),
({"status": "blocked", "blocked_reason": "validation_failed"}, "blocked", "validation_failed"),
({"status": "gap"}, "not_computed", None),
({"status": "not_applicable", "reason": "requires a relationship comparison"}, "not_applicable", None),
])
def test_states_keep_explicit_blocking_and_applicability_reasons(item, execution, reason):
row, = rf._participation([{"id": "example", **item}], "general", {}, False)
assert (row["execution_status"], row["interpretation_usage"]) == (execution, "not_used")
assert row.get("blocked_reason") == reason
if execution == "not_applicable":
assert row["reason"] == item["reason"]
assert "value" not in row
def test_badhaka_missing_ascendant_is_blocked_for_missing_input():
row, = rf._participation([{"id": "Badhaka", **_badhaka({})}], "health", {}, True)
assert (row["state"], row["execution_status"], row["blocked_reason"]) == ("blocked", "blocked", "missing_input")
def test_kp_method_label_is_not_a_calculation_result():
item = {"id": "kp", "status": "value", "check": "label", "value": "conditional method"}
missing, = rf._participation([item], "general", {}, True)
assert missing["state"] == "executed"
assert (missing["execution_status"], missing["interpretation_usage"]) == ("not_computed", "not_used")
assert "value" not in missing
view = {"worksheets": {"advanced_systems": {"kp": {"planets": {"Moon": {"sub": "Mars"}}}}}}
computed, = rf._participation([item], "general", view, True)
assert (computed["execution_status"], computed["interpretation_usage"]) == ("executed", "conditional")
def test_summary_keeps_routing_separate_from_calculation_gaps():
rows = rf._participation([
{"id": "Gulika", "status": "blocked"},
{"id": "D6", "status": "withheld"},
{"id": "D8", "status": "gap"},
], "health", {"worksheets": {"divisional_and_special_charts": {
"upagrahas": {"raw": {"Gulika": {"sign_idx": 7}}},
}}}, False)
transits = {"planets": {"Jupiter": {}}, "double_transit": {"checked_houses": [7]}}
result = rf._blocked_states({"health": {"participation": rows}}, [], [], {}, transits, {})
assert result["not_in_domain_route"] == ["health:Gulika"]
assert result["withheld"] == ["health:D6"]
assert result["blocked"] == ["health:D6"]
assert result["not_computed"] == ["health:D8"]
def test_public_report_axes_and_badhaka_positions(public_report):
case, body, reading, packet = public_report
facts = _reading_facts(packet, reading, **_reading_request(body))
participation = {name: {row["id"]: row for row in domain["participation"]}
for name, domain in facts["domains"].items()}
unused = [participation["wealth"]["Indu"]]
unused.extend(participation["health"][name] for name in ("Gulika", "Mandi", "Pranapada"))
for row in unused:
assert row["state"] == "not_in_domain_route"
assert (row["execution_status"], row["interpretation_usage"]) == ("executed", "not_used")
assert row["value"] and row["computed"] is True
health = participation["health"]["Badhaka"]
assert (health["state"], health["execution_status"], health["interpretation_usage"]) == (
"executed", "executed", "supporting",
)
record = health["value"]
sthana, lord = record["badhaka_sthana"], record["badhakesh"]
expected = {
"steve_jobs_1955_aa": (7, "Pisces", "Jupiter", 10),
"barack_obama_1961_aa": (11, "Scorpio", "Mars", 9),
}
assert (sthana["house"], sthana["sign"], lord["planet"], lord["house"]) == expected[case]
assert lord["dignity"] and lord["dignity"] == facts["planets"][lord["planet"]]["d1_dignity"]
if case == "steve_jobs_1955_aa":
assert "Moon" in sthana["occupants"]
assert lord["planet"] not in sthana["occupants"]
assert record["profile"] == "badhaka_modality_v1"
assert record["status"] == "product_computation_pending_astrologer_review"
assert facts["evidence_grades"]["claims"]["health_obstruction"]["cap"] != "D"
rows = [*facts["shared_techniques"], *(row for domain in participation.values() for row in domain.values())]
for row in rows:
assert row["execution_status"] in CALCULATION_LABELS.keys() | {"not_computed"}
assert row["interpretation_usage"] in INTERPRETATION_LABELS.keys() - {"blocked", "observation_only"}
if row["execution_status"] == "not_computed":
assert "value" not in row
if row["execution_status"] == "blocked":
assert row["blocked_reason"] in {"missing_input", "call_failed", "validation_failed"}
+5 -5
View File
@@ -25,12 +25,12 @@ def test_mudda_report_reads_the_engine_timeline_and_coverage(case):
coverage = mudda["coverage_interval"]
assert f"| 原始完整时间线 | {_rounded(raw[0]['start'])} | {_rounded(raw[-1]['end'])} |" in chinese
assert f"| 名义年 | | | {mudda['nominal_year_days']} 天。{NOMINAL_YEAR_DAYS_NOTE} |" in chinese
assert f"| 本报告覆盖区间 | {_rounded(coverage['start'])} | {_rounded(coverage['end'])} | {coverage['days']:.4f} 天 |" in chinese
assert "报告覆盖区间视图(本次真实返照 → 下一次真实返照);完整原始时间线见上表「原始完整时间线」一行" in chinese
assert f"| 本报告覆盖区间 | {_rounded(coverage['start'])} | {_rounded(coverage['end'])} | {coverage['days']} 天 |" in chinese
assert mudda["periods_view"]["label"] in chinese
assert f"| Full raw timeline | {_rounded(raw[0]['start'])} | {_rounded(raw[-1]['end'])} |" in english
assert f"| Nominal year | | | {mudda['nominal_year_days']} days." in english
assert f"| Report coverage | {_rounded(coverage['start'])} | {_rounded(coverage['end'])} | {coverage['days']:.4f} days |" in english
assert "Report coverage view (this actual solar return -> next actual solar return); full raw timeline in the “Full raw timeline” row above" in english
assert f"| Report coverage | {_rounded(coverage['start'])} | {_rounded(coverage['end'])} | {coverage['days']} days |" in english
assert mudda["periods_view"]["label_en"] in english
for key in ("moon_in_varshaphala", "without_dasha_balance"):
profile = annual["mudda_profiles"]["profiles"][key]
assert profile["label"] in chinese
@@ -43,7 +43,7 @@ def test_mudda_report_reads_the_engine_timeline_and_coverage(case):
crossing = [row for row in raw if datetime.fromisoformat(row["start"]) < return_time < datetime.fromisoformat(row["end"])]
assert crossing
for row in crossing:
assert MUDDA_CROSSING_TEXT.format(end=_rounded(row["end"])) + "。" in chinese
assert MUDDA_CROSSING_TEXT.format(end=_rounded(row["end"])) in chinese
for report in (chinese, english):
for section in re.split(r"(?m)(?=^#{2,6} )", report):
title = section.split("\n", 1)[0]
+1 -1
View File
@@ -109,7 +109,7 @@ def test_audit_chapter_english_edition_has_no_han_text() -> None:
assert "| Functional Benefic/Malefic |" in en
assert "## D1-D60 Complete Raw Divisional Ledger (20 formal + 40 research D-N)" in en
assert HAN.search(en) is None
assert "conditional interpretation (parameter_sensitive)" in en
assert "conditional (parameter_sensitive)" in en
assert SNAKE.search(en.replace(" (parameter_sensitive)", "")) is None
+2 -4
View File
@@ -69,14 +69,12 @@ def test_missing_required_layer_or_boundary_varga_gives_d():
boundary["birth_precision"]["ascendant_margins"]["D6"] = {"before_minutes": 0.4, "after_minutes": 9.0}
health = claim_grade(boundary, "health_pressure")
assert health["cap"] == "D"
assert health["ledger"]["purpose_stability"] == "not_evaluated"
d6 = next(row for row in health["ledger"]["boundary_check"]["rows"] if row["id"] == "v:D6")
assert d6["state"] == "boundary_sensitive"
assert health["ledger"]["purpose_stability"] == "boundary_sensitive"
checked_only = _facts()
checked_only["birth_precision"]["ascendant_margins"]["D30"] = {"before_minutes": 0.4, "after_minutes": 9.0}
disclosed = claim_grade(checked_only, "health_pressure")
assert disclosed["cap"] == "C"
assert {"v:D30", "purpose_stability_not_evaluated"} <= set(disclosed["must_disclose"])
assert disclosed["must_disclose"] == ["v:D30"]
def test_unconfirmed_birth_time_is_not_evaluated_not_assumed_stable():
@@ -222,8 +222,7 @@ def test_strength_audit_and_facts_use_same_per_planet_results_as_the_table(case_
summary = note.split("Shadbala:", 1)[1]
assert "status=" not in summary.split("Ashtakavarga", 1)[0]
original = next(row["note"] for row in packet["technique_audit_table"] if row["technique"] == "Strength Layers")
assert original.split("; ")[1] in note
assert "Vimsopaka status=" not in note
assert original.split("; ", 1)[1] in note
strength_table = [line for line in report.split("| BPHS minimum | Site band |", 1)[1].split("\n\n", 1)[0].splitlines()
if line.startswith("|") and "取决于未定" in line]
sensitive = {planet_name(name, "zh") for name, row in planets.items()
+4 -7
View File
@@ -89,23 +89,20 @@ def _class_args(**overrides):
def _without_report_status_cells(markdown: str) -> str:
from scripts.report_render.technique_status_axes import (
CALCULATION_LABELS, CALCULATION_LABELS_EN, CONDITIONAL_EVIDENCE,
INTERPRETATION_LABELS, INTERPRETATION_LABELS_EN,
)
from scripts.report_render.technique_status_axes import CALCULATION_LABELS, CONDITIONAL_EVIDENCE, INTERPRETATION_LABELS
from scripts.report_render.yoga_status_words import YOGA_STATUS_MAPPING
def choices(values):
return "(?:" + "|".join(re.escape(value) for value in sorted(values)) + ")"
evidence = choices(CONDITIONAL_EVIDENCE)
en_calc = choices(CALCULATION_LABELS_EN.values())
en_reading = choices(INTERPRETATION_LABELS_EN.values())
en_calc = choices([*CALCULATION_LABELS, "not_run"])
en_reading = choices(INTERPRETATION_LABELS)
zh_calc = choices([*CALCULATION_LABELS.values(), "未完成"])
zh_reading = choices(INTERPRETATION_LABELS.values())
tables = {
"| Yoga candidate | Status | Category | Strength | Combination | Effects / notes |": choices(YOGA_STATUS_MAPPING),
"| Technique | Status | Source | Audit note |": rf"{en_calc}, {en_reading}(?: \({evidence}\))?",
"| Technique | Status | Source | Audit note |": rf"calculation: {en_calc}; interpretation: {en_reading}(?: \({evidence}\))?",
"| 技法 | 状态 | 依据来源 | 审计说明 |": rf"计算{zh_calc},解读{zh_reading}(?:({evidence}))?",
}
selected = None
+3 -6
View File
@@ -54,9 +54,9 @@ def test_reading_facts_are_packet_leaves(chart) -> None:
assert [row["lord"] for row in facts["timing"]["next_antardashas"]] == [row["lord"] for row in rows[index + 1:index + 3]]
# 原值:'"status"'、'"citation"'、"references/" 一律不进模型输入,总长 < 20000
# 新值:内部 leaf 的 path / check / citation 键仍不进;技法状态(executed / blocked / withheld)、
# 依据来源(references/ 条目)与证据路径进入契约;写作输入的紧凑 JSON 总长 < 80000
# 依据来源(references/ 条目)与证据路径进入契约;总长 < 80000
# 原因:产品 2026-11-07 占星师方案 item 10:reading_facts 契约要带证据路径、方法差异和阻塞状态
encoded = json.dumps(facts, ensure_ascii=False, separators=(",", ":"))
encoded = json.dumps(facts, ensure_ascii=False)
for marker in ('"path"', '"check"', '"citation"'):
assert marker not in encoded
assert len(encoded) < 80_000
@@ -135,10 +135,7 @@ def test_withheld_varga_houses_stay_out_without_a_confirmed_minute(chart) -> Non
health = facts["domains"]["health"]
assert health["varga_charts"]["D30"] == "出生时间精度不够,不参与"
d30 = next(row for row in health["participation"] if row["id"] == "D30")
assert d30 == {
"id": "D30", "state": "withheld", "reason": "出生时间精度不够,不参与", "source": d30["source"],
"execution_status": "blocked", "interpretation_usage": "not_used", "blocked_reason": "missing_input",
}
assert d30 == {"id": "D30", "state": "withheld", "reason": "出生时间精度不够,不参与", "source": d30["source"]}
assert facts["special_points"]["Ghati_Lagna"] == "出生时间精度不够,不参与"
assert "health:D30" in facts["blocked_states"]["withheld"]
+1 -1
View File
@@ -213,7 +213,7 @@ def test_public_reports_match_native_yoga_status_and_audit_axes(case):
assert row["calculation_status"] in CALCULATION_LABELS
assert row["interpretation_status"] in INTERPRETATION_LABELS
zh_parity = "| Narayana / Patyayini / Tajika 对拍 | 计算已执行,解读有条件(parameter_sensitive) |"
en_parity = "| Narayana / Patyayini / Tajika cross-check | Calculation completed, conditional interpretation (parameter_sensitive) |"
en_parity = "| Narayana / Patyayini / Tajika cross-check | calculation: executed; interpretation: conditional (parameter_sensitive) |"
assert zh_parity in chinese and zh_parity in projected_zh
assert en_parity in english and en_parity in projected_en
assert "本仓功能吉凶" not in chinese
@@ -1,60 +0,0 @@
"""Regression checks for reader wording on real, public report packets."""
from copy import deepcopy
import re
import pytest
from scripts.report_render.appendix_language import planet_name
from scripts.report_render.audit_chapters import render_audit_chapters
from tests.public_chart_reports import PUBLIC_REPORT_CASES, public_chart_reports
from tests.test_report_chart_blank_columns import _heading_table
_MUDDA_HEADER = "| 余额口径 | 主星 | 开始 | 结束 | 边界口径 |"
@pytest.mark.parametrize("case_id", PUBLIC_REPORT_CASES)
def test_public_reports_keep_mudda_and_audit_wording_readable(case_id):
packet, chinese, english = public_chart_reports(case_id)
assert not re.search(r"\d+\.\d{5,} 天", chinese)
assert not re.search(r"\d+\.\d{5,} days", english)
assert "raw_periods" not in chinese and "raw_periods" not in english
assert "完整原始时间线见上表「原始完整时间线」一行" in chinese
assert "the “Full raw timeline” row above" in english
crossing = [line for line in chinese.splitlines() if "原始计算延伸至" in line]
assert crossing and all(line.endswith(")。") for line in crossing)
assert "| balance_profile | lord |" not in chinese
assert chinese.count(_MUDDA_HEADER) >= 2
assert "| balance_profile | lord | start | end | boundary_status |" in english
strengths = packet["worksheets"]["strengths_and_scores"]
vimsopaka = strengths["vimsopaka"]
scores = vimsopaka.get("planets") or vimsopaka
expected = {name: score["total_score"] for name, score in scores.items()
if isinstance(score, dict) and isinstance(score.get("total_score"), (int, float))}
assert expected
for language, report, label, missing in (("zh", chinese, "上升", "不适用"), ("en", english, "Ascendant", "N/A")):
assert "Vimsopaka status=" not in report
note = next(line for line in report.splitlines() if line.startswith("| Strength Layers |"))
node_labels = {"Rahu": "罗睺", "Ketu": "计都"} if language == "zh" else {}
for name, score in expected.items():
assert f"{node_labels.get(name, planet_name(name, language))} {score:.2f}" in note
raw_heading = "行星原始位置" if language == "zh" else "Planetary Raw Positions"
lords_heading = "行星主星与状态" if language == "zh" else "Planet Lords and Dignity"
raw = _heading_table(report, raw_heading)[1]
lords = _heading_table(report, lords_heading)[1]
assert raw[0] == lords[0] == label
assert (raw[1], raw[8], lords[3], lords[6]) == (missing,) * 4
assert "calculation: " not in report and "; interpretation: " not in report
assert "Calculation completed, primary interpretive basis" in english
def test_strength_audit_omits_the_vimsopaka_summary_when_scores_are_missing():
packet = deepcopy(public_chart_reports(PUBLIC_REPORT_CASES[0])[0])
packet["worksheets"]["strengths_and_scores"]["vimsopaka"] = {"status": "良好"}
for language in ("zh", "en"):
audit = render_audit_chapters(packet, language)
note = next(line for line in audit.splitlines() if line.startswith("| Strength Layers |"))
assert "Vimsopaka" not in note
assert "Shadbala" in note and "Ashtakavarga SAV=" in note
@@ -33,9 +33,9 @@ def test_audit_filter_preserves_other_clauses_and_uses_empty_cell_marker() -> No
"note": "First clause; global web evidence; last clause",
})
text = render_audit_chapters(packet, "en")
assert "| Sample method | Calculation completed, primary interpretive basis | — | First clause; last clause |" in text
assert "| Sample method | calculation: executed; interpretation: primary | — | First clause; last clause |" in text
packet["technique_audit_table"][-1]["note"] = "real case calibration"
assert "| Sample method | Calculation completed, primary interpretive basis | — | — |" in render_audit_chapters(packet, "en")
assert "| Sample method | calculation: executed; interpretation: primary | — | — |" in render_audit_chapters(packet, "en")
def test_chinese_limit_line_matches_consult_literal() -> None: