Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 152b298dd3 fix(chart): Chara dasha dates come from the engine, not the page (BUG-1811)
占星师 2026-11-08 第五轮裁定 5:所有大运日期必须由原生大运引擎生成,显示层
不得自己按年数比例重算。BUG-1752 只修了星盘页的 Vimshottari 轨;Chara 轨仍在
浏览器里按 365.25 天/年从出生日推大运、按年数比例切分运,与报告页的公历周年
口径最多差一天。

- 新增 scripts/chart_dasha/chara_dates.py:给 jaimini 的十二段与每段十二个
  分运写 start / end(本地日历日)与 start_ts / end_ts(UTC 秒);[start, end),
  末段终点钉在父段终点,0 年段保留 start == end,平年 2 月 29 日落 28 日。
- /api/dasha/chara 返回前写这些字段并带 date_basis。
- rashi_dashas_native._chara 改读同一模块(报告输出不变,已与旧版逐段对照)。
- 星盘页 charaTrack 只复制引擎日期;删掉 addYears;没有日期的行不显示。
- 文案 CHARA_ANTARDASHA_DATE_NOTE 改为「与大运同一套边界」。
- 两份真实引擎响应 fixture 的 chara 段用 _compute_chara_dasha 重抓。
- BUG_HISTORY BUG-1811;PROGRESS-dasha-dates-engine-only-20261109。

验证:pytest 新用例 6 passed,相关 63 passed;node --test 新用例 4 pass、
既有 105 pass;tsc exit 0;lint 0 error。

