Compare commits

...
Author SHA1 Message Date
Claude a09fbfd3e3 fix(annual): Muntha lines name the ayanamsa and skip combustion for the Sun (BUG-1880)
Independent Staging Quality Gate / publish (push) Blocked by required conditions
Independent Staging Quality Gate / validate-python (push) In progress
Independent Staging Quality Gate / validate-frontend (push) In progress
The ayanamsa label comes from AYANAMSA_OPTIONS instead of the raw value
(raman becomes Raman). The Sun cannot be combust, so no combustion flag
is shown when it is Munthesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-14 01:39:47 +08:00
jesse-ux fab3ebf573 Record final Muntha checks against staging
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m47s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m15s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-14 01:12:13 +08:00
jesse-ux d411abbc42 Merge remote-tracking branch 'origin/staging' into codex/annual-muntha-records-20261113
# Conflicts:
#	BLOCKED.md
#	docs/BUG_HISTORY.md
#	frontend/src/lib/annual-report-generate.ts
#	scripts/annual_service/report_facts.py
2026-11-14 01:05:24 +08:00
jesse-ux 3562171be4 Record Muntha verification and baseline comparison 2026-11-14 01:04:56 +08:00
jesse-ux 530a030217 Preserve shared node stations in Muntha sign events 2026-11-14 00:55:28 +08:00
jesse-ux 17df8fef7b Add four independent Muntha appendix records 2026-11-14 00:47:47 +08:00
jesse-ux 3911967269 Merge remote-tracking branch 'origin/codex/annual-muntha-records-20261113' into codex/annual-muntha-records-20261113 2026-11-14 00:36:53 +08:00
Claude aaddf7835b docs(tasks): brief for annual-muntha-records-20261113
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-13 22:17:15 +08:00
12 changed files with 629 additions and 2 deletions
+4
View File
@@ -1,5 +1,9 @@
# BLOCKED
## #257 年运 Muntha 验收环境
- Windows Docker Desktop Linux 引擎不可用。碎片扫描和预检定向套件超时,远端和适配器诊断通过。替代证据见 [#257 进度](docs/tasks/PROGRESS-annual-muntha-records-20261113.md)。
## #256 年运 Tajika 验收环境
- Windows Docker Desktop 的 Linux 引擎不可用,碎片扫描和预检定向套件超时。替代证据见 [#256 进度](docs/tasks/PROGRESS-annual-tajika-yoga-states-20261113.md)。
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-11-14 — 年运附录分列 Muntha 四项记录
- 计算依据新增年盘结构、Muntha 主状态、慢星换座与停滞、逐年推进。沿用当前 Skill 版本(BUG-1880)。
## 2026-11-13 — 年运报告可导出 Markdown
- 写完后从顶栏导出整篇或选取章节,计算依据可整组带走。旧报告也可导出。
+12
View File
@@ -25395,3 +25395,15 @@
- 验证及防复发:公开盘、形成条件定向测试、旧快照与渲染回归。十段写作证据哈希逐段对比。详见 [#256 进度](tasks/PROGRESS-annual-tajika-yoga-states-20261113.md)。
- 关联记录:#256、BUG-1652、BUG-1655、BUG-1868。
- 修复版本:`codex/annual-tajika-yoga-states-20261113`,待发布。
## BUG-1880 | 年运未分列 Muntha 四项记录及慢星换座
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-13 / 2026-11-14。
- 影响面:年运报告计算依据和 Markdown 附录。
- 现象和触发:读取年运报告时,Muntha 事实仅给位置及其主星,缺少同星座结构、主星状态、覆盖期内慢星事件及逐年推进。
- 根因:原写作事实只投影年盘结构摘要。慢星扫描结果未按每张年盘的 Muntha 星座和有效区间投影。
- 修复:附录分四组,复用年盘字段和慢星扫描,按口径与有效区间过滤事件。逐年推进复用原生产者。
- 验证及防复发:公开盘和虚构盘核对四项、逆行往返、停滞、空区间、岁差与交点、旧快照及写作证据。详见 [#257 进度](tasks/PROGRESS-annual-muntha-records-20261113.md)。
- 关联记录:#257、BUG-1212、BUG-1219、BUG-1817、BUG-1868。
- 修复版本:`codex/annual-muntha-records-20261113`,待发布。
@@ -0,0 +1,23 @@
# #257 年运 Muntha 四项记录
执行者 Codex。开工基线 `881fb75ef`,可信任务书提交 `aaddf783`。沿用预留编号 BUG-1880。
## 实现
- 年盘结构记录 Muntha 星座、从年盘上升数的宫位、同星座行星及起盘地点方案。核对 `annual_tajika_pack.py`、`solar_return.py` 及上游 `3509ccd7` 的 `skills/tajika-annual`。这些入口没有本场景可用的 Muntha 星座相位规则或 Muntha 度数,故相位留为 `not_built`。
- Muntha 主的落座、落宫、尊贵、逆行、五分力使用同一年盘字段。返照生产者当前没有给出燃烧状态,显示未取得。
- 慢星行运从现有完整区间扫描中筛选每张年盘与报告重叠区间内的进出和停滞,左含右不含。保存时刻和逆行标记。岁差与交点对照返照引擎回传的年盘口径,月亮和快星不在本轮范围。上游 11 月 6 日审查未批准通用行运容许度,本单使用实际星座边界和停滞。
- 扫描沿用逐日取样后二分的精度,同日内的多次过界可能未被生产者捕获。计都沿用扫描返回的罗睺停滞时刻,只有对宫是 Muntha 星座时才列入该年盘。
- 逐年推进沿用本命上升加已满年数的生产者,列出覆盖的各年及下一年。报告和 Markdown 共用同一附录投影。
## 验证
- 同一虚构盘在 staging 和本分支以 `PYTHONHASHSEED=0` 实算。十个写作区段的 evidence 哈希一致。区段名和哈希清单 SHA-256 为 `0fbe3e4b5c89dc9330794f4a754e7b19aac262a39899318d184ccc096a24ae78`。
- 公开盘、现居地虚构盘和事件过滤控制覆盖四项独立、跨年、逆行重复、停滞、空事件、口径不匹配与旧附录。既有断言未改。
- Python 年运定向 39 项通过,最终 Muntha 回归及仓库隐私 66 项通过。前端附录及导出定向 10 项通过,ESLint 0 错误、62 条既有警告。
- 同机无 Docker 前端全量 3731 项,3648 通过、46 失败、37 跳过、0 取消。与 `881fb75ef` 对比,保留全部旧用例,新增 2 项通过,失败清单逐项一致。
- TypeScript 与生产构建通过,首页仍为静态,首屏 gzip JS 516100 字节、CSS 92069 字节,与开工 staging 相同。预检远端和适配器通过,碎片扫描与定向预检超时。
- 补齐计都停滞后,最终 Muntha 回归及隐私 66 项、前端定向 10 项均通过。最终同机无 Docker 前端全量 3731 项,3649 通过、45 失败、37 跳过、0 取消。与开工基线逐名对比,新增 2 项通过,失败无新增。基线中失败的一项咨询计时测试本次通过。
- 本机 Python 快速门退出 1,1490 项通过、2 项跳过、29 个子测试通过。3 项 Kaala / PyJHora 失败在开工 staging 定向复现。完整快速门待独立验收环境复核。
- 同步已验收的 #256 和后续相位显示修正(staging `cbc7fdccf`),保留 Tajika、Mudda 和 Muntha 各自的附录字段。以同步后的 staging 重算,十个写作 evidence 哈希全部一致。
- 同步后 Python 定向与隐私检查 92 项、前端定向 12 项、TypeScript 和生产构建通过,ESLint 0 错误。无 Docker 前端全量 3733 项,3650 通过、46 失败、37 跳过、0 取消;同期 staging 3731 项,3648 通过。两项新增用例通过,旧用例及失败清单无缺失或新增失败。最新 staging 同样复现快速门的 3 项 Kaala / PyJHora 失败。
@@ -0,0 +1,86 @@
# TASK:年运 Muntha 四项分开记录,加行运扫描(2026-11-13)
- Gitea:#257
- 分支:`codex/annual-muntha-records-20261113`,从 staging `097d5b3a` 切出。
- Bug 编号:BUG-1880。开工时核对 `docs/BUG_HISTORY.md` 末尾;已被占用就顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-99)。
任务书没写到的取舍,不得写成"产品决定"或"裁定",一律标「需拍板」。不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 逐条写明原值、新值和原因。
同时在做的还有 #233、#255、#256。各单只改自己的函数;后合入的那张负责 rebase。
## 背景
占星师 2026-11-12 回复(年运 Q6),原意:
- 分别记录以下四项。四者不是同一件事,不能重复计证。
1. 年盘内与 Muntha 的结构关系;
2. 年盘 Munthesh(Muntha 主)的状态;
3. 实际行运经过该年固定的 Muntha 点或星座;
4. Muntha 自身的逐年推进。
- 不设「只有土星、罗睺才算」的全局白名单。每条要说明是同宫、同星座、精确度数接触还是某种相位,以及用哪套角距和容许度,不能自己发明统一阈值。只有星座位置时,不得输出精确到日的度数交会。
- 行运要扫描整个覆盖区间,保留逆行造成的重复命中、停滞、进入和离开边界的实际日期;单一观察日不代表全年。
- ayanamsa、node mode、年盘位置方案要统一,不能把另一种回归盘里的点不加说明地混算。
- 月亮和快星如果用,只是对应时间尺度上的触发候选,不自动获得年度主导权。
- 她 2026-11-06 的来源审查(上游 `3509ccd7` 的 `docs/research/tajika_source_adjudication_2026_11_06.md`,「Future transit influence windows」节):没有批准任何本命—行运的数值容许度,只保留精确根(0° 残差)以及离散的进入、停滞事件。
现状:
- `package.py` 的 `_muntha_block`:Muntha 星座、从年盘上升数的宫位、Muntha 主及其落座和落宫。Muntha 取本命上升加已过整年数(BUG-1212)。
- `package.py` 的 `slow_transits`:慢星(木星、土星、罗睺、计都)在区间内的进入、停滞等事件。`_transit_context` 用它算 Sade Sati、Ashtakavarga 和 Vedha。
- facts 的 `cards.*.muntha` 只有星座、宫位、主星、主星位置。
## 要做的
在 facts 每年新增 `muntha_records`,四项各占一个键,互不引用。
1. **`structure`:年盘内的结构关系**
- Muntha 星座,从年盘上升数的宫位。
- 年盘里落在 Muntha 星座的星,标为「同星座」。
- 星座层面的相位关系:只有在仓里找到有出处的 Muntha 相位规则时才做(先查 `annual_tajika_pack.py`、`solar_return.py`、上游 `skills/tajika-annual`),并写明出处。找不到就写 `not_built` 和理由,不自己发明。
2. **`munthesh`:Muntha 主的状态**
- 落座、落宫、尊贵、燃烧、逆行、五分力分数。都从已有字段取,缺的写缺。
3. **`transits`:行运经过 Muntha 星座**
- 用 `slow_transits` 的事件,列出每颗慢星进入和离开 Muntha 星座的实际时刻,以及在星座内的停滞。逆行造成的重复进出都保留,按时间排序。
- Muntha 只有星座、没有度数,所以只做星座级,不输出度数交会。若仓里 Muntha 有定义好的度数,可以另加精确根(0° 残差),不加容许度。
- 月亮和快星这次不做,在 note 写明。
- 写明用的 ayanamsa 和 node mode,必须和年盘一致,用测试断言。
4. **`progression`:逐年推进**
- 报告覆盖范围内每一年的 Muntha 星座,加上下一年的。
- 取法和 `_muntha_block` 一致(本命上升加已过整年数)。
页面附录新增「Muntha 四项记录」小节,按四项分四组列出。行运按时间列,例如「土星 2026-05-03 进入 Muntha 所在的摩羯座」「2026-09-10 逆行退出」。没有事件就写「本期间没有慢星进出 Muntha 星座」。
写作输入不变:十个写作区段的 evidence 哈希不变。`muntha_records` 不进写作输入。
## 不要碰
- 冻结文件、`references/**`、`consult-preset-prompts.ts`。
- 不改 Muntha 的取法,不加容许度,不加快星扫描。
## 要改的文件
- `scripts/annual_service/report_facts.py`,视情况改 `package.py`
- `frontend/src/lib/annual-report-generate.ts`,只加 Muntha 一个小节
- 测试:`tests/test_annual_muntha_records_20261113.py`、`frontend/tests/annual-muntha-records-20261113.test.tsx`
- `docs/BUG_HISTORY.md`,只在末尾追加
- `docs/tasks/PROGRESS-annual-muntha-records-20261113.md`
## 验收标准
1. 公开名人盘和一个合成盘上,四项都有,各自独立。
2. 行运测试覆盖以下几种情况:
- 有逆行重复进出的情况,用合成数据;
- 停滞;
- 区间内没有事件;
- ayanamsa 和 node mode 与年盘一致。
3. 输出里没有度数交会、容许度和精确到日的星座外推。
4. 写作输入哈希不变;旧快照没有这一节时不报错。
5. 用户可见文字里没有「·」、字段名或下划线。
6. tsc、eslint(0 错)、`npm test`(基线失败除外)、quick gate 全部通过。
## 自测命令
```
cd frontend && npx tsc --noEmit -p . && npx eslint . && npm test
PATH=<venv>/bin:$PATH PYTHONHASHSEED=0 python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
```
+1
View File
@@ -1408,6 +1408,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
- **本命瑜伽(BUG-1857):** 年运写作材料中的瑜伽按原有顺序提供完整清单及状态词,沿用本命段落的版式和篇幅。
- **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。
- **年运附录(BUG-1878):** 「这份报告的口径」在年内展开说明下列出 Mudda 主结果和两种对照口径。跨年结果不同时按年盘分行。技法执行表保留西方占星各行,计算栏显示「不适用」,使用栏附理由。
- **年运 Muntha(#257,BUG-1880):** 计算依据按年盘列「Muntha 四项记录」。年盘结构、Muntha 主、慢星进出与停滞、逐年推进各自列出,导出沿用同一顺序。
- **入口**:「我的报告」最上面两个横条并排(手机上下排,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 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。
@@ -5,6 +5,7 @@
* second failure stops the report (nothing is charged, see the worker).
*/
import { AYANAMSA_OPTIONS } from "./ayanamsa.ts";
import { planetZh, signZh } from "./chart-view-labels.ts";
import { displayTechniqueAuditLabel, techniqueAuditAxes, techniqueAuditStatusText, techniqueCalculationStatusLabel, techniqueInterpretationStatusLabel } from "./consultation-technique-audit.ts";
import {
@@ -251,6 +252,56 @@ const YEAR_LORD_ROLES: Readonly<Partial<Record<string, string>>> = {
varsha_lagna_lord: "年盘上升主", tri_rasi_lord: "Tri-Rasi 主",
};
const AYANAMSA_NAMES: Readonly<Record<string, string>> = Object.fromEntries(
AYANAMSA_OPTIONS.map((option) => [option.value, option.label.replace(/(默认)$/, "")]),
);
function munthaRecordLines(value: unknown): string[] {
const data = record(value);
if (!Object.keys(data).length) return [];
const structure = record(data.structure);
const lord = record(data.munthesh);
const transits = record(data.transits);
const progression = record(data.progression);
const zodiac = (value: unknown) => str(value) ? `${signZh(str(value))}座` : "未取得星座";
const house = (value: unknown) => typeof value === "number" ? `第 ${value} 宫` : "宫位未取得";
const sameSign = rows(structure.same_sign).map((row) => planetZh(str(row.planet))).filter(Boolean);
const site = structure.return_location_mode === "current_city" ? "按现居地起盘。" : structure.return_location_mode === "birth_place" ? "按出生地起盘。" : "";
const aspects = str(record(structure.aspects).reason);
const dignity = str(lord.dignity).replace(/\([^)]*\)$/, "").trim();
const flags = [
// The Sun cannot be combust; only other planets carry the combustion flag.
lord.planet === "Sun" ? "" : lord.combust === true ? "燃烧" : lord.combust === false ? "未燃烧" : "燃烧状态未取得",
lord.retrograde === true ? "逆行" : lord.retrograde === false ? "顺行" : "顺逆状态未取得",
typeof lord.panchavargiya === "number" ? `五分力 ${lord.panchavargiya}` : "五分力未取得",
].filter(Boolean);
const node = transits.node_mode === "true" ? "真交点" : transits.node_mode === "mean" ? "平均交点" : "交点未标明";
const events = rows(transits.events).flatMap((event) => {
const when = localMinute(event.instant);
const planet = str(event.body);
if (!when || !planet) return [];
const motion = event.retrograde === true ? "逆行" : "";
const sign = zodiac(event.sign);
const station = event.motion === "direct_to_retrograde" ? "停滞转逆行"
: event.motion === "retrograde_to_direct" ? "停滞转顺行" : "停滞";
const action = event.kind === "entry" ? `${motion}进入 Muntha 所在的${sign}`
: event.kind === "exit" ? `${motion}退出 Muntha 所在的${sign}`
: event.kind === "station" ? `在 Muntha 所在的${sign}${station}` : "";
const offset = str(record(event.instant).utc_offset);
return action ? [`${planetZh(planet)} ${when}${offset ? `(UTC${offset})` : ""} ${action}。`] : [];
});
const transitLines = transits.status === "computed"
? (events.length ? events : ["本期间没有慢星进出 Muntha 星座,也没有在该星座停滞。"])
: [transits.reason === "muntha_scan_frame_mismatch" ? "行运与年盘口径不一致,本期间的行运未列出。" : "本期间的行运未算出。"];
return [
`年盘结构:Muntha 在${zodiac(structure.sign)},从年盘上升数为${house(structure.house)}。${site}${sameSign.length ? `同星座:${sameSign.join("、")}。` : structure.sign ? "没有同星座的行星。" : ""}${aspects}`,
`Muntha 主:${str(lord.planet) ? planetZh(str(lord.planet)) : "未取得主星"},${zodiac(lord.sign)},${house(lord.house)},${dignity || "尊贵状态未取得"},${flags.join(",")}。`,
`慢星行运:岁差 ${AYANAMSA_NAMES[str(transits.ayanamsa).toLowerCase()] ?? (str(transits.ayanamsa) || "未标明")},${node}。${str(transits.note)}`,
...transitLines,
`逐年推进:${rows(progression.years).map((row) => `${row.target_year} 年${zodiac(row.sign)}`).join("、") || "未算出"}。`,
];
}
const TAJIKA_MOTION: Readonly<Partial<Record<string, string>>> = {
applying: "入相", separating: "出相", exact: "精确成相", stationary_relative: "相对停滞",
};
@@ -299,6 +350,7 @@ function appendicesByYear(root: Row): AnnualAppendixBlock[] {
});
if (str(lord.selection_basis_text)) candidates.push(`年主为${planetZh(str(lord.planet))}。${str(lord.selection_basis_text)}`);
add("year-lord", "年主星怎么选出", candidates);
add("muntha-records", "Muntha 四项记录", munthaRecordLines(year.muntha_records));
const aspects = tajikaAspectLines(year.tajika_aspect_layer);
if (aspects.length) result.push({
id: `tajika-aspects-${year.target_year}`, title: "Tajika 相位(相位联系,不是具名瑜伽)" + suffix,
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { AnnualReportView } from "../src/components/annual/annual-report-page.tsx";
import { annualAppendixMarkdown, buildAppendix, type AnnualSnapshot } from "../src/lib/annual-report-generate.ts";
const { year, yearWithEvents } = JSON.parse(readFileSync(new URL("./fixtures/annual-muntha-records-20261113.json", import.meta.url), "utf8"));
const range = { kind: "birthday_year", key: "annual:birthday_year:2026", label: "生日年度",
span: "2026 到 2027", crossesBirthday: false, birthdays: [] } as const;
const birth = { known: false, beforeMinutes: null, afterMinutes: null, source: null, rectification: "none" } as const;
const makeAppendix = (annualYear: unknown) => buildAppendix({
cards: { year_overview: [{ target_year: year.target_year }] },
page_appendix: { years: [annualYear] },
}, range, birth);
test("Muntha appendix shows four distinct records with no-event and missing-data wording", () => {
const appendix = makeAppendix(year);
const block = appendix.find((entry) => entry.id === "muntha-records-2026");
assert.ok(block);
assert.equal(block.title, "Muntha 四项记录");
const markdown = annualAppendixMarkdown(appendix);
const snapshot: AnnualSnapshot = {
kind: "annual_report_snapshot", version: 1, status: "ready", range, appendix,
subject: { name: "公开盘", isSelf: false }, plan: [], sections: {}, windows: [], remaining: [], factsSha256: "",
};
const html = renderToStaticMarkup(createElement(AnnualReportView, { snapshot, generating: false }));
const section = html.split("<h4>Muntha 四项记录</h4>")[1]?.split("</section>")[0] ?? "";
const positions = ["年盘结构", "Muntha 主", "慢星行运", "逐年推进"].map((label) => section.indexOf(label));
assert.ok(positions[0]! >= 0 && positions.every((position, i) => i === 0 || position > positions[i - 1]!));
assert.match(section, /同星座:火星/);
assert.match(section, /燃烧状态未取得/);
assert.match(section, /本期间没有慢星进出 Muntha 星座/);
assert.match(section, /平均交点/);
assert.doesNotMatch(section, /[·_]/);
assert.ok(block.lines.every((line) => markdown.includes(line)));
});
test("Muntha appendix formats actual sign boundaries and keeps old snapshots readable", () => {
const block = makeAppendix(yearWithEvents).find((entry) => entry.id === "muntha-records-2026");
assert.ok(block);
const lines = block.lines.join("\n");
assert.match(lines, /真交点/);
assert.match(lines, /\d{4}-\d{2}-\d{2} \d{2}:\d{2}(UTC[+-]\d{2}:\d{2}) (?:逆行)?进入 Muntha 所在的/);
assert.match(lines, /退出 Muntha 所在的/);
assert.doesNotMatch(lines, /度数交会|容许度|[·_]/);
assert.equal(makeAppendix({ target_year: 2026 }).some((entry) => entry.id.startsWith("muntha-records-")), false);
});
@@ -0,0 +1,169 @@
{
"year": {
"target_year": 2026,
"muntha_records": {
"structure": {
"sign": "Gemini",
"house": 10,
"return_location_mode": "birth_place",
"same_sign": [
{
"planet": "Mars",
"relation": "same_sign"
}
],
"aspects": {
"status": "not_built",
"reason": "现有年盘规则没有可用于此处的 Muntha 星座相位口径。"
}
},
"munthesh": {
"planet": "Mercury",
"sign": "Cancer",
"house": 11,
"dignity": "中性(Neutral)",
"combust": null,
"retrograde": false,
"panchavargiya": 8.91,
"missing": [
"combust"
]
},
"transits": {
"basis": "sign_boundary",
"ayanamsa": "raman",
"node_mode": "mean",
"coverage": {
"start": {
"utc": "2026-08-05T21:09:24Z",
"local": "2026-08-05T11:09:24-10:00",
"utc_offset": "-10:00",
"timezone_id": "Pacific/Honolulu",
"jd_ut": 2461258.38152408
},
"end": {
"utc": "2027-08-06T03:16:30Z",
"local": "2027-08-05T17:16:30-10:00",
"utc_offset": "-10:00",
"timezone_id": "Pacific/Honolulu",
"jd_ut": 2461623.63645874
}
},
"events": [],
"note": "按星座边界记录木星、土星、罗睺和计都的换座及停滞。本次不扫描月亮和快星。",
"status": "computed"
},
"progression": {
"status": "computed",
"basis": "natal_ascendant_plus_completed_years",
"years": [
{
"target_year": 2026,
"sign": "Gemini"
},
{
"target_year": 2027,
"sign": "Cancer"
}
]
}
}
},
"yearWithEvents": {
"target_year": 2026,
"muntha_records": {
"structure": {
"sign": "Leo",
"house": 10,
"return_location_mode": "birth_place",
"same_sign": [
{
"planet": "Ketu",
"relation": "same_sign"
}
],
"aspects": {
"status": "not_built",
"reason": "现有年盘规则没有可用于此处的 Muntha 星座相位口径。"
}
},
"munthesh": {
"planet": "Sun",
"sign": "Gemini",
"house": 8,
"dignity": "入敌(Enemy Sign)",
"combust": null,
"retrograde": false,
"panchavargiya": 8.3,
"missing": [
"combust"
]
},
"transits": {
"basis": "sign_boundary",
"ayanamsa": "lahiri",
"node_mode": "true",
"coverage": {
"start": {
"utc": "2026-06-15T09:29:25Z",
"local": "2026-06-15T17:29:25+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461206.89543255
},
"end": {
"utc": "2027-06-15T15:32:28Z",
"local": "2027-06-15T23:32:28+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461572.14754354
}
},
"events": [
{"body": "Ketu", "instant": {"utc": "2026-06-19T17:54:23Z", "local": "2026-06-20T01:54:23+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461211.24610329}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-06-21T21:48:43Z", "local": "2026-06-22T05:48:43+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461213.40882778}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-07-04T07:48:34Z", "local": "2026-07-04T15:48:34+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461225.82539463}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-07-07T19:38:41Z", "local": "2026-07-08T03:38:41+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461229.31852531}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-07-17T00:25:56Z", "local": "2026-07-17T08:25:56+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461238.518013}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-07-21T10:57:20Z", "local": "2026-07-21T18:57:20+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461242.95648193}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-07-31T11:50:56Z", "local": "2026-07-31T19:50:56+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461252.99369812}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-08-06T02:30:07Z", "local": "2026-08-06T10:30:07+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461258.60424423}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-08-13T09:52:23Z", "local": "2026-08-13T17:52:23+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461265.911376}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-08-20T02:41:52Z", "local": "2026-08-20T10:41:52+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461272.61241245}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-08-22T21:39:39Z", "local": "2026-08-23T05:39:39+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461275.40253925}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-08-27T18:51:38Z", "local": "2026-08-28T02:51:38+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461280.28585815}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-09-04T07:57:34Z", "local": "2026-09-04T15:57:34+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461287.83164787}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-09-09T19:18:45Z", "local": "2026-09-10T03:18:45+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461293.30468369}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-09-18T20:33:14Z", "local": "2026-09-19T04:33:14+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461302.35641098}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-09-24T02:40:20Z", "local": "2026-09-24T10:40:20+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461307.61134529}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-10-03T13:32:44Z", "local": "2026-10-03T21:32:44+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461317.06439972}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-10-07T01:20:14Z", "local": "2026-10-07T09:20:14+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461320.55571842}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-10-18T16:01:24Z", "local": "2026-10-19T00:01:24+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461332.16763401}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-10-21T08:53:34Z", "local": "2026-10-21T16:53:34+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461334.87052822}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Jupiter", "instant": {"utc": "2026-10-31T06:32:44Z", "local": "2026-10-31T14:32:44+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461344.77272701}, "sign": "Leo", "kind": "entry", "retrograde": false},
{"body": "Ketu", "instant": {"utc": "2026-11-01T20:33:38Z", "local": "2026-11-02T04:33:38+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461346.35668468}, "sign": "Leo", "kind": "station", "motion": "retrograde_to_direct"},
{"body": "Ketu", "instant": {"utc": "2026-11-03T03:05:07Z", "local": "2026-11-03T11:05:07+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461347.62855434}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Ketu", "instant": {"utc": "2026-11-25T12:16:07Z", "local": "2026-11-25T20:16:07+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461370.01119518}, "sign": "Leo", "kind": "exit", "retrograde": true},
{"body": "Jupiter", "instant": {"utc": "2026-12-13T00:56:40Z", "local": "2026-12-13T08:56:40+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461387.53935623}, "sign": "Leo", "kind": "station", "motion": "direct_to_retrograde"},
{"body": "Jupiter", "instant": {"utc": "2027-01-24T20:01:29Z", "local": "2027-01-25T04:01:29+08:00", "utc_offset": "+08:00", "timezone_id": "Asia/Shanghai", "jd_ut": 2461430.33436108}, "sign": "Leo", "kind": "exit", "retrograde": true}
],
"note": "按星座边界记录木星、土星、罗睺和计都的换座及停滞。本次不扫描月亮和快星。",
"status": "computed"
},
"progression": {
"status": "computed",
"basis": "natal_ascendant_plus_completed_years",
"years": [
{
"target_year": 2026,
"sign": "Leo"
},
{
"target_year": 2027,
"sign": "Virgo"
}
]
}
}
}
}
+5 -1
View File
@@ -948,6 +948,7 @@ def _chart_block(report: dict, houses: dict, sr: dict) -> dict[str, Any]:
chart = report.get("sr_chart") if isinstance(report.get("sr_chart"), dict) else None
if not chart or houses.get("status") == "blocked":
return block("blocked", "solar_return.calc_solar_return_chart", reason=str(houses.get("reason") or "annual_chart_missing"))
frame = chart.get("birth_info") or {}
asc_lon = float(houses["ascendant_longitude"])
asc_idx = _sign_idx(asc_lon)
planets = {}
@@ -962,6 +963,8 @@ def _chart_block(report: dict, houses: dict, sr: dict) -> dict[str, Any]:
"house": _house(_sign_idx(lon), asc_idx),
"speed": row.get("speed"),
"retrograde": row.get("retrograde"),
"dignity": row.get("status"),
"combust": row.get("combust"),
"nakshatra": row.get("nakshatra"),
}
lord = SIGN_LORDS[SIGNS[asc_idx]]
@@ -974,7 +977,8 @@ def _chart_block(report: dict, houses: dict, sr: dict) -> dict[str, Any]:
planets=planets,
houses=_plain(houses.get("houses")),
house_system="whole_sign",
node_mode_of_chart=report.get("node_mode", "mean"),
ayanamsa=frame.get("ayanamsa_name"),
node_mode_of_chart=frame.get("node_mode") or report.get("node_mode", "mean"),
)
+96 -1
View File
@@ -1893,6 +1893,96 @@ def _page_year_lord(blocks: dict, natal: dict) -> dict:
}
def _muntha_transits(slow: dict, annual_chart: dict, sign: str | None, coverage: dict) -> dict:
frame = {"ayanamsa": annual_chart.get("ayanamsa"), "node_mode": annual_chart.get("node_mode_of_chart")}
note = "按星座边界记录木星、土星、罗睺和计都的换座及停滞。本次不扫描月亮和快星。"
result = {"basis": "sign_boundary", **frame, "coverage": coverage, "events": [], "note": note}
if slow.get("status") != "computed" or sign not in ap.SIGNS or not coverage:
return {"status": "blocked", "reason": "muntha_scan_inputs_missing", **result}
if not all(value and slow.get(key) == value for key, value in frame.items()):
return {"status": "blocked", "reason": "muntha_scan_frame_mismatch", **result}
start, end = _jd(coverage.get("start")), _jd(coverage.get("end"))
if start is None or end is None or start >= end:
return {"status": "blocked", "reason": "muntha_scan_coverage_missing", **result}
bodies = set(slow.get("bodies") or [])
opposite = ap.SIGNS[(ap.SIGNS.index(sign) + 6) % 12]
events = []
for row in slow.get("events") or []:
at = _jd(row.get("instant"))
body = row.get("body")
if at is None or not start <= at < end or body not in bodies:
continue
if row.get("kind") == "ingress":
entered, exited = row.get("to_sign") == sign, row.get("from_sign") == sign
if entered == exited:
continue
detail = {"kind": "entry" if entered else "exit", "retrograde": row.get("retrograde")}
elif row.get("kind") == "station":
# The producer emits the shared node station once, under Rahu.
if body == "Rahu" and "Ketu" in bodies and row.get("sign") == opposite:
body = "Ketu"
elif row.get("sign") != sign:
continue
detail = {"kind": "station", "motion": row.get("motion")}
else:
continue
events.append({"body": body, "instant": row["instant"], "sign": sign, **detail})
events.sort(key=lambda row: (row["instant"]["jd_ut"], row["body"], row["kind"]))
return {**result, "status": "computed", "events": events}
def _muntha_progression(charts: list[dict]) -> dict:
years, anchor = [], None
for chart in charts:
field = (chart.get("blocks") or {}).get("muntha") or {}
data = (field.get("data") or {}) if field.get("status") == "computed" else {}
years.append({"target_year": chart["target_year"], "sign": data.get("muntha_sign")})
if isinstance(data.get("natal_asc_sign_idx"), int) and isinstance(data.get("age"), int):
anchor = chart["target_year"], data["natal_asc_sign_idx"], data["age"]
if not years:
return {"status": "blocked", "years": []}
next_year = charts[-1]["target_year"] + 1
next_sign = None
# A natal ascendant from any available covered return supplies the same annual progression.
if anchor is not None:
anchor_year, natal_ascendant, age = anchor
next_sign = ap._module("solar_return")._muntha_from_natal_ascendant(
natal_ascendant, age + next_year - anchor_year)["muntha_sign"]
years.append({"target_year": next_year, "sign": next_sign})
return {"status": "computed" if all(row["sign"] for row in years) else "partial",
"basis": "natal_ascendant_plus_completed_years", "years": years}
def _muntha_records(chart: dict, slow: dict, progression: dict) -> dict:
blocks = chart["blocks"]
field = blocks["muntha"]
muntha = (field.get("data") or {}) if field.get("status") == "computed" else {}
sign, lord = muntha.get("muntha_sign"), muntha.get("muntha_lord")
annual = blocks["chart"]
planets = annual.get("planets") or {}
same_sign = [{"planet": planet, "relation": "same_sign"} for planet, row in planets.items()
if sign and row.get("sign") == sign]
placement = muntha.get("muntha_lord_placement") or {}
lord_row = planets.get(lord) or {}
strength = (blocks["annual_strength"].get("data") or {}).get("panchavargiya_bala") or {}
munthesh = {
"planet": lord, "sign": placement.get("sign"), "house": placement.get("house_from_annual_ascendant"),
"dignity": lord_row.get("dignity"), "combust": lord_row.get("combust"), "retrograde": lord_row.get("retrograde"),
"panchavargiya": (strength.get(lord) or {}).get("score"),
}
return {
"structure": {
"sign": sign, "house": muntha.get("muntha_house_from_annual_ascendant"),
"return_location_mode": (chart.get("return_location") or {}).get("mode"),
"same_sign": same_sign,
"aspects": {"status": "not_built", "reason": "现有年盘规则没有可用于此处的 Muntha 星座相位口径。"},
},
"munthesh": {**munthesh, "missing": [key for key, value in munthesh.items() if value is None]},
"transits": _muntha_transits(slow, annual, sign, chart.get("in_report_range") or {}),
"progression": progression,
}
_TAJIKA_COMMON_CONDITIONS = {
"orb_profile": "双方容许度的来源口径",
"future_perfection": "未来能否成相",
@@ -2030,8 +2120,12 @@ def _page_tajika(field: dict) -> dict:
def _page_appendix(package: dict, natal: dict) -> dict:
charts = package.get("year_charts") or []
slow = package.get("slow_transits") or {}
progression = _muntha_progression(charts)
years = []
for chart in package.get("year_charts") or []:
for chart in charts:
if chart.get("status") != "computed":
continue
blocks = chart["blocks"]
@@ -2041,6 +2135,7 @@ def _page_appendix(package: dict, natal: dict) -> dict:
years.append({
"target_year": chart["target_year"],
"year_lord": _page_year_lord(blocks, natal),
"muntha_records": _muntha_records(chart, slow, progression),
"mudda_balance": {
"status": mudda.get("status"),
"balance_profile": mudda.get("balance_profile"),
@@ -0,0 +1,127 @@
"""The four Muntha appendix records use year-chart and slow-transit evidence."""
import json
from pathlib import Path
import subprocess
import pytest
from tests.test_annual_audit_axes_20261112 import public_facts
from tests.test_annual_report_facts import _body, _facts, annual_api, annual_report_facts as rf
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
TSX = FRONTEND / "node_modules/tsx/dist/cli.mjs"
def test_public_muntha_records_are_independent(public_facts):
year = public_facts["page_appendix"]["years"][0]
records = year["muntha_records"]
fixture = FRONTEND / "tests/fixtures/annual-muntha-records-20261113.json"
assert json.loads(fixture.read_text(encoding="utf-8"))["year"] == {
key: year[key] for key in ("target_year", "muntha_records")
}
assert set(records) == {"structure", "munthesh", "transits", "progression"}
structure, lord = records["structure"], records["munthesh"]
assert structure["aspects"]["status"] == "not_built"
assert all(row["relation"] == "same_sign" for row in structure["same_sign"])
overview = public_facts["cards"]["year_overview"][0]
assert structure["sign"] == overview["muntha"]["sign"]
assert structure["house"] == overview["muntha"]["annual_house"]
assert lord["planet"] == overview["muntha"]["lord"]
assert lord["dignity"] and type(lord["retrograde"]) is bool and isinstance(lord["panchavargiya"], (int, float))
assert lord["combust"] is None and "combust" in lord["missing"]
assert "muntha_records" not in overview
assert [row["target_year"] for row in records["progression"]["years"]] == [2026, 2027]
def test_fictional_cross_year_report_matches_annual_chart_frame_and_progression():
single_year = _facts(ayanamsa="lahiri", node_mode="true")["page_appendix"]["years"][0]
fixture = FRONTEND / "tests/fixtures/annual-muntha-records-20261113.json"
assert json.loads(fixture.read_text(encoding="utf-8"))["yearWithEvents"] == {
key: single_year[key] for key in ("target_year", "muntha_records")
}
inputs = dict(report_range={"kind": "calendar_year", "year": 2026}, ayanamsa="lahiri", node_mode="true",
return_location={"mode": "current_city", "latitude": 51.5074, "longitude": -0.1278,
"timezone_id": "Europe/London"})
facts = _facts(**inputs)
package = annual_api.compute_annual_package(_body(**inputs))["package"]
years = facts["page_appendix"]["years"]
assert [year["target_year"] for year in years] == [2025, 2026]
for year, annual in zip(years, package["year_charts"], strict=True):
records = year["muntha_records"]
assert set(records) == {"structure", "munthesh", "transits", "progression"}
assert records["structure"]["return_location_mode"] == "current_city"
scan = records["transits"]
assert scan["status"] == "computed" and scan["basis"] == "sign_boundary"
assert scan["coverage"] == annual["in_report_range"]
assert scan["ayanamsa"] == annual["blocks"]["chart"]["ayanamsa"] == package["slow_transits"]["ayanamsa"] == "lahiri"
assert scan["node_mode"] == annual["blocks"]["chart"]["node_mode_of_chart"] == package["slow_transits"]["node_mode"] == "true"
assert all(event["sign"] == records["structure"]["sign"] for event in scan["events"])
assert all("longitude" not in event and "orb" not in event for event in scan["events"])
progression = records["progression"]["years"]
assert [row["target_year"] for row in progression] == [2025, 2026, 2027]
assert progression[year["target_year"] - 2025]["sign"] == records["structure"]["sign"]
first = rf.ap.SIGNS.index(progression[0]["sign"])
assert [row["sign"] for row in progression] == [rf.ap.SIGNS[(first + offset) % 12] for offset in range(3)]
def test_muntha_sign_filter_preserves_repeated_entries_exits_and_station():
frame = {"ayanamsa": "raman", "node_mode_of_chart": "true"}
scan = {"status": "computed", "ayanamsa": "raman", "node_mode": "true", "bodies": ["Saturn"], "events": []}
def instant(n):
return {"jd_ut": n}
def crossing(n, source, target, retrograde):
return {"kind": "ingress", "body": "Saturn", "instant": instant(n),
"from_sign": source, "to_sign": target, "retrograde": retrograde}
scan["events"] = [
crossing(5, "Capricorn", "Aquarius", False),
crossing(3, "Aquarius", "Capricorn", True),
crossing(1, "Capricorn", "Aquarius", False),
{"kind": "station", "body": "Saturn", "instant": instant(2), "sign": "Aquarius",
"motion": "direct_to_retrograde", "longitude": 305.0},
{"kind": "station", "body": "Saturn", "instant": instant(4), "sign": "Capricorn", "motion": "retrograde_to_direct"},
crossing(7, "Aquarius", "Pisces", False),
]
coverage = {"start": instant(1), "end": instant(7)}
result = rf._muntha_transits(scan, frame, "Aquarius", coverage)
assert [(row["instant"]["jd_ut"], row["kind"]) for row in result["events"]] == [
(1, "entry"), (2, "station"), (3, "exit"), (5, "entry"),
]
assert result["events"][2]["retrograde"] is True
assert "longitude" not in result["events"][1]
empty = rf._muntha_transits(scan, frame, "Leo", coverage)
assert empty["status"] == "computed" and empty["events"] == []
blocked = rf._muntha_transits(scan, {**frame, "node_mode_of_chart": "mean"}, "Aquarius", coverage)
assert blocked["status"] == "blocked" and blocked["events"] == []
assert rf._muntha_transits(scan, {**frame, "ayanamsa": "lahiri"}, "Aquarius", coverage)["status"] == "blocked"
node_scan = {**scan, "bodies": ["Rahu", "Ketu"], "events": [
{"kind": "station", "body": "Rahu", "instant": instant(4), "sign": "Leo", "motion": "retrograde_to_direct"},
]}
assert rf._muntha_transits(node_scan, frame, "Aquarius", coverage)["events"] == [
{"kind": "station", "body": "Ketu", "instant": instant(4), "sign": "Aquarius", "motion": "retrograde_to_direct"},
]
@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies are required for the writer contract")
def test_muntha_appendix_leaves_ten_writer_evidence_hashes_unchanged(public_facts):
program = """
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { EMPTY_ANNUAL_SITUATION as situation } from "./src/lib/annual-report-shared.ts";
import { buildAnnualPlan, sectionEvidence } from "./src/lib/annual-report-sections.ts";
const after = JSON.parse(readFileSync(0, "utf8"));
const before = structuredClone(after);
for (const year of before.page_appendix.years) delete year.muntha_records;
const hashes = facts => buildAnnualPlan(facts, situation).map(entry => [
entry.id, createHash("sha256").update(JSON.stringify(sectionEvidence(facts, entry, situation))).digest("hex"),
]);
assert.equal(hashes(after).length, 10);
assert.deepEqual(hashes(after), hashes(before));
"""
subprocess.run(["node", str(TSX), "-e", program], cwd=FRONTEND, check=True, timeout=45,
input=json.dumps(public_facts, ensure_ascii=False).encode("utf-8"))