Merge remote-tracking branch 'origin/codex/annual-kp-20261114' into acc-annual-b2
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m15s
Independent Staging Quality Gate / validate-python (push) Successful in 6m41s
Independent Staging Quality Gate / publish (push) Successful in 2m48s

# Conflicts:
#	BLOCKED.md
#	CHANGELOG.md
#	docs/BUG_HISTORY.md
#	docs/research/pre_work_error_ledger.md
#	docs/tasks/README.md
#	frontend/DESIGN.md
#	frontend/src/lib/annual-report-generate.ts
#	scripts/annual_service/package.py
#	scripts/annual_service/report_facts.py
This commit is contained in:
Claude
2026-11-14 08:59:12 +08:00
15 changed files with 1557 additions and 4 deletions
+3
View File
@@ -1,5 +1,8 @@
# BLOCKED
## #264 年运 KP 验收环境
- Windows Docker Linux 引擎不可用,预检碎片扫描及定向套件超时。用同机 staging 对照和针对性回归提供替代证据。见 [#264 进度](docs/tasks/PROGRESS-annual-kp-20261114.md)。
## #263 年运行运交叉与其他参照
- Sudarshana 本命三参照已接入。上游与仓内引擎均缺逐年推进。采用何种年度推进口径需拍板。
+3
View File
@@ -1,5 +1,8 @@
# 印度占星 Skill 更新日志
## 2026-11-14 — 年运附录增加 KP 宫头和子主
- 按主题列出 Placidus 宫头及子主。记录出生时间误差范围内的变化,误差未知时标注参考取样。沿用当前 Skill 版本(BUG-1884)。
## 2026-11-14 — 年运附录增加行运交叉与其他参照
- 计算依据分组列出木星与土星的主题宫位核对、Sudarshana 本命三参照、Bhrigu Pada 年龄段观察。沿用当前 Skill 版本(BUG-1883)。
+11
View File
@@ -25408,6 +25408,17 @@
- 关联记录:#257、BUG-1212、BUG-1219、BUG-1817、BUG-1868。
- 修复版本:`codex/annual-muntha-records-20261113`,待发布。
## BUG-1884 | 年运未记录 KP 宫头子主和出生精度分支
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-14。
- 影响面:年运计算依据、技法执行表及 Markdown 导出。
- 现象和触发:读取年运技法执行表,KP 宫头及子主一直列为未完成。
- 根因:年包未把 Placidus 真宫头、原生 KP 代表星和出生时间范围接入独立观察层。
- 修复:使用与生时纠正相同的 Placidus 计算口径,保留原始精度,按宫头至下一宫头判落宫。按主题记录子主分钟样点和分支,实际误差未知时区分参考取样。无真宫头时阻塞。
- 验证和防复发:公开盘和生时纠正引擎对照,合成盘覆盖稳定、跨界、误差未知、极区无宫头,另有页面、审计及写作哈希回归。见 [#264 进度](tasks/PROGRESS-annual-kp-20261114.md)。
- 关联记录:#264、BUG-1319、BUG-1383、BUG-1543、BUG-1709。
- 修复版本:`codex/annual-kp-20261114`。
## BUG-1883 | 年运未列主题宫位的行运交叉及本命参照
- 状态:fixed-pending-verify(待独立验收)。
+3
View File
@@ -2,6 +2,9 @@
Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility.
## 2026-11-14 · #264 年运 KP 预检
- 本机 Python、Gitea 远端可见性与引擎适配器诊断通过。碎片扫描和定向套件分别在 90 秒、45 秒超时。见 [#264 进度](../tasks/PROGRESS-annual-kp-20261114.md)。
## 2026-11-14 · #263 年运行运交叉预检
- 本机 Python、Gitea 远端可见性与引擎适配器诊断通过。碎片扫描与预检定向套件分别在 90 秒、45 秒超时。证据和环境缺口见 [#263 进度](../tasks/PROGRESS-annual-transit-checks-20261114.md)。
+26
View File
@@ -0,0 +1,26 @@
# #264 年运 KP 宫头子主与出生精度
- [任务书](TASK-annual-kp-20261114.md)
- 基线 `a09fbfd3e3e0cbe15342a00b31569329abfde209`
- 执行分支 `codex/annual-kp-20261114`
- BUG-1884 按任务书预留。BUG-1881 至 BUG-1883 由并行工单使用。
## 实现
- 使用与生时纠正一致的恒星黄道 Placidus 宫头算法和报告请求的岁差参数。经度保留原值,行星按宫头至下一宫头归属。真实宫头不可得时该层阻塞。
- 按六个主题列出宫头、星宿主星、子主和原生 ABCD 代表星。逐分钟取样,记录同一子主的连续样点,跨界时保留全部分支。误差未知仍写未知,前后各 30 分钟只供参考。区间端点是取样时刻。
- 附录独立折叠展示 KP 宫头,注明与正文整宫制的区别。双轴审计记录跨界宫位和实际用途。页面与 Markdown 共用文案。
## 验证
- 公开盘对照生时纠正的宫头星宿主星与子主,合成盘覆盖稳定、跨界、误差未知及极区无法计算。页面与 Markdown 核对同一份真实引擎样本。
- 年运 Python 定向回归 51 项通过。公开盘和合成盘各十个写作区段的 evidence 哈希与开工 staging 逐项一致,旧单轴审计哈希通过。
- TypeScript、lint 和生产构建通过。lint 为 0 error、62 warnings。基线和分支首页均为静态,首页 gzip 均为 JS 516100 B、CSS 92069 B。
- 前端无 Docker 全量 3734 项,3652 通过、45 失败、37 跳过、0 取消。基线 3733 项,少一项新通过用例,失败名称逐项一致。
- Python 快速门 1489 项通过、2 项跳过,另有 29 个子测试通过。三项 Kaala / PyJHora 失败与同机 staging 一致。另有一项咨询缓存时序断言在全量运行失败,分支和基线各自定向复跑均通过。快速门退出 1,完整绿灯留待独立验收。
- 仓库隐私检查 62 项通过。
- 远端可见性和外部适配器诊断通过。Windows 碎片扫描和预检定向套件超时,Docker Linux 引擎不可用。
## 交接
- 既有断言保持原样,Skill 版本沿用。请以 PR head 做独立验收。
+1
View File
@@ -33,6 +33,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| [#264 任务书](TASK-annual-kp-20261114.md) | [执行进度](PROGRESS-annual-kp-20261114.md) | 年运 KP 宫头子主与出生精度(BUG-1884) | 待验收 | `codex/annual-kp-20261114` |
| [#263 任务书](TASK-annual-transit-checks-20261114.md) | [执行进度](PROGRESS-annual-transit-checks-20261114.md) | 年运行运交叉与其他参照(BUG-1883) | 待验收 | `codex/annual-transit-checks-20261114` |
| [#262 任务书](TASK-annual-dashas-20261114.md) | [执行进度](PROGRESS-annual-dashas-20261114.md) | 年运附录接入四种本命运限(BUG-1882) | 待验收 | `codex/annual-dashas-20261114` |
| [#260 任务书](TASK-rect-adaptive-20261114.md) | [执行进度](PROGRESS-rect-adaptive-20261114.md) | 自适应生时纠正(BUG-1881) | 待验收 | `codex/rect-adaptive-20261114` |
+61
View File
@@ -0,0 +1,61 @@
# TASK:年运 KP 宫头子主与出生精度分支(2026-11-14)
- Gitea:#264
- 分支:`codex/annual-kp-20261114`,从 staging `a09fbfd3` 切出。
- Bug 编号:BUG-1884。开工时核对 `docs/BUG_HISTORY.md` 末尾;已被占用就顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-99)。
任务书没写到的取舍,不得写成"产品决定"或"裁定",一律标「需拍板」。不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 逐条写明原值、新值和原因。
同时在做的还有 #262、#263,它们改同样几个文件。各单只改自己的函数;后合入的负责 rebase。
## 背景
占星师 2026-11-12 年运 Q3(原意):
- KP 在涉及事件或细时窗时执行,保留独立参数。
- 出生精度不支持时,精确结论要分支或阻塞,不伪造精度。
- KP 与整宫制的结果不同,写成方法差异(R-05)。
现状:
- 审计「KP / Sub-lord」是 `not_built`。
- 引擎:`kp_system.calc_kp_analysis(planet_positions, asc_sign, house_cusps=None, house_occupancy_basis=None)`,输出 `house_basis`(`explicit_cusps` 或 `whole_sign_proxy`)、宫和星的 `kp_lords` 与 significators。
- 真实宫头可以对照生时纠正链路:`chart_choice/astrologer_engine.py:112 kp_placidus_cusps`,以及 `astrologer.py` 226、244、311 行(没有宫头时 kp_layer 写 blocked)。
- 精度机制:`report_facts._birth_survey` / `_stability`,取法沿用 #224(用户自报误差,否则 ±30 分钟)。
## 要做的
1. **只用真实宫头**:用与生时纠正相同的 KP Placidus 宫头算法。拿不到宫头时整块写 blocked,不退回 `whole_sign_proxy`。
2. **只看主题宫**:用 `HOUSE_DOMAINS` 里各主题的宫位,记录每个宫的宫头星座、星主、子主和 significators(引擎原样输出)。
3. **精度分支**
- 在出生时间窗口内取样,看每个主题宫的宫头子主会不会变。
- 窗口内不变:标 `stable_in_window`。
- 会变:标 `boundary_sensitive`,列出所有出现过的子主和对应的出生时刻区间,不选其中一个。
- 窗口未知:照 `_birth_survey` 的 `range_unknown` 写,并说明「出生时间误差未知,子主是否稳定无法判断」。
4. **与整宫制分开**:KP 的宫位结果单独一节,写明「KP 宫头制,与正文的整宫制是不同方法」,不和整宫结论比对,也不合并。
5. **审计**:「KP / Sub-lord」写实际状态。
- 宫头可得:`executed` + `observation_only`。有跨界的宫,note 里写出来。
- 拿不到宫头:blocked。
6. **页面附录**新增小节「KP 宫头(另一种分宫方法)」,按主题列出宫头子主和稳定性。行数多时折叠。
7. **写作输入不变**:十个写作区段的 evidence 哈希不变。
## 不要碰
- 冻结文件只能 import。`kp_system.py` 和生时纠正链路的文件不改,只调用。
- `references/**`、`consult-preset-prompts.ts`。
## 要改的文件
- `scripts/annual_service/package.py`、`scripts/annual_service/report_facts.py`
- `frontend/src/lib/annual-report-generate.ts`
- 测试:`tests/test_annual_kp_20261114.py`、`frontend/tests/annual-kp-20261114.test.tsx`
- 覆盖:窗口内稳定;跨子主边界;窗口未知;拿不到宫头时 blocked、不退回整宫;写作输入哈希不变。
- `docs/BUG_HISTORY.md`,只在末尾追加
- `docs/tasks/PROGRESS-annual-kp-20261114.md`
## 验收标准
1. 公开名人盘上各主题宫的子主和稳定性都列出来了,`house_basis` 是真实宫头。
2. 合成盘测试覆盖第 3 节的三种情况,以及拿不到宫头的情况。
3. 附录明确写着这是另一种分宫方法;正文没有变化。
4. 用户可见文字里没有「·」、字段名或下划线。
5. tsc、eslint(0 错)、`npm test`(基线失败除外)、quick gate 全部通过。
+1
View File
@@ -1411,6 +1411,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
- **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。
- **年运附录(BUG-1878):** 「这份报告的口径」在年内展开说明下列出 Mudda 主结果和两种对照口径。跨年结果不同时按年盘分行。技法执行表保留西方占星各行,计算栏显示「不适用」,使用栏附理由。
- **年运 Muntha(#257,BUG-1880):** 计算依据按年盘列「Muntha 四项记录」。年盘结构、Muntha 主、慢星进出与停滞、逐年推进各自列出,导出沿用同一顺序。
- **年运 KP 宫头(#264,BUG-1884):** 计算依据增加独立小节,首行说明分宫口径及未知误差,按主题折叠宫头和子主分钟样点。审计列出子主变化的宫位,Markdown 导出使用同一文案。
- **行运交叉与其他参照(#263,BUG-1883):** 计算依据分三组展示木星与土星的主题宫位核对、Sudarshana 三参照和 Bhrigu Pada 关系参照。逐段显示核对范围、时间和作用方式,多行沿用原生折叠。Markdown 导出沿用同一分组。
- **年运其他运限(#262,BUG-1882):** 每张年盘的计算依据按 Chara、Yogini、Ashtottari、Kalachakra 分组。首行列本期大运、小运及各自主题,余行沿用折叠。适用性与出生精度的说明留在对应分组,导出沿用相同顺序。
- **年运研究回归(#269,BUG-1885):** 正文月相回归块内默认折叠具名候选表。打开后用既有可横向滚动的表格显示每个候选、当地时刻、两种年长和用途。附录「几种回归的完整数据」先显示第一条,展开时再挂载其余行。Markdown 同时导出正文表和附录。
+41 -1
View File
@@ -650,6 +650,7 @@ const AUDIT_NAMES: Readonly<Record<string, string>> = {
"Tajika Aspects / Panchavargiya Bala": "五分力",
"Tajika Aspects": "Tajika 相位",
"Functional Benefic/Malefic": "功能属性",
"KP / Sub-lord": "KP 宫头子主",
};
function annualAudit(root: Row): AnnualAppendixBlock {
@@ -662,7 +663,7 @@ function annualAudit(root: Row): AnnualAppendixBlock {
const name = str(row.technique);
const extraDasha = Object.values(EXTRA_NATAL_DASHA_NAMES).some((method) => name === `${method} Dasha`);
const transitCheck = str(row.raw_path).startsWith("package.independent_annual_views.transit_checks.");
const reason = extraDasha || transitCheck || ["blocked", "not_applicable", "not_built"].includes(axes.calculation_status) ? str(row.note) : "";
const reason = name === "KP / Sub-lord" || extraDasha || transitCheck || ["blocked", "not_applicable", "not_built"].includes(axes.calculation_status) ? str(row.note) : "";
return {
method: AUDIT_NAMES[name] ?? displayTechniqueAuditLabel(name),
calculation: techniqueCalculationStatusLabel(axes.calculation_status),
@@ -724,6 +725,44 @@ function muddaBalanceLines(root: Row): string[] {
return lines.map(({ year, text }) => `${year} 年 Mudda 余额口径:${text}`);
}
const KP_THEMES: Readonly<Record<string, string>> = {
career: "事业", wealth: "财富", relationship: "关系", home: "家宅", energy: "身心", learning: "学习",
};
function kpCuspAppendix(root: Row): AnnualAppendixBlock[] {
const kp = record(record(root.page_appendix).kp_cusps);
if (!kp.status) return [];
const precision = record(kp.precision);
const domains = record(kp.domains);
const ayanamsa = AYANAMSA_NAMES[str(kp.ayanamsa)] ?? "未标明";
const node = kp.node_mode === "true" ? "真交点" : "平交点";
const time = (edge: unknown) => str(record(edge).local).slice(0, 19).replace("T", " ");
const band = (branch: Row) => {
const start = time(branch.first_sample);
const end = time(branch.last_sample);
return `${start === end ? start : `${start} 至 ${end}`}为${planetZh(str(branch.sub_lord))}`;
};
const lines = Object.entries(KP_THEMES).flatMap(([domain, title]) => rows(domains[domain]).map((row) => {
const lords = record(row.kp_lords);
const branches = rows(row.branches);
const cusp = `${title}第 ${row.house} 宫。记录时刻的宫头在${signZh(str(row.sign))}座,星宿主星为${planetZh(str(lords.nakshatra_lord))}。`;
if (row.stability === "boundary_sensitive") return `${cusp}出生时间范围内子主有变化。逐分钟取样,${branches.map(band).join(";")}。`;
const lord = `记录时刻的子主为${planetZh(str(lords.sub_lord))}。`;
if (row.stability === "stable_in_window") return `${cusp}${lord}出生时间范围内的分钟样点一致。`;
const variations = branches.length > 1 ? `参考取样中,${branches.map(band).join(";")}。` : "";
return `${cusp}${lord}误差未知,稳定性未判定。${variations}`;
}));
return [{
id: "kp-cusps", title: "KP 宫头(另一种分宫方法)", collapseAfterFirst: true,
lines: kp.status === "computed" ? [
`${str(kp.method_note)}。${precision.status === "range_unknown"
? `${str(precision.note)}。参考取样用记录时间的前后各 30 分钟。` : ""}`,
`采用 Placidus 宫头、${ayanamsa} 岁差与${node}。`,
...lines,
] : [str(kp.reason) || "KP 宫头未算出。"],
}];
}
export function buildAppendix(facts: unknown, range: AnnualSnapshot["range"], birth: AnnualBirthTime, cityLabel?: string | null): AnnualAppendixBlock[] {
const root = record(facts);
const parameters = record(root.parameters);
@@ -772,6 +811,7 @@ export function buildAppendix(facts: unknown, range: AnnualSnapshot["range"], bi
...appendicesByYear(root),
...annualPraveshaAppendix(root),
...transitCheckAppendices(root),
...kpCuspAppendix(root),
annualAudit(root),
{
id: "conflicts",
@@ -0,0 +1,47 @@
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 { known, audit, unknown, blocked } = JSON.parse(readFileSync(
new URL("./fixtures/annual-kp-20261114.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 appendixFor = (kp: unknown) => buildAppendix({
page_appendix: { kp_cusps: kp }, technique_audit_axes: [audit],
}, range, birth);
test("annual KP appendix distinguishes stable and crossing sub-lords from an unknown or blocked window", () => {
const appendix = appendixFor(known);
const block = appendix.find((row) => row.id === "kp-cusps");
assert.ok(block?.collapseAfterFirst);
assert.equal(block.title, "KP 宫头(另一种分宫方法)");
const markdown = annualAppendixMarkdown([block]);
assert.match(markdown, /KP 宫头制,与正文的整宫制是不同方法/);
assert.match(markdown, /采用 Placidus 宫头、Raman 岁差与平交点/);
assert.match(markdown, /事业第 10 宫.*分钟样点一致/);
assert.match(markdown, /事业第 6 宫.*子主有变化.*逐分钟取样.*\d{2}:\d{2}:\d{2}.*;/);
assert.match(markdown, /关系第 7 宫/);
assert.doesNotMatch(markdown, /[·_]|stable_in_window|boundary_sensitive|range_unknown/);
const auditRow = appendix.find((row) => row.id === "execution")?.audit?.[0];
assert.ok(auditRow?.usage.includes("子主有变化"));
const unknownLines = appendixFor(unknown).find((row) => row.id === "kp-cusps")?.lines.join("\n") ?? "";
assert.match(unknownLines, /出生时间误差未知,子主是否稳定无法判断/);
assert.match(unknownLines, /前后各 30 分钟/);
assert.deepEqual(appendixFor(blocked).find((row) => row.id === "kp-cusps")?.lines,
["出生地的 Placidus 宫头无法计算。"]);
assert.ok(!appendixFor(undefined).some((row) => row.id === "kp-cusps"));
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 }));
assert.ok(html.includes(block.title));
assert.match(html, /展开其余/);
});
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
"""Natal KP cusps and minute-resolution birth-window observations for the appendix."""
from __future__ import annotations
from datetime import timedelta, timezone
from math import isfinite
from . import package as ap
PRODUCER = "kp_system.calc_kp_analysis"
METHOD_NOTE = "KP 宫头制,与正文的整宫制是不同方法"
UNKNOWN_NOTE = "出生时间误差未知,子主是否稳定无法判断"
NO_CUSPS = "出生地的 Placidus 宫头无法计算。"
def _cusps(calculator, ctx, jd):
# The rectification KP helper uses the same Swiss Ephemeris Placidus
# calculation and sidereal offset, but rounds the returned longitude.
values = calculator.calculate_cusps(0.0, 0.0, "placidus", jd, ctx.birth["lat"], ctx.birth["lon"])
if len(values) != 12 or not all(isfinite(value) and 0 <= value < 360 for value in values):
raise ValueError("invalid Placidus cusps")
return values
def _runs(samples, birth_utc, zone):
intervals = []
for offset, lord in samples:
at = ap.instant(birth_utc + timedelta(minutes=offset), zone)
if intervals and intervals[-1]["sub_lord"] == lord:
intervals[-1]["last_sample"] = at
else:
intervals.append({"sub_lord": lord, "first_sample": at, "last_sample": at})
return intervals
def _calculate(ctx, natal, window, domains):
if abs(ctx.birth["lat"]) >= 66:
return ap.block("blocked", PRODUCER, reason=NO_CUSPS)
lons = natal.get("longitudes") or {}
planets = ap.NATAL_POINTS[1:]
if natal.get("status") != "computed" or any(name not in lons or not isfinite(lons[name]) for name in planets):
return ap.block("blocked", PRODUCER, reason="缺少本命行星经度,无法计算 KP 宫位的代表星。")
kp = ap._module("kp_system")
ayanamsa = ap._module("ayanamsa_utils")
lock = ap._module("domain_calculation_service").swiss_ephemeris_lock
calculator = ap._module("bhava_chalit").BhavaChalitCalculator()
houses = tuple(dict.fromkeys(house for group in domains.values() for house in group))
known = window is not None and window["known"]
before, after = (window["before_minutes"], window["after_minutes"]) if known else (30, 30)
zone = timezone(timedelta(hours=ctx.tz_hours))
samples = {house: [] for house in houses}
birth_jd = ap._jd_from_utc(ctx.birth_utc)
with lock(), ayanamsa.temporary_ayanamsa(ctx.ayanamsa):
natal_cusps = _cusps(calculator, ctx, birth_jd)
for offset in range(-before, after + 1):
moment = ctx.birth_utc + timedelta(minutes=offset)
cusps = _cusps(calculator, ctx, ap._jd_from_utc(moment)) if offset else natal_cusps
for house in houses:
samples[house].append((offset, kp.get_kp_lords(cusps[house - 1])["sub_lord"]))
positions = {
name: {"sign": ap.SIGNS[ap._sign_idx(lons[name])], "degree": lons[name] % 30,
"house": calculator._planet_bhava_from_cusps(lons[name], natal_cusps)}
for name in planets
}
result = kp.calc_kp_analysis(
positions, ap.SIGNS[ap._sign_idx(natal_cusps[0])], house_cusps=natal_cusps, house_occupancy_basis="cusp_to_cusp",
)
if result.get("house_basis") != "explicit_cusps":
return ap.block("blocked", PRODUCER, reason=NO_CUSPS)
rows = {}
changed = []
for house in houses:
intervals = _runs(samples[house], ctx.birth_utc, zone)
if len(intervals) > 1:
changed.append(house)
stability = "range_unknown"
if known:
stability = "boundary_sensitive" if len(intervals) > 1 else "stable_in_window"
rows[house] = {"house": house, **result["houses"][house], "stability": stability, "branches": intervals}
return ap.block(
"computed", PRODUCER, profile="sidereal_placidus_cusp_to_cusp",
house_basis=result["house_basis"], house_occupancy_basis=result["house_occupancy_basis"],
house_system="placidus", ayanamsa=ctx.ayanamsa, node_mode=ctx.node_mode, method_note=METHOD_NOTE,
precision={"source": "declared" if known else "reference",
"before_minutes": before, "after_minutes": after, "resolution_minutes": 1,
"status": "checked" if known else "range_unknown",
"note": None if known else UNKNOWN_NOTE, "changing_houses": sorted(changed)},
domains={domain: [rows[house] for house in group] for domain, group in domains.items()},
)
def build(ctx, natal, window, domains):
try:
return _calculate(ctx, natal, window, domains)
except Exception: # noqa: BLE001 - a failed cusp sample blocks the appendix and no proxy can replace it.
return ap.block("blocked", PRODUCER, reason=NO_CUSPS)
+3 -1
View File
@@ -523,7 +523,8 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full",
slow = block("blocked", "jyotish_engine._calc_sidereal_planets_for_jd", reason=reason)
transit_context = block("blocked", "annual_service.package._transit_context", reason=reason)
tithi = []
from .report_facts import ANNUAL_TRANSIT_CHECK_HOUSES
from .kp_appendix import build as build_kp_appendix
from .report_facts import ANNUAL_TRANSIT_CHECK_HOUSES, HOUSE_DOMAINS
from .transit_checks import build as build_transit_checks
package = {
@@ -537,6 +538,7 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full",
"slow_transits": slow,
"transit_context": transit_context,
"independent_annual_views": {
"kp_cusps": build_kp_appendix(ctx, natal, birth_window, HOUSE_DOMAINS),
"transit_checks": build_transit_checks(ctx, natal, slow, range_start, range_end, ANNUAL_TRANSIT_CHECK_HOUSES),
"tithi_pravesha": block(
"computed" if tithi else "blocked",
+13 -2
View File
@@ -1837,8 +1837,19 @@ def _technique_audit_axes(package: dict, natal: dict) -> list[dict[str, Any]]:
raw_path=f"package.independent_annual_views.transit_checks.{key}" if field else None,
profile=field.get("profile"), interpretation="observation_only",
))
kp = annual_views.get("kp_cusps") or {}
kp_precision = kp.get("precision") or {}
changed = "、".join(str(house) for house in kp_precision.get("changing_houses", ()))
kp_note = kp_precision.get("note") or "已计算真实宫头。"
if changed:
source = "参考取样内" if kp_precision.get("source") == "reference" else "出生时间范围内"
kp_note = f"{kp_note.rstrip('。')}。{source}第 {changed} 宫的子主有变化。"
rows.append(_audit_axes_row(
"KP / Sub-lord", kp.get("status", "blocked"), kp.get("reason") or kp_note,
raw_path="package.independent_annual_views.kp_cusps" if kp else None,
profile=kp.get("profile"), interpretation="observation_only",
))
for name, note in (
("KP / Sub-lord", "年运未计算 KP 宫头与子主"),
("Transit LL / 7L", "未单独追踪上升主与七宫主的行运窗口"),
("Bhava Chalit", "年运本命宫位采用整宫,未另算 Bhava Chalit"),
("Nakshatra Overlay", "本命月宿字段已保存,未运行叠加核对"),
@@ -2201,7 +2212,7 @@ def _page_appendix(package: dict, natal: dict) -> dict:
})
views = package.get("independent_annual_views") or {}
return {"years": years, "tithi_pravesha": (views.get("tithi_pravesha") or {}).get("years") or [],
"transit_checks": views.get("transit_checks") or {}, **{
"transit_checks": views.get("transit_checks") or {}, "kp_cusps": views.get("kp_cusps") or {}, **{
key: views[key] for key in ("yoga_pravesha", "nakshatra_pravesha", "satabdika") if key in views
}}
+131
View File
@@ -0,0 +1,131 @@
"""Annual KP uses unrounded Placidus cusps and keeps its own birth-window evidence."""
from datetime import datetime, timedelta
import json
from pathlib import Path
import subprocess
import pytest
from tests.test_annual_report_facts import _body, _facts, annual_api
from tests.test_annual_audit_axes_20261112 import public_facts
from annual_service import kp_appendix, package as ap
from annual_service.report_facts import HOUSE_DOMAINS
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
TSX = FRONTEND / "node_modules/tsx/dist/cli.mjs"
WINDOW = {"known": True, "before_minutes": 1, "after_minutes": 1}
def _kp(body):
package = annual_api.compute_annual_package(body)["package"]
return package["independent_annual_views"]["kp_cusps"]
def test_public_chart_uses_real_rectification_cusps_and_cusp_to_cusp_significators(public_facts):
field = public_facts["page_appendix"]["kp_cusps"]
assert field["house_basis"] == "explicit_cusps"
assert field["house_occupancy_basis"] == "cusp_to_cusp"
assert field["house_system"] == "placidus"
params = public_facts["parameters"]
birth = params["birth"]
at = datetime.fromisoformat(birth["instant"]["utc"])
rectification = ap._module("chart_choice.astrologer_engine")
native = rectification.kp_placidus_cusps(
{"birth_info": {"julian_day": ap._jd_from_utc(at)}},
birth["latitude"], birth["longitude"], field["ayanamsa"],
)
assert field["ayanamsa"] == params["ayanamsa"]
for domain, houses in HOUSE_DOMAINS.items():
rows = field["domains"][domain]
assert tuple(row["house"] for row in rows) == houses
for row in rows:
cusp = native[row["house"]]
assert row["cusp_longitude"] == pytest.approx(cusp["cusp_degree"], abs=0.000051)
assert row["kp_lords"]["sub_lord"] == cusp["sub_lord"]
assert row["kp_lords"]["nakshatra_lord"] == cusp["nakshatra_lord"]
assert set(row["significators"]) == {"A", "B", "C", "D"}
occupied = row["significators"]["B"]
assert set(occupied) <= set(ap.NATAL_POINTS[1:])
assert row["stability"] == "range_unknown"
audit = next(r for r in public_facts["technique_audit_axes"] if r["technique"] == "KP / Sub-lord")
assert (audit["calculation_status"], audit["interpretation_status"]) == ("executed", "observation_only")
assert "参考取样内" in audit["note"] and "子主有变化" in audit["note"]
def test_declared_window_keeps_every_minute_and_separates_stable_and_crossing_houses():
field = _kp(_body(birth_window=WINDOW))
assert field["precision"]["source"] == "declared"
stable, changing = field["domains"]["career"]
assert stable["house"] == 10 and stable["stability"] == "stable_in_window"
assert len(stable["branches"]) == 1
assert changing["house"] == 6 and changing["stability"] == "boundary_sensitive"
assert len({band["sub_lord"] for band in changing["branches"]}) > 1
def to_time(edge):
return datetime.fromisoformat(edge["local"])
assert to_time(stable["branches"][0]["last_sample"]) - to_time(stable["branches"][0]["first_sample"]) == timedelta(minutes=2)
for left, right in zip(changing["branches"], changing["branches"][1:]):
assert to_time(right["first_sample"]) - to_time(left["last_sample"]) == timedelta(minutes=1)
assert left["sub_lord"] != right["sub_lord"]
assert changing["branches"][0]["first_sample"] == stable["branches"][0]["first_sample"]
assert changing["branches"][-1]["last_sample"] == stable["branches"][0]["last_sample"]
def test_unknown_window_preserves_uncertainty_and_labels_reference_samples():
field = _kp(_body())
precision = field["precision"]
assert (precision["status"], precision["source"]) == ("range_unknown", "reference")
assert (precision["before_minutes"], precision["after_minutes"]) == (30, 30)
assert precision["note"] == kp_appendix.UNKNOWN_NOTE
assert all(row["stability"] == "range_unknown" for rows in field["domains"].values() for row in rows)
def test_missing_placidus_blocks_without_whole_sign_proxy():
facts = _facts(lat=67.0, timezone_id=None)
field = facts["page_appendix"]["kp_cusps"]
assert field["status"] == "blocked" and "Placidus" in field["reason"]
assert "domains" not in field and "house_basis" not in field
audit = next(r for r in facts["technique_audit_axes"] if r["technique"] == "KP / Sub-lord")
assert audit["calculation_status"] == "blocked"
def _frontend_fixture():
known = _facts(birth_window=WINDOW)
kp = known["page_appendix"]["kp_cusps"]
audit = next(row for row in known["technique_audit_axes"] if row["technique"] == "KP / Sub-lord")
unknown = _facts()["page_appendix"]["kp_cusps"]
blocked = _facts(lat=67.0, timezone_id=None)["page_appendix"]["kp_cusps"]
return {"known": kp, "audit": audit, "unknown": {**unknown, "domains": {"career": unknown["domains"]["career"]}},
"blocked": blocked}
def test_frontend_fixture_comes_from_native_annual_facts():
fixture = FRONTEND / "tests/fixtures/annual-kp-20261114.json"
assert json.loads(fixture.read_bytes()) == _frontend_fixture()
@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies are required for the writer contract")
def test_kp_does_not_enter_any_of_ten_writer_evidence_sections(public_facts):
script = """
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 current = JSON.parse(readFileSync(0, "utf8"));
const previous = structuredClone(current);
delete previous.page_appendix.kp_cusps;
Object.assign(previous.technique_audit_axes.find(row => row.technique === "KP / Sub-lord"), {
calculation_status: "not_built", interpretation_status: "not_used", raw_path: null, profile: null,
note: "年运未计算 KP 宫头与子主",
});
const hashes = facts => buildAnnualPlan(facts, situation).map(entry => {
const evidence = JSON.stringify(sectionEvidence(facts, entry, situation));
assert.doesNotMatch(evidence, /kp_cusps|sidereal_placidus_cusp_to_cusp|stable_in_window/);
return [entry.id, createHash("sha256").update(evidence).digest("hex")];
});
assert.equal(hashes(current).length, 10);
assert.deepEqual(hashes(current), hashes(previous));
"""
subprocess.run(["node", str(TSX), "-e", script], cwd=FRONTEND, check=True, timeout=45,
input=json.dumps(public_facts, ensure_ascii=False).encode("utf-8"))