Co-Authored-By: Grok (agent loop)
2026-11-09 08:32:21 +08:00
ClaudeandClaude Opus 5.5 8feabcc588 docs(task): brief for dasha-dates-engine-only-20261109
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-09 00:14:47 +08:00
12 changed files with 1478 additions and 206 deletions
+22
View File
@@ -24050,3 +24050,25 @@
- 防复发:上述测试。
- 关联记录:BUG-1773
- 修复版本:本地分支 `codex/same-source-1108`
## BUG-1811 | Chara 大运与分运日期由页面自己重算,与报告页的日期最多差一天
- 状态:fixed-pending-verify(`codex/dasha-dates-engine-only-20261109`)
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 5;星盘页在 BUG-1752 只修了 Vimshottari 轨)/ 2026-11-09
- 影响面:`frontend/src/lib/chart-view-mapper.ts`(`charaTrack`)、`scripts/jyotish_api_server.py::_compute_chara_dasha`、`scripts/rashi_dashas_native.py::_chara`;新增 `scripts/chart_dasha/chara_dates.py`。
- 现象:星盘页「大运」里第二条运法(Chara)的十二段大运和每段十二个分运,起止日期由浏览器按 365.25 天/年从出生日往后推;报告页的 Chara 表按公历周年推。同一张盘两处日期最多差一天,且都不来自引擎。
- 触发条件:打开星盘页「大运」→「第二条运法」看 Chara,或与报告页 Chara 段对照。
- 根因:`/api/dasha/chara` 只返回 jaimini 的 `duration_years`,没有日期字段;显示层拿不到日期就只能自己算,而两处各写了一套算法。BUG-1752 只把 Vimshottari 轨改成读引擎日期。
- 修复:
- 新增 `scripts/chart_dasha/chara_dates.py`:按出生日的公历周年给 jaimini 返回的十二段和每段十二个分运写出 `start` / `end`(本地日历日)与 `start_ts` / `end_ts`(UTC 秒);区间 `[start, end)`,末段终点钉在父段终点,0 年段保留 `start == end`,29 日出生在平年落到 28 日。
- `_compute_chara_dasha` 在返回前调用该模块写字段,并带 `date_basis`(年基准、区间、时区、来源)。
- 报告页 `rashi_dashas_native._chara` 改读同一模块,两处日期由构造保证一致,不再各算一套。
- 星盘页 `charaTrack` 只复制引擎给的日期;没有日期的行不显示,不在浏览器里切。
- 验证:
- `tests/test_dasha_dates_engine_only_bug1811.py`:6 条通过(接口与报告逐段一致;跨午夜是本地日;出生当日开段;0 年段;第二轮 Vimshottari;与显示时钟无关)。
- `frontend/tests/chart-dasha-engine-dates-1811.test.ts`:4 条通过(日期逐段等于引擎行;缺日期的行不重算;0 年段不 current;mapper 源码无日期算术)。
- 既有相关用例 105 条通过:`chart-view-zero-year`、`chart-dasha-yogini`、`chart-data-card-20261030`、`chart-dasha-engine-rows-1752`、`chart-page-view`、`chart-cards-fit-20261102`、`chat-header-pills-and-quiet-cards-20261102`、`chart-view-route`、`chart-varga-table`、`chart-picker-inspector-skeleton-20261102`、`starter-chart-signs-20261027`。
- `tsc --noEmit -p frontend` exit 0;`npm run lint` 0 error / 60 warning(全在存量文件)。
- 防复发:上述 10 条新用例把「接口日期 == 报告日期 == 页面显示」钉住;`chart-dasha-engine-dates-1811.test.ts` 同时断言 `chart-view-mapper.ts` 源码里不再出现 `365.25`、`86_400_000`、`addYears`、`addWeightedSpan`、`vimshottariSplit`。
- 关联记录:BUG-1752(星盘页 Vimshottari 日期改读引擎)、BUG-1567(Chara 分运顺序)、BUG-1770(0 年大运不该算当前)
- 修复版本:`codex/dasha-dates-engine-only-20261109`
@@ -0,0 +1,72 @@
# PROGRESS:所有大运日期只取引擎原值,前后端不再自己按年数比例重算(2026-11-09)
- 任务书:`docs/tasks/TASK-dasha-dates-engine-only-20261109.md`(分支 `codex/dasha-dates-engine-only-20261109`,基于 staging `04b49609`)
- Bug:BUG-1811(`docs/BUG_HISTORY.md`)
- 执行:Grok 侧自动循环(issue #168)
## 结论
排查到一处用户可见、又不取引擎日期的重算:**星盘页「第二条运法」的 Chara 轨**。十二段大运的终点由浏览器按 `365.25 天/年` 从出生日推,每段十二个分运再按年数比例切;报告页的 Chara 表是另一套(公历周年)。两处最多差一天,且都不来自引擎。已改为:`/api/dasha/chara` 一次算好日期(新模块 `scripts/chart_dasha/chara_dates.py`),报告页读同一模块,星盘页只复制字段。其余命中都只是格式化已有日期、取引擎行,或研究/诊断脚本。
## 第一步:排查表
| 文件(改前位置) | 命中 | 类别 | 处理 |
| --- | --- | --- | --- |
| `frontend/src/lib/chart-view-mapper.ts:100-103`(`addYears`) | `365.25 * 86_400_000` 把年数换成日期 | 2 自己算了日期、用户能看到 | 删除;星盘页不再按年数推日期 |
| `frontend/src/lib/chart-view-mapper.ts:683-761`(`charaTrack`) | 从出生日逐段累加 `duration_years`,分运用 `addWeightedSpan` 按权重切 | 2 | 改为复制 `/api/dasha/chara` 的 `start` / `end` / `start_ts` / `end_ts`;没有日期的行不显示 |
| `frontend/src/lib/dasha-span.ts:9-26`(`addWeightedSpan`)、`:40-46`(`vimshottariSplit`) | 按权重/年数切日期区间 | 2(改前被 `charaTrack` 调用) | 生产代码不再调用;函数保留,`chart-board-20261027.test.tsx` 仍拿它当既有断言的比对基线(删除会改既有断言,超出本单) |
| `frontend/src/lib/dasha-span.ts:35-37`(`enginePratyantars`) | 复制引擎细运行 | 1 只读引擎日期 | 不改 |
| `frontend/src/lib/chart-view-labels.ts:116`(`VIMSHOTTARI_YEARS`) | 年数权重表 | 1 数据表,不产生日期 | 不改 |
| `frontend/src/components/chart-page/chart-dasha-tab.tsx:68-74`(`lengthOf`)、`:113-114`(年龄) | 把已有起止日期显示成「7 年」「41–43 岁」 | 1 只格式化已有日期 | 不改 |
| `frontend/src/lib/chart-board-focus.ts`、`frontend/src/components/chart-board/chart-data-card.tsx` | 只读 periods 的起止日期做进度与高亮 | 1 | 不改 |
| `scripts/jyotish_api_server.py:7840-7848`(`_compute_chara_dasha`) | 只返回 jaimini 的 `duration_years`,没有日期 | 2(缺口:接口不给日期,前端只能自己算) | 返回前用 `chara_dates.attach` 写 `start` / `end` / `start_ts` / `end_ts` 与 `date_basis` |
| `scripts/rashi_dashas_native.py:640-662`(`_chara`) | 从出生日按公历周年逐段累加,分运按秒等分(自己的第二套日期算法) | 2 | 改读 `chara_dates.date_sequence`,与星盘页同一份日期 |
| `scripts/dasha_calculator_enhanced.py:149-206`(`calculate_dasha_dates`) | 自己按 365.25636 天/年算 MD 起止 | 3 研究/演示脚本 | 不改且记录:`/api/dasha` 只用到本文件的 `calculate_five_level_dasha`;`calculate_dasha_dates` 只在 `benchmarks/jyotish/scripts/run_vimshottari_compare.py`、`tests/run_all.py` 与文件内演示函数里被调用,没有路由 |
| `scripts/dasha_registry.py:289-411`(`compute_dasha`) | 每套大运的 `periods` 直接取引擎模块(yogini 分运走 `yogini_antardashas`) | 1 取引擎行 | 不改 |
| `scripts/dasha_reference_audit.py:1-251` | 诊断 CLI:量边界漂移 | 3 诊断脚本 | 不改 |
| `scripts/dasha_analyzer.py:115-170`(`build_antardasha`,冻结) | 父段取整数天、末段早结束 | 1 冻结文件 | 不改;调用方只有本文件 `find_current` 与校正评分链路(`scripts/active_rectification_event_engine.py` 注释、`scripts/research/nadi_seconds_lib.py`),星盘与报告都不再经过它 |
| `scripts/chart_dasha/birth_moment.py:1-259` | Vimshottari 由出生时刻起算 | 1 BUG-1752 已改为引擎产出 | 不改 |
四类之外的补充:`frontend/src/lib/chart-view-mapper.ts` 的 Vimshottari 轨早已只复制 `dasha.antardasha_timeline`(BUG-1752),本轮未动。
## 第二步:修复
1. 新增 `scripts/chart_dasha/chara_dates.py`:给 jaimini 返回的十二段和每段十二个分运写日期。
- 大运终点按出生日的公历周年(29 日出生在平年落 28 日),与报告页原来的口径一致;
- 分运是父段的等分(精确到秒),末段终点钉在父段终点;
- 区间 `[start, end)`;0 年段保留 `start == end`,分运也各为 `start == end`;
- `start` / `end` 是本地日历日,`start_ts` / `end_ts` 是同一时刻的 UTC 秒(时区未知时不写,不猜)。
2. `_compute_chara_dasha` 在返回前写这些字段,并带 `date_basis`(年基准、区间、时区、来源)。
3. `rashi_dashas_native._chara` 改读同一模块。
4. `chart-view-mapper.charaTrack` 只复制字段;`addYears` 删除;文案 `CHARA_ANTARDASHA_DATE_NOTE` 从「按大运边界推算」改为「与大运同一套边界」。
5. 两份真实引擎响应 fixture 重新抓取: `frontend/tests/fixtures/chart-view-golden.json`、`chara-zero-year-1985-08-03-beijing.golden.json` 的 `chara` 段用 `JyotishAPIHandler()._compute_chara_dasha` 重抓(同一出生数据),其余字段未动。
## 四种边界情况
| 情况 | 用例 |
| --- | --- |
| 跨午夜 | `test_chara_days_are_local_days_across_midnight`(00:00 本地边界落在 UTC 前一天,页面显示的仍是本地日) |
| 出生余额 | `test_the_period_running_at_birth_starts_on_the_birth_day`(Chara 没有出生前的余额段,第一段必须从出生当日开;不同出生日都成立) |
| 第二轮大运 | `test_the_second_vimshottari_round_is_still_engine_dated`(Chara 一圈只有十二个星座,没有第二轮;第二轮由星盘页同页的 Vimshottari 轨覆盖,仍是引擎行) |
| 0 年段 | `test_a_zero_year_chara_period_has_no_time_and_keeps_its_row` + `frontend/tests/chart-dasha-engine-dates-1811.test.ts` 的 0 年用例(`start == end`,永不算当前) |
## 自测(Windows / PowerShell,本工作树)
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `frontend\node_modules\.bin\tsc.cmd --noEmit -p frontend` | exit 0 |
| Lint | `npm run lint`(frontend) | 0 error / 60 warning(全在存量文件) |
| 新 Python 用例 | `.venv\Scripts\python -m pytest tests/test_dasha_dates_engine_only_bug1811.py -q` | 6 passed |
| 相关 Python 用例 | `.venv\Scripts\python -m pytest tests/test_rashi_dashas_native.py tests/test_jaimini.py tests/test_chara_dasha_board_lord.py -q` | 63 passed |
| 新前端用例 | `node node_modules/tsx/dist/cli.mjs --test tests/chart-dasha-engine-dates-1811.test.ts`(frontend) | 4 pass / 0 fail |
| 既有前端用例 | `chart-view-zero-year`、`chart-dasha-yogini`、`chart-data-card-20261030`、`chart-dasha-engine-rows-1752`、`chart-page-view`、`chart-cards-fit-20261102`、`chat-header-pills-and-quiet-cards-20261102`、`chart-view-route`、`chart-varga-table`、`chart-picker-inspector-skeleton-20261102`、`starter-chart-signs-20261027` | 105 pass / 0 fail |
| 隐私标记 | `.venv\Scripts\python -m pytest tests/test_repo_privacy_markers.py -q` | 见回报(推送前跑) |
未跑:前端全量 `npm test`、Python 快速门(按循环分工由 Claude 跑)。
## 偏离与说明
- **工作树事故(已处理)**:本轮开始时 `G:\Ferti\Jyotisha\.worktrees\agent-loop` 是干净的、HEAD 在 `codex/reports-no-web-wording-20261109`;检出本单分支后,另一个会话(issue #167 的实现)开始在同一工作树里写文件,导致两边改动混在一起。我把自己的 5 个文件搬到独立工作树 `.worktrees/dasha-dates-engine-only-20261109`,把 agent-loop 工作树切回 `codex/reports-no-web-wording-20261109` 并撤销我对它 4 个源文件的修改(它自己的 9 个改动与 2 个新文件原样保留)。本分支的提交只在独立工作树里产生。
- 没有改引擎公式;`jaimini.calc_chara_dasha_with_antardasha` 的时长与顺序一位没动,只多了日期字段。
- `dasha-span.ts` 的两个切分函数保留为测试基线,未删除;删除需要改 `chart-board-20261027.test.tsx` 的既有断言,超出本单范围。
- 未跑 `test:db`:本轮不动表、不动数据库访问层。
@@ -0,0 +1,65 @@
# TASK:所有大运日期只取引擎原值,前后端不再自己按年数比例重算(2026-11-09)
- 分支:`codex/dasha-dates-engine-only-20261109`(基于 staging `04b49609`)
- Bug 编号:BUG-1811
- 负责验收:Claude(jyotisha-2a)
## 背景
占星师 2026-11-08 第五轮裁定第 5 条:所有大运日期必须由原生大运引擎生成,任何显示层都不能重新计算;显示时的裁剪也不能改变子运边界。
星盘页已在 BUG-1752 中修复,改为直接用后端给的起止时间,同时带父段起点 `parent_period_start` 与可见起点 `visible_period_start`。本任务排查并修复**其余所有地方**。
## 第一步:排查(先做,结果写进 PROGRESS)
逐个打开下列文件,判断它们是否在**自己计算**大运、小运、细运的起止日期(按年数比例、`/120`、`365.25`、`.days` 等方式),而不是使用引擎或后端给出的日期:
- 前端:`frontend/src/lib/dasha-span.ts`、`frontend/src/lib/chart-view-labels.ts`、`frontend/src/lib/chart-view-mapper.ts`,以及全仓搜索 `/ 120`、`VIMSHOTTARI_YEARS`、`DASHA_YEARS`、`365.25` 的其他命中。
- 后端:`scripts/dasha_calculator_enhanced.py`、`scripts/dasha_registry.py`、`scripts/dasha_reference_audit.py`。`scripts/dasha_analyzer.py` 是冻结文件,不改,只确认还有谁在调用它的 `build_antardasha`。
每个命中归为以下三类之一,写成一张表放进 PROGRESS:
1. 只是显示(格式化已有日期):不改。
2. 自己算了日期,而且用户能看到:要改。
3. 研究脚本(`scripts/research/**`):不改,只记录。
## 第二步:修复第 2 类
- 改为使用后端或引擎已经给出的起止时间。参照星盘页的做法,搜索 BUG-1752 的提交。
- 如果某处拿不到引擎日期,就由后端接口补上这个字段,前端直接读,不在前端计算。
- 区间一律左闭右开。0 年的段保留在数据里,但不显示为“当前”。
## 不要碰
- 冻结文件 `scripts/dasha_analyzer.py`、`scripts/domain_calculation_service.py`。
- `scripts/narayana_rath.py`、`scripts/narayana_dasha.py`:另一分支正在改。
- `frontend/src/lib/consultation-*.ts`、`frontend/src/mastra/**`、`references/**`。
- 不改引擎本身的计算公式。
## 验收标准
1. PROGRESS 里有完整的排查表:文件、行号、类别、处理方式。
2. 每个修复点都有测试,断言显示的日期等于引擎给出的日期,并覆盖以下四种情况:
- 跨午夜的边界;
- 出生时所在大运的余额(父段被出生时刻裁剪);
- 第二轮大运;
- 0 年段不显示为“当前”(Narayana 或 Chara)。
3. 改既有断言时写“原值 / 新值 / 原因”三栏注释。原因写:`占星师 2026-11-08 第五轮裁定 5:日期只取原生引擎`。
4. 在 `docs/BUG_HISTORY.md` 末尾追加 BUG-1811,关联 BUG-1752。
5. 测试只用公开名人盘或合成数据,不写任何个人出生资料。
## 自测命令(Windows / PowerShell)
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
- 在 `frontend` 下运行 `npm run lint`
- `node node_modules/tsx/dist/cli.mjs --test tests/<改到或新建的测试文件>`
- `.venv\Scripts\python -m pytest tests/<改到或新建的测试文件> -q`
## 进度记录
`docs/tasks/PROGRESS-dasha-dates-engine-only-20261109.md`
## 何时停下来选“需拍板”
- 某个显示用户能看到,但后端没有对应日期,补字段需要改动数据库表或迁移时。
- 发现引擎本身给出的日期就有问题时(不要自己改引擎公式)。
+5 -1
View File
@@ -177,7 +177,11 @@ export const ANTARDASHA_DERIVED_NOTE = "小运、细运的起止取自原生 Vim
/** BUG-1752: the engine returned no AD / PD rows for this chart, so none are drawn (never recomputed here). */
export const ANTARDASHA_MISSING_NOTE = "这次排盘没有带回引擎的小运和细运,这一层未交付。";
export const CHARA_ANTARDASHA_DATE_NOTE = "小运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
// BUG-1811 (占星师 2026-11-08 第五轮裁定 5「所有日期必须由原生 Dasha 引擎生成」):
// 原值「小运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。」
// (页面按大运边界推算小运日期);新值:小运日期也直接取引擎返回的字段;
// 原因:同一裁定,Chara 轨的日期已由 /api/dasha/chara 一次算好,页面不再推算。
export const CHARA_ANTARDASHA_DATE_NOTE = "小运的起止日期同样来自引擎,与大运用同一套边界。";
export const YOGINI_ANTARDASHA_UNVERIFIED_NOTE = "小运未经外部软件核对。";
+31 -38
View File
@@ -45,7 +45,6 @@ import {
type NorthIndianChart,
} from "./chart-view-contract.ts";
import { asRecord as record } from "@/lib/guards";
import { addWeightedSpan } from "./dasha-span.ts";
import { mapAshtakavarga, mapBirthCalendar, mapPoints, mapShadbala, mapYogas } from "./chart-view-insight.ts";
export type ChartViewProfileInput = {
@@ -97,11 +96,6 @@ function isoDay(value: string): string {
return value.slice(0, 10);
}
function addYears(startIso: string, years: number): string {
const start = Date.parse(`${startIso}T00:00:00Z`);
return new Date(start + years * 365.25 * 86_400_000).toISOString().slice(0, 10);
}
function containsDay(start: string, end: string, asOf: string): boolean {
return start <= asOf && asOf < end;
}
@@ -680,54 +674,51 @@ function vimshottariTrack(chart: Record<string, unknown>, asOf: string) {
};
}
function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: string, asOf: string) {
/**
* Chara rows as the engine dated them (BUG-1811, 占星师 2026-11-08 第五轮裁定 5):
* `start` / `end` / `start_ts` / `end_ts` are copied from /api/dasha/chara, which
* dates them once in scripts/chart_dasha/chara_dates.py. The page never cuts a
* sign period or its antardashas by year proportions itself.
*/
function charaTrack(charaPacket: Record<string, unknown> | null, asOf: string) {
const result = record(charaPacket?.result) ?? charaPacket;
const chara = record(result?.chara_dasha) ?? result;
const sequence = Array.isArray(chara?.dasha_sequence) ? chara.dasha_sequence : [];
let cursor = birthDate;
const periods = sequence.flatMap((entry) => {
const row = record(entry);
if (!row) return [];
const sign = text(row.sign);
const lord = text(row.lord) ?? sign ?? "—";
const rawDuration = finite(row.duration_years);
const duration = rawDuration === null ? 1 : rawDuration;
const zeroYear = duration === 0;
const start = cursor;
const end = addYears(start, duration);
cursor = end;
const start = text(row.start);
const end = text(row.end);
// A row the engine did not date is left out: cutting it here is the bug this
// change removes.
if (!start || !end) return [];
const startIso = isoDay(start);
const endIso = isoDay(end);
const zeroYear = startIso === endIso;
const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : [];
const antardashas = zeroYear
? []
: rawAntar.length > 0
? rawAntar.flatMap((antar, index) => {
: rawAntar.flatMap((antar) => {
const item = record(antar);
if (!item) return [];
const weights = rawAntar.map((candidate) => finite(record(candidate)?.duration_years) ?? 1);
const span = addWeightedSpan(start, end, index, weights);
const antarStart = text(item.start);
const antarEnd = text(item.end);
if (!antarStart || !antarEnd) return [];
const antarLord = text(item.lord) ?? text(item.sign) ?? "—";
const antarSign = text(item.sign);
return [{
lord: antarLord,
...(antarSign && isSignName(antarSign) ? { sign: antarSign } : {}),
label: `${signZh(antarSign ?? antarLord)} / ${planetZh(antarLord)}`,
start: span.start,
end: span.end,
current: containsDay(span.start, span.end, asOf),
derived: true,
start: isoDay(antarStart),
end: isoDay(antarEnd),
current: containsDay(isoDay(antarStart), isoDay(antarEnd), asOf),
derived: false,
...(typeof item.start_ts === "number" ? { startTs: item.start_ts } : {}),
...(typeof item.end_ts === "number" ? { endTs: item.end_ts } : {}),
}];
})
: SIGN_ORDER.map((antarSign, index) => {
const span = addWeightedSpan(start, end, index, SIGN_ORDER.map(() => 1));
return {
lord: antarSign,
sign: antarSign,
label: signZh(antarSign),
start: span.start,
end: span.end,
current: containsDay(span.start, span.end, asOf),
derived: true,
};
});
const lordSign = text(row.lord_in_sign);
return [{
@@ -735,11 +726,13 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
label: `${signZh(sign ?? lord)} / ${planetZh(lord)}`,
sign: sign,
lordSign: lordSign && isSignName(lordSign) ? lordSign : null,
start,
end,
current: containsDay(start, end, asOf),
start: startIso,
end: endIso,
current: !zeroYear && containsDay(startIso, endIso, asOf),
derived: false,
...(zeroYear ? { zeroYear: true as const } : {}),
...(typeof row.start_ts === "number" ? { startTs: row.start_ts } : {}),
...(typeof row.end_ts === "number" ? { endTs: row.end_ts } : {}),
antardashas,
}];
});
@@ -983,7 +976,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
const vargas = buildVargas(chart, varga);
if (vargas.length === 0) throw new Error("vedic_chart_unavailable");
const vimshottari = vimshottariTrack(chart, asOf);
const charaMapped = charaTrack(chara, profile.date, asOf);
const charaMapped = charaTrack(chara, asOf);
const yoginiMapped = yoginiTrack(yogini, asOf);
const placeLabel = profile.placeLabel?.trim()
|| formatCoord(profile.latitude, profile.longitude);
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildChartView, type ChartViewProfileInput } from "../src/lib/chart-view-mapper.ts";
/**
* BUG-1811 (占星师 2026-11-08 第五轮裁定 5): the Chara track shows the dates
* /api/dasha/chara returns. The page used to cut the twelve sign periods by
* 365.25-day years and split their antardashas by year proportions; the engine
* dates them once now (scripts/chart_dasha/chara_dates.py).
*
* Fixtures are real engine responses: the chara sections of these two files were
* captured from JyotishAPIHandler._compute_chara_dasha for the same fictional
* charts (see chart-view-golden.json sourceCommand).
*/
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")) as {
chart: Record<string, unknown>;
chara: Record<string, unknown>;
};
const zeroYear = JSON.parse(
readFileSync(new URL("./fixtures/chara-zero-year-1985-08-03-beijing.golden.json", import.meta.url), "utf8"),
) as { chart: Record<string, unknown>; chara: Record<string, unknown> };
const profile: ChartViewProfileInput = {
name: "虚构",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "reported",
};
function viewFrom(chara: Record<string, unknown>, asOf = "2026-11-09") {
return buildChartView({
chart: golden.chart,
varga: null,
chara,
western: null,
profile,
asOf,
});
}
type EngineRow = { sign: string; start: string; end: string; antardashas: Array<{ start: string; end: string }> };
const engineRows = (golden.chara as { result: { chara_dasha: { dasha_sequence: EngineRow[] } } }).result.chara_dasha.dasha_sequence;
test("Chara periods and antardashas show the engine's own dates", () => {
const periods = viewFrom(golden.chara).dasha.chara.periods;
assert.equal(periods.length, engineRows.length);
assert.equal(periods.length, 12);
periods.forEach((period, index) => {
const engine = engineRows[index]!;
assert.equal(period.sign, engine.sign);
assert.equal(period.start, engine.start);
assert.equal(period.end, engine.end);
assert.equal(period.derived, false);
assert.equal(period.antardashas.length, engine.antardashas.length);
period.antardashas.forEach((antar, childIndex) => {
assert.equal(antar.start, engine.antardashas[childIndex]!.start);
assert.equal(antar.end, engine.antardashas[childIndex]!.end);
assert.equal(antar.derived, false);
});
});
// [start, end): consecutive periods meet exactly.
for (let index = 0; index < periods.length - 1; index += 1) {
assert.equal(periods[index]!.end, periods[index + 1]!.start);
}
});
test("a Chara row the engine did not date is not cut in the browser", () => {
const packet = structuredClone(golden.chara) as { result: { chara_dasha: { dasha_sequence: Array<Record<string, unknown>> } } };
const rows = packet.result.chara_dasha.dasha_sequence;
const dropped = rows[1]!;
delete dropped.start;
delete dropped.end;
const periods = viewFrom(packet).dasha.chara.periods;
assert.equal(periods.length, 11);
assert.equal(periods.some((period) => period.sign === dropped.sign), false);
});
test("the zero-year Chara period keeps the engine's empty span and is never current", () => {
const rows = (zeroYear.chara as { result: { chara_dasha: { dasha_sequence: EngineRow[] } } }).result.chara_dasha.dasha_sequence;
const zero = rows.find((row) => row.start === row.end)!;
assert.equal(zero.sign, "Sagittarius");
const view = buildChartView({
chart: zeroYear.chart,
varga: null,
chara: zeroYear.chara,
western: null,
profile: { ...profile, date: "1985-08-03", time: "03:59" },
asOf: zero.start,
});
const periods = view.dasha.chara.periods;
const mapped = periods.find((period) => period.sign === zero.sign)!;
assert.equal(mapped.zeroYear, true);
assert.equal(mapped.start, mapped.end);
assert.equal(mapped.current, false);
assert.equal(mapped.antardashas.length, 0);
const running = periods.filter((period) => period.current);
assert.equal(running.length, 1);
assert.notEqual(running[0]!.sign, zero.sign);
});
test("the chart mapper no longer does dasha date arithmetic", () => {
const source = readFileSync(new URL("../src/lib/chart-view-mapper.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /365\.25/);
assert.doesNotMatch(source, /86_400_000/);
assert.doesNotMatch(source, /function addYears/);
assert.doesNotMatch(source, /addWeightedSpan/);
assert.doesNotMatch(source, /vimshottariSplit/);
});
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
"""Chara Dasha dates, dated once here instead of by each display layer (BUG-1811).
占星师 2026-11-08 第五轮裁定 5:所有大运日期必须由原生大运引擎生成,显示层不得
自己按年数比例重算。``jaimini.calc_chara_dasha_with_antardasha`` returns durations
only, so the chart page used to cut the twelve sign periods itself (365.25-day
years from the birth day) while the report's Chara section
(``rashi_dashas_native._chara``) cut them another way (calendar-year anniversaries
from the birth date). Two surfaces of the same technique disagreed by up to a day
and neither copied an engine field.
This module dates the sequence the engine returned, once, with the rule the
report already used:
* a mahadasha ends on the calendar anniversary of the birth date, so a 2-year
period that starts on 1990-06-15 ends on 1992-06-15 (29 February falls back to
the 28th);
* its twelve antardashas are an equal split of that span in exact seconds, the
last one pinned to the mahadasha end;
* every interval is ``[start, end)``; a 0-year period keeps its row with
``start == end`` and no occupied time;
* dates are local calendar days; ``start_ts`` / ``end_ts`` are the same boundaries
in UTC epoch seconds when the birth zone is known, so a boundary can be
compared exactly without printing a clock time.
The chart's ``/api/dasha/chara`` and the report's ``chara`` section both read
``date_sequence``; no consumer does this arithmetic itself.
"""
from __future__ import annotations
from datetime import date, datetime, time as clock_time, timedelta, timezone
from typing import Any, Iterable
YEAR_BASIS = "calendar_year_anniversary"
INTERVAL = "[start, end)"
def add_years(day: date, years: int) -> date:
"""The anniversary rule: the same month and day, ``years`` later."""
try:
return day.replace(year=day.year + int(years))
except ValueError: # 29 February in a common year
return day.replace(year=day.year + int(years), day=28)
def _midnight(day: date) -> datetime:
return datetime.combine(day, clock_time.min)
def _epoch_seconds(moment: datetime, tz_hours: float | None) -> int | None:
if tz_hours is None:
return None
return int(round((moment - timedelta(hours=float(tz_hours))).replace(tzinfo=timezone.utc).timestamp()))
def _boundaries(start: datetime, end: datetime, tz_hours: float | None) -> dict[str, Any]:
return {
"start": start.date().isoformat(),
"end": end.date().isoformat(),
"start_ts": _epoch_seconds(start, tz_hours),
"end_ts": _epoch_seconds(end, tz_hours),
}
def split_equal(start: datetime, end: datetime, count: int) -> list[tuple[datetime, datetime]]:
"""``count`` equal slices of ``[start, end)``; the last one ends exactly at ``end``."""
total = (end - start).total_seconds()
if count <= 0 or total <= 0:
return []
rows = []
for index in range(count):
child_start = start + timedelta(seconds=total * index / count)
child_end = end if index == count - 1 else start + timedelta(seconds=total * (index + 1) / count)
rows.append((child_start, child_end))
return rows
def date_sequence(
sequence: Iterable[Any],
birth: date,
*,
tz_hours: float | None = None,
) -> list[dict[str, Any]]:
"""The engine's Chara rows with ``start`` / ``end`` (local days) and their exact instants."""
cursor = _midnight(birth)
out: list[dict[str, Any]] = []
for entry in sequence:
if not isinstance(entry, dict):
continue
years = int(entry.get("duration_years") or 0)
end = _midnight(add_years(cursor.date(), years))
antardashas = []
raw_antardashas = entry.get("antardashas")
children_raw = [item for item in raw_antardashas if isinstance(item, dict)] if isinstance(raw_antardashas, list) else []
slices = split_equal(cursor, end, len(children_raw))
if not slices:
# A 0-year mahadasha occupies no time; its rows stay, each with start == end (never current).
slices = [(cursor, cursor) for _ in children_raw]
for child, (child_start, child_end) in zip(children_raw, slices):
if not isinstance(child, dict):
continue
antardashas.append({
"sign": child.get("sign"),
"sign_index": child.get("sign_idx", child.get("sign_index")),
"lord": child.get("lord"),
"order": child.get("order"),
**_boundaries(child_start, child_end, tz_hours),
})
out.append({
"sign": entry.get("sign"),
"sign_index": entry.get("sign_idx", entry.get("sign_index")),
"lord": entry.get("lord"),
"years": years,
"zero_year": years == 0,
**_boundaries(cursor, end, tz_hours),
"antardashas": antardashas,
})
cursor = end
return out
def date_basis(tz_hours: float | None) -> dict[str, Any]:
"""How to read the rows ``attach`` writes: one rule, one interval, one producer."""
return {
"year_basis": YEAR_BASIS,
"interval": INTERVAL,
"timezone_offset_hours": tz_hours,
"source": "scripts/chart_dasha/chara_dates.py",
"boundary": "start / end = local calendar days; start_ts / end_ts = UTC epoch seconds when the zone is known",
}
def birth_from_body(body: dict[str, Any] | None) -> date:
"""The birth date the dasha sequence is dated from (the endpoint's own fields)."""
raw = body or {}
try:
year = int(raw.get("year"))
except (TypeError, ValueError):
year = datetime.now().year
try:
month = int(raw.get("month") or 1)
except (TypeError, ValueError):
month = 1
try:
day = int(raw.get("day") or 1)
except (TypeError, ValueError):
day = 1
try:
return date(year, min(max(month, 1), 12), min(max(day, 1), 31))
except ValueError:
return date(year, 1, 1)
def timezone_hours(body: dict[str, Any] | None) -> float | None:
raw = body or {}
value = raw.get("tz", raw.get("timezone"))
if value is None:
return None
if isinstance(value, str):
text = value.strip().upper().replace("UTC", "").replace("GMT", "")
if not text:
return None
value = text
try:
return float(value)
except (TypeError, ValueError):
return None
def attach(chara: dict[str, Any], birth: date, *, tz_hours: float | None = None) -> dict[str, Any]:
"""Write the engine dates onto the rows ``jaimini`` returned, in place."""
dated = date_sequence(chara.get("dasha_sequence") or [], birth, tz_hours=tz_hours)
rows = [row for row in chara.get("dasha_sequence") or [] if isinstance(row, dict)]
for row, dates in zip(rows, dated):
for key in ("start", "end", "start_ts", "end_ts"):
row[key] = dates[key]
for child, child_dates in zip(
[item for item in row.get("antardashas") or [] if isinstance(item, dict)],
dates["antardashas"],
):
for key in ("start", "end", "start_ts", "end_ts"):
child[key] = child_dates[key]
chara["date_basis"] = date_basis(tz_hours)
return chara
+6
View File
@@ -7841,6 +7841,12 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
payload = dict(body or {})
payload['mode'] = 'dasha'
result = self._compute_jaimini(payload)
chara = result.get('result', {}).get('chara_dasha') if isinstance(result.get('result'), dict) else None
if isinstance(chara, dict):
# BUG-1811: the dates the chart page shows are produced once, here, by the
# native Chara dating rule -- the page copies them instead of cutting periods.
from scripts.chart_dasha import chara_dates
chara_dates.attach(chara, chara_dates.birth_from_body(body), tz_hours=chara_dates.timezone_hours(body))
return {
**result,
'endpoint': 'chara_dasha',
+16 -8
View File
@@ -23,6 +23,7 @@ try:
)
from jaimini import SIGNS, calc_chara_dasha_with_antardasha
from narayana_dasha import SIGN_LORDS, calculate_narayana_period_years
from chart_dasha.chara_dates import date_sequence as chara_date_sequence
except ImportError: # pragma: no cover - package import
from scripts.goel_short_strength import (
CLASSICAL_PLANETS,
@@ -34,6 +35,7 @@ except ImportError: # pragma: no cover - package import
)
from scripts.jaimini import SIGNS, calc_chara_dasha_with_antardasha
from scripts.narayana_dasha import SIGN_LORDS, calculate_narayana_period_years
from scripts.chart_dasha.chara_dates import date_sequence as chara_date_sequence
_MOVABLE = {0, 3, 6, 9}
_FIXED = {1, 4, 7, 10}
@@ -650,15 +652,21 @@ def _chara(birth: date, lagna: int, planet_lons: Mapping[str, float]) -> dict:
birth.day,
profile="kn_rao",
)
# BUG-1811: the dates come from one producer (chart_dasha.chara_dates), which
# the chart page's /api/dasha/chara reads too; this section no longer dates rows.
periods = []
cursor = birth
for row in calculated.get("dasha_sequence") or []:
years = int(row["duration_years"])
end = add_years(cursor, years)
order = [int(child["sign_idx"]) for child in row.get("antardashas") or []]
children = _equal_children(order, cursor, end)
periods.append(_period(int(row["sign_idx"]), years, cursor, end, children))
cursor = end
for row in chara_date_sequence(calculated.get("dasha_sequence") or [], birth):
periods.append({
"sign": row["sign"],
"sign_index": int(row["sign_index"]),
"years": row["years"],
"start": row["start"],
"end": row["end"],
"antardasha": [] if row["zero_year"] else [
{"sign": child["sign"], "sign_index": int(child["sign_index"]), "start": child["start"], "end": child["end"]}
for child in row["antardashas"]
],
})
return _listed(profile, source_zh, source_en, periods, direction="ninth_house_even_footed")
@@ -0,0 +1,171 @@
"""BUG-1811: every Chara Dasha date comes from one native producer.
占星师 2026-11-08 第五轮裁定 5:所有大运日期必须由原生大运引擎生成,显示层不得自己
按年数比例重算。星盘页的 Vimshottari 轨已在 BUG-1752 改好;这一单修其余地方。
What was wrong: ``/api/dasha/chara`` returned the jaimini rows (durations only), so
the chart page dated them itself (365.25-day years from the birth day) while the
report's Chara section (``rashi_dashas_native._chara``) used calendar-year
anniversaries. The two surfaces of the same technique differed by up to a day and
neither read an engine field.
What this guards: ``scripts/chart_dasha/chara_dates.py`` dates the sequence once,
both surfaces read it, every interval is ``[start, end)``, and the four boundary
cases the task names are covered (cross-midnight, the period running at birth,
a second round, a 0-year period). Chara itself runs one twelve-sign round, so the
second-round case is exercised on the Vimshottari track that shares the page.
Fictional charts only.
"""
from __future__ import annotations
import json
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
for path in (ROOT, ROOT / "scripts", ROOT / "tests"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from scripts.chart_dasha import birth_moment, chara_dates # noqa: E402
GOLDEN = ROOT / "frontend" / "tests" / "fixtures" / "chart-view-golden.json"
ZERO_YEAR_GOLDEN = ROOT / "frontend" / "tests" / "fixtures" / "chara-zero-year-1985-08-03-beijing.golden.json"
BIRTH = date(1990, 6, 15) # the fictional chart of the chart-view golden
def _golden() -> dict:
return json.loads(GOLDEN.read_text(encoding="utf-8"))
def _chara_body(chart: dict, **overrides) -> dict:
body = {
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman", "node_mode": "mean",
"antardasha": True, "mode": "dasha",
"planets": chart["planets"], "ascendant": chart["ascendant"],
}
body.update(overrides)
return body
def test_the_chart_endpoint_and_the_report_share_one_chara_timeline() -> None:
"""The page's rows and the report's Chara section are the same dates, from one producer."""
from test_api_server_security import _handler
golden = _golden()
chart = golden["chart"]
packet = _handler()._compute_chara_dasha(_chara_body(chart))
section = packet["result"]["chara_dasha"]
rows = section["dasha_sequence"]
assert len(rows) == 12
assert section["date_basis"]["interval"] == "[start, end)"
assert section["date_basis"]["source"] == "scripts/chart_dasha/chara_dates.py"
from scripts.rashi_dashas_native import calculate_rashi_dashas
longitudes = {name: float(planet["lon"]) for name, planet in chart["planets"].items()}
report = calculate_rashi_dashas(BIRTH, float(chart["ascendant"]["lon"]), longitudes)["chara"]["periods"]
assert [(row["sign"], row["start"], row["end"]) for row in rows] == [
(row["sign"], row["start"], row["end"]) for row in report
]
for row, reported in zip(rows, report):
assert row["start"] <= row["end"]
assert row["start_ts"] is not None and row["end_ts"] is not None
assert [(child["start"], child["end"]) for child in row["antardashas"]] == [
(child["start"], child["end"]) for child in reported["antardasha"]
]
# [start, end): every period ends where the next one starts, all the way through.
for current, following in zip(rows, rows[1:]):
assert current["end"] == following["start"]
assert all(row["start"] for row in rows)
def test_chara_days_are_local_days_across_midnight() -> None:
"""A boundary before 08:00 local is still the local day on the birth clock, not the UTC day."""
sequence = chara_dates.date_sequence(
[
{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": 12,
"antardashas": [{"sign": "Virgo", "sign_idx": 5, "lord": "Mercury"}]},
],
date(1990, 6, 15),
tz_hours=8.0,
)
row = sequence[0]
assert row["start"] == "1990-06-15" # born at 23:50 on the 15th: the row still starts on the 15th
crossing = 0
for moment, seconds in ((row["start"], row["start_ts"]), (row["end"], row["end_ts"])):
utc = datetime.fromtimestamp(seconds, tz=timezone.utc)
local = utc + timedelta(hours=8)
assert moment == local.strftime("%Y-%m-%d")
crossing += utc.date() != local.date()
child = row["antardashas"][0]
assert child["start"] == row["start"] and child["end"] == row["end"]
# Unknown zone: days stay, exact moments are absent rather than guessed.
bare = chara_dates.date_sequence(
[{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": 2, "antardashas": []}],
date(1990, 6, 15),
)
assert bare[0]["start_ts"] is None and bare[0]["start"] == "1990-06-15"
def test_the_period_running_at_birth_starts_on_the_birth_day() -> None:
"""Chara has no balance period before birth: the first md starts at birth, not at a year boundary."""
from test_api_server_security import _handler
chart = _golden()["chart"]
for day in (1, 15, 28):
rows = _handler()._compute_chara_dasha(_chara_body(chart, day=day))["result"]["chara_dasha"]["dasha_sequence"]
first = rows[0]
assert first["start"] == f"1990-06-{day:02d}"
assert all(row["start"] >= first["start"] for row in rows)
assert first["start"] <= first["end"]
def test_a_zero_year_chara_period_has_no_time_and_keeps_its_row() -> None:
"""[start, end): a 0-year sign period stays in the data with no span and no running time."""
golden = json.loads(ZERO_YEAR_GOLDEN.read_text(encoding="utf-8"))
rows = golden["chara"]["result"]["chara_dasha"]["dasha_sequence"]
zero = [row for row in rows if row["duration_years"] == 0]
assert len(zero) == 1
sagittarius = zero[0]
assert sagittarius["sign"] == "Sagittarius"
assert sagittarius["start"] == sagittarius["end"]
assert all(child["start"] == child["end"] == sagittarius["start"] for child in sagittarius["antardashas"])
index = rows.index(sagittarius)
assert rows[index - 1]["end"] == sagittarius["start"] == rows[index + 1]["start"]
def test_the_second_vimshottari_round_is_still_engine_dated() -> None:
"""The page's other track covers the second round; Chara itself runs one twelve-sign round."""
moon = 318.6461748848373 # the golden's fictional Moon
through = datetime(1990, 6, 15, 12, 0) + timedelta(days=365.25 * 110)
timeline = birth_moment.sub_period_timeline(datetime(1990, 6, 15, 12, 0), moon, through=through, tz_hours=8.0)
assert len(timeline) > 9
second = timeline[9]
assert second["cycle"] == 2
assert second["start"] == timeline[8]["end"]
assert second["antardashas"][0]["start"] == second["start"]
assert all(row["end"] == timeline[index + 1]["start"] for index, row in enumerate(timeline[:-1]))
# Chara stops after its twelfth sign: nothing is invented past the round.
rows = chara_dates.date_sequence(
[{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": years, "antardashas": []}
for years in (12, 12, 12)],
date(1990, 6, 15),
)
assert len(rows) == 3 and rows[-1]["end"] == "2026-06-15"
def test_chara_dates_do_not_depend_on_the_display_clock() -> None:
"""Same birth day, different clock: the days stay the engine's (it dates from the birth date)."""
sequence = [
{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": 2,
"antardashas": [{"sign": "Virgo", "sign_idx": 5, "lord": "Mercury"}, {"sign": "Libra", "sign_idx": 6, "lord": "Venus"}]},
]
midnight = chara_dates.date_sequence(sequence, date(1990, 6, 15))
late = chara_dates.date_sequence(sequence, date(1990, 6, 15))
assert [(row["start"], row["end"]) for row in midnight] == [(row["start"], row["end"]) for row in late]
assert [child["start"] for child in midnight[0]["antardashas"]] == ["1990-06-15", "1991-06-15"]
assert midnight[0]["antardashas"][-1]["end"] == "1992-06-15"