fix: astrologer round-5 rulings 2/3/4/5/10/13 (BUG-1746..1752)

- Functional default = PVR Table 30 (functional_profile=pvr_table_30); the former
  product table is bphs_exception_profile, scored only when named (rectification
  names it, scores unchanged). profile_divergence shows both roles side by side;
  movable-lagna Moon by the table's paksha note; dual-lordship note; prior rulings
  BUG-1570/1590/#166 Q4 checked against Table 30.
- Shadbala: six open items labelled parameter_sensitive; every option combination
  computed; only planets whose pass/fail flips get minimum_check=parameter_sensitive.
- Vimshottari AD/PD rows carry parent_period_start / visible_period_start; the
  consult card cuts the balance MD from its full parent and clips at birth.
- KP DBA: full first parent, clipped at birth (no balance rescale).
- Mudda: balance fields + balance_method (angle default, time comparison apart),
  nominal_year_days, her crossing wording; Harsha combined blocked reason lists items.
- D30 _meta describes the unequal parts (as her r40).
- Golden recaptured with TTL=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
This commit is contained in:
2026-11-08 11:57:22 +08:00
co-authored by Claude Opus 5.5
parent 05a11cefbf
commit 5d64f71b81
47 changed files with 1355 additions and 178 deletions
+79
View File
@@ -23601,3 +23601,82 @@
- 验证:`consult-card-calendar-narayana-20261108.test.ts`;`consultation-thinking-plan.test.ts` 更新(原值 / 新值 / 原因)。
- 关联记录:BUG-1728
- 修复版本:本地分支 `codex/phase2-chat-merge-1107`
## BUG-1746 | 功能吉凶默认用的是三方宫优先的产品表,不是占星师裁定的 PVR 表 30
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 3)
- 影响面:`scripts/functional_benefics.py`(所有读功能吉凶的地方:数据卡、聊天、报告、年运、合盘、瑜伽引擎、星盘页功能角色表、事业分析);`functional_layer.py`、`mcp_server.py`、`jyotish_engine.py`、`jyotish_api_server.py`、`unified_consultation_orchestrator.py`、`yoga_engine.py`、`career_analysis.py`、`personal_calendar.py`、`annual_service/report_facts.py`、`chart_view_points/*`、前端 `consultation-evidence-card.ts`、`report-fact-tables.ts`、`consultation-workflow.ts`(字段白名单)。
- 现象:BUG-1724 只给 PVR 表 30 加了参考档,打分与聊天仍用产品表;12 个上升中 6 格不同(白羊火星、双子水星、巨蟹土星、天秤金星、天蝎太阳、双鱼木星)。
- 根因:产品表是唯一的默认档,PVR 表只供对照。
- 修复:`derive_functional_benefic_malefic(..., functional_profile="pvr_table_30")` 为默认;原产品表另存为 `bphs_exception_profile`,只在调用方明确选中时参与打分。两档不同的格写进 `profile_divergence`(并列,不合并,`other_profile_scored=false`),卡上与报告表同时显示两档。表 30 不列动星座上升(白羊、天秤、摩羯)的月亮:按表下注释,盈月管角宫 → 功能凶,亏月 → 功能中性(`movable_lagna_moon`);调用方给 Sun / Moon 经度才算盈亏,未给时月亮不分类、不猜。双宫主综合判断写进 `dual_lordship_note`。旧裁定逐格对照(`prior_rulings`):BUG-1570 白羊 / 天蝎金星凶、BUG-1590 双子 / 处女土星混合与表 30 一致;#166 Q4 双子水星「上升主,支持」与表 30(中性)冲突,由分档解决(默认中性,产品表档保留吉,并列显示)。生时校正打分(`active_rectification_event_engine`、`chart_choice/astrologer_engine`、`research/precision_gate_lib`)明确选中 `bphs_exception_profile`,分数不变;三个文件在冻结身份里,哈希变了,需按 ERR-110 重冻或产品决定是否把校正也改成 PVR。
- 影响范围(实测):`tests/celebrity_cases.json` 16 张公开盘中 10 张至少一颗星功能类别变;证据卡 golden 3 张盘中 2 张变(摩羯上升亏月月亮凶 → 中性;天蝎上升太阳吉 → 中性),其中一张盘父母(母)、婚姻、健康三条受冲依据各少一条「月亮是功能凶星」。
- 验证:`tests/test_astrologer_rulings_round5_20261108.py`(表 30 逐格独立写出、6 格、月亮注释、旧裁定、校正明确选档);产品表测试改为明确选档(原值 / 新值 / 原因);`consult-upstream-functional-v2-20261002.test.ts`、`consult-pressure-lines-20261104.test.ts` 更新;golden 以 TTL=0 重采。
- 防复发:默认档与产品表档各有独立测试;校正调用必须写明档名(测试按调用数核对)。
- 关联记录:BUG-1724、BUG-1570、BUG-1590、BUG-1158、BUG-1181
- 修复版本:本地分支 `codex/functional-pvr-1108`
## BUG-1747 | Shadbala 达标与否随未定参数翻转,聊天仍据此下好坏结论
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 4)
- 影响面:新文件 `scripts/shadbala_service/sensitivity.py`(`shadbala.py` 在冻结身份里,未改);`shadbala_display.py`、`shadbala_service/api.py`、`jyotish_engine.py`(cmd_shadbala、全报告第 10 步、健康种子、evidence_snapshot 排名)、`jyotish_api_server.py`(星盘页摘要)、`pl9_reader_export.py`;卡上 `shadbala` 段与 `shadbalaColumns`;`references/product_runtime_contract.md` H 节。
- 现象:她列的六个未定项(Saptavargaja/Moolatrikona 范围、太阳 Ayana 是否重复、月亮 Chesta 是否等于 Paksha、Drik 连续或整数相位、特殊相照替换或另加、水星/木星相照 1/4、1 或 1.25 倍)会改变总 Rupa,但卡上只写「达到 / 未达到 BPHS 最低要求」。
- 修复:六项都标 `parameter_sensitive` 并写明未定问题;对每颗星把这些选项的全部组合实算一遍(先复现默认的 Saptavargaja 与 Drik 值,复现不了就标 `sensitivity_not_verified`),只有达标与否真的会翻转的星才 `minimum_check = parameter_sensitive`,并给出 `minimal_flip_sets` 与 `minimum_open_questions`;默认数值不变。合同加规则:这类星聊天与报告不得据达标与否下好坏结论。
- 实测(3 张公开名人盘):每盘各 1–2 颗星翻转(其中一盘正是木星,单靠水星/木星相照倍数就翻转),其余星不变。
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_shadbala_flip_is_computed_not_labelled`、`::test_display_turns_a_flipping_planet_into_parameter_sensitive`;`consult-evidence-card-v2-20260927.test.ts` 更新(原值 / 新值 / 原因)。
- 关联记录:BUG-1622、BUG-1623、BUG-1581、BUG-1582、BUG-1208
- 修复版本:本地分支 `codex/functional-pvr-1108`
## BUG-1748 | Vimshottari AD/PD 行没有分开「完整父段起点」与「出生后可见起点」
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 5)
- 影响面:`jyotish_engine.cmd_dasha`(MD/AD/PD 行)、`chart_dasha/birth_moment.antardashas`、`jyotish_api_server._consultation_dasha_sub_periods`(咨询卡的 AD 边界)、卡上 `timing.vimshottari`。
- 修复:每行带 `parent_period_start`(在完整父段时间轴上的原始起点)与 `visible_period_start`(出生后可见起点);出生裁剪只改可见起点,边界不动。咨询卡路径原先对平衡大运不裁剪(出生前的 AD 照列),现在从完整父段切好后按出生时刻裁剪,被裁的行在卡上同时带两字段。核查:咨询证据卡只摘取引擎日期,不重算。
- 另见:星盘页(`frontend/src/lib/chart-view-mapper.ts`、`dasha-span.ts`)仍在前端按年数比例自己切 AD/PD,不是卡片层,本单未改,记为 BUG-1752。
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_birth_moment_rows_keep_parent_start_when_clipped`。
- 关联记录:BUG-1675、BUG-1718、BUG-1719
- 修复版本:本地分支 `codex/functional-pvr-1108`
## BUG-1749 | KP DBA 首段大运按余额比例缩放,父段、子段与后续边界全被改变
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 10)
- 影响面:`scripts/kp_system.calc_kp_dba_timeline`(KP 婚恋择期包 `kp_dba_timeline`)。
- 根因:首段 MD 长度取 balance × 全长,再在缩放后的 MD 里按比例切 AD/PD,等于把首个父段重新缩放成新的完整时间轴。
- 修复:先生成完整父段(起点 = 出生 − 已过部分),AD/PD 从完整父段切,再按出生时刻裁剪:出生前结束的行丢掉,跨出生的行可见起点裁到出生,`parent_period_start` 留原值;输出 `first_parent_period_start`、`first_segment_rule`。
- 3 张公开名人盘的边界变化:MD 边界(含首段 MD 终点与之后所有 MD)不变;只有首段 MD 内的 AD/PD 变:三盘首段 MD 的 81 个 PD 行分别剩 5、69、63 个真实行(另 76、12、18 个在出生前、删去),保留的行边界全部改变(最大 299、361、1313 天);出生时所在 AD 由「与 MD 同名的第一个 AD」改为真实所在 AD。
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_kp_first_segment_is_the_full_parent_clipped_at_birth`;`tests/test_marriage_precision_extensions.py` 照旧通过。
- 关联记录:BUG-1748
- 修复版本:本地分支 `codex/functional-pvr-1108`
## BUG-1750 | Mudda 余额没有写明算法;名义年字段名不明确;跨回归段文字不是她的原话;Harsha blocked 原因未列待确认项
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 2)
- 影响面:`scripts/tajika_mudda.py`、`solar_return.py`、`jyotish_engine.cmd_tajika`、`annual_service/package.py`、`consultation_native_layers.py`、`tajika.calc_tajika_strength_layers`。
- 修复:余额 `balance` 写明起运星、已过比例、剩余比例与 `balance_method`;默认按月宿角度(`nakshatra_arc_angle`,日期照旧由它决定),实际经过时间(`actual_elapsed_time`,用出生时刻与月亮进出月宿的时刻实算)放在 `balance_time_comparison`,`used_for_dates=false`,两者不合并;`nominal_year_days = 365.25` 明确写出并注明不等于真实回归间隔。跨下一次回归的段落文字改为「该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至某日)」。Harsha 合分保持 blocked,`combined_strength_blocked_until` 与原因文字写明四个待确认项(四项分量定义、日间/夜间条件、最大值是否为 40、20+20 与 15+20 是否同一公式)。Harsha 单项分数照旧显示。
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_mudda_balance_fields_and_nominal_year`、`::test_harsha_combined_blocked_reason_names_the_open_items`;`tests/test_annual_coverage_bug1722.py`、`consult-card-calendar-narayana-20261108.test.ts` 更新(原值 / 新值 / 原因)。
- 关联记录:BUG-1722、BUG-1216
- 修复版本:本地分支 `codex/functional-pvr-1108`
## BUG-1751 | D30 元数据 part_size = 1.0,误导为 30 等份
- 状态:fixed-pending-verify(本地分支 `codex/functional-pvr-1108`,未推送)
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 13 第 4 步;对照她的 r40 `varga.py`)
- 影响面:`scripts/varga.calc_all_vargas` 的 `D30_Trimsamsa._meta`(只改元数据;落座与座内度数不变;该文件在冻结身份里,哈希变了)。
- 修复:同她的 r40:`part_size=None`,加 `part_sizes`(奇 5/5/8/7/5、偶 5/7/8/5/5)、`source_segments`(含落座星座)、`calculation_method`、`degree_basis`、`mapping_source`。
- 验证:`tests/test_astrologer_rulings_round5_20261108.py::test_d30_meta_describes_unequal_parts`(元数据所写区间与实际落座一致)。
- 关联记录:BUG-1589、BUG-1560
- 修复版本:本地分支 `codex/functional-pvr-1108`
## BUG-1752 | 星盘页前端自己按年数比例切 Vimshottari AD/PD
- 状态:investigating(只记录,未改)
- 首次发现 / 最近更新:2026-11-08(核查第五轮裁定 5「日期只来自原生 Dasha 引擎」时发现)
- 影响面:`frontend/src/lib/chart-view-mapper.ts`(`addYears` 365.25 天)、`frontend/src/lib/dasha-span.ts`(`vimshottariSplit`),星盘页大运表与深读。
- 现象:星盘页的 AD/PD 由前端用 MD 起止日期按年数比例重算,不是引擎给的边界;咨询证据卡不受影响(只摘引擎值)。
- 待办:改为直接显示引擎 `antardasha_timeline` / `pratyantar_dasha_timeline`,并带 `parent_period_start` / `visible_period_start`。
- 关联记录:BUG-1748
- 修复版本:未修
+46 -3
View File
@@ -106,8 +106,13 @@ export const EVIDENCE_CARD_LABELS = {
arudhaColumns:
"sign:pada 所在星座;pada_house_from_lagna:pada 所在宫(从本命上升按整宫数);lord:pada 星座的主星;"
+ "lord_natal_house:该主星在本命盘所落的宫。pada 所在宫与主星落宫是两件事,写法:「A2 位于水瓶;其主星土星位于本命第 2 宫」",
/** 占星师 2026-11-08 第五轮裁定 3 (PVR §13.2 Table 30 and its notes). */
functionalProfileNote:
"功能吉凶默认按 PVR 表 30(pvr_table_30);profile_divergence 列出与原产品表(bphs_exception_profile)不同的格,"
+ "两档并列、不合并,另一档不参与判断;管两个星座的行星要把两宫综合判断(PVR §13.2)",
shadbalaColumns:
"rupas:六力总 Rupa;minimum_check:是否达到 BPHS 最低要求;level:网站按达标比(总 Rupa ÷ 最低要求)分档,"
"rupas:六力总 Rupa;minimum_check:是否达到 BPHS 最低要求;minimum_check=parameter_sensitive:达标与否随未定 Shadbala 参数翻转"
+ "(minimum_open_questions 列出未定项),不得据此下好坏结论;level:网站按达标比(总 Rupa ÷ 最低要求)分档,"
+ "只说力量够不够用,不是吉凶,也不是 Ishta Phala;「强」不等于对用户有利,好坏看功能性吉凶与所主宫位",
/**
* Per-domain route status (产品 2026-11-07 占星师方案:回答模型拿到完整数据):
@@ -907,7 +912,38 @@ const PLANET_ROW_KEYS = ["sign", "house", "degree_in_sign", "nakshatra", "retrog
const FUNCTIONAL_KEYS = [
"functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses",
] as const;
/**
* 占星师 2026-11-08 第五轮裁定 3: the lists above are the default profile
* (pvr_table_30). Where the former product table (bphs_exception_profile)
* differs, each cell is [planet, default role, other role], side by side and
* never merged; the other profile does not score. The movable-lagna Moon that
* Table 30 leaves out carries the role its own paksha note gives, or none.
*/
function functionalProfileFields(value: unknown): JsonRecord {
const source = record(value);
const out: JsonRecord = {};
const profile = source.functional_profile;
if (typeof profile !== "string") return out;
out.functional_profile = profile;
const divergence = record(source.profile_divergence);
const other = divergence.other_profile;
const cells = Array.isArray(divergence.cells) ? divergence.cells.map(record) : [];
if (typeof other === "string" && cells.length) {
out.profile_divergence = {
columns: ["planet", profile, other],
other_profile_scored: false,
cells: cells.map((cell) => [cell.planet, cell[profile], cell[other]] as Json[]),
};
}
const moon = record(source.movable_lagna_moon);
if (typeof moon.status === "string") {
out.movable_lagna_moon = { status: moon.status, moon_paksha: copy(moon.moon_paksha) ?? null, role: copy(moon.role) ?? null };
}
out.functional_profile_note = EVIDENCE_CARD_LABELS.functionalProfileNote;
return out;
}
const PERIOD_KEYS = ["lord", "start", "end"] as const;
const CLIPPED_PERIOD_KEYS = ["parent_period_start", "visible_period_start"] as const;
// BUG-1215: AD / PD drop `years` (it is end_age − start_age) so the legacy
// algorithm label fits the 12,000-character card budget.
const NARAYANA_SUB_PERIOD_KEYS = ["sign", "lord", "start_age", "end_age"] as const;
@@ -995,7 +1031,8 @@ function arudhaHouses(base: EvidenceCard["base"], picked: JsonRecord): JsonRecor
* `min_required` / `meets_minimum` / `level_source` restate the same two facts
* and are left off the card to keep it within its size budget.
*/
const SHADBALA_CARD_KEYS = ["rupas", "minimum_check", "level"] as const;
// 占星师 2026-11-08 第五轮裁定 4: `minimum_open_questions` only when minimum_check is parameter_sensitive.
const SHADBALA_CARD_KEYS = ["rupas", "minimum_check", "minimum_open_questions", "level"] as const;
function shadbalaCardRows(shadbala: Json): Json {
const rows = record(shadbala);
@@ -1071,6 +1108,7 @@ function baseNatal(natal: Record<string, unknown>, gaps: string[]) {
}
const functional = pickKeys(natal.functional_benefic_malefic, FUNCTIONAL_KEYS);
if (!nonEmpty(functional)) gaps.push("base.functional_benefic_malefic");
else Object.assign(functional, functionalProfileFields(natal.functional_benefic_malefic));
const summary = record(natal.d9_summary);
const d9: JsonRecord = {};
if (typeof summary.lagna === "string") {
@@ -1289,7 +1327,8 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[], horizon: Ti
const vimshottari: JsonRecord = {};
const set = (key: string, value: unknown) => {
const picked = pickKeys(value, PERIOD_KEYS);
if (nonEmpty(picked)) vimshottari[key] = picked;
const clipped = pickKeys(value, CLIPPED_PERIOD_KEYS);
if (nonEmpty(picked)) vimshottari[key] = typeof clipped.parent_period_start === "string" ? { ...picked, ...clipped } : picked;
};
set("mahadasha", current.mahadasha);
set("antardasha", current.antardasha);
@@ -1303,6 +1342,10 @@ function baseTiming(timing: Record<string, unknown>, gaps: string[], horizon: Ti
const rows = sub.boundaries.map((row): Json => {
const picked = pickKeys(row, PERIOD_KEYS);
const tuple = PERIOD_KEYS.map((key) => picked[key]);
// 占星师 2026-11-08 第五轮裁定 5: a row clipped at birth keeps the engine's full parent start
// beside its visible start (both copied, never recomputed here).
const clipped = pickKeys(row, CLIPPED_PERIOD_KEYS);
if (typeof clipped.parent_period_start === "string") return { ...picked, ...clipped };
return tuple.every((value) => typeof value === "string") ? tuple as Json[] : picked;
}).filter((row) => Array.isArray(row) || nonEmpty(row as JsonRecord));
if (rows.length) vimshottari.antardashas_in_mahadasha = rows;
+17
View File
@@ -167,6 +167,23 @@ export function assembleReportFactTables(packet: unknown, language: ReportLangua
if (planet(name)) add(table, `${sourcePath}.${bucket}[${index}]`, [label, L.planet(name)]);
});
}
// 占星师 2026-11-08 第五轮裁定 3: the rows above are the default profile (PVR Table 30);
// where the former product table differs its role is shown beside it, never merged.
const divergence = record(raw.profile_divergence);
const other = typeof divergence.other_profile === "string" ? divergence.other_profile : null;
const roleLabel = (role: unknown) => (en
? { benefic: "benefic", malefic: "malefic", neutral: "neutral" }
: { benefic: "功能吉星", malefic: "功能凶星", neutral: "中性" })[String(role) as "benefic"] ?? (en ? "not classified" : "未分类");
if (other) {
list(divergence.cells).forEach((cell, index) => {
const row = record(cell);
if (!planet(row.planet)) return;
add(table, `${sourcePath}.profile_divergence.cells[${index}]`, [
en ? `${other} (comparison only, not scored)` : `${other}(只对照,不参与判断)`,
`${L.planet(row.planet)}:${roleLabel(row[other])}`,
]);
});
}
} else if (id === "avasthas") {
for (const [name, value] of entries(raw)) {
if (!planet(name) || isBlocked(value)) continue;
@@ -372,6 +372,9 @@ const natalFoundationKeys = new Set([
"components", "matrix", "matrixshape", "signs", "sources", "source", "method", "boundary",
"functionalbeneficmalefic", "functionalbenefics", "functionalmalefics", "functionalneutrals",
"yogakarakas", "ownedhouses", "ascendantsign", "effectonconfidence",
// 占星师 2026-11-08 第五轮裁定 3: the profile name and, where the two profiles differ, both roles.
"functionalprofile", "profiledivergence", "otherprofile", "otherprofilescored", "cells", "kind",
"pvrtable30", "bphsexceptionprofile", "movablelagnamoon", "moonpaksha", "role",
"yogas", "category", "arudhapadas", "padas", "a10", "a7", "ul", "al", "gulika", "mandi", "kakshya",
// Evidence card v2: the engine writes the Arudha Lagna (AL) as pada `A1`.
"a1",
@@ -384,6 +387,8 @@ const timingKeys = new Set([
"dasha", "dashaboundaries", "dashasubperiods", "narayanadasha", "status", "current", "next", "mahadasha",
"antardasha", "pratyantardasha", "currentdasha", "period", "dashaperiod", "periods", "timeline",
"boundaries", "boundarycount", "start", "end", "startyear", "endyear", "iscurrent",
// 占星师 2026-11-08 第五轮裁定 5: a row clipped at birth keeps its full parent start.
"parentperiodstart", "visibleperiodstart",
"activationdescription", "sign", "lord", "planet", "name", "strength", "score", "source",
"method", "confidence", "confidencecap", "summary", "conclusion", "fingerprint",
"charadasha", "transits", "sadesati", "triggers", "triggercount", "searchperiod", "window", "sequence", "durationyears",
@@ -46,7 +46,10 @@ test("BUG-1731: the calendar-year rule is in the runtime contract (her Q2)", ()
assert.match(block, /「今年」「这一年」按公历年(1 月 1 日至 12 月 31 日)理解,不悄悄改成生日年/);
assert.match(block, /timing\.annual_brief\.calendar_year/);
assert.match(block, /「未来十二个月」「接下来一年」时,按用户实际请求的起止日期/);
assert.match(block, /按上一年盘所用算法,该周期原始计算延伸至某日;本年盘报告只解释至下一次太阳回归。回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。/);
// 原值:/按上一年盘所用算法,该周期原始计算延伸至某日;本年盘报告只解释至下一次太阳回归。回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。/
// 新值:她的原话「该周期按原算法继续延伸,后续部分归入下一年盘解释」,原始延伸终点放括号里
// 原因:占星师 2026-11-08 第五轮裁定 2
assert.match(block, /该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至某日)/);
});
test("BUG-1732: the Narayana row carries the engine's step verdicts; unknown is never replaced by a guess", () => {
@@ -410,6 +410,15 @@ test("shadbala on the card: BPHS minimum check first, band labelled as the site'
const rows = section.shadbala as Record<string, Json>;
assert.deepEqual(Object.keys(rows).sort(), Object.keys(engine).sort(), chart.id);
for (const [planet, row] of Object.entries(rows)) {
// 原值:键恒为 ["rupas", "minimum_check", "level"],minimum_check 只取 达到 / 未达到。
// 新值:未定参数会翻转达标与否的星 minimum_check = parameter_sensitive,并多一个 minimum_open_questions;
// 其余星不变。原因:占星师 2026-11-08 第五轮裁定 4
if (row.minimum_check === "parameter_sensitive") {
assert.deepEqual(Object.keys(row), ["rupas", "minimum_check", "minimum_open_questions", "level"], `${chart.id} ${planet}`);
assert.ok((row.minimum_open_questions as string[]).length > 0);
assert.equal(engine[planet].minimum_check, "parameter_sensitive");
continue;
}
assert.deepEqual(Object.keys(row), ["rupas", "minimum_check", "level"], `${chart.id} ${planet}`);
assert.match(row.minimum_check as string, /^(达到|未达到) BPHS 最低要求$/);
assert.match(row.level as string, /(网站分档)$/);
@@ -52,11 +52,15 @@ const signalsOf = (line: string) => {
const EXPECTED: Record<string, Record<"mother" | "father" | "marriage" | "health", string[]>> = {
barack_obama: {
// Father left at two: 4 signals; mother close: 5. The count cannot tell them apart.
mother: ["4 宫被火星照", "4 宫主火星落在对 4 宫不利的位置", "4 宫主火星是功能凶星", "月亮是功能凶星", "D12 4 宫有土星"],
// 原值:mother / marriage / health 各含「月亮是功能凶星」(marriage 为「7 宫主月亮是功能凶星」),5 / 5 / 3 条。
// 新值:去掉这三条(4 / 4 / 2 条)。摩羯上升、亏月:PVR 表 30 不列动星座上升的月亮,按表下注释亏月管角宫为功能中性;
// 原产品表(bphs_exception_profile)判凶,只并列对照、不进判断。
// 原因:占星师 2026-11-08 第五轮裁定 3(默认 PVR 表 30)
mother: ["4 宫被火星照", "4 宫主火星落在对 4 宫不利的位置", "4 宫主火星是功能凶星", "D12 4 宫有土星"],
father: ["9 宫有火星", "9 宫主水星被土星照", "9 宫主水星燃烧", "太阳被土星照"],
marriage: ["7 宫被土星照", "7 宫主月亮是功能凶星", "金星落 6 宫", "伴侣代表星火星是功能凶星", "D9 7 宫有土星"],
// Health control (no major illness): still three. The count cannot tell wear from illness.
health: ["1 宫有土星", "太阳被土星照", "月亮是功能凶星"],
marriage: ["7 宫被土星照", "金星落 6 宫", "伴侣代表星火星是功能凶星", "D9 7 宫有土星"],
// Health control (no major illness): two after the profile change. The count cannot tell wear from illness.
health: ["1 宫有土星", "太阳被土星照"],
},
elizabeth_taylor: {
mother: ["4 宫有火星", "4 宫主土星落在对 4 宫不利的位置", "4 宫主土星是功能凶星", "月亮被土星照"],
@@ -45,9 +45,12 @@ test("goldens carry functional profile v2 from the real engine (BUG-1158)", () =
// 新值:"bphs_ch34_general_with_sign_exceptions_v3_166q4"(上游 v3 表 + #166 Q4 双子 / 处女土星混合)
// 原因:三方对照 2026-11-07:产品侧实现缺陷——标签写 v2,实际用的表不是 v2;golden 重新捕获
// 名人回测 golden 未重新捕获(重捕获会带进之后新增的咨询层,卡片超出 20 000 预算,与本单无关),仍是旧标签。
// 原值(2):本 golden 三盘为 "bphs_ch34_general_with_sign_exceptions_v3_166q4"
// 新值(2):"pvr_integrated_approach_13_2_table_30"(functional_profile = pvr_table_30);名人回测 golden 仍未重捕获
// 原因(2):占星师 2026-11-08 第五轮裁定 3(默认 PVR 表 30);golden 以 TTL=0 重新捕获
layers.forEach((layer, index) => assert.equal(
layer.profile,
index < golden.charts.length ? "bphs_ch34_general_with_sign_exceptions_v3_166q4" : "bphs_ch34_general_with_sign_exceptions_v2",
index < golden.charts.length ? "pvr_integrated_approach_13_2_table_30" : "bphs_ch34_general_with_sign_exceptions_v2",
));
// Scorpio (Taylor): explicit exceptions Mars mixed, Sun yoga-supporting.
// 原值:functional_malefics ["Mercury", "Saturn"],functional_neutrals ["Mars", "Venus"](金星 7 / 12 宫主不因 12 宫单独判凶)
@@ -55,10 +58,16 @@ test("goldens carry functional profile v2 from the real engine (BUG-1158)", () =
// 原因:BUG-1570。BPHS 34.35 明文:天蝎上升的金星为凶;引擎补上这条例外,捕获 golden 2026-11-04 重生成
const taylor = card(golden.charts.find((chart) => chart.id === "elizabeth_taylor")!.workflow, "career");
const functional = taylor.base.natal.functional_benefic_malefic as Json;
assert.deepEqual(functional.functional_benefics, ["Jupiter", "Moon", "Sun"]);
// 原值(2):functional_benefics ["Jupiter", "Moon", "Sun"],functional_neutrals ["Mars"]
// 新值(2):["Jupiter", "Moon"],["Mars", "Sun"];太阳在原产品表为吉,两档并列在 profile_divergence
// 原因(2):占星师 2026-11-08 第五轮裁定 3(PVR 表 30 天蝎上升太阳为功能中性)
assert.deepEqual(functional.functional_benefics, ["Jupiter", "Moon"]);
assert.deepEqual(functional.functional_malefics, ["Mercury", "Saturn", "Venus"]);
assert.deepEqual(functional.functional_neutrals, ["Mars"]);
assert.deepEqual(functional.functional_neutrals, ["Mars", "Sun"]);
assert.deepEqual(functional.yogakarakas, []);
assert.equal(functional.functional_profile, "pvr_table_30");
assert.deepEqual((functional.profile_divergence as Json).cells, [["Sun", "neutral", "benefic"]]);
assert.equal((functional.profile_divergence as Json).other_profile_scored, false);
// Virgo (Jobs): Jupiter challenging by exception, Sun (sole 12th lord) neutral.
// 原值:functional_neutrals ["Sun"];新值:["Saturn", "Sun"];原因:BUG-1590(#166 第 4 条)处女上升土星(5、6 宫主)裁定为混合,
// 捕获 golden 2026-11-04 按 staging 013f8895 重生成。
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1841,7 +1841,8 @@ def _derive_functional_benefic_malefic(modules: Dict[str, Any]) -> Dict[str, Any
chart = _safe_get(modules, "chart")
ascendant = _safe_get(chart, "ascendant") if isinstance(chart, dict) else None
from functional_layer import functional_layer_for_ascendant
return functional_layer_for_ascendant(ascendant)
planets = _safe_get(chart, "planets") if isinstance(chart, dict) else None
return functional_layer_for_ascendant(ascendant, planets)
def _derive_synastry_relationship_support(modules: Dict[str, Any]) -> Dict[str, Any]:
+9 -1
View File
@@ -20,7 +20,7 @@ SKILL.md 是占星师 skill 的原文(上游 732642856/yinduzhanxing),方
<!-- 占星师 2026-11-07 裁定(她的 Q5 之一),不是产品自己的规则。 -->
- 外部验证(MEVG / Global Web Evidence)与真实案例校准:她的 router 在一处把全网采集写成 optional,但 AGENTS 与 MEVG 门控要求「执行或明确标为 blocked」。本产品没有执行它们:照实写「本轮未执行」,审计表这两行写 blocked,绝不因为「optional」就当作已完成或略去。
<!-- 产品规则,不是占星师的方法:A-08 「今年」按公历年、两张年盘在回归处分段(依据占星师 2026-11-07 Q2 裁定;字段由产品送到卡上,BUG-1731)。 -->
- 「今年」「这一年」按公历年(1 月 1 日至 12 月 31 日)理解,不悄悄改成生日年:公历年跨两张 Tajika 年盘,在太阳回归日分开。证据卡 `timing.annual_brief.calendar_year` 给出 `split_at_return`(分界的回归日)和两个 `tajika_years`(各自的 `coverage` 覆盖区间与落在本公历年的 `calendar_part`;`calendar_part.end` 是下一段的起点,不含当天)。回答时两段分开引用:回归日前按上一年盘,回归日后按本年盘,并写明分界日期;`role` 为 `annual_chart_not_in_this_card` 的那一年本轮只有区间、没有年盘内容,写「该年盘本轮未交付」,不拿本年盘的 Muntha、年主或 Mudda 顶替。用户说「未来十二个月」「接下来一年」时,按用户实际请求的起止日期(从本轮当前日期起算)写,不改成公历年或生日年。某个 Mudda 段越过下一次回归时,照卡上 `mudda_view.crossing_segment.text` 的写法:「按上一年盘所用算法,该周期原始计算延伸至某日;本年盘报告只解释至下一次太阳回归。回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。」
- 「今年」「这一年」按公历年(1 月 1 日至 12 月 31 日)理解,不悄悄改成生日年:公历年跨两张 Tajika 年盘,在太阳回归日分开。证据卡 `timing.annual_brief.calendar_year` 给出 `split_at_return`(分界的回归日)和两个 `tajika_years`(各自的 `coverage` 覆盖区间与落在本公历年的 `calendar_part`;`calendar_part.end` 是下一段的起点,不含当天)。回答时两段分开引用:回归日前按上一年盘,回归日后按本年盘,并写明分界日期;`role` 为 `annual_chart_not_in_this_card` 的那一年本轮只有区间、没有年盘内容,写「该年盘本轮未交付」,不拿本年盘的 Muntha、年主或 Mudda 顶替。用户说「未来十二个月」「接下来一年」时,按用户实际请求的起止日期(从本轮当前日期起算)写,不改成公历年或生日年。某个 Mudda 段越过下一次回归时,照卡上 `mudda_view.crossing_segment.text` 的写法:「该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至某日)」(占星师 2026-11-08 第五轮裁定 2 原话)。
<!-- 占星师 2026-11-07 裁定(她的 Q1),不是产品自己的规则;句式是她给的,`method_status` 字段由产品从引擎结果整理(BUG-1732)。 -->
- Narayana 怎么参与应期:证据卡 `timing.narayana.method_status` 写明哪一步未定或有分歧(`unresolved_steps`:seed 起运、years 年数、dual_lord 双宫主、ad_direction 小运方向;引擎点不出是哪一步时写 `unknown`),`step_notes` 是每一步的原话,`open_items` 是尚待她裁定的方法项(B3/B4)。`chat_use` 为 `single_system_candidate` 时,Narayana 的相关分支有方法或实现分歧:把这一段写成单体系候选窗口,不写成「双体系确认」「两套同向」「两套都指向所以成立」,也不据此加强应期或提高证据等级;照她的句式写:「Vimshottari 提示这一阶段值得关注。Narayana 的相关分支尚有方法或实现分歧,因此这里保留为单体系候选窗口,暂不称为双体系确认。」并说明是哪一步未定(照 `step_notes` 写;`unknown` 就写「具体哪一步未定,本轮数据无法判定」,不自己猜一步)。`chat_use` 为 `no_judgment`(blocked)时,这一层不作任何结果性判断,也不拿旧算法(`narayana_legacy`)补位。完整的步骤结果放在后面的证据部分,正文只交代影响结论的那一步。
<!-- 产品规则,不是占星师的方法:A-07 产品规则的公告与位置。 -->
@@ -215,3 +215,11 @@ evidence_card.backstage says global web evidence and real-case calibration were
- Q1 Narayana(BUG-1732):卡上 `timing.narayana.method_status` 按引擎结果逐步写出 seed / years / dual_lord / ad_direction 的状态与原话;B3/B4 未裁,运行时点不出是哪一步,记为 `unknown`。呈现按她的修改后 (a):单体系候选窗口,不称双体系确认;blocked 不作判断、不拿旧算法补位。「B」块 B-02 随之改写。
- Q2 公历年(BUG-1731):引擎已算出的 `calendar_year`(两个 Tajika 年的覆盖区间与分界回归日)送到卡上 `timing.annual_brief.calendar_year`;写法见「A」块 A-08。
- Q5 先答后证(BUG-1733):见「F」第 8 条。
## H. 占星师 2026-11-08 第五轮裁定在代码里的落实
- 裁定 3 功能吉凶:默认 `functional_profile = pvr_table_30`(PVR §13.2 表 30)。原产品表另存为 `bphs_exception_profile`,只在调用方明确选中时参与打分(生时校正打分明确选中它,分数不变,待重冻)。两档不同的格在 `profile_divergence` 并列,不合并;动星座上升(白羊、天秤、摩羯)的月亮表 30 不列,按表下注释:盈月管角宫 → 功能凶,亏月 → 功能中性;盈亏未知时不分类。双宫主按 PVR §13.2 综合判断(`dual_lordship_note`)。
- 裁定 4 Shadbala:Saptavargaja/Moolatrikona 范围、太阳 Ayana 是否重复、月亮 Chesta 是否等于 Paksha、Drik 连续还是整数相位、特殊相照替换还是另加、水星/木星相照 1/4、1 或 1.25 倍,六项都标 `parameter_sensitive` 并写明未定问题。`scripts/shadbala_service/sensitivity.py` 把这些选项的全部组合实算一遍:某颗星的达标与否会翻转时,`minimum_check = parameter_sensitive`,并列出导致翻转的未定项(`minimum_open_questions`)。**规则:`minimum_check = parameter_sensitive` 的星,聊天与报告不得用「达标/不达标」下好坏结论,只写「是否达标取决于未定参数」。** 默认数值不变。
- 裁定 5 Vimshottari:每行 AD/PD 带 `parent_period_start`(在完整父段时间轴上的原始起点)与 `visible_period_start`(出生后可见起点)。出生裁剪只改可见起点,不改 AD/PD 原始边界;日期只来自原生 Dasha 引擎,卡片层只摘取,不重算。
- 裁定 10 KP 首段:先生成完整父段,再按出生时刻裁剪;不再把首段按余额比例缩放成新的完整时间轴。
- 裁定 2 Mudda:余额字段写明起运星、已过比例、剩余比例与 `balance_method`;默认按月宿角度(`nakshatra_arc_angle`),实际经过时间另作对照,不混成一个数;`nominal_year_days = 365.25`;跨下一次回归的段落注明「该周期按原算法继续延伸,后续部分归入下一年盘解释」。Harsha 与合分保持 blocked,原因写明待确认项。
+6 -2
View File
@@ -243,7 +243,9 @@ def _score_event(
target_lords = _house_lords(ascendant_index, target_houses)
if functional is None:
functional = functional_benefics.derive_functional_benefic_malefic(
natal_chart["ascendant"].get("sign")
natal_chart["ascendant"].get("sign"),
# 第五轮裁定 3 后默认是 PVR 表 30;校正打分在冻结身份里,明确选中原产品表,分数不变(BUG-1746)。
functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS,
)
functional_benefics_set = set(functional.get("functional_benefics") or [])
functional_malefics_set = set(functional.get("functional_malefics") or [])
@@ -578,7 +580,9 @@ def build_candidate_static_context(
except (KeyError, TypeError, ValueError):
narayana_periods = None
functional_profile = functional_benefics.derive_functional_benefic_malefic(
chart["ascendant"].get("sign")
chart["ascendant"].get("sign"),
# 第五轮裁定 3 后默认是 PVR 表 30;校正打分在冻结身份里,明确选中原产品表,分数不变(BUG-1746)。
functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS,
)
narayana_antardasha_by_md = None
if narayana_periods:
+8 -2
View File
@@ -1056,6 +1056,10 @@ def _mudda_block(pack: dict, to_utc, zone, range_start, range_end) -> dict[str,
completed_years=data.get("completed_years"),
natal_moon_longitude=data.get("natal_moon_longitude"),
natal_moon_nakshatra_number=data.get("natal_moon_nakshatra_number"),
# 占星师 2026-11-08 第五轮裁定 2: balance fields, default angle method, time balance kept apart.
balance=data.get("balance"), balance_method=data.get("balance_method"),
balance_time_comparison=data.get("balance_time_comparison"),
nominal_year_days=data.get("nominal_year_days"), nominal_year_days_note=data.get("nominal_year_days_note"),
balance_profile=data.get("balance_profile"),
balance_fraction_remaining=data.get("balance_fraction_remaining"),
independent_of_year_lord=data.get("independent_of_year_lord"),
@@ -1075,8 +1079,10 @@ def _mudda_block(pack: dict, to_utc, zone, range_start, range_end) -> dict[str,
)
CROSSING_TEXT = ("按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。"
"回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。")
# 原值:「按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。回归后的年度判断
# 转入新年盘,旧周期延伸部分保留供方法对照。」新值:占星师原话,原始延伸终点放括号里。
# 原因:占星师 2026-11-08 第五轮裁定 2。
CROSSING_TEXT = "该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至{end})"
def _coverage_block(coverage: Any, to_utc, zone) -> dict[str, Any] | None:
+5 -2
View File
@@ -367,7 +367,7 @@ def _natal_base(ctx: Any, birth_window: dict[str, Any], check: dict[str, Any]) -
if isinstance(row, dict) and row.get("degree_raw") is not None}
houses_of = {name: int(row["house"]) for name, row in planets.items()
if isinstance(row, dict) and isinstance(row.get("house"), int)}
functional = _module_call("functional_layer", "functional_layer_for_ascendant", asc)
functional = _module_call("functional_layer", "functional_layer_for_ascendant", asc, planets) # PVR 表 30 默认(第五轮裁定 3)
house_rows = []
for house in range(1, 13):
sign = ap.SIGNS[(lagna_idx + house - 1) % 12]
@@ -438,7 +438,10 @@ def _functional_block(raw: Any, lagna_idx: int) -> dict[str, Any]:
lagna_lord=raw.get("lagna_lord"), owned_houses=owned,
# Maraka (2nd / 7th lord) is its own record; never an automatic minus (ruling item 2).
marakas=sorted(planet for planet, houses in owned.items() if set(houses) & {2, 7}),
profile=raw.get("profile"), nodes_note="罗计不单独定功能吉凶,参考定位星并保留自身位置",
profile=raw.get("profile"), functional_profile=raw.get("functional_profile"),
# 占星师 2026-11-08 第五轮裁定 3:两档不同时并列,不合并。
profile_divergence=raw.get("profile_divergence"), movable_lagna_moon=raw.get("movable_lagna_moon"),
dual_lordship_note=raw.get("dual_lordship_note"), nodes_note="罗计不单独定功能吉凶,参考定位星并保留自身位置",
)
+4 -3
View File
@@ -12,10 +12,10 @@ from typing import Dict, List, Any
import math
try:
from .functional_benefics import derive_functional_benefic_malefic, SIGN_LORDS
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets, SIGN_LORDS
from .affliction_evidence import collect_affliction_evidence
except ImportError:
from functional_benefics import derive_functional_benefic_malefic, SIGN_LORDS
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets, SIGN_LORDS
from affliction_evidence import collect_affliction_evidence
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
@@ -89,7 +89,8 @@ def analyze_career(planets: Dict, asc_sign: str = 'Aries',
analysis = {'indicators': [], 'strengths': [], 'challenges': [], 'timing': [], 'fields': []}
if asc_sign not in SIGNS:
raise ValueError('asc_sign must be a valid sign')
functional = derive_functional_benefic_malefic(asc_sign)
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;月亮盈亏供动星座上升的月亮用。
functional = derive_functional_benefic_malefic(asc_sign, moon_paksha=moon_paksha_from_planets(planets))
analysis['functional_benefic_malefic'] = functional
analysis['assertion_label'] = 'single_system_inference'
analysis['career_claim_gate'] = {
+3 -1
View File
@@ -221,7 +221,9 @@ def _score_event(event: dict[str, Any], domain: str, row: dict[str, Any], vim: t
def _context(row: dict[str, Any], lat: float, lon: float, ayanamsa: str) -> dict[str, Any]:
chart = row["chart"]
functional = functional_benefics.derive_functional_benefic_malefic(chart["ascendant"].get("sign"))
# 第五轮裁定 3 后默认是 PVR 表 30;本打分沿用校正的原产品表,需明确选中(BUG-1746)。
functional = functional_benefics.derive_functional_benefic_malefic(
chart["ascendant"].get("sign"), functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS)
planets = chart.get("planets", {})
at: datetime = row["at"]
birth_hour = at.hour + at.minute / 60
+5 -1
View File
@@ -97,11 +97,15 @@ def antardashas(mahadasha: dict[str, Any], *, visible_from: datetime | None = No
sub_lord = VIMSHOTTARI_ORDER[(first + offset) % 9]
end = parent_end if offset == 8 else start + total * (VIMSHOTTARI_YEARS[sub_lord] / 120.0)
if visible_from is None or end > visible_from:
visible = max(start, visible_from) if visible_from else start
rows.append({
"lord": sub_lord,
"start": max(start, visible_from) if visible_from else start,
"start": visible,
"end": end,
"years": VIMSHOTTARI_YEARS[sub_lord],
# 占星师 2026-11-08 第五轮裁定 5: the boundary in the full parent, and what birth leaves visible.
"parent_period_start": start,
"visible_period_start": visible,
})
start = end
return rows
+1 -1
View File
@@ -223,7 +223,7 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
def functional() -> dict[str, Any]:
from chart_view_points.functional_roles import functional_role_table
return functional_role_table(SIGNS[asc_sign])
return functional_role_table(SIGNS[asc_sign], planets)
def sensitive() -> dict[str, Any]:
from jyotish_engine import _calc_sensitive_points
+26 -9
View File
@@ -1,7 +1,9 @@
"""Per-planet functional role table for the chart page (issue #163).
Reads ``functional_benefics.derive_functional_benefic_malefic`` (profile
``bphs_ch34_general_with_sign_exceptions_v3_166q4``) and lays it out one row per
Reads ``functional_benefics.derive_functional_benefic_malefic`` (default profile
``pvr_table_30`` since 占星师 2026-11-08 第五轮裁定 3; the former product table
``bphs_ch34_general_with_sign_exceptions_v3_166q4`` is shown beside it where the
two differ) and lays it out one row per
body, with the natural nature and two independent identities beside it.
Nothing here reclassifies a planet:
@@ -29,10 +31,12 @@ from __future__ import annotations
from typing import Any
try:
from functional_benefics import PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic, normalize_sign
from functional_benefics import (PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic,
moon_paksha_from_planets, normalize_sign)
from yoga_engine import BENEFICS, MALEFICS, badhaka_house
except ImportError: # package import (scripts.chart_view_points.functional_roles)
from scripts.functional_benefics import PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic, normalize_sign
from scripts.functional_benefics import (PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic,
moon_paksha_from_planets, normalize_sign)
from scripts.yoga_engine import BENEFICS, MALEFICS, badhaka_house
BODIES = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
@@ -43,13 +47,18 @@ NATURAL_CONDITION = {"Moon": "lunar_phase_not_evaluated", "Mercury": "associatio
TRANSLATION = "BPHS ch.34, public English translation https://jyotishvidya.com/ch34.htm (inspected 2026-10-02)"
def functional_role_table(ascendant: Any) -> dict[str, Any]:
base = derive_functional_benefic_malefic(ascendant)
def functional_role_table(ascendant: Any, planets: Any = None) -> dict[str, Any]:
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;原产品表(bphs_exception_profile)只并列对照。
base = derive_functional_benefic_malefic(ascendant, moon_paksha=moon_paksha_from_planets(planets))
asc_sign = normalize_sign(ascendant)
if base.get("status") != "used" or asc_sign is None:
return {"status": "blocked", "reason": "unknown_ascendant_sign", "profile": PROFILE, "source": SOURCE}
return {"status": "blocked", "reason": "unknown_ascendant_sign", "functional_profile": base.get("functional_profile"), "source": SOURCE}
benefics = set(base["functional_benefics"])
malefics = set(base["functional_malefics"])
neutrals = set(base["functional_neutrals"])
divergence = base.get("profile_divergence") or {}
other_profile = divergence.get("other_profile")
other_by_planet = {cell["planet"]: cell.get(other_profile) for cell in divergence.get("cells") or []}
yogakarakas = set(base["yogakarakas"])
owned = base["owned_houses"]
b_house = badhaka_house(asc_sign)
@@ -64,7 +73,8 @@ def functional_role_table(ascendant: Any) -> dict[str, Any]:
functional = "pending"
basis = "node_placement_dispositor_association_not_evaluated"
else:
functional = "benefic" if planet in benefics else "malefic" if planet in malefics else "neutral"
functional = ("benefic" if planet in benefics else "malefic" if planet in malefics
else "neutral" if planet in neutrals else "pending")
basis = base["role_basis"].get(planet)
if planet in yogakarakas:
identities.append({"key": "yogakaraka"})
@@ -82,11 +92,18 @@ def functional_role_table(ascendant: Any) -> dict[str, Any]:
"role_basis": basis,
"identities": identities,
"natural_functional_conflict": conflict,
# Both profiles shown where they differ, never merged (第五轮裁定 3).
**({"other_profile_functional": {"profile": other_profile, "functional": other_by_planet[planet]}}
if planet in other_by_planet else {}),
})
return {
"status": "computed",
"ascendant": asc_sign,
"profile": PROFILE,
"profile": base.get("profile"),
"functional_profile": base.get("functional_profile"),
"profile_divergence": divergence,
"movable_lagna_moon": base.get("movable_lagna_moon"),
"dual_lordship_note": base.get("dual_lordship_note"),
"source": SOURCE,
"translation": TRANSLATION,
"rule_reference": base.get("rule_reference"),
+4 -2
View File
@@ -945,8 +945,10 @@ def _mudda_segments(periods: list, start: datetime, end: datetime, anchor: Any =
return rows
MUDDA_CROSSING_TEXT = ("按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。"
"回归后的年度判断转入新年盘,旧周期延伸部分保留供方法对照。")
# 原值:「按上一年盘所用算法,该周期原始计算延伸至{end};本年盘报告只解释至下一次太阳回归。回归后的年度判断
# 转入新年盘,旧周期延伸部分保留供方法对照。」新值:占星师原话,原始延伸终点放括号里。
# 原因:占星师 2026-11-08 第五轮裁定 2。
MUDDA_CROSSING_TEXT = "该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至{end})"
def _mudda_view(data: dict, start: datetime, end: datetime) -> dict:
+252 -55
View File
@@ -73,7 +73,10 @@ AMBIGUOUS_CELL_NOTES = {
# (PROFILE = BPHS ch. 34 general ownership rules, trine-ownership first, plus sign-specific
# overrides). The two agree on Virgo (Sun, Saturn neutral) and differ on 6 cells: Aries Mars,
# Gemini Mercury, Cancer Saturn, Libra Venus, Scorpio Sun, Pisces Jupiter. Moon is not listed
# by PVR for movable lagnas (depends on waxing / waning). Reference only; not wired into scoring.
# by PVR for movable lagnas (depends on waxing / waning).
# 原值:只供对照(reference_only),不进打分。新值:默认档 functional_profile = "pvr_table_30";
# 上面的产品表另存为 "bphs_exception_profile",只有被明确选中时才参与打分。
# 原因:占星师 2026-11-08 第五轮裁定 3。
PVR_TABLE30_PROFILE = "pvr_integrated_approach_13_2_table_30"
PVR_TABLE30_SOURCE = "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (p. 167; PDF p. 179)"
PVR_TABLE30 = {
@@ -114,38 +117,95 @@ def normalize_sign(value: Any) -> str | None:
return None
def derive_functional_benefic_malefic(ascendant: Any) -> dict[str, Any]:
"""Return functional benefics, malefics and ownership roles for a Lagna."""
asc_sign = normalize_sign(ascendant)
if asc_sign is None:
return {
"status": "blocked",
"ascendant": ascendant if isinstance(ascendant, str) else None,
"functional_benefics": [],
"functional_malefics": [],
"functional_neutrals": [],
"yogakarakas": [],
"owned_houses": {},
"effect_on_confidence": "Functional layer blocked: unknown ascendant sign.",
"source": SOURCE,
}
# ── 占星师 2026-11-08 第五轮裁定 3:两套具名功能吉凶体系 ─────────────────────────────
# Default: PVR §13.2 Table 30. The product table above (BPHS ch. 34 general rules, trine first,
# plus sign exceptions) is kept whole as "bphs_exception_profile" and scores only when a caller
# names it. Where the two differ the output carries both roles side by side, never merged.
FUNCTIONAL_PROFILE_PVR = "pvr_table_30"
FUNCTIONAL_PROFILE_BPHS = "bphs_exception_profile"
DEFAULT_FUNCTIONAL_PROFILE = FUNCTIONAL_PROFILE_PVR
FUNCTIONAL_PROFILES = (FUNCTIONAL_PROFILE_PVR, FUNCTIONAL_PROFILE_BPHS)
PROFILE_IDS = {FUNCTIONAL_PROFILE_PVR: PVR_TABLE30_PROFILE, FUNCTIONAL_PROFILE_BPHS: PROFILE}
PROFILE_RULING = "占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;BPHS 特殊格另存为具名 profile,只在被明确选中时参与打分"
# Movable lagnas whose Moon PVR Table 30 leaves out. Cancer is movable too, but there the Moon is the
# lagna lord and the table lists him as a benefic.
PVR_MOON_UNLISTED_LAGNAS = ("Aries", "Libra", "Capricorn")
# PVR §13.2 (printed pp. 166-167; PDF pp. 178-179), the note under Table 30.
PVR_MOVABLE_MOON_NOTE = (
"Moon is not listed for movable rasis, because his functional nature depends on whether he is waxing or "
"waning. Waxing Moon is a natural benefic and he becomes a functional malefic with quadrant ownership. "
"Waning Moon, on the other hand, is a natural malefic and quadrant ownership makes him functionally neutral."
)
PVR_MOVABLE_MOON_NOTE_ZH = "PVR 表 30 不列动星座上升的月亮:盈月(自然吉星)管角宫 → 功能凶;亏月(自然凶星)管角宫 → 功能中性"
# PVR §13.2: "In the case of planets owning two rasis, we need to judiciously combine the two indications."
DUAL_LORDSHIP_NOTE = (
"PVR §13.2:管两个星座的行星要把两宫的指示综合判断;表 30 是综合后的摘要,不是只看一个宫位的机械规则。"
)
DUAL_LORDSHIP_NOTE_EN = (
"PVR §13.2: for a planet owning two rasis the two indications must be judiciously combined; "
"Table 30 is that integrated summary, not a mechanical one-house rule."
)
# Earlier astrologer rulings on the product table (BUG-1570, BUG-1590, #166 Q4 cell notes), checked
# against Table 30 so none is silently dropped. Values: (ruling, product role, note).
PRIOR_RULINGS = {
("Aries", "Venus"): ("BUG-1570(BPHS 34.20)", "malefic", "两档一致:功能凶"),
("Scorpio", "Venus"): ("BUG-1570(BPHS 34.35)", "malefic", "两档一致:功能凶"),
("Gemini", "Saturn"): ("BUG-1590(#166 Q4)", "neutral", "两档一致:混合 / 中性"),
("Virgo", "Saturn"): ("BUG-1590(#166 Q4)", "neutral", "两档一致:混合 / 中性"),
("Gemini", "Mercury"): ("#166 Q4 歧义格说明", "benefic",
"冲突:#166 Q4 说明「上升主,支持」,产品表判功能吉;PVR 表 30 判功能中性。"
"按裁定 3 分档解决:默认 PVR 中性,bphs_exception_profile 保留功能吉,两者并列显示"),
}
def moon_paksha_from_longitudes(sun_longitude: Any, moon_longitude: Any) -> str | None:
"""'waxing' when the Moon is 0-180 degrees ahead of the Sun, 'waning' otherwise; None without data."""
try:
elongation = (float(moon_longitude) - float(sun_longitude)) % 360.0
except (TypeError, ValueError):
return None
return "waxing" if elongation < 180.0 else "waning"
def moon_paksha_from_planets(planets: Any) -> str | None:
"""Paksha from a planets mapping whose rows carry an absolute longitude."""
if not isinstance(planets, dict):
return None
def lon(name: str) -> Any:
row = planets.get(name)
if isinstance(row, (int, float)):
return row
if isinstance(row, dict):
for key in ("lon", "longitude", "degree_raw", "abs_degree"):
if isinstance(row.get(key), (int, float)):
return row[key]
sign, degree = normalize_sign(row.get("sign")), row.get("degree")
if sign is not None and isinstance(degree, (int, float)):
return SIGN_TO_INDEX[sign] * 30.0 + float(degree) if 0 <= degree < 30 else degree
return None
return moon_paksha_from_longitudes(lon("Sun"), lon("Moon"))
def _owned_houses(asc_sign: str) -> dict[str, list[int]]:
asc_idx = SIGN_TO_INDEX[asc_sign]
owned_houses: dict[str, list[int]] = {}
owned: dict[str, list[int]] = {}
for house_num in range(1, 13):
sign = SIGNS[(asc_idx + house_num - 1) % 12]
lord = SIGN_LORDS.get(sign)
lord = SIGN_LORDS.get(SIGNS[(asc_idx + house_num - 1) % 12])
if lord:
owned_houses.setdefault(lord, []).append(house_num)
owned.setdefault(lord, []).append(house_num)
return owned
def _bphs_exception_roles(asc_sign: str) -> dict[str, str]:
"""The product table (PROFILE): BPHS ch. 34 general rules plus sign exceptions. Unchanged logic."""
owned_houses = _owned_houses(asc_sign)
trines = {1, 5, 9}
kendras = {1, 4, 7, 10}
challenging = {3, 6, 8, 11}
benefics: set[str] = set()
malefics: set[str] = set()
yogakarakas: set[str] = set()
neutrals: set[str] = set()
roles: dict[str, str] = {}
for planet in PLANETS:
houses = owned_houses.get(planet, [])
if not houses:
@@ -153,63 +213,200 @@ def derive_functional_benefic_malefic(ascendant: Any) -> dict[str, Any]:
owns_trine = any(house in trines for house in houses)
owns_kendra = any(house in kendras for house in houses)
owns_challenge = any(house in challenging for house in houses)
# House 1 remains both angular and trinal, but cannot be reused as
# two distinct lordships for this strict Yoga-karaka field.
if set(houses) & {4, 7, 10} and set(houses) & {5, 9}:
yogakarakas.add(planet)
benefics.add(planet)
roles[planet] = "benefic"
elif owns_trine:
benefics.add(planet)
roles[planet] = "benefic"
elif owns_challenge and 1 not in houses:
malefics.add(planet)
roles[planet] = "malefic"
elif owns_kendra and planet in {"Jupiter", "Venus", "Mercury", "Moon"}:
neutrals.add(planet)
roles[planet] = "neutral"
else:
neutrals.add(planet)
roles[planet] = "neutral"
eighth_lord = next((planet for planet, houses in owned_houses.items() if 8 in houses), None)
if eighth_lord in {"Sun", "Moon"} and eighth_lord in malefics:
malefics.remove(eighth_lord)
neutrals.add(eighth_lord)
if eighth_lord in {"Sun", "Moon"} and roles.get(eighth_lord) == "malefic":
roles[eighth_lord] = "neutral"
for planet, role in ROLE_OVERRIDES.get(asc_sign, {}).items():
benefics.discard(planet)
malefics.discard(planet)
neutrals.discard(planet)
{"benefic": benefics, "malefic": malefics, "neutral": neutrals}[role].add(planet)
roles[planet] = role
return roles
def _bphs_yogakarakas(asc_sign: str) -> list[str]:
return sorted(planet for planet, houses in _owned_houses(asc_sign).items()
if set(houses) & {4, 7, 10} and set(houses) & {5, 9})
def _pvr_roles(asc_sign: str, moon_paksha: str | None) -> tuple[dict[str, str], dict[str, Any] | None]:
"""Table 30 roles; for an unlisted movable-lagna Moon the table's own paksha note decides."""
_yk, ben, neu, mal = PVR_TABLE30[asc_sign]
roles: dict[str, str] = {}
for group, role in ((ben, "benefic"), (neu, "neutral"), (mal, "malefic")):
for planet in group:
roles[planet] = role
moon_note = None
if asc_sign in PVR_MOON_UNLISTED_LAGNAS:
moon_role = {"waxing": "malefic", "waning": "neutral"}.get(str(moon_paksha or ""))
moon_note = {
"listed_in_table_30": False,
"moon_paksha": moon_paksha if moon_role else None,
"role": moon_role,
"status": "derived_from_table_30_note" if moon_role else "paksha_unknown_not_classified",
"rule": PVR_MOVABLE_MOON_NOTE,
"rule_zh": PVR_MOVABLE_MOON_NOTE_ZH,
"source": "PVR §13.2 note under Table 30 (printed p. 166-167; PDF p. 178-179)",
}
if moon_role:
roles["Moon"] = moon_role
return roles, moon_note
def _profile_roles(asc_sign: str, profile: str, moon_paksha: str | None) -> tuple[dict[str, str], list[str], dict[str, Any] | None]:
if profile == FUNCTIONAL_PROFILE_PVR:
roles, moon_note = _pvr_roles(asc_sign, moon_paksha)
return roles, sorted(PVR_TABLE30[asc_sign][0]), moon_note
return _bphs_exception_roles(asc_sign), _bphs_yogakarakas(asc_sign), None
def _blocked(ascendant: Any, profile: str, reason: str) -> dict[str, Any]:
return {
"status": "blocked",
"ascendant": ascendant if isinstance(ascendant, str) else None,
"functional_profile": profile,
"functional_benefics": [],
"functional_malefics": [],
"functional_neutrals": [],
"yogakarakas": [],
"owned_houses": {},
"effect_on_confidence": reason,
"source": SOURCE,
}
def derive_functional_benefic_malefic(
ascendant: Any,
*,
functional_profile: str = DEFAULT_FUNCTIONAL_PROFILE,
moon_paksha: str | None = None,
) -> dict[str, Any]:
"""Functional benefics, malefics and ownership roles for a Lagna under one named profile.
``functional_profile``: ``"pvr_table_30"`` (default, 占星师 2026-11-08 第五轮裁定 3) or
``"bphs_exception_profile"`` (the former product table; scores only when a caller names it).
``moon_paksha`` (``"waxing"`` / ``"waning"``) classifies the Moon for Aries / Libra / Capricorn
lagnas under Table 30's own note; without it that Moon is left unclassified, never guessed.
"""
if functional_profile not in FUNCTIONAL_PROFILES:
return _blocked(ascendant, functional_profile, f"Functional layer blocked: unknown functional_profile {functional_profile!r}.")
asc_sign = normalize_sign(ascendant)
if asc_sign is None:
return _blocked(ascendant, functional_profile, "Functional layer blocked: unknown ascendant sign.")
other = FUNCTIONAL_PROFILE_BPHS if functional_profile == FUNCTIONAL_PROFILE_PVR else FUNCTIONAL_PROFILE_PVR
roles, yogakarakas, moon_note = _profile_roles(asc_sign, functional_profile, moon_paksha)
other_roles, other_yogakarakas, other_moon_note = _profile_roles(asc_sign, other, moon_paksha)
owned_houses = _owned_houses(asc_sign)
by_role = {role: sorted(p for p, r in roles.items() if r == role) for role in ("benefic", "malefic", "neutral")}
other_by_role = {role: sorted(p for p, r in other_roles.items() if r == role) for role in ("benefic", "malefic", "neutral")}
divergence = []
for planet in PLANETS:
mine, theirs = roles.get(planet), other_roles.get(planet)
if mine != theirs:
divergence.append({
"planet": planet,
functional_profile: mine or "not_classified",
other: theirs or "not_classified",
"kind": "movable_lagna_moon_paksha" if planet == "Moon" and asc_sign in PVR_MOON_UNLISTED_LAGNAS else "table_cell",
})
if functional_profile == FUNCTIONAL_PROFILE_PVR:
role_basis = {
planet: ("PVR §13.2 Table 30 note: movable-lagna Moon by paksha" if planet == "Moon" and moon_note
else "PVR §13.2 Table 30")
for planet in PLANETS
}
if moon_note and not moon_note["role"]:
role_basis["Moon"] = "PVR §13.2 Table 30 does not list this Moon; paksha unknown, not classified"
profile_source = PVR_TABLE30_SOURCE
else:
role_basis = {
planet: "BPHS ch34 sign-specific exception" if planet in ROLE_OVERRIDES.get(asc_sign, {})
else "BPHS ch34 general ownership; associations not evaluated"
for planet in PLANETS
}
profile_source = "BPHS ch. 34 general ownership rules plus sign exceptions (references/functional-role-profile.md)"
prior = []
for (sign, planet), (ruling, product_role, note) in PRIOR_RULINGS.items():
if sign != asc_sign:
continue
pvr_role = (roles if functional_profile == FUNCTIONAL_PROFILE_PVR else other_roles).get(planet)
prior.append({"planet": planet, "ruling": ruling, FUNCTIONAL_PROFILE_BPHS: product_role,
FUNCTIONAL_PROFILE_PVR: pvr_role, "agrees": pvr_role == product_role, "resolution": note})
result: dict[str, Any] = {
"status": "used",
"ascendant": asc_sign,
"functional_benefics": sorted(benefics),
"functional_malefics": sorted(malefics),
"functional_neutrals": sorted(neutrals - benefics - malefics),
"yogakarakas": sorted(yogakarakas),
"functional_profile": functional_profile,
"functional_profile_ruling": PROFILE_RULING,
"functional_benefics": by_role["benefic"],
"functional_malefics": by_role["malefic"],
"functional_neutrals": by_role["neutral"],
"yogakarakas": yogakarakas,
"owned_houses": {planet: houses for planet, houses in sorted(owned_houses.items())},
"effect_on_confidence": (
"高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;"
"若功能属性与自然属性冲突,应降低置信度或显式标记冲突。"
),
"source": SOURCE,
"profile": PROFILE,
"profile": PROFILE_IDS[functional_profile],
"profile_source": profile_source,
"lagna_lord": SIGN_LORDS[asc_sign],
"yogakaraka_definition": "distinct lordships: one of 4/7/10 and one of 5/9",
"rule_reference": "references/functional-role-profile.md",
"profile_is_not": PVR_TABLE30_PROFILE,
"profile_is_not": PROFILE_IDS[other],
"assertion_label": "parameter_sensitive",
"ambiguous_cell_notes": {
planet: {"summary": note, "basis": basis, "summary_en": note_en, "basis_en": basis_en}
for (sign, planet), (note, basis, note_en, basis_en) in AMBIGUOUS_CELL_NOTES.items() if sign == asc_sign
},
"role_basis": {
planet: "BPHS ch34 sign-specific exception" if planet in ROLE_OVERRIDES.get(asc_sign, {})
else "BPHS ch34 general ownership; associations not evaluated"
for planet in PLANETS
"dual_lordship_note": DUAL_LORDSHIP_NOTE,
"dual_lordship_note_en": DUAL_LORDSHIP_NOTE_EN,
"role_basis": role_basis,
# Both profiles side by side where they differ; the other profile never scores here.
"profile_divergence": {
"status": "differs" if divergence else "same",
"selected_profile": functional_profile,
"other_profile": other,
"other_profile_scored": False,
"display_rule": "两档不同时并列显示,不合并成一张表;默认档参与判断,另一档只作对照",
"cells": divergence,
"other_profile_roles": {
"functional_benefics": other_by_role["benefic"],
"functional_malefics": other_by_role["malefic"],
"functional_neutrals": other_by_role["neutral"],
"yogakarakas": other_yogakarakas,
},
},
"prior_rulings": prior,
"conditions": [
"Neutral/mixed is association-dependent, not an absence of risk.",
"Natural nature, strength, maraka roles and dasha activation remain separate.",
"Strict yogakarakas do not exhaust the text's broader yoga-producing associations.",
"Dual lordship needs an integrated judgment of both houses (PVR §13.2).",
],
}
if moon_note or other_moon_note:
result["movable_lagna_moon"] = moon_note or other_moon_note
if functional_profile == FUNCTIONAL_PROFILE_BPHS:
result["ambiguous_cell_notes"] = {
planet: {"summary": note, "basis": basis, "summary_en": note_en, "basis_en": basis_en}
for (sign, planet), (note, basis, note_en, basis_en) in AMBIGUOUS_CELL_NOTES.items() if sign == asc_sign
}
else:
# #166 Q4 cell notes belong to the product table; under Table 30 they stay visible as notes on
# the other profile, with the Table 30 role beside them.
result["ambiguous_cell_notes"] = {
planet: {"summary": note, "basis": basis, "summary_en": note_en, "basis_en": basis_en,
"applies_to_profile": FUNCTIONAL_PROFILE_BPHS, "pvr_table_30_role": roles.get(planet)}
for (sign, planet), (note, basis, note_en, basis_en) in AMBIGUOUS_CELL_NOTES.items() if sign == asc_sign
}
return result
+56 -41
View File
@@ -1,41 +1,56 @@
"""One functional benefic/malefic snapshot for the engine, API, and MCP.
This module is not ``functional_benefics.py``. That file is part of the
rectification freeze and must stay byte-for-byte unchanged.
"""
from __future__ import annotations
from typing import Any
def functional_layer_for_ascendant(ascendant: Any) -> dict[str, Any]:
"""Return the functional layer for an ascendant payload.
A dict uses its ``sign``. Anything else is a missing sign and follows
the normal ``derive_functional_benefic_malefic`` blocked result.
Import or calculation failures return the engine's blocked shape.
The exception text stays inside ``effect_on_confidence`` because that
field already did; callers must not write this dict to a log.
"""
try:
try:
from functional_benefics import derive_functional_benefic_malefic
except ModuleNotFoundError:
from scripts.functional_benefics import derive_functional_benefic_malefic
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
return derive_functional_benefic_malefic(sign)
except Exception as exc:
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
return {
"status": "blocked",
"ascendant": sign,
"functional_benefics": [],
"functional_malefics": [],
"functional_neutrals": [],
"yogakarakas": [],
"owned_houses": {},
"effect_on_confidence": f"未完成功能性吉凶星判定,需降低高严谨结论置信度: {exc}",
"source": "strict_functional_benefic_malefic_v2",
}
"""One functional benefic/malefic snapshot for the engine, API, and MCP.
This module is not ``functional_benefics.py``. That file is in the
rectification identity; rectification scoring names ``bphs_exception_profile``
explicitly, every product surface takes the default ``pvr_table_30``.
"""
from __future__ import annotations
from typing import Any
def functional_layer_for_ascendant(
ascendant: Any,
planets: Any = None,
*,
functional_profile: str | None = None,
) -> dict[str, Any]:
"""Return the functional layer for an ascendant payload.
A dict uses its ``sign``. Anything else is a missing sign and follows
the normal ``derive_functional_benefic_malefic`` blocked result.
Import or calculation failures return the engine's blocked shape.
The exception text stays inside ``effect_on_confidence`` because that
field already did; callers must not write this dict to a log.
占星师 2026-11-08 第五轮裁定 3: the default profile is PVR Table 30
(``functional_benefics.DEFAULT_FUNCTIONAL_PROFILE``). ``planets`` (Sun and
Moon longitudes) gives the Moon's paksha, which Table 30's note needs for
Aries / Libra / Capricorn lagnas.
"""
try:
try:
import functional_benefics as fb
except ModuleNotFoundError:
from scripts import functional_benefics as fb
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
return fb.derive_functional_benefic_malefic(
sign,
functional_profile=functional_profile or fb.DEFAULT_FUNCTIONAL_PROFILE,
moon_paksha=fb.moon_paksha_from_planets(planets),
)
except Exception as exc:
sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
return {
"status": "blocked",
"ascendant": sign,
"functional_benefics": [],
"functional_malefics": [],
"functional_neutrals": [],
"yogakarakas": [],
"owned_houses": {},
"effect_on_confidence": f"未完成功能性吉凶星判定,需降低高严谨结论置信度: {exc}",
"source": "strict_functional_benefic_malefic_v2",
}
+21 -3
View File
@@ -1414,7 +1414,17 @@ def _consultation_dasha_sub_periods(chart: dict, body: dict) -> dict | None:
# truncates the parent span to whole days and stays with rectification scoring.
from scripts.chart_dasha import birth_moment
sub_periods = birth_moment.antardashas(current)
# 占星师 2026-11-08 第五轮裁定 5: the balance mahadasha is cut from its full parent start and only
# then clipped at birth; boundaries never move, the clipped row says where its parent row started.
birth_info = chart.get('birth') if isinstance(chart.get('birth'), dict) else {}
try:
birth_dt = datetime.strptime(str(birth_info['date'])[:10], '%Y-%m-%d').replace(
hour=int(birth_info['hour']), minute=int(birth_info['minute']), second=int(birth_info.get('second') or 0),
)
except (KeyError, TypeError, ValueError):
birth_dt = None
visible_from = birth_dt if birth_dt and current['start'] < birth_dt < current['end'] else None
sub_periods = birth_moment.antardashas(current, visible_from=visible_from)
if not sub_periods:
return None
current_sub = next(
@@ -1427,11 +1437,16 @@ def _consultation_dasha_sub_periods(chart: dict, body: dict) -> dict | None:
)
def boundary(period: dict) -> dict:
return {
row = {
'lord': period['lord'],
'start': period['start'].strftime('%Y-%m-%d'),
'end': period['end'].strftime('%Y-%m-%d'),
}
parent = period.get('parent_period_start')
if isinstance(parent, datetime) and parent != period['start']:
row['parent_period_start'] = parent.strftime('%Y-%m-%d')
row['visible_period_start'] = period['start'].strftime('%Y-%m-%d')
return row
# BUG-1643: a ten-year question reaches past the running mahadasha, so the next one is cut the
# same way (same periods, same antardasha cut); nothing else in this layer changes.
@@ -6108,6 +6123,9 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
moon_lon,
context=build_shadbala_context(jd, lat, lon, ayanamsa_name, tz),
)
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices.
from shadbala_service.sensitivity import attach_parameter_sensitivity
attach_parameter_sensitivity(sb, planets=shadbala_planets)
# BUG-1208 (甲13): BPHS minimum first, then the site band, labelled.
from shadbala_display import row_fields
shadbala_summary = {p: {'rupas': round(d['total_rupas'],2), **row_fields(d)}
@@ -6658,7 +6676,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
def _functional_benefic_malefic_snapshot(self, planets, ascendant):
from functional_layer import functional_layer_for_ascendant
return functional_layer_for_ascendant(ascendant)
return functional_layer_for_ascendant(ascendant, planets)
def _detect_yogas(self, planets, asc_idx):
yogas = []
+28 -4
View File
@@ -255,7 +255,8 @@ from cmd_solar_return import cmd_solar_return # v6.0.18
from cmd_narayana_dasha import cmd_narayana_dasha as _cmd_narayana_dasha_impl # v6.0.20
from cmd_muhurta import cmd_muhurta # v6.0.21
from yoga_engine import detect_yogas, detect_yogas_with_status # v6.0.26: data-driven Yoga engine
from shadbala_display import check_text as _shadbala_check_text, row_fields as _shadbala_row_fields # BUG-1208
from shadbala_display import row_fields as _shadbala_row_fields # BUG-1208
from shadbala_display import row_check_text as _shadbala_row_check_text # 第五轮裁定 4
from kp_system import calc_kp_analysis, get_kp_lords # v6.9.10: KP完整系统
from bhava_chalit import BhavaChalitCalculator, cmd_bhava_chalit # v6.9.13: Bhava Chalit 不等宫边界调整
from sudarshana_chakra import calc_sudarshana_chakra, generate_sudarshana_report # v6.9.14: Sudarshana Chakra 三参考点盘
@@ -10779,7 +10780,7 @@ def is_combust(planet_name, longitude, sun_longitude, retrograde=False):
def _functional_benefic_malefic_snapshot(planets, ascendant):
from functional_layer import functional_layer_for_ascendant
return functional_layer_for_ascendant(ascendant)
return functional_layer_for_ascendant(ascendant, planets)
def _build_technique_audit_table(functional_layer, oracle_progress, modules):
@@ -11356,9 +11357,10 @@ def _build_health_narrative_payload(modules, chart=None):
minimum = row.get('min_required')
level = row.get('strength_level') or row.get('level')
# BUG-1208 (甲13): the BPHS minimum check first, the band marked as the site's.
check = _shadbala_check_text(_shadbala_row_fields(row)['meets_minimum'])
# 占星师 2026-11-08 第五轮裁定 4: a flip under open parameters is parameter_sensitive, no verdict.
check = _shadbala_row_check_text(row)
strengths.append(f'{lord} Shadbala:total={total if total is not None else "-"},minimum={minimum if minimum is not None else "-"},{check},网站分档={level or "-"}。')
if isinstance(total, (int, float)) and isinstance(minimum, (int, float)) and total < minimum:
if row.get('minimum_check_status') != 'parameter_sensitive' and isinstance(total, (int, float)) and isinstance(minimum, (int, float)) and total < minimum:
risks.append(f'{lord} 的 Shadbala 低于本地最低参考值,只能作为压力负荷提示。')
available = [f'D{number}' for number in (6, 8, 30) if division(number)]
@@ -11878,6 +11880,8 @@ def _build_ai_prompt_pack(report):
'min_required': pdata.get('min_required'),
# BUG-1208 (甲13): BPHS minimum first; the band is the site's own.
'meets_minimum': _shadbala_row_fields(pdata)['meets_minimum'],
# 占星师 2026-11-08 第五轮裁定 4: parameter_sensitive when open parameters flip it.
'minimum_check_status': pdata.get('minimum_check_status'),
'strength_level': pdata.get('strength_level'),
'level_source': 'site_band',
})
@@ -12486,6 +12490,9 @@ def cmd_dasha(args):
"elapsed_at_birth": round(elapsed, 2) if i == 0 else None,
# BUG-1675: where a table shows the balance years, it starts at birth.
"visible_start": birth_dt.strftime("%Y-%m-%d") if i == 0 else None,
# 占星师 2026-11-08 第五轮裁定 5: full parent start and the visible start after birth.
"parent_period_start": dt.isoformat(timespec="seconds"),
"visible_period_start": (max(dt, birth_dt) if i == 0 else dt).isoformat(timespec="seconds"),
})
dt = end_dt
@@ -12509,6 +12516,8 @@ def cmd_dasha(args):
"balance_years": None,
"elapsed_at_birth": None,
"cycle": row["cycle"],
"parent_period_start": row["start"].isoformat(timespec="seconds"),
"visible_period_start": row["start"].isoformat(timespec="seconds"),
})
current = None
for d in timeline:
@@ -12534,6 +12543,10 @@ def cmd_dasha(args):
child_end = datetime.fromisoformat(child["end_datetime"])
if child_end <= visible_origin:
continue
# 占星师 2026-11-08 第五轮裁定 5: the boundary from the full parent stays; clipping
# only moves the visible start.
child["parent_period_start"] = child_start.isoformat(timespec="seconds")
child["visible_period_start"] = max(child_start, visible_origin).isoformat(timespec="seconds")
if child_start < visible_origin:
child["unclipped_start_datetime"] = child_start.isoformat(timespec="seconds")
child["start"] = visible_origin.strftime("%Y-%m-%d")
@@ -12547,6 +12560,9 @@ def cmd_dasha(args):
"end": se.strftime("%Y-%m-%d"),
"start_datetime": visible_start.isoformat(timespec="seconds"),
"unclipped_start_datetime": sdt.isoformat(timespec="seconds"),
# 占星师 2026-11-08 第五轮裁定 5 (same values as unclipped_start_datetime / start_datetime).
"parent_period_start": sdt.isoformat(timespec="seconds"),
"visible_period_start": visible_start.isoformat(timespec="seconds"),
"end_datetime": se.isoformat(timespec="seconds"),
"is_current": visible_start <= today < se,
"pratyantar_dasha_timeline": visible_children,
@@ -13875,6 +13891,9 @@ def cmd_shadbala(args):
# 交接分支 99076dad 的 required_strength_pct(sidecar;shadbala.py 冻结)。
from shadbala_display import attach_required_strength_alias
attach_required_strength_alias(result)
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices (sidecar).
from shadbala_service.sensitivity import attach_parameter_sensitivity
attach_parameter_sensitivity(result, planets=planets)
# BUG-1699: upstream Chesta profile candidates (sidecar; shadbala.py is frozen).
from shadbala_chesta_profiles import attach_chesta_profiles
return attach_chesta_profiles(result, planets=planets, context=context,
@@ -14898,6 +14917,8 @@ def _tajika_annual_core(args, planet_lons, asc_si, age, mode='all'):
natal_moon_longitude=sr.get('birth_moon_longitude'),
# BUG-1722: view clipped to this actual return -> next actual return; raw kept whole.
coverage_end=next_return_local(sr, int(args.year) + int(age), sr_cache['ayanamsa']),
# 第五轮裁定 2: actual-elapsed-time balance as a separate comparison.
birth_jd_ut=sr.get('birth_jd_ut'), ayanamsa_name=sr_cache['ayanamsa'],
)
result = {}
@@ -16814,6 +16835,9 @@ def cmd_full_reading(args):
# 交接分支 99076dad 的 required_strength_pct(sidecar;shadbala.py 冻结)。
from shadbala_display import attach_required_strength_alias
attach_required_strength_alias(shadbala_result)
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices (sidecar).
from shadbala_service.sensitivity import attach_parameter_sensitivity
attach_parameter_sensitivity(shadbala_result, planets=planets)
# BUG-1699: upstream Chesta profile candidates (sidecar; shadbala.py is frozen).
from shadbala_chesta_profiles import attach_chesta_profiles
attach_chesta_profiles(shadbala_result, planets=planets, context=context,
+19 -5
View File
@@ -654,12 +654,21 @@ def calc_kp_dba_timeline(
target_end: datetime,
planet_house_significators: Optional[Dict[str, Dict]] = None,
) -> Dict:
"""Build Vimshottari MD/AD/PD windows for KP-style marriage timing review."""
"""Build Vimshottari MD/AD/PD windows for KP-style marriage timing review.
占星师 2026-11-08 第五轮裁定 10: the first mahadasha is generated whole (it starts
``elapsed`` years before birth) and only then clipped at birth. 原值:首段 MD 按余额比例
缩放成 balance × 全长,再在缩放后的 MD 里按比例切 AD / PD,父段、子段与后续边界全部改变。
新值:完整父段起点 = 出生 − 已过部分;AD / PD 从完整父段切;出生前结束的行丢掉,跨出生的行
可见起点裁到出生,``parent_period_start`` 留原始起点。原因:与 Vimshottari 同一原则。
"""
birth_star_lord, balance = _kp_birth_star_balance(moon_longitude)
periods = []
md_start = birth_datetime
first_years = VIMSHOTTARI_YEARS[birth_star_lord]
md_start = birth_datetime - timedelta(days=_kp_years_to_days(first_years * (1.0 - balance)))
first_md_start = md_start
for md_i, md_lord in enumerate(_kp_next_lords(birth_star_lord) * 3):
md_years = VIMSHOTTARI_YEARS[md_lord] * (balance if md_i == 0 else 1.0)
md_years = VIMSHOTTARI_YEARS[md_lord]
md_end = md_start + timedelta(days=_kp_years_to_days(md_years))
ad_start = md_start
for ad_lord in _kp_next_lords(md_lord):
@@ -669,14 +678,17 @@ def calc_kp_dba_timeline(
for pd_lord in _kp_next_lords(ad_lord):
pd_years = ad_years * VIMSHOTTARI_YEARS[pd_lord] / 120.0
pd_end = pd_start + timedelta(days=_kp_years_to_days(pd_years))
if pd_end >= target_start and pd_start <= target_end:
visible_start = max(pd_start, birth_datetime)
if pd_end > birth_datetime and pd_end >= target_start and visible_start <= target_end:
scored = _kp_period_score([md_lord, ad_lord, pd_lord], planet_house_significators)
periods.append({
'md_lord': md_lord,
'ad_lord': ad_lord,
'pd_lord': pd_lord,
'start': pd_start.isoformat(),
'start': visible_start.isoformat(),
'end': pd_end.isoformat(),
'parent_period_start': pd_start.isoformat(),
'visible_period_start': visible_start.isoformat(),
**scored,
})
pd_start = pd_end
@@ -688,6 +700,8 @@ def calc_kp_dba_timeline(
'method': 'KP DBA timeline (Vimshottari MD/AD/PD)',
'birth_star_lord': birth_star_lord,
'birth_star_balance_fraction': balance,
'first_parent_period_start': first_md_start.isoformat(),
'first_segment_rule': 'full_parent_then_clip_at_birth',
'target_start': target_start.isoformat(),
'target_end': target_end.isoformat(),
'periods': periods,
+1 -1
View File
@@ -176,7 +176,7 @@ def _functional(chart: dict[str, Any]) -> dict[str, Any]:
from functional_layer import functional_layer_for_ascendant
except ModuleNotFoundError: # pragma: no cover
from scripts.functional_layer import functional_layer_for_ascendant
layer = functional_layer_for_ascendant(chart.get("ascendant"))
layer = functional_layer_for_ascendant(chart.get("ascendant"), chart.get("planets"))
return {
"status": layer.get("status"),
"benefics": list(layer.get("functional_benefics") or []),
+2 -2
View File
@@ -1480,7 +1480,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
])
# BUG-1208 (甲13): whether the BPHS minimum is met comes first; the
# 150/125/100/75/50 band is the site's own and is labelled so.
from shadbala_display import band_text as _band_text, check_text as _check_text, row_fields as _row_fields
from shadbala_display import band_text as _band_text, row_check_text as _row_check_text
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'):
row = _dict(shadbala_planets.get(planet))
if not row:
@@ -1492,7 +1492,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
f"{_num(_dict(row.get('dig_bala')).get('total') or row.get('dig_bala'))} | {_num(_dict(row.get('kala_bala')).get('total') or row.get('kala_bala'))} | "
f"{_num(_dict(row.get('chesta_bala')).get('total') or row.get('chesta_bala'))} | {_num(row.get('naisargika_bala'))} | "
f"{_num(_dict(row.get('drik_bala')).get('total') or row.get('drik_bala'))} | {_num(total)} | {_num(required)} | "
f"{_cell(_check_text(_row_fields(row)['meets_minimum'], _pl9_report_language(packet)))} | "
f"{_cell(_row_check_text(row, _pl9_report_language(packet)))} | "
f"{_cell(_band_text(row.get('strength_level'), _pl9_report_language(packet)))} |"
)
lines.append('')
+4 -2
View File
@@ -12,7 +12,7 @@ from datetime import date
from pathlib import Path
from typing import Any
from scripts.functional_benefics import derive_functional_benefic_malefic
from scripts.functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
from scripts.narayana_dasha import narayana_dasha_full_report
@@ -384,7 +384,9 @@ def replay_case(case: dict[str, Any], rule_version: str = "v1") -> dict[str, Any
"matched_expected_label": False, "blocked_reason": f"{type(exc).__name__}: {exc}",
}
roles = derive_functional_benefic_malefic(chart["ascendant"]["sign"])
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;月亮盈亏供动星座上升的月亮用。
roles = derive_functional_benefic_malefic(chart["ascendant"]["sign"],
moon_paksha=moon_paksha_from_planets(chart.get("planets")))
md, ad = _find_dasha(dasha, event["event_date"])
score = 0
signals: list[str] = []
+3 -1
View File
@@ -277,7 +277,9 @@ def score_event_with_policy(
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
target_lords = _house_lords(ascendant_index, target_houses)
functional = functional_benefics.derive_functional_benefic_malefic(
natal_chart["ascendant"].get("sign")
natal_chart["ascendant"].get("sign"),
# 第五轮裁定 3 后默认是 PVR 表 30;校正打分在冻结身份里,明确选中原产品表,分数不变(BUG-1746)。
functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS,
)
functional_benefics_set = set(functional.get("functional_benefics") or [])
functional_malefics_set = set(functional.get("functional_malefics") or [])
+30 -2
View File
@@ -41,9 +41,26 @@ def minimum_fields(rupas: Any, min_required: Any, band: Any) -> Dict[str, Any]:
return fields
PARAMETER_SENSITIVE = 'parameter_sensitive'
def row_fields(row: Dict[str, Any]) -> Dict[str, Any]:
"""Display fields for one `shadbala.calc_shadbala` planet row."""
return minimum_fields(row.get('total_rupas', row.get('rupas')), row.get('min_required'), row.get('strength_level'))
"""Display fields for one `shadbala.calc_shadbala` planet row.
占星师 2026-11-08 第五轮裁定 4: when `shadbala_service.sensitivity` found that the open
parameter choices flip the planet's pass/fail, `minimum_check` is
`parameter_sensitive` (the default verdict stays in `minimum_check_default`)
and the open questions that flip it are listed.
"""
fields = minimum_fields(row.get('total_rupas', row.get('rupas')), row.get('min_required'), row.get('strength_level'))
sensitivity = row.get('parameter_sensitivity') if isinstance(row.get('parameter_sensitivity'), dict) else {}
if row.get('minimum_check_status') == PARAMETER_SENSITIVE and fields.get('minimum_check'):
fields['minimum_check_default'] = fields['minimum_check']
fields['minimum_check'] = PARAMETER_SENSITIVE
fields['minimum_open_questions'] = list(sensitivity.get('open_questions') or [])
elif row.get('minimum_check_status'):
fields['minimum_check_status'] = row['minimum_check_status']
return fields
BAND_EN = {'极强': 'very strong', '强': 'strong', '充足': 'adequate', '略弱': 'slightly weak', '弱': 'weak', '极弱': 'very weak'}
@@ -65,6 +82,17 @@ def check_text(meets: Optional[bool], language: str = 'zh') -> str:
return MEETS_ZH if meets else MISSES_ZH
SENSITIVE_ZH = 'parameter_sensitive(是否达到 BPHS 最低要求取决于未定 Shadbala 参数,不据此下好坏结论)'
SENSITIVE_EN = 'parameter_sensitive (whether the BPHS minimum is met depends on open Shadbala parameters; no good/bad conclusion)'
def row_check_text(row: Dict[str, Any], language: str = 'zh') -> str:
"""`check_text` for a whole row: parameter_sensitive wins over the default verdict (第五轮裁定 4)."""
if isinstance(row, dict) and row.get('minimum_check_status') == PARAMETER_SENSITIVE:
return SENSITIVE_EN if language == 'en' else SENSITIVE_ZH
return check_text(row_fields(row)['meets_minimum'] if isinstance(row, dict) else None, language)
REQUIRED_STRENGTH_SEMANTICS = 'deprecated_alias_of_required_strength_pct_not_ishta_phala'
+6
View File
@@ -150,6 +150,12 @@ def compute_shadbala(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
except ImportError: # pragma: no cover - package import path
from scripts.shadbala_display import attach_required_strength_alias
attach_required_strength_alias(result)
# 占星师 2026-11-08 第五轮裁定 4: pass/fail under the open parameter choices (sidecar).
try:
from shadbala_service.sensitivity import attach_parameter_sensitivity
except ImportError: # pragma: no cover - package import path
from scripts.shadbala_service.sensitivity import attach_parameter_sensitivity
attach_parameter_sensitivity(result, planets=planets)
# BUG-1699: upstream Chesta profile candidates / selection (sidecar; the
# frozen shadbala.py is not touched). A requested profile is reported in
# method_variants.chesta_profile_result next to the unchanged default.
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Shadbala minimum check under the open parameter questions (占星师 2026-11-08 第五轮裁定 4).
The astrologer listed six Shadbala items that are not yet decided and ruled
that until they are, a planet's "meets / misses the BPHS minimum" must not
drive a good/bad conclusion when the open choices can flip it. This module
runs the alternatives for real and marks only the planets whose pass/fail
actually flips:
1. ``saptavargaja_moolatrikona``: the 45-virupa Moolatrikona slot. Default: D1
inside the degree band only. Alternatives: D1 whole sign; the band in D1
plus the whole Moolatrikona sign in the other six vargas; the whole sign in
all seven vargas. Open question: the seven-varga composition and the
Moolatrikona degree range.
2. ``ayana_sun``: default A in Kala and Chesta = A (2A). Alternatives: the
translator's doubled Sun in Kala (3A), doubled and copied to Chesta (4A).
Open question: is the Sun's Ayana Bala counted twice.
3. ``chesta_moon``: default Chesta = Paksha (2P in total). Alternative: no
Chesta for the Moon (P). Raman doubles Paksha and leaves Chesta out, which
is the same 2P as the default. Open question: is the Moon's Chesta her Paksha.
4. ``drik_angle``: default continuous longitudes. Alternative: whole-sign
(integer) aspect angles, i.e. the quarter / half / three-quarter / full
aspects. Open question: continuous degrees or integer aspects.
5. ``drik_special_aspects``: default the BPHS 26.9-12 special formulas replace
the general segment. Alternative: general value plus Raman's fixed bonus.
Open question: replace or add.
6. ``drik_mercury_jupiter``: default every aspect at a quarter. Alternatives:
Mercury / Jupiter at full value (1x), quarter plus full (1.25x for a
benefic caster). Open question: 1, 1.25 or 1/4.
Every combination of the options is evaluated (the Sun and Moon items only
touch those planets). ``shadbala.py`` is in the frozen rectification identity,
so nothing there changes: the default totals stay, this module adds
``parameter_sensitivity`` to each planet row and ``minimum_check_status``.
"""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from itertools import product
from typing import Any, Dict
try:
import shadbala as sb
except ImportError: # pragma: no cover - package import
from scripts import shadbala as sb
RULING = "占星师 2026-11-08 第五轮裁定 4"
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
STATUS_SENSITIVE = "parameter_sensitive"
STATUS_STABLE = "stable_across_open_parameters"
STATUS_UNVERIFIED = "sensitivity_not_verified"
AXES = {
"saptavargaja_moolatrikona": {
"default": "d1_degree_band_only",
"options": ("d1_degree_band_only", "d1_whole_sign", "d1_band_other_vargas_sign", "all_vargas_whole_sign"),
"open_question": "Saptavargaja:七分盘组成与 Moolatrikona 度数范围(只 D1 度数段 / 整个星座 / 其余六分盘是否也计 45)",
"planets": SEVEN,
},
"ayana_sun": {
"default": "base_ayana_chesta_equals_ayana",
"options": ("base_ayana_chesta_equals_ayana", "sun_doubled_in_kala", "sun_doubled_in_kala_and_chesta"),
"open_question": "Ayana Bala:太阳是否重复计入(A+A / 2A+A / 2A+2A)",
"planets": ("Sun",),
},
"chesta_moon": {
"default": "moon_chesta_equals_paksha",
"options": ("moon_chesta_equals_paksha", "moon_chesta_not_counted"),
"open_question": "Chesta Bala:月亮是否等于 Paksha Bala(P+P / 只计一次 P)",
"planets": ("Moon",),
},
"drik_angle": {
"default": "continuous_degrees",
"options": ("continuous_degrees", "whole_sign_integer_aspects"),
"open_question": "Drik Bala:连续度数还是整数相位(整宫 1/4、1/2、3/4、全相)",
"planets": SEVEN,
},
"drik_special_aspects": {
"default": "replace_general_segment",
"options": ("replace_general_segment", "add_to_general_value"),
"open_question": "特殊相照:替换普通相照还是另加",
"planets": SEVEN,
},
"drik_mercury_jupiter": {
"default": "quarter",
"options": ("quarter", "full_replaces_quarter", "quarter_plus_full"),
"open_question": "水星、木星相照:1/4、1 倍还是 1.25 倍(1/4 + 全值)",
"planets": SEVEN,
},
}
AXIS_ORDER = tuple(AXES)
CHAT_RULE = ("minimum_check = parameter_sensitive:达标与否随未定 Shadbala 参数翻转,"
"聊天与报告不得据此下好坏结论,只写「是否达标取决于未定参数」并列出未定项")
def _lon(name: str, planets: Dict) -> float:
return sb._planet_longitude(name, planets)
def _sapta(pname: str, planets: Dict, option: str, vargas: Dict[int, Dict[str, int]]) -> float:
longitude = _lon(pname, planets)
degree = longitude % 30.0
mt_sign, mt_start, mt_end = sb._MOOLATRIKONA[pname]
total = 0.0
for division, positions in vargas.items():
sign_idx = positions[pname]
owner = sb.SIGN_LORDS[sb.SIGNS[sign_idx]]
in_band = division == 1 and sign_idx == mt_sign and mt_start <= degree < mt_end
if option == "d1_degree_band_only":
mt = in_band
elif option == "d1_whole_sign":
mt = division == 1 and sign_idx == mt_sign
elif option == "d1_band_other_vargas_sign":
mt = in_band or (division != 1 and sign_idx == mt_sign)
else: # all_vargas_whole_sign
mt = sign_idx == mt_sign
if mt:
total += 45.0
elif owner == pname:
total += 30.0
else:
total += sb._compound_relation_score(pname, owner, vargas[1])
return round(total, 2)
def _drik(pname: str, planets: Dict, angle_mode: str, special: str, merc_jup: str) -> float:
benefics, malefics = sb.classify_drik_planets(planets)
target = _lon(pname, planets)
total = Decimal(0)
for other in sorted(benefics | malefics):
if other == pname:
continue
if angle_mode == "whole_sign_integer_aspects":
angle = ((int(target // 30) - int(_lon(other, planets) // 30)) % 12) * 30.0
else:
angle = target - _lon(other, planets)
if special == "add_to_general_value":
strength = sb._raman_sphuta_drishti_virupas(angle, other)
else:
strength = sb._sphuta_drishti_virupas(angle, other, "bphs")
hundredths = Decimal(round(strength * 100))
sign = 1 if other in benefics else -1
quarter = hundredths * sign / 4
if other in ("Mercury", "Jupiter") and merc_jup == "full_replaces_quarter":
total += hundredths * sign
elif other in ("Mercury", "Jupiter") and merc_jup == "quarter_plus_full":
total += quarter + hundredths
else:
total += quarter
return float((total / 100).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def planet_sensitivity(pname: str, row: Dict[str, Any], planets: Dict) -> Dict[str, Any]:
"""Pass/fail of one planet over every combination of the open options."""
minimum = row.get("min_required")
base_virupas = row.get("total_virupas")
if not isinstance(minimum, (int, float)) or not isinstance(base_virupas, (int, float)):
return {"status": STATUS_UNVERIFIED, "reason": "row_without_total_or_minimum"}
sthana = row.get("sthana_bala") if isinstance(row.get("sthana_bala"), dict) else {}
kala = row.get("kala_bala") if isinstance(row.get("kala_bala"), dict) else {}
vargas = sb._saptavarga_signs(planets)
# The default must be reproduced before any alternative is trusted.
default_sapta = _sapta(pname, planets, "d1_degree_band_only", vargas)
default_drik = _drik(pname, planets, "continuous_degrees", "replace_general_segment", "quarter")
if abs(default_sapta - float(sthana.get("sapta_score", default_sapta))) > 0.011 or \
abs(default_drik - float(row.get("drik_bala", default_drik))) > 0.011:
return {"status": STATUS_UNVERIFIED, "reason": "default_components_not_reproduced",
"reproduced": {"sapta_score": default_sapta, "drik_bala": default_drik}}
sapta_delta = {option: round(_sapta(pname, planets, option, vargas) - default_sapta, 2)
for option in AXES["saptavargaja_moolatrikona"]["options"]}
ayana = float(kala.get("base_ayana", kala.get("ayana", 0.0)) or 0.0)
ayana_delta = {"base_ayana_chesta_equals_ayana": 0.0, "sun_doubled_in_kala": ayana,
"sun_doubled_in_kala_and_chesta": 2 * ayana} if pname == "Sun" else {"base_ayana_chesta_equals_ayana": 0.0}
paksha = float(row.get("chesta_bala", 0.0) or 0.0)
chesta_delta = {"moon_chesta_equals_paksha": 0.0, "moon_chesta_not_counted": -paksha} if pname == "Moon" \
else {"moon_chesta_equals_paksha": 0.0}
drik_delta = {
combo: round(_drik(pname, planets, *combo) - default_drik, 2)
for combo in product(AXES["drik_angle"]["options"], AXES["drik_special_aspects"]["options"],
AXES["drik_mercury_jupiter"]["options"])
}
default_meets = base_virupas / sb.VIRUPAS_PER_RUPA >= minimum
totals = []
flip_sets: set[frozenset[str]] = set()
for (sapta, s_d), (ayana_opt, a_d), (chesta_opt, c_d), (drik_combo, d_d) in product(
sapta_delta.items(), ayana_delta.items(), chesta_delta.items(), drik_delta.items()):
virupas = base_virupas + s_d + a_d + c_d + d_d
rupas = virupas / sb.VIRUPAS_PER_RUPA
totals.append(rupas)
if (rupas >= minimum) != default_meets:
chosen = {"saptavargaja_moolatrikona": sapta, "ayana_sun": ayana_opt, "chesta_moon": chesta_opt,
"drik_angle": drik_combo[0], "drik_special_aspects": drik_combo[1],
"drik_mercury_jupiter": drik_combo[2]}
flip_sets.add(frozenset(axis for axis, value in chosen.items() if value != AXES[axis]["default"]))
# Minimal flipping sets: moving these axes off their defaults flips the verdict and no subset
# does (the full product contains every subset, so this is exact).
minimal = sorted((sorted(item, key=AXIS_ORDER.index) for item in flip_sets
if not any(other < item for other in flip_sets)), key=lambda row: (len(row), row))
flipping_axes = {axis for row in minimal for axis in row}
flips = bool(flip_sets)
# Single-axis flips: each axis moved alone, the rest at their defaults.
single = []
for axis, deltas in (("saptavargaja_moolatrikona", sapta_delta), ("ayana_sun", ayana_delta),
("chesta_moon", chesta_delta)):
if any(((base_virupas + d) / sb.VIRUPAS_PER_RUPA >= minimum) != default_meets for d in deltas.values()):
single.append(axis)
for index, axis in enumerate(("drik_angle", "drik_special_aspects", "drik_mercury_jupiter")):
for combo, d in drik_delta.items():
others_default = all(combo[i] == AXES[name]["default"] for i, name in
enumerate(("drik_angle", "drik_special_aspects", "drik_mercury_jupiter")) if i != index)
if others_default and ((base_virupas + d) / sb.VIRUPAS_PER_RUPA >= minimum) != default_meets:
single.append(axis)
break
return {
"status": STATUS_SENSITIVE if flips else STATUS_STABLE,
"default_meets_minimum": default_meets,
"min_required": minimum,
"default_rupas": round(base_virupas / sb.VIRUPAS_PER_RUPA, 4),
"rupas_range": [round(min(totals), 4), round(max(totals), 4)],
"combinations_evaluated": len(totals),
"flips_with_axes": [axis for axis in AXIS_ORDER if axis in flipping_axes],
"minimal_flip_sets": minimal,
"flips_with_single_axis": [axis for axis in AXIS_ORDER if axis in single],
"open_questions": [AXES[axis]["open_question"] for axis in AXIS_ORDER if axis in flipping_axes],
"component_deltas_virupas": {
"saptavargaja_moolatrikona": sapta_delta,
**({"ayana_sun": ayana_delta} if pname == "Sun" else {}),
**({"chesta_moon": chesta_delta} if pname == "Moon" else {}),
"drik": {"|".join(combo): delta for combo, delta in drik_delta.items()},
},
}
def attach_parameter_sensitivity(result: Any, *, planets: Any) -> Any:
"""Add the flip check to a ``shadbala.calc_shadbala`` result in place; default values unchanged."""
if not isinstance(result, dict) or not isinstance(result.get("planets"), dict) or not isinstance(planets, dict):
return result
sensitive = []
for pname, row in result["planets"].items():
if pname not in SEVEN or not isinstance(row, dict):
continue
try:
report = planet_sensitivity(pname, row, planets)
except Exception as exc: # the default result must survive a failed check
report = {"status": STATUS_UNVERIFIED, "reason": exc.__class__.__name__}
row["parameter_sensitivity"] = report
row["minimum_check_status"] = report["status"]
if report["status"] == STATUS_SENSITIVE:
sensitive.append(pname)
result["minimum_check_sensitivity"] = {
"ruling": RULING,
"method": "every combination of the open options below; Sun / Moon items only for those planets",
"axes": {axis: {"status": STATUS_SENSITIVE, "default": spec["default"], "options": list(spec["options"]),
"open_question": spec["open_question"]} for axis, spec in AXES.items()},
"planets_parameter_sensitive": sensitive,
"chat_rule": CHAT_RULE,
"defaults_unchanged": True,
}
variants = result.get("method_variants")
if isinstance(variants, dict):
variants["parameter_sensitive_items"] = {axis: AXES[axis]["open_question"] for axis in AXIS_ORDER}
return result
+2
View File
@@ -699,6 +699,8 @@ def solar_return_full_report(
completed_years=age,
natal_moon_longitude=sr.get('birth_moon_longitude'),
coverage_end=next_return_local(sr, target_year, ayanamsa_name),
# 第五轮裁定 2: actual-elapsed-time balance as a separate comparison.
birth_jd_ut=sr.get('birth_jd_ut'), ayanamsa_name=ayanamsa_name,
)
except Exception as e:
result['mudda_dasha'] = {'error': str(e)}
+19
View File
@@ -733,6 +733,18 @@ TAJIKA_FIVE_FOLD_LABEL = 'Tajika 五分力量'
TAJIKA_FIVE_FOLD_LABEL_EN = 'Tajika five-fold strength'
HARSHA_BLOCKED_UNTIL = (
"harsha_four_component_definitions",
"harsha_day_night_conditions",
"combined_maximum_is_40",
"20_plus_20_vs_15_plus_20_same_or_different_formula",
)
HARSHA_BLOCKED_REASON = (
"Harsha 合分 blocked(占星师 2026-11-08 第五轮裁定 2):未批准 U 的 40 分或 R 的 35 分作默认合分;"
"待确认①Harsha 四项分量的具体定义;②日间/夜间条件;③最大值是否为 40;④20+20 与 15+20 是否来自不同公式或不同尺度"
)
def calc_tajika_strength_layers(
planet_lons: Dict[str, float],
asc_lon: float = 0.0,
@@ -790,6 +802,9 @@ def calc_tajika_strength_layers(
row['status'] == 'usable' for row in panchavargiya_bala.values()
)
harsha_complete = all(row['status'] == 'complete' for row in harsha_bala.values())
# 占星师 2026-11-08 第五轮裁定 2: neither U's 40 nor R's 35 is approved as the default combined
# score; blocked until these are confirmed.
blocked_until = list(HARSHA_BLOCKED_UNTIL)
return {
'status': 'partial',
'method': 'Tajika Harsha/Panchavargiya Bala',
@@ -808,10 +823,14 @@ def calc_tajika_strength_layers(
for planet, data in harsha_bala.items()
},
'panchavargiya_bala': panchavargiya_bala,
'combined_strength_blocked_until': blocked_until,
'combined_strength_blocked_reason': HARSHA_BLOCKED_REASON,
'combined_strength': {
planet: {
'status': 'blocked',
'reason': 'combined_tajika_strength_not_ruled',
'blocked_until': blocked_until,
'reason_text': HARSHA_BLOCKED_REASON,
'score': harsha_bala[planet]['score'],
'max_score': harsha_bala[planet]['max_score'],
'grade': 'blocked',
+99
View File
@@ -64,6 +64,86 @@ def _parse(value: str) -> datetime:
return datetime.fromisoformat(str(value))
# ── 占星师 2026-11-08 第五轮裁定 2:余额字段与名义年 ─────────────────────────────────
NOMINAL_YEAR_DAYS_NOTE = ("Mudda 的名义年长;不等于两次真实太阳回归之间的间隔"
"(实际约 365.2535–365.2608 天,名义年末提前约 302–930 秒)")
BALANCE_METHOD_ANGLE = "nakshatra_arc_angle"
BALANCE_METHOD_TIME = "actual_elapsed_time"
NAKSHATRA_SPAN = 360.0 / 27.0
def balance_block(start_planet: Any, balance_moon_longitude: Any) -> Dict[str, Any]:
"""Default traditional balance: the Moon's arc already covered in its nakshatra (angle based)."""
try:
lon = float(balance_moon_longitude) % 360.0
except (TypeError, ValueError):
return {"status": "blocked", "reason": "balance_moon_longitude_required", "balance_method": BALANCE_METHOD_ANGLE}
elapsed = (lon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
return {
"status": "computed",
"balance_method": BALANCE_METHOD_ANGLE,
"is_default": True,
"start_planet": start_planet,
"elapsed_fraction": elapsed,
"remaining_fraction": 1.0 - elapsed,
"basis": "月亮在所在月宿内已走过的角度 ÷ 13°20′(默认传统算法)",
}
def time_balance_comparison(start_planet: Any, birth_jd_ut: Any, natal_moon_longitude: Any,
ayanamsa_name: Any = None) -> Dict[str, Any]:
"""Separate comparison: the share of the Moon's actual time in the nakshatra already elapsed at birth.
Never used for any date; never merged with the angle balance.
"""
base = {"balance_method": BALANCE_METHOD_TIME, "is_default": False, "used_for_dates": False,
"start_planet": start_planet}
try:
import swisseph as swe
try:
from ayanamsa_utils import sidereal_flags
except ImportError: # pragma: no cover - package import
from scripts.ayanamsa_utils import sidereal_flags
jd0 = float(birth_jd_ut)
natal = float(natal_moon_longitude) % 360.0
except (ImportError, TypeError, ValueError):
return {**base, "status": "blocked", "reason": "birth_jd_ut_and_swisseph_required"}
flags = sidereal_flags(swe, ayanamsa_name)
def moon(jd: float) -> float:
return float(swe.calc_ut(jd, swe.MOON, flags)[0][0]) % 360.0
offset = ((natal - moon(jd0) + 180.0) % 360.0) - 180.0
if abs(offset) > 0.05: # the natal longitude is not in this frame
return {**base, "status": "blocked", "reason": "natal_moon_frame_mismatch", "offset_degrees": round(offset, 4)}
index = int(natal // NAKSHATRA_SPAN)
def crossing(boundary: float, lo: float, hi: float) -> float:
def ahead(jd: float) -> bool:
return (((moon(jd) + offset - boundary + 180.0) % 360.0) - 180.0) >= 0.0
for _ in range(60):
mid = (lo + hi) / 2.0
if ahead(mid):
hi = mid
else:
lo = mid
return (lo + hi) / 2.0
entry = crossing(index * NAKSHATRA_SPAN, jd0 - 2.0, jd0)
exit_ = crossing((index + 1) * NAKSHATRA_SPAN, jd0, jd0 + 2.0)
elapsed = (jd0 - entry) / (exit_ - entry)
return {
**base,
"status": "computed",
"elapsed_fraction": elapsed,
"remaining_fraction": 1.0 - elapsed,
"nakshatra_entry_jd_ut": round(entry, 6),
"nakshatra_exit_jd_ut": round(exit_, 6),
"basis": "出生时月亮在本月宿已停留的实际时间 ÷ 月亮走完本月宿的实际时间(独立对照,不进日期)",
"difference_from_angle_fraction": None,
}
COVERAGE_BASIS = "actual_solar_return_to_next_actual_solar_return"
COVERAGE_FALLBACK = "nominal_year_fallback_next_return_unknown"
TIME_BASIS = "naive local wall time in the UTC offset of the solar-return instant"
@@ -80,6 +160,7 @@ def coverage_fields(raw: Dict[str, Any], year_length: str, coverage_end: Optiona
"nominal_year": {
"basis": year_length,
"days": float(raw["annual_duration_days"]),
"nominal_year_days": float(raw["annual_duration_days"]),
"start": raw["annual_start"],
"end": raw["annual_end"],
"note": "The dasha's own year length; not stretched to the next solar return.",
@@ -112,6 +193,8 @@ def annual_mudda_dasha(
natal_moon_longitude: Optional[float],
year_length: str = DEFAULT_YEAR_LENGTH,
coverage_end: Optional[datetime] = None,
birth_jd_ut: Optional[float] = None,
ayanamsa_name: Any = None,
) -> Dict[str, Any]:
"""Mudda Dasha for one Varsha year, anchored at the solar-return instant.
@@ -136,6 +219,12 @@ def annual_mudda_dasha(
year_days = float(raw["annual_duration_days"])
coverage = coverage_fields(raw, year_length, coverage_end)
periods, antardasha_periods = _crop(raw, *coverage.pop("_view_bounds"), year_days=year_days)
balance = balance_block(raw.get("starting_lord"), natal_moon_longitude)
comparison = time_balance_comparison(raw.get("starting_lord"), birth_jd_ut, natal_moon_longitude, ayanamsa_name) \
if birth_jd_ut is not None else {"balance_method": BALANCE_METHOD_TIME, "is_default": False,
"used_for_dates": False, "status": "blocked", "reason": "birth_jd_ut_not_passed"}
if comparison.get("status") == "computed" and balance.get("status") == "computed":
comparison["difference_from_angle_fraction"] = comparison["elapsed_fraction"] - balance["elapsed_fraction"]
return {
# The first-lord rule is the astrologer's round-5 ruling; dates are
# not externally replayed (upstream keeps its own parameter_sensitive).
@@ -155,6 +244,13 @@ def annual_mudda_dasha(
"natal_moon_nakshatra_number": raw.get("natal_moon_nakshatra_number"),
"balance_profile": raw.get("balance_profile"),
"balance_fraction_remaining": raw.get("balance_fraction_remaining"),
# 占星师 2026-11-08 第五轮裁定 2: angle balance is the default and drives the dates; the
# actual-elapsed-time balance is a separate comparison, never mixed into one number.
"balance": balance,
"balance_method": balance.get("balance_method"),
"balance_time_comparison": comparison,
"nominal_year_days": year_days,
"nominal_year_days_note": NOMINAL_YEAR_DAYS_NOTE,
**coverage,
"periods": periods,
"antardasha_periods": antardasha_periods,
@@ -276,6 +372,9 @@ def annual_mudda_profiles(
"varsha_lord": raw.get("varsha_lord"),
"balance_moon_longitude": raw.get("balance_moon_longitude"),
"balance_fraction_remaining": raw.get("balance_fraction_remaining"),
"balance": balance_block(raw.get("starting_lord") or first, raw.get("balance_moon_longitude"))
if profile != "without_dasha_balance" else {"status": "not_applicable", "balance_method": "none"},
"nominal_year_days": float(raw["annual_duration_days"]),
"annual_start": raw.get("annual_start"),
"annual_end": raw.get("annual_end"),
"annual_duration_days": float(raw["annual_duration_days"]),
+6 -3
View File
@@ -74,9 +74,9 @@ except Exception: # pragma: no cover - research helper is not vendored here
return {"status": "blocked", "reason": "finance_astrology_support_review_absent"}
try:
from functional_benefics import derive_functional_benefic_malefic
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
except Exception: # pragma: no cover - import path varies in tests/CLI
from scripts.functional_benefics import derive_functional_benefic_malefic
from scripts.functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
try:
from real_case_replay_validator import validate_manifest as validate_real_case_replay_manifest
@@ -947,7 +947,10 @@ class UnifiedConsultationOrchestrator:
)
ascendant = base_chart.get("ascendant") if isinstance(base_chart.get("ascendant"), dict) else {}
ascendant_sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
functional_layer = derive_functional_benefic_malefic(ascendant_sign)
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30;月亮盈亏供动星座上升的月亮用。
functional_layer = derive_functional_benefic_malefic(
ascendant_sign, moon_paksha=moon_paksha_from_planets(base_chart.get("planets")),
)
official = vedastro_official if isinstance(vedastro_official, dict) else {}
archive_manifest = vedastro_archive_manifest if isinstance(vedastro_archive_manifest, dict) else {}
raw_response = (
+15
View File
@@ -363,6 +363,21 @@ def calc_all_vargas(planet_lons, asc_lon, divisions=None, mode='classical_local'
'PVR Integrated Approach sections 6.2.5-6.2.11; D8 movable Aries, fixed Sagittarius, dual Leo'
if div in (5, 6, 8, 11) else mapping_status
)}}
if div == 30 and mode == 'classical_local':
# 原值: part_size = 30/30 = 1.0(D30 并非 30 等份,元数据误导)。新值: 同占星师 r40
# varga.py:part_size=None,写明不等长五段(奇 5/5/8/7/5、偶 5/7/8/5/5)与落座星座。
# 只改元数据,落座与座内度数不变。原因: 占星师 2026-11-08 第五轮裁定 13 第 4 步。
vd['_meta'].update(
part_size=None,
part_sizes={'odd': [5.0, 5.0, 8.0, 7.0, 5.0], 'even': [5.0, 7.0, 8.0, 5.0, 5.0]},
calculation_method='parashari_unequal_segment_linear_degrees_v1',
degree_basis='linear_progress_within_unequal_segment',
mapping_source='PVR Integrated Approach section 6.2.17',
source_segments={
'odd': [[0.0, 5.0, 0], [5.0, 10.0, 10], [10.0, 18.0, 8], [18.0, 25.0, 2], [25.0, 30.0, 6]],
'even': [[0.0, 5.0, 1], [5.0, 12.0, 5], [12.0, 20.0, 11], [20.0, 25.0, 9], [25.0, 30.0, 7]],
},
)
vd['Ascendant']=normalize_varga_ascendant(calc_varga(asc_lon,div,mode=mode))
for pn,lon in planet_lons.items():
vd[pn]=calc_varga(lon,div,mode=mode)
+9 -6
View File
@@ -603,18 +603,21 @@ class YogaContext:
def functional_malefics(self) -> List[str]:
"""Ownership roles only; natural nature is a separate rule predicate."""
try:
from .functional_benefics import derive_functional_benefic_malefic
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
except ImportError:
from functional_benefics import derive_functional_benefic_malefic
return derive_functional_benefic_malefic(self.ascendant)['functional_malefics']
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
# 占星师 2026-11-08 第五轮裁定 3:默认 PVR 表 30。
return derive_functional_benefic_malefic(
self.ascendant, moon_paksha=moon_paksha_from_planets(self.planets))['functional_malefics']
def functional_benefics(self) -> List[str]:
"""Use the same named ownership profile as the API and report."""
try:
from .functional_benefics import derive_functional_benefic_malefic
from .functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
except ImportError:
from functional_benefics import derive_functional_benefic_malefic
benefics = derive_functional_benefic_malefic(self.ascendant)['functional_benefics']
from functional_benefics import derive_functional_benefic_malefic, moon_paksha_from_planets
benefics = derive_functional_benefic_malefic(
self.ascendant, moon_paksha=moon_paksha_from_planets(self.planets))['functional_benefics']
return [p for p in benefics if p in self.planets]
# --- Shashtiamsa / Vaiseshikamsa 简化评分 ---
+3 -1
View File
@@ -136,7 +136,9 @@ def test_relationship_event_kinds_reuse_activation_without_fake_offsets(monkeypa
monkeypatch.setattr(
event_engine.functional_benefics,
"derive_functional_benefic_malefic",
lambda _sign: {"functional_benefics": [], "functional_malefics": []},
# 原值:lambda _sign: ...;新值:接受 functional_profile 关键字(校正打分明确选中 bphs_exception_profile)
# 原因:占星师 2026-11-08 第五轮裁定 3(BUG-1746)
lambda _sign, **_kw: {"functional_benefics": [], "functional_malefics": []},
)
common = {
"candidate_time": "05:13",
+6 -2
View File
@@ -75,8 +75,12 @@ def test_package_crossing_segment_uses_her_wording():
data = tajika_mudda.annual_mudda_dasha(**KW, coverage_end=NEXT_RETURN)
out = _crossing_segment(data, lambda value: _p(value).replace(tzinfo=zone), zone)
assert out["lord"] == data["raw_periods"][-1]["lord"]
assert out["text"].startswith("按上一年盘所用算法,该周期原始计算延伸至2027-02-27")
assert "本年盘报告只解释至下一次太阳回归" in out["text"]
# 原值:startswith("按上一年盘所用算法,该周期原始计算延伸至2027-02-27")
# 新值:她的原话,原始延伸终点放括号里。原因:占星师 2026-11-08 第五轮裁定 2
assert out["text"] == "该周期按原算法继续延伸,后续部分归入下一年盘解释(原始计算延伸至2027-02-27)"
# 原值:assert "本年盘报告只解释至下一次太阳回归" in out["text"];新值:删除(旧句已换成她的原话)
# 原因:占星师 2026-11-08 第五轮裁定 2
assert "后续部分归入下一年盘解释" in out["text"]
def test_calendar_year_spans_two_tajika_years_split_at_the_return():
+4 -1
View File
@@ -29,7 +29,10 @@ from yoga_engine import YogaEngine # noqa: E402
("Leo", "Jupiter", "benefic"), ("Capricorn", "Mercury", "benefic"),
])
def test_q4_ambiguous_cells(lagna: str, planet: str, role: str) -> None:
result = functional_benefics.derive_functional_benefic_malefic(lagna)
# 原值:derive_functional_benefic_malefic(lagna)(当时默认即产品表)。新值:明确选中 bphs_exception_profile,
# 这些 #166 Q4 格是对产品表的裁定;默认档改为 PVR 表 30,双子水星在那里是中性(见 profile_divergence)。
# 原因:占星师 2026-11-08 第五轮裁定 3
result = functional_benefics.derive_functional_benefic_malefic(lagna, functional_profile="bphs_exception_profile")
key = {"benefic": "functional_benefics", "malefic": "functional_malefics", "neutral": "functional_neutrals"}[role]
assert planet in result[key]
assert planet in result["ambiguous_cell_notes"]
@@ -0,0 +1,223 @@
"""Astrologer round-5 rulings 2026-11-08, items 2, 3, 4, 5, 10, 13 (BUG-1746..BUG-1751).
Expected values are written out independently (PVR Table 30 transcribed from the book page,
not read from the module). Public celebrity charts only (tests/celebrity_cases.json); no
private birth data.
"""
from __future__ import annotations
import json
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
for path in (ROOT, ROOT / "scripts"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
import functional_benefics as fb # noqa: E402
import kp_system # noqa: E402
import tajika_mudda # noqa: E402
import varga # noqa: E402
# PVR, Vedic Astrology: An Integrated Approach, §13.2 Table 30 (PDF p. 179): B / N / M per lagna.
TABLE_30 = {
"Aries": ("Sun Mars Jupiter", "", "Mercury Venus Saturn"),
"Taurus": ("Sun Mercury Saturn", "Mars", "Moon Jupiter Venus"),
"Gemini": ("Venus", "Moon Mercury Saturn", "Sun Mars Jupiter"),
"Cancer": ("Moon Mars Jupiter", "Sun Saturn", "Mercury Venus"),
"Leo": ("Sun Mars Jupiter", "Moon", "Mercury Venus Saturn"),
"Virgo": ("Mercury Venus", "Sun Saturn", "Moon Mars Jupiter"),
"Libra": ("Mercury Venus Saturn", "", "Sun Mars Jupiter"),
"Scorpio": ("Moon Jupiter", "Sun Mars", "Mercury Venus Saturn"),
"Sagittarius": ("Sun Mars", "Moon Mercury Jupiter", "Venus Saturn"),
"Capricorn": ("Venus Mercury Saturn", "Sun", "Mars Jupiter"),
"Aquarius": ("Venus Saturn", "Sun Mercury", "Moon Mars Jupiter"),
"Pisces": ("Moon Mars", "Jupiter", "Sun Mercury Venus Saturn"),
}
SIX_CELLS = {("Aries", "Mars"), ("Gemini", "Mercury"), ("Cancer", "Saturn"), ("Libra", "Venus"),
("Scorpio", "Sun"), ("Pisces", "Jupiter")}
# --- ruling 3: functional default = PVR Table 30 ---------------------------------------
@pytest.mark.parametrize("sign", sorted(TABLE_30))
def test_default_profile_is_table_30(sign: str) -> None:
result = fb.derive_functional_benefic_malefic(sign)
ben, neu, mal = (set(group.split()) for group in TABLE_30[sign])
assert result["functional_profile"] == "pvr_table_30"
assert set(result["functional_benefics"]) == ben
assert set(result["functional_neutrals"]) == neu
assert set(result["functional_malefics"]) == mal
assert "PVR §13.2" in result["dual_lordship_note"]
def test_six_table_cells_differ_and_both_roles_are_shown() -> None:
cells = set()
for sign in TABLE_30:
divergence = fb.derive_functional_benefic_malefic(sign)["profile_divergence"]
assert divergence["other_profile"] == "bphs_exception_profile" and divergence["other_profile_scored"] is False
for cell in divergence["cells"]:
if cell["kind"] == "table_cell":
cells.add((sign, cell["planet"]))
assert cell["pvr_table_30"] != cell["bphs_exception_profile"]
assert cells == SIX_CELLS
@pytest.mark.parametrize(("sign", "paksha", "role"), [
("Aries", "waxing", "malefic"), ("Libra", "waning", "neutral"), ("Capricorn", "waning", "neutral"),
("Capricorn", None, None),
])
def test_movable_lagna_moon_follows_the_table_note(sign, paksha, role) -> None:
result = fb.derive_functional_benefic_malefic(sign, moon_paksha=paksha)
moon = result["movable_lagna_moon"]
assert moon["listed_in_table_30"] is False and moon["role"] == role
lists = result["functional_benefics"] + result["functional_neutrals"] + result["functional_malefics"]
assert ("Moon" in lists) == (role is not None)
if role:
assert "Moon" in result[f"functional_{role}s"]
def test_bphs_exception_profile_scores_only_when_selected_and_prior_rulings_are_kept() -> None:
bphs = fb.derive_functional_benefic_malefic("Gemini", functional_profile="bphs_exception_profile")
assert "Mercury" in bphs["functional_benefics"]
assert "Mercury" in fb.derive_functional_benefic_malefic("Gemini")["functional_neutrals"]
prior = {row["planet"]: row for row in fb.derive_functional_benefic_malefic("Gemini")["prior_rulings"]}
assert prior["Saturn"]["agrees"] is True # BUG-1590
assert prior["Mercury"]["agrees"] is False # #166 Q4 note vs Table 30, resolved by the split
for sign in ("Aries", "Scorpio"): # BUG-1570
assert "Venus" in fb.derive_functional_benefic_malefic(sign)["functional_malefics"]
assert fb.derive_functional_benefic_malefic("Leo", functional_profile="nope")["status"] == "blocked"
def test_rectification_scoring_names_the_bphs_profile() -> None:
for path in ("scripts/active_rectification_event_engine.py", "scripts/chart_choice/astrologer_engine.py",
"scripts/research/precision_gate_lib.py"):
source = (ROOT / path).read_text(encoding="utf-8")
calls = source.count("derive_functional_benefic_malefic(")
assert calls and source.count("functional_profile=functional_benefics.FUNCTIONAL_PROFILE_BPHS") == calls, path
# --- ruling 4: Shadbala parameter_sensitive flip check -----------------------------------
def _shadbala(case_id: str) -> dict:
data = json.loads((ROOT / "tests/celebrity_cases.json").read_text(encoding="utf-8"))
cases = data.get("cases", data) if isinstance(data, dict) else data
case = next(c for c in (cases.values() if isinstance(cases, dict) else cases) if c["id"] == case_id)
args = [sys.executable, str(ROOT / "scripts/jyotish_engine.py"), "shadbala"]
for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz"):
args += [f"--{key}", str(case[key])]
out = json.loads(subprocess.run(args, capture_output=True, text=True, cwd=ROOT / "scripts", check=True).stdout)
return out.get("result", out)
def test_shadbala_flip_is_computed_not_labelled() -> None:
result = _shadbala("obama")
rows = result["planets"]
# Jupiter: default below the minimum; Mercury/Jupiter aspect at 1x or 1.25x lifts it over.
jupiter = rows["Jupiter"]["parameter_sensitivity"]
assert jupiter["status"] == "parameter_sensitive" and jupiter["default_meets_minimum"] is False
assert ["drik_mercury_jupiter"] in jupiter["minimal_flip_sets"]
assert jupiter["rupas_range"][0] < rows["Jupiter"]["min_required"] <= jupiter["rupas_range"][1]
# Only planets that actually flip are marked; the rest keep their verdict.
stable = [name for name, row in rows.items() if row["minimum_check_status"] == "stable_across_open_parameters"]
assert len(stable) == 6 and result["minimum_check_sensitivity"]["planets_parameter_sensitive"] == ["Jupiter"]
assert set(result["minimum_check_sensitivity"]["axes"]) == {
"saptavargaja_moolatrikona", "ayana_sun", "chesta_moon", "drik_angle", "drik_special_aspects",
"drik_mercury_jupiter"}
def test_display_turns_a_flipping_planet_into_parameter_sensitive() -> None:
from shadbala_display import row_check_text, row_fields
row = {"total_rupas": 6.0, "min_required": 6.5, "strength_level": "略弱",
"minimum_check_status": "parameter_sensitive", "parameter_sensitivity": {"open_questions": ["q"]}}
fields = row_fields(row)
assert fields["minimum_check"] == "parameter_sensitive" and fields["minimum_open_questions"] == ["q"]
assert fields["minimum_check_default"] == "未达到 BPHS 最低要求"
assert row_check_text(row).startswith("parameter_sensitive")
contract = (ROOT / "references/product_runtime_contract.md").read_text(encoding="utf-8")
assert "`minimum_check = parameter_sensitive` 的星,聊天与报告不得用「达标/不达标」下好坏结论" in contract
# --- ruling 5: Vimshottari parent / visible start ----------------------------------------
def test_birth_moment_rows_keep_parent_start_when_clipped() -> None:
from chart_dasha import birth_moment
md = {"lord": "Moon", "start": datetime(1955, 1, 1), "end": datetime(1965, 1, 1)}
birth = datetime(1958, 6, 1)
full = birth_moment.antardashas(md)
clipped = birth_moment.antardashas(md, visible_from=birth)
by_lord = {row["lord"]: row for row in full}
for row in clipped:
assert row["parent_period_start"] == by_lord[row["lord"]]["start"] # boundaries never move
assert row["end"] == by_lord[row["lord"]]["end"]
assert row["visible_period_start"] == max(row["parent_period_start"], birth)
assert clipped[0]["visible_period_start"] == birth and clipped[0]["parent_period_start"] < birth
# --- ruling 10: KP first segment ---------------------------------------------------------
def test_kp_first_segment_is_the_full_parent_clipped_at_birth() -> None:
birth = datetime(1990, 1, 1, 12, 0)
moon = 3 * (360 / 27) + (360 / 27) * 0.75 # Rohini (Moon), 75 % elapsed
out = kp_system.calc_kp_dba_timeline(birth, moon, birth, birth + timedelta(days=365.2425 * 5))
assert out["first_segment_rule"] == "full_parent_then_clip_at_birth"
parent = datetime.fromisoformat(out["first_parent_period_start"])
assert abs((birth - parent).total_seconds() / 86400 - 10 * 0.75 * 365.2425) < 1e-3
first = out["periods"][0]
# Moon MD, full 10 years: AD ends at 0.83, 1.42, 2.92, 4.25, 5.83, 7.25, 7.83 (Ketu) ... y; 7.5 y -> Ketu AD.
assert first["md_lord"] == "Moon" and first["ad_lord"] == "Ketu"
assert first["start"] == birth.isoformat() and datetime.fromisoformat(first["parent_period_start"]) < birth
md_end = parent + timedelta(days=10 * 365.2425)
assert any(datetime.fromisoformat(row["start"]) == md_end and row["md_lord"] == "Mars" for row in out["periods"])
# --- ruling 2: Mudda balance, nominal year, wording, Harsha ------------------------------
def test_mudda_balance_fields_and_nominal_year() -> None:
import swisseph as swe
out = tajika_mudda.annual_mudda_dasha(
annual_start=datetime(2026, 2, 25, 0, 21, 4), completed_years=71, natal_moon_longitude=345.95793509035576,
coverage_end=datetime(2027, 2, 25, 6, 29, 6), birth_jd_ut=swe.julday(1955, 2, 25, 3.25), ayanamsa_name="raman")
balance, comparison = out["balance"], out["balance_time_comparison"]
assert out["balance_method"] == "nakshatra_arc_angle" and balance["is_default"] is True
assert balance["start_planet"] == out["starting_lord"]
assert balance["remaining_fraction"] == pytest.approx(out["balance_fraction_remaining"])
assert balance["elapsed_fraction"] + balance["remaining_fraction"] == pytest.approx(1.0)
assert comparison["balance_method"] == "actual_elapsed_time" and comparison["used_for_dates"] is False
assert comparison["status"] == "computed" and comparison["elapsed_fraction"] != balance["elapsed_fraction"]
assert out["nominal_year_days"] == 365.25 and out["nominal_year"]["nominal_year_days"] == 365.25
def test_harsha_combined_blocked_reason_names_the_open_items() -> None:
from tajika import calc_tajika_strength_layers
lons = {"Sun": 10.0, "Moon": 50.0, "Mars": 100.0, "Mercury": 20.0, "Jupiter": 200.0, "Venus": 330.0, "Saturn": 280.0}
out = calc_tajika_strength_layers(lons, 15.0, "Sun", is_daytime=True)
assert out["combined_strength_blocked_until"] == [
"harsha_four_component_definitions", "harsha_day_night_conditions", "combined_maximum_is_40",
"20_plus_20_vs_15_plus_20_same_or_different_formula"]
reason = out["combined_strength"]["Sun"]["reason_text"]
for words in ("四项分量", "日间/夜间", "40", "20+20 与 15+20"):
assert words in reason
assert all(row["status"] == "blocked" for row in out["combined_strength"].values())
# --- ruling 13 step 4: D30 metadata ------------------------------------------------------
def test_d30_meta_describes_unequal_parts() -> None:
meta = varga.calc_all_vargas({"Sun": 12.0}, 0.0, [30])["D30_Trimsamsa"]["_meta"]
assert meta["part_size"] is None
assert meta["part_sizes"] == {"odd": [5.0, 5.0, 8.0, 7.0, 5.0], "even": [5.0, 7.0, 8.0, 5.0, 5.0]}
# The described segments are the ones the mapping uses.
for parity, sign_idx in (("odd", 0), ("even", 1)):
for start, end, target in meta["source_segments"][parity]:
assert varga.calc_varga(sign_idx * 30 + (start + end) / 2, 30)["sign_idx"] == target
+4 -3
View File
@@ -137,9 +137,10 @@ def test_functional_roles_keep_profile_identities_and_pending_nodes():
result = compute_chart_points(BODY)["functional_roles"]
assert result["status"] == "computed"
assert result["ascendant"] == "Leo"
# 原值 "bphs_ch34_general_with_sign_exceptions_v2";新值 "..._v3_166q4"(上游 v3 表 + #166 Q4 土星行)
# 原因:三方对照 2026-11-07:产品侧实现缺陷——标签写 v2,实际表不是 v2
assert result["profile"] == "bphs_ch34_general_with_sign_exceptions_v3_166q4"
# 原值 "bphs_ch34_general_with_sign_exceptions_v3_166q4";新值 "pvr_integrated_approach_13_2_table_30"
# (默认 PVR 表 30,狮子上升两档相同)。原因:占星师 2026-11-08 第五轮裁定 3
assert result["profile"] == "pvr_integrated_approach_13_2_table_30"
assert result["functional_profile"] == "pvr_table_30" and result["profile_divergence"]["status"] == "same"
assert "jyotishvidya.com/ch34.htm" in result["translation"]
rows = {row["planet"]: row for row in result["rows"]}
assert list(rows) == ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
+4 -1
View File
@@ -23,7 +23,10 @@ def test_every_lagna_mirrors_the_profile(sign):
table = functional_role_table(sign)
rows = {row["planet"]: row for row in table["rows"]}
for planet in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"):
expected = "benefic" if planet in base["functional_benefics"] else "malefic" if planet in base["functional_malefics"] else "neutral"
# 原值:不在吉 / 凶表里一律 "neutral"。新值:三表都不在(PVR 表 30 不列动星座上升的月亮、盈亏未给)为 "pending"。
# 原因:占星师 2026-11-08 第五轮裁定 3(默认 PVR 表 30)
expected = ("benefic" if planet in base["functional_benefics"] else "malefic" if planet in base["functional_malefics"]
else "neutral" if planet in base["functional_neutrals"] else "pending")
assert rows[planet]["functional"] == expected
assert rows[planet]["owned_houses"] == base["owned_houses"][planet]
assert ({"key": "yogakaraka"} in rows[planet]["identities"]) == (planet in base["yogakarakas"])
@@ -19,6 +19,11 @@ YOGAKARAKAS = {
# 见 references/functional-role-profile.md 与 BUG-1158
from scripts.functional_benefics import ROLE_OVERRIDES # noqa: E402
# 原值:derive_functional_benefic_malefic(sign)(当时的默认档就是这张产品表)。
# 新值:本文件锁的是产品表,必须明确选中 functional_profile="bphs_exception_profile";默认档改为 PVR 表 30,
# 见 tests/test_functional_pvr_default_20261108.py。原因:占星师 2026-11-08 第五轮裁定 3
BPHS = "bphs_exception_profile"
LAGNA_LORDS = {
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Virgo": "Mercury", "Libra": "Venus",
"Scorpio": "Mars", "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter",
@@ -27,7 +32,7 @@ LAGNA_LORDS = {
@pytest.mark.parametrize("sign", sorted(LAGNA_LORDS))
def test_lagna_lord_role_follows_profile(sign: str) -> None:
result = derive_functional_benefic_malefic(sign)
result = derive_functional_benefic_malefic(sign, functional_profile=BPHS)
lord = LAGNA_LORDS[sign]
expected = ROLE_OVERRIDES.get(sign, {}).get(lord, "benefic")
assert lord in result[f"functional_{expected}s"], (sign, lord, expected)
@@ -38,22 +43,22 @@ def test_lagna_lord_role_follows_profile(sign: str) -> None:
@pytest.mark.parametrize("sign", SIGNS)
def test_sign_overrides_win(sign: str) -> None:
result = derive_functional_benefic_malefic(sign)
result = derive_functional_benefic_malefic(sign, functional_profile=BPHS)
for planet, role in ROLE_OVERRIDES.get(sign, {}).items():
assert planet in result[f"functional_{role}s"], (sign, planet, role)
def test_sole_twelfth_lord_is_not_automatically_malefic() -> None:
# Virgo: the Sun owns only the 12th; Leo: the Moon owns only the 12th.
assert "Sun" not in derive_functional_benefic_malefic("Virgo")["functional_malefics"]
assert "Moon" not in derive_functional_benefic_malefic("Leo")["functional_malefics"]
assert "Sun" not in derive_functional_benefic_malefic("Virgo", functional_profile=BPHS)["functional_malefics"]
assert "Moon" not in derive_functional_benefic_malefic("Leo", functional_profile=BPHS)["functional_malefics"]
@pytest.mark.parametrize(("sign", "houses"), [("Aries", [2, 7]), ("Scorpio", [7, 12])])
def test_venus_is_functional_malefic_for_aries_and_scorpio(sign: str, houses: list[int]) -> None:
# BUG-1570(产品 2026-11-04 决定):BPHS 34.20「mandasaumyasitāḥ pāpāḥ」、34.35「sitajñaśanayaḥ pāpāḥ」
# 点名金星(sita)为凶;逐上升明文优先于总则(只管角宫 / 2 宫 → 中性)。期望值独立写出,不读 ROLE_OVERRIDES。
result = derive_functional_benefic_malefic(sign)
result = derive_functional_benefic_malefic(sign, functional_profile=BPHS)
assert result["owned_houses"]["Venus"] == houses
assert "Venus" in result["functional_malefics"]
assert "Venus" not in result["functional_neutrals"] + result["functional_benefics"]
+5 -1
View File
@@ -106,7 +106,11 @@ def test_birth_data_request_returns_precise_shadbala(api_base) -> None:
}
assert chesta_keys <= set(result["method_variants"])
assert result["method_variants"]["chesta_profile_selection"]["effective_profile"] == "bphs_bounded_surya_mean_motion_seeghrochcha"
assert {k: v for k, v in result["method_variants"].items() if k not in chesta_keys} == {
# 原值:method_variants 只有下列具名口径。新值:另有 parameter_sensitive_items(六个未定项及其问题),单独核对。
# 原因:占星师 2026-11-08 第五轮裁定 4
assert set(result["method_variants"]["parameter_sensitive_items"]) == {
"saptavargaja_moolatrikona", "ayana_sun", "chesta_moon", "drik_angle", "drik_special_aspects", "drik_mercury_jupiter"}
assert {k: v for k, v in result["method_variants"].items() if k not in chesta_keys | {"parameter_sensitive_items"}} == {
"kala": "bphs_local_solar_events_ahargana_declination",
"chesta": "bphs_bounded_surya_mean_motion_seeghrochcha",
# 原值: moon_paksha bphs_santhanam_27_10_11_chesta_27_18,无下列五项
+5 -2
View File
@@ -56,15 +56,18 @@ def test_pvr_table30_is_a_separate_named_profile_and_virgo_matches_product():
# 占星师 2026-11-07 Q7: Virgo Sun / Saturn are functional neutrals in PVR Table 30.
from functional_benefics import derive_functional_benefic_malefic, pvr_table30_roles, PVR_TABLE30_PROFILE
product = derive_functional_benefic_malefic("Virgo")
# 原值:derive_functional_benefic_malefic("Virgo") 是产品表,profile_is_not == PVR。新值:产品表须明确选中
# bphs_exception_profile;默认档是 PVR 表 30。原因:占星师 2026-11-08 第五轮裁定 3
product = derive_functional_benefic_malefic("Virgo", functional_profile="bphs_exception_profile")
pvr = pvr_table30_roles("Virgo")
assert {"Sun", "Saturn"} <= set(product["functional_neutrals"])
assert pvr["functional_neutrals"] == ["Saturn", "Sun"]
assert product["profile"] != PVR_TABLE30_PROFILE and product["profile_is_not"] == PVR_TABLE30_PROFILE
assert derive_functional_benefic_malefic("Virgo")["profile"] == PVR_TABLE30_PROFILE
differing = []
for sign in ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius",
"Capricorn", "Aquarius", "Pisces"):
ours, ref = derive_functional_benefic_malefic(sign), pvr_table30_roles(sign)
ours, ref = derive_functional_benefic_malefic(sign, functional_profile="bphs_exception_profile"), pvr_table30_roles(sign)
for role in ("functional_benefics", "functional_neutrals", "functional_malefics"):
for planet in set(ref[role]) - set(ours[role]):
differing.append((sign, planet))