Merge annual-narayana-conditional-20261112 into staging (accepted by Claude)
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m12s
Independent Staging Quality Gate / validate-python (push) Successful in 6m56s
Independent Staging Quality Gate / publish (push) Successful in 3m10s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8

# Conflicts:
#	CHANGELOG.md
#	docs/BUG_HISTORY.md
This commit is contained in:
2026-11-12 21:04:55 +08:00
12 changed files with 428 additions and 57 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-11-12 — 年运报告将 Narayana 列为条件参考
- 未决种子的具名分支独立列出,窗口来源数及把握等级依据独立证据。
- 本轮沿用当前 Skill 版本(BUG-1855)。
## 2026-11-12 — 个人报告的 Mudda 表、上升行和技法审计措辞
- Mudda 覆盖天数保留四位小数,说明引用报告内的完整时间线,中文后续年表沿用首年表头。
+14
View File
@@ -25071,3 +25071,17 @@
- 防复发:合成段落覆盖书名和普通引语、第 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`。
@@ -0,0 +1,45 @@
# 年运 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 项通过。
@@ -0,0 +1,100 @@
# 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
View File
@@ -1391,6 +1391,7 @@ 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 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。
+26 -10
View File
@@ -116,6 +116,19 @@ 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]}`;
@@ -131,10 +144,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 对同一领域方向相反,两边都写`;
if (row.code === "tracks_differ") return `${label}:${str(row.phase_id)} Vimshottari 和 Narayana 对同一领域方向相反。${NARAYANA_REFERENCE}。`;
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)}`;
}
@@ -189,20 +202,20 @@ export function buildAppendix(facts: unknown, range: AnnualSnapshot["range"], bi
id: "method",
title: "这份报告的口径",
lines: [
// BUG-1601 (#166 Q9): Rath is the Narayana skeleton; legacy is comparison only.
"阶段骨架看本命大运(Vimshottari)和 Narayana;Narayana 用 Sanjay Rath 版,旧算法只作对照,不算第二份支持",
`阶段以本命 Vimshottari 为主要依据。${NARAYANA_REFERENCE},采用 Sanjay Rath 版,旧算法供对照。`,
...narayanaCandidateLines(facts),
// BUG-1650 (astrologer 2026-11-06): no weighted sum, per domain and per track.
"本命大运作背景;小运、细运和 Narayana 小运分领域、分体系判断,再对照两套体系;不加权求和,正负不互相抵消",
"宫主只有掌管或落入某领域的宫才进入该领域,吉星凶星同一标准;功能吉凶只是倾向,不是结果;Narayana 运所在星座只表示主题被激活",
`本命大运作背景,小运和细运分领域判断。${NARAYANA_REFERENCE},可说明主题是否被激活。`,
"宫主只有掌管或落入某领域的宫才进入该领域,吉星凶星同一标准。功能吉凶说明倾向。",
"只判断了部分领域时只写这些领域,不概括整段;和前一段相同的写「延续前一阶段的主题」;「平稳」暂不使用",
"宫主的功能吉凶、所管宫、所落宫、尊贵、燃烧分别记录;Maraka 单独记录,不作为负分;罗睺计都参考定位星,保留自身位置",
"各领域方向一致才有主调,不一致就分领域写,看不出就写看不出;基调只决定建议的方向,正文不写顺紧总评",
"年内展开看 Mudda 和 Patyayini,不参与本命运限判断;年盘分四层记:事项关联、Tajika 联系(九种结构判断,来源未核定,只列联系、不作因素)、阻碍、五分力(偏弱不等于压力);只列支持与压力因素,不下成败结论",
// 产品 2026-11-07 占星师方案:年盘完整层。
"年盘另看:Deeptamsa 内的 Tajika 相位、五分力与 Harsha、年盘 Shadbala(年盘时刻重算,与本命分开)、Saham(PVR 表 74,按回归时刻昼夜取式,参数敏感)",
"Varsha Narayana:移植上游 PVR 30.4 太阳角距版,从 Muntha 起,太阳每走 3° 为一名义年,星座强弱用本产品 Narayana(Rath)规则;参数敏感,与本命 Narayana 分开",
"Varsha Narayana:移植上游 PVR 30.4 太阳角距版,从 Muntha 起,太阳每走 3° 为一名义年。星座强弱用 Rath 规则,与本命运限分开。条件参考,不作为独立印证。",
"行运另看:Sade Sati 阶段(土星在本命月亮第 12、1、2 座)、慢星所在星座的本命 Ashtakavarga(BAV / SAV)、木星土星的 Vedha(只查慢星,参数敏感)",
"写法:每节先写盘面依据再写解读;本命两轨(Vimshottari、Narayana)分开判断,不一致两边都写;把握按 A/B/C 标,A 需要你过往事件验证,这份报告最高 B",
`每节先写盘面依据,再写解读。${NARAYANA_REFERENCE}。把握上限为 C。A 需要你过往事件验证。`,
"行运只算木星、土星、罗睺计都的换宫、停滞和精确经过本命点,只收与本命或报告领域有关的;木星、土星按 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°,及同座)单独列表:只记达到角度的时刻,同一次顺逆往返算一个序列,不设影响范围,也不进转折窗口",
@@ -332,11 +345,14 @@ export async function writeAnnualReport(input: Readonly<{
input.signal.addEventListener("abort", onAbort, { once: true });
try {
const written = await input.write({
system: systemPrompt(),
system: `${systemPrompt()}\n${NARAYANA_WRITING_RULE}`,
user: userPrompt({
name: input.subject.name,
entry,
evidence: sectionEvidence(input.facts, entry, input.situation),
evidence: {
...sectionEvidence(input.facts, entry, input.situation),
Narayana参考: [NARAYANA_REFERENCE, ...narayanaCandidateLines(input.facts)],
},
failures,
written: writtenSoFar(),
}),
+5 -12
View File
@@ -539,19 +539,12 @@ 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, 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.
* 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.
*/
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";
export function confidenceCap(facts: unknown, domain?: AnnualDomain): "B" | "C";
export function confidenceCap(): "B" | "C" {
return "C";
}
function pointsOf(facts: unknown): Map<string, Row> {
@@ -0,0 +1,33 @@
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("不作为独立印证")));
}
});
+19 -2
View File
@@ -1581,10 +1581,27 @@ 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"):
return block("blocked", "narayana_rath.calc_narayana_rath",
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",
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()))
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)
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]
+54 -30
View File
@@ -168,27 +168,32 @@ 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 {"profile": "sanjay_rath", "status": "module_unavailable"}
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": 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",
**{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"),
}
CONFIG["narayana_method"] = _narayana_method()
NARAYANA_CONDITIONAL_NOTE = "条件参考,不作为独立印证"
# ── birth range ────────────────────────────────────────────────────────────
@@ -644,7 +649,8 @@ 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")
profile=CONFIG["narayana_skeleton_profile"], basis="natal_lagna_house",
counts_as_support=False, note=NARAYANA_CONDITIONAL_NOTE)
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"))}
@@ -680,6 +686,13 @@ 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,
@@ -688,6 +701,7 @@ 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")),
@@ -934,28 +948,27 @@ 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
support.append(entry)
contributed.add(item["planet"])
if track != "narayana":
support.append(entry)
contributed.add(item["planet"])
elif item["role"] == "pressure":
pre = True
pressure.append(entry)
contributed.add(item["planet"])
if track != "narayana":
pressure.append(entry)
contributed.add(item["planet"])
tracks[track] = "conflict" if sup and pre else "support" if sup else "pressure" if pre else "none"
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"]}
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 {})}
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": [row for row in activations if set(row["houses"]) & wanted],
"activated": activated,
"support": support, "pressure": pressure, "background": background,
}
judged = {domain: card["verdict"] for domain, card in domains.items() if card["verdict"] != "none"}
@@ -1030,6 +1043,7 @@ 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"),
@@ -1087,6 +1101,8 @@ 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
@@ -1234,8 +1250,10 @@ 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 not (event["system"] == "tithi_pravesha" and not CONFIG["tithi_pravesha_in_windows"])]
if event["system"] != "narayana"
and 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]] = {}
@@ -1282,6 +1300,11 @@ 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)
@@ -1319,6 +1342,7 @@ 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,
@@ -0,0 +1,119 @@
"""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
+7 -3
View File
@@ -316,9 +316,11 @@ 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" for row in wealth["activated"])
assert any(row["via"] == "dasha_sign" and row["counts_as_support"] is False for row in wealth["activated"])
assert wealth["tracks"]["narayana"] == "none"
assert reading["label"] == "split", "domains that differ say so; no tie rule"
# #209 原值 split;新值仅 Vimshottari 有方向的领域为 partial。Narayana 压力仍单列。
assert reading["label"] == "partial" and reading["partial_direction"] == "support"
assert reading["domains"]["relationship"]["verdict"] == "none"
# 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"]]
@@ -328,7 +330,9 @@ 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)
assert differ["domains"]["relationship"]["verdict"] == "tracks_differ"
# #209 原值 tracks_differ;新值主判沿用 Vimshottari,条件参考仍保留反向倾向。
assert differ["domains"]["relationship"]["verdict"] == "support"
assert differ["domains"]["relationship"]["tracks"] == {"vimshottari": "support", "narayana": "pressure"}
# 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)