diff --git a/CHANGELOG.md b/CHANGELOG.md index ce717dde..383de736 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-05 — 报告页可下载全量数据附录 + +主报告仍是五章叙事。ready 之后可以另下一份 Markdown 全量数据附录(三年年度、KP 月度、功能吉凶、校时敏感度等)。附录失败只显示暂不可用,不会把主报告打回失败,也不退款。Skill 版本未变。 + ## 2026-09-05 — 有现成区分卡时先问卡,没有卡才补采集 生时校正里,家人那题已经覆盖或说「没有」之后,如果引擎已经算出能分开剩余分钟的选择卡,就先问那张卡。只有这一轮拿不出可点的区分卡时,才继续问还没问过的钱、搬家、身体这些带年份的经历。Skill 版本仍是 10.0.14。 diff --git a/docs/tasks/PROGRESS-report-longform-parity-20260905.md b/docs/tasks/PROGRESS-report-longform-parity-20260905.md new file mode 100644 index 00000000..f71bb480 --- /dev/null +++ b/docs/tasks/PROGRESS-report-longform-parity-20260905.md @@ -0,0 +1,90 @@ +# PROGRESS · 长报告对齐全量版并挂入产品(2026-09-05) + +工作树:`/Users/jesse/Downloads/Copse/astrology/.worktrees/report-longform-parity-20260905` +分支:`codex/report-longform-parity-20260905` +基线:任务书写 `d2955c62` 之后;开工 `origin/staging` 为 `3fd001ca`(含本任务书);收尾已快进到 `18a5a488`。 +任务书:仓库根 `TASK-report-longform-parity-20260905.md` +未改 `.gitea/workflows/**`,不提升 main,不反向同步上游 yinduzhanxing,未把两份真实对标报告入库。 +五章 writer / bundle / schema 未改。附录失败不改 `personal_reports.status`、不触发退款。 + +并行队列:BUG-535 chart-cap 已在 staging `4ef4c406` / `73c2a1a5` 关闭并真实五章 ready,本轮未重做。 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 0 全量时长与覆盖基线 | 完成 | 虚构北京盘;同步生成,不改异步 job | +| 1 调用面 / 装配对齐 | 完成 | 三年年度、KP 月度、五系统表、专题三表、Upagraha、KP Lord/Sub、功能吉凶 | +| 2 年度层 Muntha / Mudda / Patyayini | 完成(诚实标签,见下) | 未把 blocked/conflict 涂成可读 | +| 3 校时敏感层 | 完成 | 无窗「未做校时」;有窗走 `provisional` + `candidate_range` | +| 4 成品阅读导航 | 完成 | 生成器确定性产出,不依赖 Agent 后处理 | +| 5 产品附录存储 + 下载入口 | 完成 | 首次请求同步生成 + 缓存;`maxDuration=300` | +| 6 部署后真实核对 | 未做 | 待 staging 部署后按覆盖表抽查 | + +## 任务 0 · 虚构北京盘 smoke + +出生资料按任务书:`1990-01-01 12:00`,`39.9042, 116.4074`,tz 8,Raman,`--today 2026-09-05 --target-year 2026 --pack full --format markdown`。产物只在 `/tmp/longform-parity-20260905/`,不入库。 + +| 指标 | 装配前(同盘) | 本轮装配后 | +| --- | --- | --- | +| 墙钟 | 20.67s | **7.64s** | +| 体积 | 250,819 B / 2605 行 / 155 标题 | **282,502 B / 2965 行 / 178 标题** | +| 旧 Web 参考版 | 约 188K / 2088 行 | 已超过 | +| skill 全量版 | 约 391K / 3634 行 | 仍薄一截,剩余主要是受限材料与未 vendor 的 `professional_parity_closure` | + +止损:单次远低于 5 分钟,任务 5 保持同步生成 + 表缓存。`maxDuration = 300` 足够。 + +## 覆盖对照(标题结构,虚构盘) + +| 差距项 | 装配后 | 状态标签 | +| --- | --- | --- | +| 成品阅读导航五块 | present | 质量门本次 `blocked`(37/49 passed),不改写正文 | +| 2026/2027/2028 年度重点 | present | 年度层可读壳 | +| 大运与时间主线五系统表 | present | Vimshottari / Narayana / Yogini / Ashtottari / Kala Chakra 均为 `executed` + `parameter_sensitive` | +| KP 三年流月 + 重点月份 | present | `parameter_sensitive` | +| Muntha | present | **`conflict`**(solar_return 与 tajika 同 profile 不一致,保持仲裁标签) | +| Mudda Dasha | present | `executed` / `partial_verified` | +| Patyayini | present(外部回放段) | 回放 `pyjhora_behavior_only / not_multiengine_parity`;质量门 `patyayini_annual_support=blocked`(本地 producer 仍缺) | +| 专题 KP 小表 / 宫位核对 / 月度 MD-AD | present | `parameter_sensitive` | +| 功能性吉凶与 Yogakaraka | present | `used` | +| Upagraha 表 | present | `parameter_sensitive` | +| KP Lord/Sub、Ruling Planets、ABCD | present | `parameter_sensitive` | +| 出生时间敏感度 / 未做校时 | present | `not_rectified` | +| 质量门对照表若干受限层 | missing | `restricted_or_unclosed`:`jaimini_special_points` / `tajika_named_yoga` / `annual_sahams` / `kranti` / `alternate_ashtottari`(正文另有本地 Saham/Jaimini 段,质量门走受限材料路径,未涂成 closed) | + +## 实现要点 + +### 引擎调用面 + +- `_export_args` 与 API `_high_rigor_birth_payload` 默认 `today` / `target_year` / `age`;`age` 不再重复 kwargs。 +- `cmd_full_reading` 补 `cmd_kp`、`_build_natal_foundation_modules`(panchanga / upagrahas / bhava_chalit / moon_chart / sudarshana / functional_benefic_malefic)、`varga_research_high`。 +- `cmd_kp` 算完立即把 Swiss Ephemeris 岁差从 KP 恢复到报告口径(默认 Raman),避免后续本命模块被带跑。 +- `professional_parity_closure` 未 vendor(约 3500 行)。`dasha_master_pack` 装配失败时用本地 Vimshottari / Narayana / Yogini / Ashtottari / Kala Chakra 填五系统表,不假装外部 dasha oracle 已闭环。 +- 无候选窗时 `birth_time_sensitivity` 保留为 `not_rectified` /「未做校时」,不再整段消失。 +- 成品阅读导航由 `_render_finished_reading_navigation` 写出。 + +### 产品附录 + +- 迁移 `frontend/supabase/migrations/20260905010000_personal_report_longform_appendices.sql`:PK=`report_id`,另有 `(user_id, request_id)` FK;owner SELECT;`service_role` 写;错误码无正文。未双写冻结的 `frontend/db/migrations`。 +- `POST /api/reports/[reportId]/professional-reference`:`runtime=nodejs`,`maxDuration=300`;鉴权/同源/本人/主报告 ready 不变;缓存命中直接返回;`unavailable` → 503「全量数据附录暂不可用」;payload 带 `packs:["full"]`、`today`/`target_year`/`age`、有窗则 `birth_time_accuracy=provisional`。缓存写入失败不阻断下载,也不改 `personal_reports`。 +- UI:报告中心与阅读页按钮改为「全量数据附录」;打印通道仍独立。 + +## 测试与门禁 + +- 定向 pytest(`test_report_longform_parity` + 参考导出 / quality gate / 生时敏感 / KP monthly / calculation profile / annual pack):全部绿,3 skipped(未 vendor 的 dasha-master gate)。 +- `./node_modules/.bin/tsc --noEmit`:0 +- 定向前端:`professional-report-reference-route` + `personal-report-longform-appendix` + entry/view 相关:**通过** +- `tests/database-personal-report-longform-appendices.test.ts`:**1 pass**(owner 可读、他人 0 行、authenticated 不能 insert、appendix `unavailable` 后 `personal_reports.status` 仍 `ready`) +- 未改 `.gitea/workflows/**`;未跑全量 `npm test` / 未提升 main +- 任务 6 部署后真实用户附录未做 + +## 让步 + +- 未 vendor `professional_parity_closure.py`。五系统表走本地模块状态;辅助大运明细表仍可能缺 master-pack 周期行。 +- Patyayini 本地 producer 仍无;只呈现 PyJHora 回放并保持 `pyjhora_behavior_only`。质量门继续 `blocked`。 +- Muntha `conflict` 未做 producer 仲裁。 +- 质量门整体 `blocked`(37/49)。对照表里受限材料 missing 保持 missing。 +- 附录是首次点击同步生成,不是后台异步 job。7.64s 墙钟支持该选择。 +- 任务 6 必须等 staging 部署。 + +## BUG_HISTORY + +无新增产品 Bug。BUG-535 保持 resolved,本轮未重开。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 5ce5e069..cbe3b6dd 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -83,6 +83,7 @@ | `TASK-report-sensitivity-crash-20260904.md`(仓库根) | `PROGRESS-report-sensitivity-crash-20260904.md` | consultation_workflow float 时辰崩溃,全量 500 | 执行中 | `codex/report-sensitivity-crash-20260904`(BUG-524) | | `TASK-report-candidate-range-read-20260904.md`(仓库根) | `PROGRESS-report-candidate-range-20260904.md` | accepted 生时报告直读已收权校正表 | 待验收 | `e27d5dc5`(BUG-526/534;终稿见 BUG-535) | | `TASK-report-chart-cap-20260904.md`(仓库根) | `PROGRESS-report-chart-cap-20260904.md` | 文档 charts 上限绑定 CHART_IDS | 待验收 | `4ef4c406`(BUG-535) | +| `TASK-report-longform-parity-20260905.md`(仓库根) | `PROGRESS-report-longform-parity-20260905.md` | 长报告对齐全量版并挂入产品附录 | 待验收 | `codex/report-longform-parity-20260905`(任务 6 待部署后核对) | ### 前端基础与工程 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 4b9b5a56..08c37d61 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -346,20 +346,20 @@ Text release is paced, not animated: the frame buffer commits at most once per a - **Surface:** `--color-canvas-soft` floor; cards use `--color-canvas`, a warm hairline, and `--radius-lg`. No drop shadow. Status is a caption badge, not a colored block. - **Typography:** page title uses `--type-display-lg` / `--font-display` at weight 400 with `text-wrap: balance`. Card titles are `--type-title-md` at weight 500. Body stays `--type-body-md` or `--type-body-sm`. - **Width:** 900px centered, matching long-form chat reading. Cards stack below 720px. -- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records. +- **Actions:** “生成完整报告” is the one filled action. A ready report keeps “查看报告” as the primary document action and may add the quieter “全量数据附录” action beside it. The appendix action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records. Appendix generation never changes the stored five-chapter report status. - **States:** loading, empty, populated, generating, ready, failed, unauthorized, list error. Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow. - **Accessibility:** back, generate, refresh, and row actions are 44px. Status text uses a live region while a report is generating. ### Personal report reader -- **Structure:** sticky screen chrome (back, print), then an answer-first document: cover, executive judgement, natal chart evidence, thematic sections, appendix, provenance. +- **Structure:** sticky screen chrome (back, print, full-data appendix), then an answer-first document: cover, executive judgement, natal chart evidence, thematic sections, appendix, provenance. - **Surface:** page floor `--color-canvas-soft`; the document is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome, and pins the light palette. - **Typography:** the subject name uses `--type-display-lg` serif at weight 400. Section titles use `--type-display-sm` serif at weight 400. Theme headings are `--type-title-md` sans at weight 500. Narrative is `--type-body-md` at 1.65, the same measure as chat answers. Labels stay 12–13px. - **Accent:** claim-status pills and evidence links may use the action color; headings stay ink. Dark ink is never a page-scale rule or card edge. - **Charts:** North-Indian SVG uses theme ink and canvas fills, never hardcoded light-only hex. - **Width:** 900px for the document; chart and evidence columns stack at 760px. - **States:** loading, generating, timed-out, unauthorized, not-found, failed, invalid, network-error, ready. Waiting uses `InlineSpinner`. -- **Accessibility:** back, print, and appendix disclosure are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. +- **Accessibility:** back, print, appendix download, and appendix disclosure are 44px. Generating copy uses `role="status"`. Print remains keyboard-initiated from the chrome button. ### Product entrypoint card diff --git a/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts index b273a9b2..f7085d09 100644 --- a/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts +++ b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts @@ -1,21 +1,31 @@ import { NextResponse } from "next/server"; +import { + LONGFORM_APPENDIX_TABLE, + nextLongformAppendixState, + parseLongformAppendixRow, +} from "@/lib/personal-report-longform-appendix"; import { checkSameOrigin, resolveAllowedReportOrigins, } from "@/lib/personal-report-entitlement"; import { createSupabasePersonalReportService } from "@/lib/personal-report-service"; +import { loadReportCandidateRange } from "@/lib/report-candidate-range"; import { ACCOUNT_BIRTH_SELECT, globalBirthProfileFromAccountRow, } from "@/lib/server-owned-birth-profile"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; export const runtime = "nodejs"; +export const maxDuration = 300; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; +const APPENDIX_SELECT = + "report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code"; type RouteContext = { params: Promise<{ reportId: string }> }; @@ -24,7 +34,19 @@ type ProfessionalReferenceResponse = Readonly<{ markdown?: unknown; }>; -function birthPayload(row: unknown): Record | null { +function utcToday(): string { + return new Date().toISOString().slice(0, 10); +} + +function birthPayload( + row: unknown, + extras: Readonly<{ + today: string; + targetYear: number; + candidateRange: { start_time: string; end_time: string } | null; + birthTimeAccuracy: "confirmed" | "provisional"; + }>, +): Record | null { const profile = globalBirthProfileFromAccountRow(row); const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? ""); const timeMatch = /^(\d{2}):(\d{2})$/.exec(profile.time ?? ""); @@ -37,8 +59,9 @@ function birthPayload(row: unknown): Record | null { ) { return null; } + const year = Number.parseInt(dateMatch[1], 10); return { - year: Number.parseInt(dateMatch[1], 10), + year, month: Number.parseInt(dateMatch[2], 10), day: Number.parseInt(dateMatch[3], 10), hour: Number.parseInt(timeMatch[1], 10), @@ -49,19 +72,36 @@ function birthPayload(row: unknown): Record | null { ayanamsa: profile.ayanamsa, format: "markdown", packs: ["full"], + today: extras.today, + target_year: extras.targetYear, + age: extras.targetYear - year, + birth_time_accuracy: extras.birthTimeAccuracy, + ...(extras.candidateRange ? { candidate_range: extras.candidateRange } : {}), }; } +function unavailableResponse() { + return NextResponse.json({ error: "全量数据附录暂不可用" }, { status: 503 }); +} + function upstreamError(status: number, retryAfter: string | null) { return NextResponse.json( - { error: status === 429 ? "专业参考版生成繁忙,请稍后重试" : "专业参考版暂时无法生成" }, + { error: status === 429 ? "全量数据附录生成繁忙,请稍后重试" : "全量数据附录暂不可用" }, { - status, + status: status === 429 ? 429 : 503, headers: retryAfter ? { "Retry-After": retryAfter } : undefined, }, ); } +function tryAdminClient() { + try { + return createAdminSupabaseClient(); + } catch { + return null; + } +} + export async function POST(request: Request, context: RouteContext) { try { const supabase = await createServerSupabaseClient(); @@ -93,16 +133,63 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: "报告尚未完成" }, { status: 409 }); } + const appendixRead = await supabase + .from(LONGFORM_APPENDIX_TABLE) + .select(APPENDIX_SELECT) + .eq("report_id", reportId) + .maybeSingle(); + const cached = appendixRead.error ? null : parseLongformAppendixRow(appendixRead.data); + if (cached?.status === "ready" && cached.markdown) { + return NextResponse.json({ format: "markdown", markdown: cached.markdown }); + } + if (cached?.status === "unavailable") { + return unavailableResponse(); + } + const { data: profileRow, error: profileError } = await supabase .from("profiles") .select(ACCOUNT_BIRTH_SELECT) .eq("id", user.id) .maybeSingle(); - const payload = profileError ? null : birthPayload(profileRow); + const admin = tryAdminClient(); + const range = admin + ? await loadReportCandidateRange(admin, { userId: user.id }) + : null; + const today = utcToday(); + const payload = profileError ? null : birthPayload(profileRow, { + today, + targetYear: Number(today.slice(0, 4)), + candidateRange: range ? { start_time: range.startTime, end_time: range.endTime } : null, + birthTimeAccuracy: range && range.startTime !== range.endTime ? "provisional" : "confirmed", + }); if (!payload) { return NextResponse.json({ error: "出生资料不完整" }, { status: 422 }); } + const persistAppendix = async (input: { + successMarkdown?: string | null; + errorCode?: string | null; + }) => { + if (!admin || !report.requestId) return; + const next = nextLongformAppendixState({ + current: cached, + successMarkdown: input.successMarkdown, + errorCode: input.errorCode, + }); + await admin.from(LONGFORM_APPENDIX_TABLE).upsert({ + report_id: reportId, + user_id: user.id, + request_id: report.requestId, + status: next.status, + markdown: next.markdown, + content_sha256: next.contentSha256, + attempt_count: next.attemptCount, + last_error_code: next.lastErrorCode, + generated_at: next.status === "ready" ? new Date().toISOString() : null, + updated_at: new Date().toISOString(), + }); + }; + const upstream = await fetch(`${jyotishApiBase}/api/professional_report_reference`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, @@ -110,19 +197,24 @@ export async function POST(request: Request, context: RouteContext) { cache: "no-store", }); if (!upstream.ok) { + await persistAppendix({ + errorCode: upstream.status === 429 ? "upstream_busy" : "upstream_unavailable", + }).catch(() => undefined); return upstreamError(upstream.status, upstream.headers.get("retry-after")); } const result = await upstream.json().catch(() => null) as ProfessionalReferenceResponse | null; if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) { + await persistAppendix({ errorCode: "empty_markdown" }).catch(() => undefined); return upstreamError(502, null); } + await persistAppendix({ successMarkdown: result.markdown }).catch(() => undefined); return NextResponse.json({ format: "markdown", markdown: result.markdown }); } catch (error) { if (isSupabaseConfigurationError(error)) { return NextResponse.json({ error: "数据库尚未配置" }, { status: 503 }); } console.error("professional_report_reference_failed", error instanceof Error ? error.name : "UnknownError"); - return NextResponse.json({ error: "专业参考版暂时无法生成" }, { status: 500 }); + return NextResponse.json({ error: "全量数据附录暂不可用" }, { status: 500 }); } } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index b9e6ed19..0cd58f51 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3528,7 +3528,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class text-decoration: none; } .personal-report-back:hover { color: var(--color-ink); background: var(--color-canvas-muted); } -.personal-report-action-end { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3); } +.personal-report-action-end { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); } .personal-report-action-end > span { max-width: 440px; display: inline-flex; diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index d10d3ecd..35d0eece 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GeneratePersonalReportButton } from "./generate-personal-report-button"; import { Button } from "@/components/ui/button"; import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; -import { downloadMarkdownReport } from "@/lib/consultation-report-export"; +import { downloadPersonalReportLongformAppendix } from "@/lib/personal-report-longform-download"; const LIST_POLL_INTERVAL_MS = 3000; @@ -143,21 +143,11 @@ export function PersonalReportCenter() { setExportingReportId(reportId); setExportError(null); try { - const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, { - method: "POST", - credentials: "same-origin", - headers: { Accept: "application/json" }, - }); - const result: unknown = await response.json().catch(() => null); - const payload = result && typeof result === "object" ? result as Record : {}; - if (!response.ok || payload.format !== "markdown" || typeof payload.markdown !== "string") { - throw new Error(typeof payload.error === "string" ? payload.error : "专业参考版暂时无法生成"); - } - downloadMarkdownReport("个人专业参考版", payload.markdown); + await downloadPersonalReportLongformAppendix(reportId); } catch (error) { setExportError({ reportId, - message: error instanceof Error ? error.message : "专业参考版暂时无法生成", + message: error instanceof Error ? error.message : "全量数据附录暂不可用", }); } finally { setExportingReportId(null); @@ -240,7 +230,7 @@ export function PersonalReportCenter() { onClick={() => void downloadProfessionalReference(report.id)} > {exportingReportId === report.id ? : null} - 专业参考版(导出) + 全量数据附录 {exportError?.reportId === report.id ? ( diff --git a/frontend/src/components/personal-report/report-actions.tsx b/frontend/src/components/personal-report/report-actions.tsx index e11492b6..70dc10b4 100644 --- a/frontend/src/components/personal-report/report-actions.tsx +++ b/frontend/src/components/personal-report/report-actions.tsx @@ -6,7 +6,7 @@ import { useState, useSyncExternalStore } from "react"; import Link from "next/link"; -import { ArrowLeft, Printer, TriangleAlert } from "lucide-react"; +import { ArrowLeft, FileText, Printer, TriangleAlert } from "lucide-react"; import { detectPrintRestriction, @@ -14,6 +14,7 @@ import { printPersonalReport, safeReportFilename, } from "@/lib/client-report-export"; +import { downloadPersonalReportLongformAppendix } from "@/lib/personal-report-longform-download"; import { Button } from "@/components/ui/button"; interface ReportActionsProps { @@ -27,23 +28,37 @@ function subscribePrintCapability(onStoreChange: () => void): () => void { } export function ReportActions({ reportId, reportTitle }: ReportActionsProps) { - const [busy, setBusy] = useState(false); + const [printBusy, setPrintBusy] = useState(false); + const [appendixBusy, setAppendixBusy] = useState(false); const [notice, setNotice] = useState(null); const printSupported = useSyncExternalStore(subscribePrintCapability, isPrintSupported, () => false); async function handlePrint() { - if (busy || !printSupported) return; + if (printBusy || appendixBusy || !printSupported) return; const restriction = detectPrintRestriction(); if (restriction.restricted) { setNotice(restriction.message ?? "当前浏览器无法可靠打印,请在系统浏览器中打开本页。"); return; } - setBusy(true); + setPrintBusy(true); setNotice(null); try { await printPersonalReport({ title: reportTitle?.trim() || safeReportFilename(reportId) }); } finally { - setBusy(false); + setPrintBusy(false); + } + } + + async function handleAppendix() { + if (printBusy || appendixBusy) return; + setAppendixBusy(true); + setNotice(null); + try { + await downloadPersonalReportLongformAppendix(reportId); + } catch (error) { + setNotice(error instanceof Error ? error.message : "全量数据附录暂不可用"); + } finally { + setAppendixBusy(false); } } @@ -55,11 +70,14 @@ export function ReportActions({ reportId, reportTitle }: ReportActionsProps) {
{notice && } - +
); -} +} \ No newline at end of file diff --git a/frontend/src/lib/personal-report-longform-appendix.ts b/frontend/src/lib/personal-report-longform-appendix.ts new file mode 100644 index 00000000..c9ff8c65 --- /dev/null +++ b/frontend/src/lib/personal-report-longform-appendix.ts @@ -0,0 +1,91 @@ +import { createHash } from "node:crypto"; + +export const LONGFORM_APPENDIX_TABLE = "personal_report_longform_appendices"; +export const LONGFORM_APPENDIX_MAX_ATTEMPTS = 2; + +export type LongformAppendixStatus = "pending" | "ready" | "unavailable"; + +export type LongformAppendixRow = Readonly<{ + reportId: string; + userId: string; + requestId: string; + status: LongformAppendixStatus; + markdown: string | null; + contentSha256: string | null; + attemptCount: number; + lastErrorCode: string | null; +}>; + +export type LongformAppendixWrite = Readonly<{ + status: LongformAppendixStatus; + attemptCount: number; + markdown: string | null; + contentSha256: string | null; + lastErrorCode: string | null; +}>; + +export function hashLongformMarkdown(markdown: string): string { + return createHash("sha256").update(markdown, "utf8").digest("hex"); +} + +export function parseLongformAppendixRow(value: unknown): LongformAppendixRow | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const reportId = text(row.report_id ?? row.reportId); + const userId = text(row.user_id ?? row.userId); + const requestId = text(row.request_id ?? row.requestId); + const status = row.status; + if (!reportId || !userId || !requestId) return null; + if (status !== "pending" && status !== "ready" && status !== "unavailable") return null; + const markdown = typeof row.markdown === "string" && row.markdown.trim() ? row.markdown : null; + const contentSha256 = text(row.content_sha256 ?? row.contentSha256); + const attemptCount = typeof row.attempt_count === "number" && Number.isFinite(row.attempt_count) + ? row.attempt_count + : typeof row.attemptCount === "number" && Number.isFinite(row.attemptCount) + ? row.attemptCount + : 0; + return { + reportId, + userId, + requestId, + status, + markdown, + contentSha256, + attemptCount, + lastErrorCode: text(row.last_error_code ?? row.lastErrorCode), + }; +} + +export function nextLongformAppendixState(input: Readonly<{ + current: LongformAppendixRow | null; + successMarkdown?: string | null; + errorCode?: string | null; +}>): LongformAppendixWrite { + const successMarkdown = input.successMarkdown?.trim() ? input.successMarkdown : null; + if (successMarkdown) { + return { + status: "ready", + attemptCount: input.current?.attemptCount ?? 0, + markdown: successMarkdown, + contentSha256: hashLongformMarkdown(successMarkdown), + lastErrorCode: null, + }; + } + const attemptCount = Math.min( + (input.current?.attemptCount ?? 0) + 1, + LONGFORM_APPENDIX_MAX_ATTEMPTS, + ); + return { + status: attemptCount >= LONGFORM_APPENDIX_MAX_ATTEMPTS ? "unavailable" : "pending", + attemptCount, + markdown: null, + contentSha256: null, + lastErrorCode: input.errorCode ?? "generation_failed", + }; +} + +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/frontend/src/lib/personal-report-longform-download.ts b/frontend/src/lib/personal-report-longform-download.ts new file mode 100644 index 00000000..56b35580 --- /dev/null +++ b/frontend/src/lib/personal-report-longform-download.ts @@ -0,0 +1,25 @@ +import { downloadMarkdownReport } from "./consultation-report-export"; + +export async function requestPersonalReportLongformAppendix(reportId: string): Promise { + const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, { + method: "POST", + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + const result: unknown = await response.json().catch(() => null); + const payload = result && typeof result === "object" ? result as Record : {}; + if ( + !response.ok + || payload.format !== "markdown" + || typeof payload.markdown !== "string" + || !payload.markdown.trim() + ) { + throw new Error(typeof payload.error === "string" ? payload.error : "全量数据附录暂不可用"); + } + return payload.markdown; +} + +export async function downloadPersonalReportLongformAppendix(reportId: string): Promise { + const markdown = await requestPersonalReportLongformAppendix(reportId); + downloadMarkdownReport("个人全量数据附录", markdown); +} diff --git a/frontend/supabase/migrations/20260905010000_personal_report_longform_appendices.sql b/frontend/supabase/migrations/20260905010000_personal_report_longform_appendices.sql new file mode 100644 index 00000000..b128c75d --- /dev/null +++ b/frontend/supabase/migrations/20260905010000_personal_report_longform_appendices.sql @@ -0,0 +1,70 @@ +-- Cached full-mode longform appendix. Parallel to the five-chapter document: +-- a failed or missing appendix must not change personal_reports.status. +-- Owner-read only; writes are service_role. Markdown is the user's own report +-- body, never logged. + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'personal_report_longform_appendices_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +create table if not exists public.personal_report_longform_appendices ( + report_id uuid primary key + references public.personal_reports(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + request_id uuid not null, + status text not null + check (status in ('pending', 'ready', 'unavailable')), + markdown text, + content_sha256 text + check (content_sha256 is null or content_sha256 ~ '^[0-9a-f]{64}$'), + attempt_count integer not null default 0 check (attempt_count >= 0), + max_attempts integer not null default 2 check (max_attempts between 1 and 10), + last_error_code text + check (last_error_code is null or last_error_code ~ '^[a-z][a-z0-9_]{0,63}$'), + generated_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint personal_report_longform_appendices_report_fk + foreign key (user_id, request_id) + references public.personal_reports (user_id, request_id) + on delete cascade, + constraint personal_report_longform_appendices_ready_markdown_check + check ((status = 'ready') = (markdown is not null)), + constraint personal_report_longform_appendices_ready_hash_check + check ((status = 'ready') = (content_sha256 is not null)), + constraint personal_report_longform_appendices_unavailable_error_check + check ((status = 'unavailable') = (last_error_code is not null)), + constraint personal_report_longform_appendices_attempt_budget_check + check (attempt_count <= max_attempts) +); + +create unique index if not exists personal_report_longform_appendices_request_idx + on public.personal_report_longform_appendices (user_id, request_id); + +alter table public.personal_report_longform_appendices enable row level security; + +revoke all on table public.personal_report_longform_appendices + from public, anon, authenticated, service_role; +revoke all on table public.personal_report_longform_appendices + from app_runtime, admin_runtime, migration_runner, backup_reader; + +drop policy if exists personal_report_longform_appendices_select_own + on public.personal_report_longform_appendices; +create policy personal_report_longform_appendices_select_own + on public.personal_report_longform_appendices + for select + to authenticated + using (auth.uid() = user_id); + +grant select on table public.personal_report_longform_appendices to authenticated; +grant select, insert, update, delete on table public.personal_report_longform_appendices + to service_role; + +commit; diff --git a/frontend/tests/database-personal-report-longform-appendices.test.ts b/frontend/tests/database-personal-report-longform-appendices.test.ts new file mode 100644 index 00000000..b32841d2 --- /dev/null +++ b/frontend/tests/database-personal-report-longform-appendices.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); + +function dockerAvailable(): boolean { + return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + stdio: "ignore", + }).status === 0; +} + +const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host"; + +const USER_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const USER_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const REPORT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; +const REQUEST_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; +const HASH = "1111111111111111111111111111111111111111111111111111111111111111"; +const COMMIT = "2222222222222222222222222222222222222222"; + +function selectAsAuthenticated(userId: string, sql: string): string { + return ` + set role authenticated; + select set_config('request.jwt.claim.sub', '${userId}', true); + ${sql} + `; +} + +function serviceSql(sql: string): string { + return `set role service_role;\n${sql}`; +} + +test("longform appendices are owner-read, service-written, and never change report status", { skip: skipWithoutDocker }, () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + assert.match(migration.stdout, /applied 20260905010000_personal_report_longform_appendices\.sql/); + + fixture.psql(` + insert into identity.users (id, name, email, email_verified, email_verified_at) + values + ('${USER_A}', 'Appendix User A', 'appendix-a@example.com', true, now()), + ('${USER_B}', 'Appendix User B', 'appendix-b@example.com', true, now()); + insert into public.personal_reports ( + id, user_id, request_id, request_fingerprint, report_type, status, + schema_version, presentation_mode, requested_themes, depth, + skill_name, skill_version, skill_source_commit, skill_snapshot_sha256 + ) values ( + '${REPORT_ID}', '${USER_A}', '${REQUEST_ID}', '${HASH}', 'personal_full', 'generating', + 'report_document.v2', 'default', array['career']::text[], 'standard', + 'jyotish-personal-report', '1.0.0', '${COMMIT}', '${HASH}' + ); + update public.personal_reports + set status = 'ready', + report_document = '{"schemaVersion":"report_document.v2"}'::jsonb, + completed_at = now(), + calculation_hash = '${HASH}', + evidence_hash = '${HASH}' + where id = '${REPORT_ID}'; + `); + + assert.throws( + () => fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + selectAsAuthenticated(USER_A, ` + insert into public.personal_report_longform_appendices ( + report_id, user_id, request_id, status + ) values ('${REPORT_ID}', '${USER_A}', '${REQUEST_ID}', 'pending'); + `), + ), + /permission denied for table personal_report_longform_appendices/, + ); + + fixture.psqlAs( + "service_runtime", + "service-runtime-test-password", + serviceSql(` + insert into public.personal_report_longform_appendices ( + report_id, user_id, request_id, status, markdown, content_sha256, generated_at + ) values ( + '${REPORT_ID}', '${USER_A}', '${REQUEST_ID}', 'ready', + '# Full', '${HASH}', now() + ); + `), + ); + + assert.equal( + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + selectAsAuthenticated(USER_A, ` + select status || ':' || length(markdown) from public.personal_report_longform_appendices + where report_id = '${REPORT_ID}' + `), + ), + `SET\n${USER_A}\nready:6`, + ); + assert.equal( + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + selectAsAuthenticated(USER_B, ` + select count(*) from public.personal_report_longform_appendices + where report_id = '${REPORT_ID}' + `), + ), + `SET\n${USER_B}\n0`, + ); + + fixture.psqlAs( + "service_runtime", + "service-runtime-test-password", + serviceSql(` + update public.personal_report_longform_appendices + set status = 'unavailable', markdown = null, content_sha256 = null, + last_error_code = 'upstream_unavailable', attempt_count = 2 + where report_id = '${REPORT_ID}'; + `), + ); + + assert.equal( + fixture.psql(`select status from public.personal_reports where id = '${REPORT_ID}'`), + "ready", + ); + assert.equal( + fixture.psql(` + select status || ':' || coalesce(last_error_code, '') + from public.personal_report_longform_appendices + where report_id = '${REPORT_ID}' + `), + "unavailable:upstream_unavailable", + ); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index 06c17a00..b57cf53f 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -24,6 +24,10 @@ const reportCenterSource = readFileSync( new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url), "utf8", ); +const longformDownloadSource = readFileSync( + new URL("../src/lib/personal-report-longform-download.ts", import.meta.url), + "utf8", +); const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); test("request shape: POST /api/reports body carries only report identity fields", () => { @@ -281,10 +285,11 @@ test("legacy consultation Markdown export is untouched and still works", () => { }); -test("ready reports expose the professional Markdown reference export only in the ready branch", () => { - assert.match(reportCenterSource, /专业参考版(导出)/); - assert.match(reportCenterSource, /professional-reference/); - assert.match(reportCenterSource, /downloadMarkdownReport\("个人专业参考版", payload\.markdown\)/); +test("ready reports expose the full-data appendix Markdown export only in the ready branch", () => { + assert.match(reportCenterSource, /全量数据附录/); + assert.match(reportCenterSource, /downloadPersonalReportLongformAppendix/); + assert.match(longformDownloadSource, /professional-reference/); + assert.match(longformDownloadSource, /downloadMarkdownReport\("个人全量数据附录", markdown\)/); const readyActionStart = reportCenterSource.indexOf( '{report.status === "ready" ? (', ); @@ -294,6 +299,6 @@ test("ready reports expose the professional Markdown reference export only in th ); assert.ok(readyActionStart >= 0 && generatingActionStart > readyActionStart); const readyBranch = reportCenterSource.slice(readyActionStart, generatingActionStart); - assert.match(readyBranch, /专业参考版(导出)/); - assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /专业参考版(导出)/); + assert.match(readyBranch, /全量数据附录/); + assert.doesNotMatch(reportCenterSource.slice(generatingActionStart), /全量数据附录/); }); diff --git a/frontend/tests/personal-report-longform-appendix.test.ts b/frontend/tests/personal-report-longform-appendix.test.ts new file mode 100644 index 00000000..920d0374 --- /dev/null +++ b/frontend/tests/personal-report-longform-appendix.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + hashLongformMarkdown, + nextLongformAppendixState, + parseLongformAppendixRow, +} from "../src/lib/personal-report-longform-appendix.ts"; + +test("hashLongformMarkdown is stable sha256", () => { + assert.equal(hashLongformMarkdown("# a"), hashLongformMarkdown("# a")); + assert.equal(hashLongformMarkdown("# a").length, 64); + assert.notEqual(hashLongformMarkdown("# a"), hashLongformMarkdown("# b")); +}); + +test("parseLongformAppendixRow rejects foreign or incomplete rows", () => { + assert.equal(parseLongformAppendixRow(null), null); + assert.equal(parseLongformAppendixRow({ status: "ready" }), null); + assert.equal(parseLongformAppendixRow({ + report_id: "123e4567-e89b-12d3-a456-426614174000", + user_id: "user-1", + request_id: "123e4567-e89b-12d3-a456-426614174001", + status: "ready", + markdown: "# Full", + })?.status, "ready"); +}); + +test("nextLongformAppendixState marks unavailable after two failures and never stores error bodies", () => { + const first = nextLongformAppendixState({ current: null, errorCode: "upstream_unavailable" }); + assert.equal(first.status, "pending"); + assert.equal(first.attemptCount, 1); + assert.equal(first.markdown, null); + assert.equal(first.lastErrorCode, "upstream_unavailable"); + + const second = nextLongformAppendixState({ + current: { + reportId: "r", + userId: "u", + requestId: "q", + status: "pending", + markdown: null, + contentSha256: null, + attemptCount: 1, + lastErrorCode: "upstream_unavailable", + }, + errorCode: "empty_markdown", + }); + assert.equal(second.status, "unavailable"); + assert.equal(second.attemptCount, 2); + assert.equal(second.markdown, null); + + const ready = nextLongformAppendixState({ current: null, successMarkdown: "# Full" }); + assert.equal(ready.status, "ready"); + assert.equal(ready.markdown, "# Full"); + assert.equal(ready.contentSha256, hashLongformMarkdown("# Full")); + assert.equal(ready.lastErrorCode, null); +}); diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index 7e5379b2..f8bd8881 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -410,8 +410,10 @@ test("ready reports expose the browser print/PDF action with capability and hydr assert.match(actionsSource, /href="\/reports"/); assert.match(actionsSource, /返回报告中心/); assert.match(actionsSource, /打印 \/ 保存为 PDF/); + assert.match(actionsSource, /全量数据附录/); + assert.match(actionsSource, /downloadPersonalReportLongformAppendix/); assert.match(actionsSource, /printPersonalReport/); assert.match(actionsSource, /Printer/); assert.match(actionsSource, /useSyncExternalStore\(subscribePrintCapability, isPrintSupported, \(\) => false\)/); - assert.match(actionsSource, /disabled=\{busy \|\| !printSupported\}/); + assert.match(actionsSource, /disabled=\{printBusy \|\| appendixBusy \|\| !printSupported\}/); }); diff --git a/frontend/tests/professional-report-reference-route.test.ts b/frontend/tests/professional-report-reference-route.test.ts index 04f919c3..aea16df3 100644 --- a/frontend/tests/professional-report-reference-route.test.ts +++ b/frontend/tests/professional-report-reference-route.test.ts @@ -25,9 +25,20 @@ function executeReadyReportExport() { }}); mock.module("@/lib/personal-report-service", { namedExports: { createSupabasePersonalReportService: () => ({ - getOwnedById: async () => ({ status: "ready" }), + getOwnedById: async () => ({ status: "ready", requestId: "123e4567-e89b-12d3-a456-426614174111" }), }), }}); + mock.module("@/lib/supabase/admin", { namedExports: { + createAdminSupabaseClient: () => { throw new Error("admin unused in this unit test"); }, + }}); + mock.module("@/lib/report-candidate-range", { namedExports: { + loadReportCandidateRange: async () => null, + }}); + mock.module("@/lib/personal-report-longform-appendix", { namedExports: { + LONGFORM_APPENDIX_TABLE: "personal_report_longform_appendices", + parseLongformAppendixRow: () => null, + nextLongformAppendixState: () => ({ status: "ready", attemptCount: 0, markdown: "# Professional reference", contentSha256: "ab", lastErrorCode: null }), + }}); mock.module("@/lib/supabase/config", { namedExports: { isSupabaseConfigurationError: () => false, }}); @@ -126,9 +137,14 @@ test("birth data stays server-owned and the route calls only the public Python e assert.match(routeSource, /\/api\/professional_report_reference/); assert.match(routeSource, /format: "markdown"/); assert.match(routeSource, /packs: \["full"\]/); + assert.match(routeSource, /target_year/); + assert.match(routeSource, /birth_time_accuracy/); + assert.match(routeSource, /loadReportCandidateRange/); + assert.match(routeSource, /LONGFORM_APPENDIX_TABLE/); + assert.match(routeSource, /from "@\/lib\/personal-report-longform-appendix"/); assert.doesNotMatch(routeSource, /request\.json\(/); assert.doesNotMatch(routeSource, /mastra|writer|billing|personal_report_sections/i); - assert.doesNotMatch(routeSource, /\.insert\(|\.update\(|\.delete\(/); + assert.doesNotMatch(routeSource, /from\("personal_reports"\)/); }); test("ready report export calls the Python endpoint without model calls or writer telemetry", () => { @@ -140,19 +156,22 @@ test("ready report export calls the Python endpoint without model calls or write assert.equal(result.upstream.length, 1); assert.equal(result.upstream[0].url, "http://127.0.0.1:5200/api/professional_report_reference"); assert.equal(result.upstream[0].method, "POST"); - assert.deepEqual(result.upstream[0].body, { - year: 1990, - month: 1, - day: 2, - hour: 3, - minute: 4, - lat: 39.9, - lon: 116.4, - tz: 8, - ayanamsa: "lahiri", - format: "markdown", - packs: ["full"], - }); + assert.equal(result.upstream[0].body.year, 1990); + assert.equal(result.upstream[0].body.month, 1); + assert.equal(result.upstream[0].body.day, 2); + assert.equal(result.upstream[0].body.hour, 3); + assert.equal(result.upstream[0].body.minute, 4); + assert.equal(result.upstream[0].body.lat, 39.9); + assert.equal(result.upstream[0].body.lon, 116.4); + assert.equal(result.upstream[0].body.tz, 8); + assert.equal(result.upstream[0].body.ayanamsa, "lahiri"); + assert.equal(result.upstream[0].body.format, "markdown"); + assert.deepEqual(result.upstream[0].body.packs, ["full"]); + assert.match(String(result.upstream[0].body.today), /^\d{4}-\d{2}-\d{2}$/); + assert.equal(result.upstream[0].body.target_year, Number(String(result.upstream[0].body.today).slice(0, 4))); + assert.equal(result.upstream[0].body.age, Number(result.upstream[0].body.target_year) - 1990); + assert.equal(result.upstream[0].body.birth_time_accuracy, "confirmed"); + assert.equal(result.upstream[0].body.candidate_range, undefined); }); test("busy upstream responses preserve 429 and Retry-After", () => { diff --git a/scripts/annual_pyjhora_replay.py b/scripts/annual_pyjhora_replay.py new file mode 100755 index 00000000..01d9a4d4 --- /dev/null +++ b/scripts/annual_pyjhora_replay.py @@ -0,0 +1,986 @@ +"""PyJHora annual replay boundary. + +PyJHora/JHora is treated as an external reference engine. This wrapper imports +it only inside `replay_pyjhora_annual`, preserves raw return shapes, and never +vendors or normalizes it into the native engine as a hard dependency. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import subprocess +import sys +from datetime import datetime, timedelta +from importlib.metadata import PackageNotFoundError, version as _distribution_version +from importlib import import_module as _import_module +from inspect import signature +from pathlib import Path +from typing import Any, Callable + + +ENGINE = "PyJHora/JHora" +REPO_ROOT = Path(__file__).resolve().parents[1] +_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka" +_YEAR_LORD_MODULES = ( + "jhora.horoscope.dhasa.annual.mudda", + "jhora.horoscope.dhasa.annual.patyayini", +) +_YEAR_LORD_CALLABLES = ( + "panchadhikari_year_lord", + "get_panchadhikari_year_lord", + "get_year_lord", + "year_lord", +) +_ANNUAL_SURFACE_KEYS = ( + "annual_chart_snapshot", + "annual_chart_speed_snapshot", + "mudda_dasha", + "patyayini_dasha", + "sahams", + "solar_return_boundary", + "tajika_yogas", +) +_PYJHORA_SAHAM_CALLABLES = ( + "apamrithyu_saham", "artha_saham", "asha_saham", "bandhana_saham", "bandhu_saham", + "bhratri_saham", "gaurava_saham", "jadya_saham", "jalapatna_saham", "jeeva_saham", + "kali_saham", "karma_saham", "karyasiddhi_saham", "laabha_saham", "maathri_saham", + "mahatmaya_saham", "mitra_saham", "mrithyu_saham", "paradara_saham", "paradesa_saham", + "pithri_saham", "preethi_saham", "punya_saham", "puthra_saham", "rajya_saham", + "roga_sagam_1", "roga_saham", "samartha_saham", "santapa_saham", "sastra_saham", + "sathru_saham", "sraddha_saham", "vanika_saham", "vidya_saham", "vivaha_saham", + "vyaapaara_saham", "yasas_saham", +) + + +def probe_pyjhora_year_lord( + profile: dict[str, Any], + *, + birth_julian_day: float, + place: Any, + age: int | float, + import_module: Callable[[str], Any] = _import_module, + distribution_version: Callable[[str], str] = _distribution_version, + isolated_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Probe PyJHora for an annual Tajaka/Panchadhikari Year Lord API. + + This is deliberately an external-engine observation. PyJHora's annual + modules are not assumed to implement Panchadhikari simply because they + expose other Varshaphala calculations. The public Tajaka callable is + preferred when present; annual-dasha modules remain a legacy fallback. + """ + + replay = { + "status": "blocked", + "reason": "pyjhora_panchadhikari_year_lord_api_missing", + "missing_api": "Panchadhikari/Year Lord callable", + "probed_modules": [_TAJAKA_YEAR_LORD_MODULE, *_YEAR_LORD_MODULES], + "available_annual_callables": [], + "raw": None, + "normalized_candidates": [], + } + result = { + "engine": ENGINE, + "input_profile_id": profile.get("profile_id"), + "pyjhora_version": _pyjhora_version(distribution_version), + "license_boundary": { + "mode": "external_reference_only", + "dependency_required": False, + }, + "evidence_scope": "pyjhora_behavior_only", + "parity_status": "not_multiengine_parity", + "year_lord_replay": replay, + } + + replay_runner = isolated_replay or _run_isolated_tajaka_year_lord + isolated = replay_runner(profile, birth_julian_day, place, age) + if isolated.get("reason") != "pyjhora_tajaka_api_missing": + replay.update(isolated) + replay["normalized_candidates"] = _normalize_tajaka_candidates(replay.get("raw_candidates")) + if isolated.get("pyjhora_version"): + result["pyjhora_version"] = isolated["pyjhora_version"] + return result + + modules: list[Any] = [] + for module_name in _YEAR_LORD_MODULES: + try: + modules.append(import_module(module_name)) + except Exception as exc: + replay["status"] = "blocked" + replay["reason"] = f"pyjhora_annual_module_import_failed:{module_name}:{exc.__class__.__name__}" + replay["missing_api"] = module_name + return result + + for module in modules: + replay["available_annual_callables"].extend( + sorted(name for name in _YEAR_LORD_CALLABLES if callable(getattr(module, name, None))) + ) + for name in _YEAR_LORD_CALLABLES: + candidate = getattr(module, name, None) + if not callable(candidate): + continue + return _call_year_lord_candidate(result, replay, candidate, name, birth_julian_day, place, age) + return result + + +def _run_isolated_tajaka_year_lord( + profile: dict[str, Any], + birth_julian_day: float, + place: Any, + age: int | float, +) -> dict[str, Any]: + settings = _replay_settings(profile) + request = { + "birth_julian_day": birth_julian_day, + "place": _place_payload(place), + "age": age, + "settings": settings, + } + request_hash = _request_hash(request) + command = [ + sys.executable, + "-c", + ( + "import json, sys; " + "from scripts.annual_pyjhora_replay import _isolated_tajaka_worker; " + "print(json.dumps(_isolated_tajaka_worker(json.load(sys.stdin)), sort_keys=True))" + ), + ] + try: + completed = subprocess.run( + command, + input=json.dumps(request, sort_keys=True), + capture_output=True, + text=True, + timeout=30, + check=False, + cwd=str(REPO_ROOT), + ) + except subprocess.TimeoutExpired: + return _isolated_blocked( + "pyjhora_isolated_replay_timeout", request_hash, settings + ) + except Exception as exc: + return _isolated_blocked( + f"pyjhora_isolated_replay_start_failed:{exc.__class__.__name__}", request_hash, settings + ) + if completed.returncode != 0: + return _isolated_blocked( + f"pyjhora_isolated_replay_failed:exit_{completed.returncode}", request_hash, settings + ) + try: + replay = json.loads(completed.stdout) + except json.JSONDecodeError: + return _isolated_blocked("pyjhora_isolated_replay_invalid_json", request_hash, settings) + if not isinstance(replay, dict): + return _isolated_blocked("pyjhora_isolated_replay_invalid_payload", request_hash, settings) + replay["request_hash"] = request_hash + return replay + + +def _isolated_tajaka_worker(request: dict[str, Any]) -> dict[str, Any]: + """Run in a child process so PyJHora global Ayanamsa state cannot leak.""" + + settings = dict(request.get("settings") or {}) + node_mode = { + "requested": settings.get("node_mode"), + "status": "unsupported_by_replay_adapter", + } + ayanamsa = settings.get("ayanamsa") + if not isinstance(ayanamsa, str) or not ayanamsa.strip(): + return { + "status": "blocked", + "reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa", + "settings": settings, + "node_mode": node_mode, + } + try: + # PyJHora can print import diagnostics; reserve stdout for the JSON reply. + with contextlib.redirect_stdout(sys.stderr): + from jhora import const + from jhora.panchanga import drik + from jhora.horoscope.transit import tajaka + + drik.set_ayanamsa_mode(ayanamsa.upper()) + effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE + if str(effective_ayanamsa).upper() != ayanamsa.upper(): + return { + "status": "blocked", + "reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch", + "settings": settings, + "effective_ayanamsa": effective_ayanamsa, + "node_mode": node_mode, + } + except Exception as exc: + return { + "status": "blocked", + "reason": f"pyjhora_ayanamsa_configuration_failed:{exc.__class__.__name__}", + "settings": settings, + "node_mode": node_mode, + } + + try: + place_data = dict(request["place"]) + place = drik.Place( + place_data["name"], + float(place_data["latitude"]), + float(place_data["longitude"]), + float(place_data["timezone"]), + place_data.get("elevation"), + ) + raw_candidates, candidate_trace_status = _tajaka_candidate_trace( + tajaka, float(request["birth_julian_day"]), place, request["age"] + ) + raw = tajaka.lord_of_the_year(float(request["birth_julian_day"]), place, request["age"]) + except Exception as exc: + return { + "status": "blocked", + "reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}", + "callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year", + "settings": settings, + "effective_ayanamsa": effective_ayanamsa, + "node_mode": node_mode, + "raw": None, + "raw_candidates": None, + } + return { + "status": "partial_verified", + "reason": "pyjhora_tajaka_year_lord_callable_observed", + "callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year", + "pyjhora_version": _pyjhora_version(_distribution_version), + "settings": settings, + "effective_ayanamsa": effective_ayanamsa, + "node_mode": node_mode, + "year_lord": raw, + "raw": raw, + "raw_candidates": raw_candidates, + "candidate_trace_status": candidate_trace_status, + } + + +def _run_isolated_annual_replay( + profile: dict[str, Any], + birth_julian_day: float, + place: Any, + age: int | float, +) -> dict[str, Any]: + """Call optional PyJHora annual surfaces with a child-process Place.""" + + settings = _replay_settings(profile) + request = { + "birth_julian_day": birth_julian_day, + "place": _place_payload(place), + "age": age, + "settings": settings, + } + request_hash = _request_hash(request) + command = [ + sys.executable, + "-c", + ( + "import json, sys; " + "from scripts.annual_pyjhora_replay import _isolated_annual_worker; " + "print(json.dumps(_isolated_annual_worker(json.load(sys.stdin)), sort_keys=True, default=str))" + ), + ] + try: + completed = subprocess.run( + command, + input=json.dumps(request, sort_keys=True), + capture_output=True, + text=True, + timeout=30, + check=False, + cwd=str(REPO_ROOT), + ) + except subprocess.TimeoutExpired: + return _annual_replay_blocked("pyjhora_isolated_annual_replay_timeout", request_hash, settings) + except Exception as exc: + return _annual_replay_blocked( + f"pyjhora_isolated_annual_replay_start_failed:{exc.__class__.__name__}", request_hash, settings + ) + if completed.returncode != 0: + return _annual_replay_blocked( + f"pyjhora_isolated_annual_replay_failed:exit_{completed.returncode}", request_hash, settings + ) + try: + replay = json.loads(completed.stdout) + except json.JSONDecodeError: + return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_json", request_hash, settings) + if not isinstance(replay, dict): + return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_payload", request_hash, settings) + replay["request_hash"] = request_hash + return replay + + +def _isolated_annual_worker(request: dict[str, Any]) -> dict[str, Any]: + """Construct ``drik.Place`` after setting Ayanamsa in the child process.""" + + settings = dict(request.get("settings") or {}) + node_mode = {"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"} + ayanamsa = settings.get("ayanamsa") + if not isinstance(ayanamsa, str) or not ayanamsa.strip(): + return _annual_worker_result( + {key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa"} for key in _ANNUAL_SURFACE_KEYS}, + settings=settings, + node_mode=node_mode, + ) + try: + with contextlib.redirect_stdout(sys.stderr): + from jhora import const + from jhora.panchanga import drik + from jhora.horoscope.dhasa.annual import mudda, patyayini + from jhora.horoscope.transit import saham + + drik.set_ayanamsa_mode(ayanamsa.upper()) + effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE + if str(effective_ayanamsa).upper() != ayanamsa.upper(): + return _annual_worker_result( + {key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch"} for key in _ANNUAL_SURFACE_KEYS}, + settings=settings, + node_mode=node_mode, + effective_ayanamsa=effective_ayanamsa, + ) + place_data = dict(request["place"]) + pyjhora_place = drik.Place( + place_data["name"], float(place_data["latitude"]), float(place_data["longitude"]), + float(place_data["timezone"]), place_data.get("elevation"), + ) + except Exception as exc: + return _annual_worker_result( + {key: {"status": "blocked", "reason": f"pyjhora_annual_adapter_setup_failed:{exc.__class__.__name__}"} for key in _ANNUAL_SURFACE_KEYS}, + settings=settings, + node_mode=node_mode, + ) + + jd = float(request["birth_julian_day"]) + years = request["age"] + surfaces = { + "annual_chart_snapshot": _call_annual_chart_snapshot(jd, pyjhora_place, years), + "annual_chart_speed_snapshot": _call_annual_chart_speed_snapshot(jd, pyjhora_place, years), + "mudda_dasha": _call_mudda(mudda, jd, pyjhora_place, years), + "patyayini_dasha": _call_patyayini(patyayini, jd, pyjhora_place, years), + "sahams": _call_sahams(saham, jd, pyjhora_place, years), + "solar_return_boundary": _call_solar_return_boundary(saham, jd, pyjhora_place, years), + "tajika_yogas": _probe_tajika_yogas(jd, pyjhora_place, years), + } + return _annual_worker_result( + surfaces, + settings=settings, + node_mode=node_mode, + effective_ayanamsa=effective_ayanamsa, + ) + + +def _annual_worker_result( + surfaces: dict[str, dict[str, Any]], + *, + settings: dict[str, Any], + node_mode: dict[str, Any], + effective_ayanamsa: Any = None, +) -> dict[str, Any]: + return { + **{key: surfaces.get(key, {"status": "blocked", "reason": "not_executed"}) for key in _ANNUAL_SURFACE_KEYS}, + "settings": settings, + "effective_ayanamsa": effective_ayanamsa, + "node_mode": node_mode, + "evidence_scope": "pyjhora_behavior_only", + "parity_status": "not_multiengine_parity", + } + + +def _annual_replay_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]: + return _annual_worker_result( + {key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS}, + settings=settings, + node_mode={"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"}, + ) | {"request_hash": request_hash} + + +def _place_payload(place: Any) -> dict[str, Any]: + return { + "name": getattr(place, "name", "External replay place"), + "latitude": getattr(place, "latitude"), + "longitude": getattr(place, "longitude"), + "timezone": getattr(place, "timezone"), + "elevation": getattr(place, "elevation", None), + } + + +def _request_hash(request: dict[str, Any]) -> str: + payload = json.dumps(request, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _isolated_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]: + return { + "status": "blocked", + "reason": reason, + "request_hash": request_hash, + "settings": settings, + "node_mode": { + "requested": settings.get("node_mode"), + "status": "unsupported_by_replay_adapter", + }, + } + + +def _replay_settings(profile: dict[str, Any]) -> dict[str, Any]: + settings = profile.get("settings") + source = settings if isinstance(settings, dict) else profile + return { + key: source[key] + for key in ("ayanamsa", "node_mode") + if source.get(key) is not None + } + + +def _call_tajaka_year_lord( + result: dict[str, Any], + replay: dict[str, Any], + tajaka: Any, + *, + birth_julian_day: float, + place: Any, + age: int | float, + settings: dict[str, Any], +) -> dict[str, Any]: + callable_name = f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year" + try: + raw_candidates, candidate_trace_status = _tajaka_candidate_trace( + tajaka, birth_julian_day, place, age + ) + raw = tajaka.lord_of_the_year(birth_julian_day, place, age) + except Exception as exc: + replay.update( + { + "status": "blocked", + "reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}", + "missing_api": None, + "callable": callable_name, + "settings": settings, + "raw": None, + "raw_candidates": None, + } + ) + return result + + replay.update( + { + "status": "partial_verified", + "reason": "pyjhora_tajaka_year_lord_callable_observed", + "missing_api": None, + "callable": callable_name, + "settings": settings, + "year_lord": raw, + "raw": raw, + "raw_candidates": raw_candidates, + "candidate_trace_status": candidate_trace_status, + "normalized_candidates": _normalize_tajaka_candidates(raw_candidates), + } + ) + return result + + +def _tajaka_candidate_trace( + tajaka: Any, + birth_julian_day: float, + place: Any, + age: int | float, +) -> tuple[Any, str]: + """Collect the source module's candidate list without reimplementing its rule.""" + + candidate_function = getattr(tajaka, "_get_lord_candidates", None) + if not callable(candidate_function): + return None, "unavailable" + try: + # Lightweight injected test modules can expose a direct trace helper. + return candidate_function(), "observed" + except TypeError: + pass + try: + rasi_chart = tajaka.charts.divisional_chart( + birth_julian_day, place, divisional_chart_factor=1 + ) + natal_planet_to_house = tajaka.utils.get_planet_house_dictionary_from_planet_positions(rasi_chart) + natal_lagna_house = natal_planet_to_house[tajaka.const._ascendant_symbol] + annual_jd = birth_julian_day + float(age) * tajaka.year_value + annual_hour = tajaka.drik.jd_to_gregorian(annual_jd)[3] + sunrise = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunrise(annual_jd, place)[1]) + sunset = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunset(annual_jd, place)[1]) + sunrise_hour = sunrise[0] + sunrise[1] / 60 + sunrise[2] / 3600 + sunset_hour = sunset[0] + sunset[1] / 60 + sunset[2] / 3600 + night_time_birth = annual_hour > sunset_hour or annual_hour < sunrise_hour + annual_chart = tajaka.charts.divisional_chart(annual_jd, place, divisional_chart_factor=1) + return candidate_function(annual_chart, age, natal_lagna_house, night_time_birth), "observed" + except Exception as exc: + return None, f"blocked:{exc.__class__.__name__}" + + +def _normalize_tajaka_candidates(raw_candidates: Any) -> list[dict[str, Any]]: + if not isinstance(raw_candidates, (list, tuple)): + return [] + return [ + {"planet_index": candidate, "selection_state": "raw_tajaka_candidate"} + for candidate in raw_candidates + if isinstance(candidate, int) + ] + + +def _pyjhora_version(distribution_version: Callable[[str], str]) -> str | None: + try: + return distribution_version("PyJHora") + except PackageNotFoundError: + return None + + +def _call_year_lord_candidate( + result: dict[str, Any], + replay: dict[str, Any], + candidate: Callable[..., Any], + callable_name: str, + birth_julian_day: float, + place: Any, + age: int | float, +) -> dict[str, Any]: + values = { + "jd": birth_julian_day, + "birth_julian_day": birth_julian_day, + "place": place, + "age": age, + "years": age, + } + try: + params = signature(candidate).parameters + kwargs = {name: values[name] for name in params if name in values} + required = [ + name for name, parameter in params.items() + if parameter.default is parameter.empty + and parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY) + and name not in kwargs + ] + if required: + replay.update( + { + "reason": "pyjhora_year_lord_callable_signature_unsupported", + "missing_api": ", ".join(required), + "callable": callable_name, + } + ) + return result + raw = candidate(**kwargs) + except Exception as exc: + replay.update( + { + "reason": f"pyjhora_year_lord_callable_failed:{callable_name}:{exc.__class__.__name__}", + "missing_api": None, + "callable": callable_name, + } + ) + return result + + replay.update( + { + "status": "partial_verified", + "reason": "pyjhora_year_lord_callable_observed", + "missing_api": None, + "callable": callable_name, + "year_lord": raw.get("year_lord") if isinstance(raw, dict) else None, + "raw": raw, + "normalized_candidates": _normalize_year_lord_candidates(raw), + } + ) + return result + + +def _normalize_year_lord_candidates(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, dict): + return [] + candidates = raw.get("candidates") or raw.get("candidate_planets") + if not isinstance(candidates, list): + return [] + return [candidate for candidate in candidates if isinstance(candidate, dict)] + + +def replay_pyjhora_annual( + profile: dict[str, Any], + *, + birth_julian_day: float, + place: Any, + age: int | float, + import_module: Callable[[str], Any] = _import_module, + year_lord_probe: Callable[..., dict[str, Any]] | None = None, + isolated_annual_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Replay annual PyJHora methods for the same profile, if available.""" + + year_lord_observer = year_lord_probe or probe_pyjhora_year_lord + year_lord_result = year_lord_observer( + profile, + birth_julian_day=birth_julian_day, + place=place, + age=age, + ) + year_lord_replay = year_lord_result.get("year_lord_replay") if isinstance(year_lord_result, dict) else None + blocked = _base(profile) + blocked["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else { + "status": "blocked", + "reason": "pyjhora_year_lord_probe_invalid_payload", + } + blocked["evidence_scope"] = "pyjhora_behavior_only" + blocked["parity_status"] = "not_multiengine_parity" + if isolated_annual_replay is not None or (import_module is _import_module and year_lord_probe is None): + observer = isolated_annual_replay or _run_isolated_annual_replay + observed = observer(profile, birth_julian_day, place, age) + if not isinstance(observed, dict): + observed = _annual_replay_blocked( + "pyjhora_isolated_annual_replay_invalid_payload", + _request_hash({"birth_julian_day": birth_julian_day, "place": _place_payload(place), "age": age}), + _replay_settings(profile), + ) + result = _base(profile) + for key in _ANNUAL_SURFACE_KEYS: + value = observed.get(key) + result[key] = value if isinstance(value, dict) else {"status": "blocked", "reason": f"{key}_missing_from_isolated_replay"} + result["year_lord_replay"] = blocked["year_lord_replay"] + for key in ("effective_ayanamsa", "node_mode", "request_hash"): + if key in observed: + result[key] = observed[key] + result["evidence_scope"] = "pyjhora_behavior_only" + result["parity_status"] = "not_multiengine_parity" + result["status"] = _aggregate_status(result) + return result + try: + mudda = import_module("jhora.horoscope.dhasa.annual.mudda") + patyayini = import_module("jhora.horoscope.dhasa.annual.patyayini") + saham = import_module("jhora.horoscope.transit.saham") + except Exception as exc: + reason = f"pyjhora_import_failed:{exc.__class__.__name__}:{exc}" + for key in _ANNUAL_SURFACE_KEYS: + blocked[key] = {"status": "blocked", "reason": reason} + blocked["status"] = "blocked" + return blocked + + result = _base(profile) + result["annual_chart_snapshot"] = _call_annual_chart_snapshot(birth_julian_day, place, age) + result["mudda_dasha"] = _call_mudda(mudda, birth_julian_day, place, age) + result["patyayini_dasha"] = _call_patyayini(patyayini, birth_julian_day, place, age) + result["sahams"] = _call_sahams(saham, birth_julian_day, place, age) + result["solar_return_boundary"] = _call_solar_return_boundary(saham, birth_julian_day, place, age) + result["tajika_yogas"] = {"status": "blocked", "reason": "pyjhora_tajika_yogas_requires_isolated_replay"} + result["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else { + "status": "blocked", + "reason": "pyjhora_year_lord_probe_invalid_payload", + } + result["evidence_scope"] = "pyjhora_behavior_only" + result["parity_status"] = "not_multiengine_parity" + result["status"] = _aggregate_status(result) + return result + + +def _base(profile: dict[str, Any]) -> dict[str, Any]: + return { + "engine": ENGINE, + "license_boundary": { + "mode": "external_reference_only", + "dependency_required": False, + "notes": "PyJHora/JHora remains an optional external oracle/replay boundary.", + }, + "input_profile_id": profile.get("profile_id"), + "annual_chart_snapshot": {"status": "blocked", "reason": "not_executed"}, + "annual_chart_speed_snapshot": {"status": "blocked", "reason": "not_executed"}, + "mudda_dasha": {"status": "blocked", "reason": "not_executed"}, + "patyayini_dasha": {"status": "blocked", "reason": "not_executed"}, + "sahams": {"status": "blocked", "reason": "not_executed"}, + "solar_return_boundary": {"status": "blocked", "reason": "not_executed"}, + "tajika_yogas": {"status": "blocked", "reason": "not_executed"}, + "year_lord_replay": {"status": "blocked", "reason": "not_executed"}, + "evidence_scope": "pyjhora_behavior_only", + "parity_status": "not_multiengine_parity", + "status": "blocked", + } + + +def _call_mudda(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]: + payload: dict[str, Any] = {} + try: + if hasattr(module, "varsha_vimsottari_dasha_start_date"): + payload["varsha_vimsottari_start"] = module.varsha_vimsottari_dasha_start_date( + birth_julian_day, place, age + ) + if hasattr(module, "mudda_dhasa_bhukthi"): + payload["periods"] = module.mudda_dhasa_bhukthi(birth_julian_day, place, age) + return {"status": "partial_verified", "raw": payload} + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_mudda_failed:{exc}"} + + +def _call_patyayini(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]: + try: + jd_for_year = birth_julian_day + float(age) * 365.256364 + return {"status": "partial_verified", "raw": {"periods": module.get_dhasa_bhukthi(jd_for_year, place)}} + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_patyayini_failed:{exc}"} + + +def _call_sahams(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]: + """Replay the current PyJHora per-Saham API against its annual chart. + + Older PyJHora releases exposed an aggregate ``sahams`` function. Current + releases expose individual functions which all consume annual-chart planet + positions. Keep both paths external-reference-only and preserve the exact + source API used for audit. + """ + try: + individual = [name for name in _PYJHORA_SAHAM_CALLABLES if callable(getattr(module, name, None))] + if not individual: + if hasattr(module, "sahams"): + return { + "status": "partial_verified", + "callable": "jhora.horoscope.transit.saham.sahams", + "raw": module.sahams(birth_julian_day, place, age), + "reason": "legacy_pyjhora_aggregate_sahams_api", + } + return {"status": "blocked", "reason": "pyjhora_sahams_callable_missing"} + + tajaka = _import_module("jhora.horoscope.transit.tajaka") + annual_chart = getattr(tajaka, "annual_chart", None) + if not callable(annual_chart): + return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"} + chart_payload = annual_chart(birth_julian_day, place, years=age) + if not isinstance(chart_payload, tuple) or len(chart_payload) < 2 or not isinstance(chart_payload[0], list): + return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"} + positions, return_marker = chart_payload[0], chart_payload[1] + annual_moment = _annual_return_datetime(return_marker) + if annual_moment is None: + return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"} + try: + try: + from scripts.saham_daynight import determine_daytime + except ImportError: # pragma: no cover - direct script execution + from saham_daynight import determine_daytime + daynight = determine_daytime( + annual_moment, + lat=float(place.latitude), + lon=float(place.longitude), + tz=float(place.timezone), + ) + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_sahams_daynight_failed:{exc.__class__.__name__}"} + + values: dict[str, Any] = {} + failures: dict[str, str] = {} + for name in individual: + candidate = getattr(module, name) + try: + parameters = signature(candidate).parameters + if "night_time_birth" in parameters: + values[name] = candidate(positions, night_time_birth=not bool(daynight["is_daytime"])) + else: + values[name] = candidate(positions) + except Exception as exc: # External API variations must remain visible. + failures[name] = exc.__class__.__name__ + if not values: + return { + "status": "blocked", + "reason": "pyjhora_individual_sahams_all_failed", + "failed_callables": failures, + } + return { + "status": "partial_verified", + "callable": "jhora.horoscope.transit.tajaka.annual_chart", + "reason": "pyjhora_individual_sahams_from_annual_chart", + "daynight": daynight, + "raw": { + "annual_return": _annual_return_marker(return_marker), + "sahams": values, + "failed_callables": failures, + "available_callables": individual, + }, + } + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_sahams_failed:{exc}"} + + +def _call_annual_chart_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]: + """Capture PyJHora's annual chart without deriving a local interpretation. + + This is an external-reference snapshot for profile diagnosis. It exists so + annual Tajika comparisons can distinguish a chart-input difference from a + candidate-rule difference. It is not a report authority or a Yoga result. + """ + try: + tajaka = _import_module("jhora.horoscope.transit.tajaka") + annual_chart = getattr(tajaka, "annual_chart", None) + if not callable(annual_chart): + return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"} + payload = annual_chart(birth_julian_day, place, years=age) + if not isinstance(payload, tuple) or len(payload) < 2 or not isinstance(payload[0], list): + return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"} + positions, return_marker = payload[0], payload[1] + return { + "status": "partial_verified", + "callable": "jhora.horoscope.transit.tajaka.annual_chart", + "reason": "pyjhora_annual_chart_snapshot_for_profile_comparison", + "raw": { + "annual_return": _annual_return_marker(return_marker), + "positions": positions, + }, + "claim_boundary": "external_annual_chart_snapshot_is_not_tajika_yoga_or_report_authority", + } + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_annual_chart_snapshot_failed:{exc.__class__.__name__}"} + + +def _signed_longitude_delta(previous: float, following: float) -> float: + return (float(following) - float(previous) + 180.0) % 360.0 - 180.0 + + +def _planet_longitudes_from_positions(positions: Any) -> dict[int, float]: + values: dict[int, float] = {} + for row in positions if isinstance(positions, list) else []: + if not isinstance(row, (list, tuple)) or len(row) < 2 or not isinstance(row[0], int): + continue + planet, position = row[0], row[1] + if planet not in range(7) or not isinstance(position, (list, tuple)) or len(position) < 2: + continue + sign, degree = position[0], position[1] + if isinstance(sign, (int, float)) and isinstance(degree, (int, float)): + values[planet] = (float(sign) * 30.0 + float(degree)) % 360.0 + return values + + +def _call_annual_chart_speed_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]: + """Observe PyJHora annual-chart motion by finite difference. + + The upstream annual-chart snapshot exposes longitudes but not speeds. This + child-process-only probe samples one minute on either side of the external + return marker so a later diagnostic can distinguish chart coordinates from + applying/separating semantics. It is not a native speed source or Tajika + Yoga authority. + """ + + try: + from jhora import utils + from jhora.panchanga import drik + tajaka = _import_module("jhora.horoscope.transit.tajaka") + annual_chart = getattr(tajaka, "annual_chart", None) + if not callable(annual_chart): + return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"} + payload = annual_chart(birth_julian_day, place, years=age) + if not isinstance(payload, tuple) or len(payload) < 2: + return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"} + annual_moment = _annual_return_datetime(payload[1]) + if annual_moment is None: + return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"} + + offset_seconds = 60 + before = annual_moment - timedelta(seconds=offset_seconds) + after = annual_moment + timedelta(seconds=offset_seconds) + before_jd = utils.julian_day_number( + (before.year, before.month, before.day), (before.hour, before.minute, before.second) + ) + after_jd = utils.julian_day_number( + (after.year, after.month, after.day), (after.hour, after.minute, after.second) + ) + before_positions = _planet_longitudes_from_positions(drik.dhasavarga(before_jd, place)) + after_positions = _planet_longitudes_from_positions(drik.dhasavarga(after_jd, place)) + if set(before_positions) != set(range(7)) or set(after_positions) != set(range(7)): + return { + "status": "blocked", + "reason": "pyjhora_annual_chart_speed_positions_incomplete", + "before_planets": sorted(before_positions), + "after_planets": sorted(after_positions), + } + interval_days = (2.0 * offset_seconds) / 86400.0 + rates = { + planet: round(_signed_longitude_delta(before_positions[planet], after_positions[planet]) / interval_days, 8) + for planet in range(7) + } + return { + "status": "partial_verified", + "callable": "jhora.panchanga.drik.dhasavarga", + "reason": "pyjhora_annual_chart_finite_difference_speed_snapshot", + "raw": { + "annual_return": _annual_return_marker(payload[1]), + "sample_offset_seconds": offset_seconds, + "planet_rates_deg_per_day": rates, + }, + "claim_boundary": "external_annual_speed_snapshot_is_comparison_only_not_tajika_yoga_or_report_authority", + } + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_annual_chart_speed_snapshot_failed:{exc.__class__.__name__}"} + + +def _annual_return_marker(value: Any) -> dict[str, Any]: + if not isinstance(value, (list, tuple)) or len(value) < 2: + return {"raw": value} + date_value, time_value = value[0], value[1] + return { + "date": list(date_value) if isinstance(date_value, (list, tuple)) else date_value, + "time": str(time_value), + } + + +def _annual_return_datetime(value: Any) -> datetime | None: + marker = _annual_return_marker(value) + date_value = marker.get("date") + time_value = marker.get("time") + if not isinstance(date_value, list) or len(date_value) != 3 or not isinstance(time_value, str): + return None + try: + hour, minute, second = [int(part) for part in time_value.split(":")] + return datetime(int(date_value[0]), int(date_value[1]), int(date_value[2]), hour, minute, second) + except (TypeError, ValueError): + return None + + +def _call_solar_return_boundary(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]: + try: + if not hasattr(module, "solar_return_chart"): + return {"status": "blocked", "reason": "pyjhora_solar_return_chart_missing"} + return { + "status": "partial_verified", + "raw": module.solar_return_chart(birth_julian_day, place, age), + } + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_solar_return_failed:{exc}"} + + +def _probe_tajika_yogas(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]: + """Probe callable availability without recreating PyJHora yoga rules.""" + + try: + module = _import_module("jhora.horoscope.transit.tajaka_yoga") + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_module_missing:{exc.__class__.__name__}"} + for name in ("tajaka_yoga", "tajika_yoga", "get_tajaka_yogas", "get_tajika_yogas"): + candidate = getattr(module, name, None) + if not callable(candidate): + continue + values = {"jd": birth_julian_day, "birth_julian_day": birth_julian_day, "place": place, "age": age, "years": age} + try: + params = signature(candidate).parameters + kwargs = {key: values[key] for key in params if key in values} + missing = [key for key, parameter in params.items() if parameter.default is parameter.empty and key not in kwargs] + if missing: + return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_signature_unsupported", "callable": name, "missing": missing} + return {"status": "partial_verified", "callable": name, "raw": candidate(**kwargs)} + except Exception as exc: + return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_callable_failed:{exc.__class__.__name__}", "callable": name} + return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_missing"} + + +def _aggregate_status(result: dict[str, Any]) -> str: + statuses = { + result[key].get("status") + for key in (*_ANNUAL_SURFACE_KEYS, "year_lord_replay") + if isinstance(result.get(key), dict) + } + if statuses == {"partial_verified"}: + return "partial_verified" + if "partial_verified" in statuses: + return "partial_verified" + return "blocked" diff --git a/scripts/annual_tajika_pack.py b/scripts/annual_tajika_pack.py new file mode 100755 index 00000000..96ddec64 --- /dev/null +++ b/scripts/annual_tajika_pack.py @@ -0,0 +1,651 @@ +"""Annual Varshaphala / Tajika report pack contract. + +The pack normalizes existing annual producers into a stable, audit-friendly +shape. It does not adjudicate conflicting annual methods yet; that belongs to +the next conflict-gate layer. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from types import SimpleNamespace +from typing import Any + +try: # pragma: no cover - import path differs under CLI vs pytest + from calculation_profile_contract import build_calculation_profile +except ImportError: # pragma: no cover + from scripts.calculation_profile_contract import build_calculation_profile + +try: # pragma: no cover - import path differs under CLI vs pytest + from tajika_named_yoga_authority import build_tajika_named_yoga_authority +except ImportError: # pragma: no cover + from scripts.tajika_named_yoga_authority import build_tajika_named_yoga_authority + + +SCHEMA = "jyotish.annual_tajika_pack.v1" + + +def build_annual_tajika_pack( + payload: dict[str, Any], + *, + solar_return_report: dict[str, Any] | None = None, + tajika_report: dict[str, Any] | None = None, + pyjhora_replay: dict[str, Any] | None = None, + vedastro_reference: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a JSON-safe annual pack for arbitrary birth payloads.""" + + profile = build_calculation_profile(payload) + args = _args_from_payload(payload) + + solar_report = solar_return_report if solar_return_report is not None else _safe_solar_return(args) + tajika_report = tajika_report if tajika_report is not None else _safe_tajika(args) + + solar_return = _field_from_report(solar_report, "solar_return", "Solar Return") + annual_chart = _annual_chart_from_solar_report(solar_report) + muntha = _field_with_conflict_gate("muntha", solar_report, tajika_report) + year_lord = _field_with_conflict_gate("year_lord", solar_report, tajika_report) + tajika_yogas = _tajika_yogas_field(solar_report) + sahams = _field_from_report(solar_report, "sahams", "Sahams") + mudda_dasha = _dasha_field("Mudda Dasha", solar_report, tajika_report, "mudda_dasha") + patyayini_dasha = _dasha_field("Patyayini Dasha", solar_report, tajika_report, "patyayini_dasha") + + pack = { + "schema": SCHEMA, + "profile": { + **profile, + "target_year": int(payload["target_year"]), + }, + "solar_return": solar_return, + "annual_chart": annual_chart, + "muntha": muntha, + "year_lord": year_lord, + "tajika_yogas": tajika_yogas, + "sahams": sahams, + "mudda_dasha": mudda_dasha, + "patyayini_dasha": patyayini_dasha, + "monthly_windows": _monthly_windows(mudda_dasha), + "external_engine_comparison": _external_engine_comparison(pyjhora_replay, vedastro_reference), + "report_sections": _report_sections( + profile, + muntha=muntha, + year_lord=year_lord, + tajika_yogas=tajika_yogas, + sahams=sahams, + ), + "exports": {}, + "audit": _audit(profile, solar_report, tajika_report, pyjhora_replay, vedastro_reference), + } + pack["exports"] = _exports(pack) + return pack + + +def _args_from_payload(payload: dict[str, Any]) -> SimpleNamespace: + birth = dict(payload.get("birth") or {}) + settings = dict(payload.get("settings") or {}) + year, month, day = [int(part) for part in str(birth["date"]).split("-")] + time_parts = [int(part) for part in str(birth.get("time") or "00:00:00").split(":")] + while len(time_parts) < 3: + time_parts.append(0) + age = payload.get("age") + if age is None and payload.get("target_year") is not None: + age = int(payload["target_year"]) - year + return SimpleNamespace( + year=year, + month=month, + day=day, + hour=time_parts[0], + minute=time_parts[1], + second=time_parts[2], + lat=float(birth["latitude"]), + lon=float(birth["longitude"]), + tz=_offset_to_hours(birth.get("utc_offset")), + target_year=int(payload["target_year"]), + age=age, + mode="all", + ayanamsa=settings.get("ayanamsa", "lahiri"), + node_mode=settings.get("node_mode", "mean"), + house_system=settings.get("house_system", "whole_sign"), + position_mode=settings.get("position_mode", "legacy"), + dasha_year_days=settings.get("dasha_year_days", 365.25), + solar_return_location_mode=settings.get("solar_return_location_mode", "birth_place"), + annual_year_policy=settings.get("annual_year_policy", "solar_return_exact"), + ) + + +def _offset_to_hours(offset: Any) -> float: + if offset is None: + return 0.0 + if isinstance(offset, (int, float)): + return float(offset) + text = str(offset).strip() + sign = -1 if text.startswith("-") else 1 + text = text.lstrip("+-") + hours, minutes = [int(part) for part in text.split(":")[:2]] + return sign * (hours + minutes / 60) + + +def _safe_solar_return(args: SimpleNamespace) -> dict[str, Any]: + try: + from scripts.cmd_solar_return import cmd_solar_return + except ImportError: # pragma: no cover + from cmd_solar_return import cmd_solar_return + try: + result = cmd_solar_return(args) + return result if isinstance(result, dict) else {"error": "solar_return_non_dict"} + except Exception as exc: # pragma: no cover - defensive boundary + return {"error": str(exc)} + + +def _safe_tajika(args: SimpleNamespace) -> dict[str, Any]: + try: + from scripts.jyotish_engine import cmd_tajika + except ImportError: # pragma: no cover + from jyotish_engine import cmd_tajika + try: + result = cmd_tajika(args) + return result if isinstance(result, dict) else {"error": "tajika_non_dict"} + except Exception as exc: # pragma: no cover - defensive boundary + return {"error": str(exc)} + + +def _field_from_report(report: dict[str, Any], key: str, label: str) -> dict[str, Any]: + if not isinstance(report, dict) or report.get("error"): + return {"status": "blocked", "producer": label, "reason": report.get("error", "producer_failed")} + value = report.get(key) + if value is None: + return {"status": "blocked", "producer": label, "reason": f"{key}_missing"} + if isinstance(value, dict) and value.get("status") in {"blocked", "conflict", "not_applicable", "parameter_sensitive", "partial_verified", "verified"}: + normalized = { + "status": value.get("status"), + "producer": label, + "data": value, + } + if value.get("reason") is not None: + normalized["reason"] = value.get("reason") + return normalized + return {"status": "partial_verified", "producer": label, "data": value} + + +def _annual_chart_from_solar_report(report: dict[str, Any]) -> dict[str, Any]: + field = _field_from_report(report, "sr_chart_info", "Solar Return Annual Chart") + if field["status"] == "partial_verified": + field["data_contract"] = "normalized_from_sr_chart_info" + return field + + +def _tajika_yogas_field(solar_report: dict[str, Any]) -> dict[str, Any]: + if not isinstance(solar_report, dict) or solar_report.get("error"): + return _field_from_report(solar_report, "tajika_yogas", "Tajika Yogas") + authority = _build_governed_tajika_named_yoga_authority(solar_report) + if authority is None: + return _field_from_report(solar_report, "tajika_yogas", "Tajika Yogas") + return { + "status": "partial_verified" if authority.get("status") == "authority_ready" else authority.get("status", "blocked"), + "producer": "Tajika Named Yoga Authority", + "data": authority, + "data_contract": "governed_named_yoga_authority_surface", + } + + +def _build_governed_tajika_named_yoga_authority(solar_report: dict[str, Any]) -> dict[str, Any] | None: + planets = _extract_tajika_motion_planets(solar_report) + if not planets: + return None + authority = build_tajika_named_yoga_authority(planets) + if not isinstance(authority, dict): + return None + return authority + + +def _extract_tajika_motion_planets(solar_report: dict[str, Any]) -> dict[str, dict[str, float]]: + chart = solar_report.get("chart") if isinstance(solar_report.get("chart"), dict) else {} + planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} + extracted: dict[str, dict[str, float]] = {} + for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"): + payload = planets.get(name) + if not isinstance(payload, dict): + continue + longitude = payload.get("degree_raw", payload.get("degree")) + speed = payload.get("speed") + if longitude is None or speed is None: + continue + extracted[name] = {"longitude": float(longitude), "speed": float(speed)} + return extracted + + +def _first_available_field(label: str, *reports_and_key: Any) -> dict[str, Any]: + *reports, key = reports_and_key + producers = [] + for report in reports: + field = _field_from_report(report, key, label) + producers.append(field) + if field["status"] == "partial_verified": + field["producers_checked"] = producers + return field + return {"status": "blocked", "producer": label, "reason": f"{key}_missing", "producers_checked": producers} + + +def _field_with_conflict_gate(key: str, solar_report: dict[str, Any], tajika_report: dict[str, Any]) -> dict[str, Any]: + values = [] + for producer, report in (("solar_return", solar_report), ("tajika", tajika_report)): + if isinstance(report, dict) and report.get(key) is not None and not report.get("error"): + values.append({"producer": producer, "value": report[key]}) + if len(values) >= 2 and values[0]["value"] != values[1]["value"]: + return { + "status": "conflict", + "field": key, + "values": values, + "reason": "same_profile_annual_producers_disagree", + } + if values: + return { + "status": "partial_verified", + "producer": values[0]["producer"], + "data": values[0]["value"], + "producers_checked": values, + } + return { + "status": "blocked", + "field": key, + "reason": f"{key}_missing", + "producers_checked": values, + } + + +def _dasha_field(label: str, solar_report: dict[str, Any], tajika_report: dict[str, Any], key: str) -> dict[str, Any]: + field = _first_available_field(label, solar_report, tajika_report, key) + data = field.get("data") if isinstance(field.get("data"), dict) else {} + periods = data.get("periods") or data.get("dasha_sequence") or [] + field["periods"] = periods if isinstance(periods, list) else [] + return field + + +def _monthly_windows(mudda_dasha: dict[str, Any]) -> list[dict[str, Any]]: + windows = [] + for idx, period in enumerate(mudda_dasha.get("periods", []), start=1): + if not isinstance(period, dict): + continue + windows.append( + { + "index": idx, + "source": "mudda_dasha", + "lord": period.get("lord"), + "duration_months": period.get("months"), + "status": "partial_verified", + } + ) + return windows + + +def _report_sections( + profile: dict[str, Any], + *, + muntha: dict[str, Any] | None = None, + year_lord: dict[str, Any] | None = None, + tajika_yogas: dict[str, Any] | None = None, + sahams: dict[str, Any] | None = None, +) -> dict[str, Any]: + blocked_fields = [ + name + for name, field in (("muntha", muntha), ("year_lord", year_lord)) + if isinstance(field, dict) and field.get("status") == "blocked" + ] + conflict_fields = [ + name + for name, field in (("muntha", muntha), ("year_lord", year_lord)) + if isinstance(field, dict) and field.get("status") == "conflict" + ] + all_flagged_fields = [ + name + for name in (*blocked_fields, *conflict_fields) + if name + ] + muntha_brief = _field_brief("Muntha", muntha) + year_lord_brief = _field_brief("Year Lord", year_lord) + tajika_brief = _field_brief("Tajika Yogas", tajika_yogas) + sahams_brief = _field_brief("Sahams", sahams) + quick_takeaways = [item for item in (muntha_brief, year_lord_brief) if item] + narrative_preview = [ + "年度层已经有可读壳层,但仍需要带着冲突标签阅读。", + muntha_brief, + year_lord_brief, + ] + if blocked_fields: + summary_status = "blocked" + summary_reason = "interpretive annual narrative waits for blocked field closure" + thematic_status = "blocked" + thematic_reason = "annual technique closure incomplete" + elif conflict_fields: + summary_status = "parameter_sensitive" + summary_reason = "interpretive annual narrative remains conflict-labeled until producer adjudication closes" + thematic_status = "parameter_sensitive" + thematic_reason = "annual technique conflict requires labeled reading" + else: + summary_status = "partial_verified" + summary_reason = "annual shell is readable but still awaits broader external parity closure" + thematic_status = "partial_verified" + thematic_reason = "annual technique shell available with current native evidence" + return { + "executive_summary": { + "status": summary_status, + "reason": summary_reason, + "blocked_fields": all_flagged_fields, + "summary_lines": narrative_preview, + "quick_takeaways": quick_takeaways, + }, + "thematic_narrative": { + "status": thematic_status, + "reason": thematic_reason, + "highlights": [ + "Solar Return / Tajika annual shell is available.", + "Muntha and Year Lord are visible, but Year Lord remains producer-disagree sensitive.", + "The report should read annual structure first, then the evidence appendix.", + ], + }, + "evidence_appendix": { + "status": "partial_verified", + "profile_id": profile["profile_id"], + "field_briefs": { + "muntha": muntha_brief, + "year_lord": year_lord_brief, + "tajika_yogas": tajika_brief, + "sahams": sahams_brief, + }, + "must_not_claim": ["exact_annual_event_prediction"], + }, + } + + +def _exports(pack: dict[str, Any]) -> dict[str, Any]: + exports = { + "json": { + "schema": pack["schema"], + "profile_id": pack["profile"]["profile_id"], + "field_statuses": { + key: value.get("status") + for key, value in pack.items() + if isinstance(value, dict) and "status" in value + }, + }, + "markdown": _markdown_summary(pack), + "ai_evidence_bundle": { + "schema": "jyotish.annual_tajika_pack.ai_evidence.v1", + "profile_id": pack["profile"]["profile_id"], + "allowed_claim_status": "blocked_until_conflict_gate", + "raw_field_paths": [ + "solar_return", + "annual_chart", + "muntha", + "year_lord", + "tajika_yogas", + "sahams", + "mudda_dasha", + "patyayini_dasha", + ], + }, + } + try: # pragma: no cover - import path differs under CLI vs pytest + from report_pack_contract import normalize_report_pack_contract + except ImportError: # pragma: no cover + from scripts.report_pack_contract import normalize_report_pack_contract + exports["unified_report_pack_contract"] = normalize_report_pack_contract( + {**pack, "exports": exports}, + pack_id="annual_tajika_pack", + ) + return exports + + +def _markdown_summary(pack: dict[str, Any]) -> str: + rows = ["| field | status |", "| --- | --- |"] + for key in ( + "solar_return", + "annual_chart", + "muntha", + "year_lord", + "tajika_yogas", + "sahams", + "mudda_dasha", + "patyayini_dasha", + "external_engine_comparison", + ): + field = pack[key] + status = field.get('status') + if key == "year_lord": + values = field.get("values") or [] + if values: + first = values[0].get("value") if isinstance(values[0], dict) else {} + if isinstance(first, dict): + status = f"{status}: {first.get('year_lord') or first.get('year_lord_sign')}" + rows.append(f"| {key} | {status} |") + summary = [ + "", + "### Annual Reading Preview", + "", + f"- blocked fields: {', '.join((pack['report_sections']['executive_summary'].get('blocked_fields') or [])) or 'none'}", + ] + return "\n".join(rows + summary) + + +def _external_engine_comparison( + pyjhora_replay: dict[str, Any] | None, + vedastro_reference: dict[str, Any] | None, +) -> dict[str, Any]: + if not pyjhora_replay and not vedastro_reference: + return { + "status": "blocked", + "reason": "external annual replay not integrated in annual_tajika_pack.v1", + "engines": [], + } + comparison: dict[str, Any] = {"status": "blocked", "engines": []} + statuses = [] + if pyjhora_replay: + comparison["engines"].append("PyJHora/JHora") + pyjhora = dict(pyjhora_replay) + patyayini = pyjhora.get("patyayini_dasha") + if isinstance(patyayini, dict): + patyayini = dict(patyayini) + patyayini["normalized_rows"] = _normalize_patyayini_replay_rows(patyayini) + pyjhora["patyayini_dasha"] = patyayini + comparison["pyjhora"] = pyjhora + statuses.append(pyjhora_replay.get("status", "blocked")) + year_lord_replay = pyjhora_replay.get("year_lord_replay") + if isinstance(year_lord_replay, dict): + raw_artifact_path = year_lord_replay.get("raw_artifact_path") + comparison["evidence_scope"] = "pyjhora_behavior_only" + comparison["parity_status"] = "not_multiengine_parity" + comparison["raw_evidence_paths"] = [raw_artifact_path] if raw_artifact_path else [] + comparison["raw_evidence_status"] = ( + "archived_artifact" if raw_artifact_path else "runtime_observation_not_archived" + ) + comparison["year_lord_replay"] = { + key: year_lord_replay[key] + for key in ( + "status", + "reason", + "callable", + "pyjhora_version", + "effective_ayanamsa", + "request_hash", + "node_mode", + ) + if key in year_lord_replay + } + if vedastro_reference: + comparison["engines"].append("VedAstro official") + comparison["vedastro"] = _sanitize_vedastro_reference(vedastro_reference) + statuses.append(comparison["vedastro"].get("status", "blocked")) + comparison["status"] = "partial_verified" if any(str(s).startswith("partial_verified") for s in statuses) else "blocked" + return comparison + + +def _normalize_patyayini_replay_rows(field: dict[str, Any]) -> list[dict[str, Any]]: + """Expose PyJHora tuple shape without inferring a local Patyayini contract.""" + + raw = field.get("raw") if isinstance(field.get("raw"), dict) else {} + periods = raw.get("periods") if isinstance(raw.get("periods"), list) else [] + rows = [] + for order, period in enumerate(periods, start=1): + if not isinstance(period, (list, tuple)) or len(period) != 3: + continue + codes, boundary, duration = period + if not isinstance(codes, (list, tuple)) or len(codes) != 2: + continue + if not isinstance(boundary, (list, tuple)) or len(boundary) != 4: + continue + try: + year, month, day = (int(value) for value in boundary[:3]) + hour_decimal = float(boundary[3]) + boundary_display = (datetime(year, month, day) + timedelta(hours=hour_decimal)).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OverflowError): + continue + rows.append({ + "order": order, + "main_code": codes[0], + "sub_code": codes[1], + "boundary_components": { + "year": year, + "month": month, + "day": day, + "hour_decimal": hour_decimal, + }, + "boundary_display": boundary_display, + "duration_raw": duration, + "timezone_semantics": "not_returned_by_pyjhora_tuple", + "boundary_semantics": "unresolved_external_tuple_boundary", + "evidence_status": "pyjhora_behavior_only / not_multiengine_parity", + }) + return rows + + +def _sanitize_vedastro_reference(reference: dict[str, Any]) -> dict[str, Any]: + allowed = { + "engine", + "status", + "chart_core", + "dasha_all", + "varshaphala_status", + "supported_annual_methods", + "secret_redaction", + } + sanitized = {key: value for key, value in reference.items() if key in allowed} + sanitized.setdefault("engine", "VedAstro official") + sanitized.setdefault("status", "blocked") + sanitized.setdefault("varshaphala_status", "blocked") + sanitized.setdefault("supported_annual_methods", []) + sanitized["secret_redaction"] = {"secret_material_included": False} + return sanitized + + +def _field_brief(label: str, field: dict[str, Any] | None) -> str: + if not isinstance(field, dict): + return f"{label}: unavailable" + status = field.get("status", "blocked") + if status == "conflict": + values = field.get("values") or [] + render_values = [] + for item in values[:2]: + if not isinstance(item, dict): + continue + value = item.get("value") + if isinstance(value, dict): + render_values.append( + ", ".join( + str(part) + for part in ( + value.get("year_lord"), + value.get("year_lord_sign"), + value.get("muntha_sign"), + value.get("muntha_lord"), + ) + if part + ) + ) + if render_values: + return f"{label}: conflict between { ' vs '.join(render_values) }" + if status == "partial_verified": + data = field.get("data") if isinstance(field.get("data"), dict) else {} + if label == "Muntha": + return f"{label}: {data.get('muntha_sign') or data.get('muntha_sign_idx')} / {data.get('muntha_lord') or 'unknown'}" + if label == "Year Lord": + return f"{label}: {data.get('year_lord') or 'unknown'}" + if label == "Tajika Yogas": + authority_status = data.get("status") + if authority_status == "authority_ready": + summary = data.get("summary") if isinstance(data.get("summary"), dict) else {} + row_count = summary.get("row_count") + supported = data.get("supported_named_yogas") if isinstance(data.get("supported_named_yogas"), list) else [] + supported_text = "/".join(str(item) for item in supported) if supported else "governed named yogas" + return ( + f"Tajika Yogas: governed authority surface visible for {supported_text}" + + (f" ({row_count} rows)" if row_count is not None else "") + ) + return "Tajika Yogas: annual candidate structures visible" + if label == "Sahams": + return "Sahams: annual sensitive points visible" + return f"{label}: {status}" + + +def _audit( + profile: dict[str, Any], + solar_report: dict[str, Any], + tajika_report: dict[str, Any], + pyjhora_replay: dict[str, Any] | None = None, + vedastro_reference: dict[str, Any] | None = None, +) -> dict[str, Any]: + muntha_status = _field_with_conflict_gate("muntha", solar_report, tajika_report).get("status", "blocked") + year_lord_status = _field_with_conflict_gate("year_lord", solar_report, tajika_report).get("status", "blocked") + audit = { + "profile_id": profile["profile_id"], + "technique_audit": [ + {"technique": "Calculation Profile", "status": "verified", "profile_id": profile["profile_id"]}, + {"technique": "Solar Return", "status": _producer_status(solar_report)}, + {"technique": "Annual Chart", "status": "partial_verified" if solar_report.get("sr_chart_info") else "blocked"}, + {"technique": "Muntha", "status": muntha_status}, + {"technique": "Year Lord", "status": year_lord_status}, + {"technique": "Tajika Yogas", "status": _tajika_yogas_field(solar_report).get("status", "blocked")}, + {"technique": "Sahams", "status": "partial_verified" if solar_report.get("sahams") else "blocked"}, + {"technique": "Mudda Dasha", "status": "partial_verified" if (solar_report.get("mudda_dasha") or tajika_report.get("mudda_dasha")) else "blocked"}, + {"technique": "Patyayini Dasha", "status": "blocked", "reason": "native producer not integrated"}, + {"technique": "External Engine Comparison", "status": "blocked", "reason": "pending Task 5/6"}, + ], + } + if pyjhora_replay: + year_lord_replay = pyjhora_replay.get("year_lord_replay") + audit["technique_audit"].append( + { + "technique": "PyJHora Annual Replay", + "status": pyjhora_replay.get("status", "blocked"), + "license_boundary": pyjhora_replay.get("license_boundary"), + **( + { + "evidence_scope": "pyjhora_behavior_only", + "parity_status": "not_multiengine_parity", + "raw_artifact_path": year_lord_replay.get("raw_artifact_path"), + "raw_evidence_status": ( + "archived_artifact" + if year_lord_replay.get("raw_artifact_path") + else "runtime_observation_not_archived" + ), + "year_lord_replay_status": year_lord_replay.get("status", "blocked"), + "node_mode": year_lord_replay.get("node_mode"), + } + if isinstance(year_lord_replay, dict) + else {} + ), + } + ) + if vedastro_reference: + vedastro = _sanitize_vedastro_reference(vedastro_reference) + audit["technique_audit"].append( + { + "technique": "VedAstro Annual Boundary", + "status": vedastro.get("status", "blocked"), + "varshaphala_status": vedastro.get("varshaphala_status", "blocked"), + } + ) + return audit + + +def _producer_status(report: dict[str, Any]) -> str: + return "blocked" if report.get("error") else "partial_verified" diff --git a/scripts/calculation_profile_contract.py b/scripts/calculation_profile_contract.py new file mode 100755 index 00000000..4b069a3b --- /dev/null +++ b/scripts/calculation_profile_contract.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Shared canonical calculation-profile contract for the Jyotish runtime. + +Single source of truth for calculation-profile identity. Both +``scripts/domain_calculation_service.py`` (CLI/REST/MCP canonical chart) and the +CLI wrappers in ``scripts/jyotish_engine.py`` delegate here, so one +(birth input, effective settings, observed ephemeris) tuple yields one profile. + +Contract rules +-------------- +1. Deterministic: identical normalized input gives identical ``input_hash`` / + ``profile_id`` / ``profile_hash`` across runs, processes and machines. +2. Privacy (AGENTS 6.6): the profile never stores birth data. Birth + date/time/place/coordinates fold into the irreversible ``input_hash``; only + calculation settings, ``timezone``, ``engine`` and ``algorithm`` remain. +3. One hash algorithm: canonical JSON ``ensure_ascii=True, sort_keys=True, + separators=(",",":"), default=str`` — identical to + ``domain_calculation_service._canonical_hash``. +4. Observed-only ephemeris: ``engine`` carries the five observed fields + (source/flags_verified/flags/provider/policy); ``ephemeris_path`` is never + part of the profile or any hash, so profiles are stable across machine + paths. No provider is hard-coded (ERR-042/ERR-121). +5. ``profile_id == profile_hash`` (self hash); ``input_hash`` covers the + normalized input contract only (no engine observation). +6. Structured errors: unparseable input raises ``CalculationProfileError`` (a + ``ValueError``); IANA timezone names are retained as-is. + +Stdlib-only so it imports identically under direct-script execution and as +``scripts.calculation_profile_contract``. +""" + +from __future__ import annotations + +import hashlib +import json +import math +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +SCHEMA = "jyotish.calculation_profile.v1" +PROFILE_VERSION = "1.0" +DEFAULT_ALGORITHM = "sidereal_natal_chart" +DEFAULTS = { + "ayanamsa": "lahiri", + "node_mode": "mean", + "position_mode": "legacy", + "house_system": "whole_sign", + "dasha_year_days": 365.25, + "solar_return_location_mode": "birth_place", + "annual_year_policy": "solar_return_exact", + "coordinate_precision": "coordinates", +} +# single truth source: every setting the profile exposes +SETTINGS_KEYS = ( + "ayanamsa", + "node_mode", + "position_mode", + "house_system", + "dasha_year_days", + "solar_return_location_mode", + "annual_year_policy", +) +EPHEMERIS_FIELDS = ( + "ephemeris_source", + "ephemeris_flags_verified", + "ephemeris_flags", + "ephemeris_provider", + "ephemeris_policy", +) +POSITION_MODES = ("legacy", "mean", "apparent") +NODE_MODES = ("mean", "true") + + +class CalculationProfileError(ValueError): + """Raised for inputs that cannot be normalized without data loss.""" + + +# --------------------------------------------------------------------------- +# canonical hashing (unified with domain_calculation_service) +# --------------------------------------------------------------------------- +def _canonical_hash(value: Any) -> str: + encoded = json.dumps( + value, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _as_float(value: Any, label: str) -> float | None: + if value is None or value == "": + return None + try: + result = float(value) + except (TypeError, ValueError): + raise CalculationProfileError(f"{label} must be numeric, got {value!r}") from None + if not math.isfinite(result): + raise CalculationProfileError(f"{label} must be finite, got {value!r}") + return result + + +def _as_int(value: Any, label: str) -> int: + if isinstance(value, bool): + raise CalculationProfileError(f"{label} must be an integer, got {value!r}") + try: + result = int(value) + except (TypeError, ValueError): + raise CalculationProfileError(f"{label} must be an integer, got {value!r}") from None + if isinstance(value, float) and not value.is_integer(): + raise CalculationProfileError(f"{label} must be an integer, got {value!r}") + return result + + +# --------------------------------------------------------------------------- +# input normalization (root and nested birth containers) +# --------------------------------------------------------------------------- +def _root_or_nested(payload: dict[str, Any], key: str) -> Any: + """Read a key from the payload root, then ``birth``, then ``location``.""" + for container in (payload, payload.get("birth"), payload.get("location")): + if isinstance(container, dict) and container.get(key) is not None: + return container[key] + return None + + +def _first_not_none(*values: Any) -> Any: + return next((value for value in values if value is not None), None) + + +def _normalize_date(payload: dict[str, Any]) -> str | None: + value = _root_or_nested(payload, "date") + if value is not None: + parts = [part for part in str(value).strip().split("-") if part != ""] + if len(parts) != 3: + raise CalculationProfileError(f"invalid date {value!r}, expected YYYY-MM-DD") + year, month, day = (_as_int(part, f"date part {part!r}") for part in parts) + if not (1 <= year <= 9999 and 1 <= month <= 12 and 1 <= day <= 31): + raise CalculationProfileError(f"invalid date {value!r}, parts out of range") + return f"{year:04d}-{month:02d}-{day:02d}" + year = _root_or_nested(payload, "year") + month = _root_or_nested(payload, "month") + day = _root_or_nested(payload, "day") + if year is None and month is None and day is None: + return None + if year is None or month is None or day is None: + raise CalculationProfileError("incomplete birth date: year/month/day must be given together") + return _normalize_date({"date": f"{_as_int(year, 'year')}-{_as_int(month, 'month')}-{_as_int(day, 'day')}"}) + + +def _normalize_time(payload: dict[str, Any]) -> list[float] | None: + value = _root_or_nested(payload, "time") + if value is not None: + parts = [part for part in str(value).strip().split(":") if part != ""] + if len(parts) not in (2, 3): + raise CalculationProfileError(f"invalid time {value!r}, expected HH:MM[:SS]") + hour = _as_int(parts[0], f"time hour {parts[0]!r}") + minute = _as_int(parts[1], f"time minute {parts[1]!r}") + second = _as_float(parts[2], f"time second {parts[2]!r}") if len(parts) == 3 else 0.0 + if not (0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second < 60): + raise CalculationProfileError(f"invalid time {value!r}, parts out of range") + return [float(hour), float(minute), float(second)] + hour = _root_or_nested(payload, "hour") + minute = _root_or_nested(payload, "minute") + second = _root_or_nested(payload, "second") + if hour is None and minute is None and second is None: + return None + if hour is None or minute is None: + raise CalculationProfileError("incomplete birth time: hour/minute must be given together") + return _normalize_time({"time": f"{_as_int(hour, 'hour')}:{_as_int(minute, 'minute')}:{_as_float(second, 'second') if second is not None else 0.0}"}) + + +def _format_utc_offset(value: Any) -> str: + """Normalize a numeric UTC offset to ``+HH:MM`` (or ``-HH:MM``).""" + if isinstance(value, str): + text = value.strip() + if not text: + raise CalculationProfileError("empty UTC offset") + if ":" in text: + sign = -1 if text.startswith("-") else 1 + parts = text.lstrip("+-").split(":") + hours = _as_float(parts[0], f"offset hour {parts[0]!r}") + minutes = _as_float(parts[1], f"offset minute {parts[1]!r}") if len(parts) > 1 else 0.0 + total = sign * (hours + minutes / 60.0) + else: + total = _as_float(text, f"UTC offset {text!r}") + elif isinstance(value, bool): + raise CalculationProfileError(f"invalid UTC offset {value!r}") + else: + total = _as_float(value, "UTC offset") + if total is None or not -14 <= total <= 14: + raise CalculationProfileError(f"invalid UTC offset {value!r}, must be within -14..+14") + total_minutes = int(round(total * 60.0)) + sign = "+" if total_minutes >= 0 else "-" + total_minutes = abs(total_minutes) + return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}" + + +def _normalize_timezone(payload: dict[str, Any]) -> tuple[str | None, str | None]: + """Return ``(utc_offset, timezone_name)``. + + A numeric ``tz``/``utc_offset`` becomes canonical ``+HH:MM``. A non-numeric + ``tz`` that looks like an IANA zone (contains ``/``) is retained as the + timezone name; an explicit ``timezone`` field wins as the name. + """ + raw_offset = _first_not_none(_root_or_nested(payload, "tz"), _root_or_nested(payload, "utc_offset")) + name = None + for key in ("timezone", "timezone_name", "tz_name"): + candidate = _root_or_nested(payload, key) + if isinstance(candidate, str) and candidate.strip(): + name = candidate.strip() + break + if raw_offset is None or raw_offset == "": + return None, name + try: + return _format_utc_offset(raw_offset), name + except CalculationProfileError: + if isinstance(raw_offset, str) and "/" in raw_offset: + return None, raw_offset.strip() + raise + + +def _normalize_birth(payload: dict[str, Any]) -> dict[str, Any]: + offset, tz_name = _normalize_timezone(payload) + return { + "date": _normalize_date(payload), + "time": _normalize_time(payload), + "utc_offset": offset, + "timezone_name": tz_name, + "latitude": _as_float( + _first_not_none(_root_or_nested(payload, "latitude"), _root_or_nested(payload, "lat")), + "latitude", + ), + "longitude": _as_float( + _first_not_none(_root_or_nested(payload, "longitude"), _root_or_nested(payload, "lon")), + "longitude", + ), + "place": _root_or_nested(payload, "place"), + "coordinate_precision": _root_or_nested(payload, "coordinate_precision") + or DEFAULTS["coordinate_precision"], + } + + +def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]: + settings_block = payload.get("settings") + settings_block = settings_block if isinstance(settings_block, dict) else {} + + def _camel(key: str) -> str: + head, _, tail = key.partition("_") + return head + tail[:1].upper() + tail[1:] + + def _setting(key: str) -> Any: + if key in settings_block and settings_block[key] is not None: + return settings_block[key] + return _first_not_none(_root_or_nested(payload, key), _root_or_nested(payload, _camel(key))) + + ayanamsa = str(_setting("ayanamsa") or DEFAULTS["ayanamsa"]).strip().lower() + if not ayanamsa: + raise CalculationProfileError("ayanamsa must be a non-empty string") + node_mode = str(_setting("node_mode") or DEFAULTS["node_mode"]).strip().lower() + if node_mode not in NODE_MODES: + raise CalculationProfileError(f"node_mode must be one of {NODE_MODES}, got {node_mode!r}") + position_mode = str(_setting("position_mode") or DEFAULTS["position_mode"]).strip().lower() + if position_mode not in POSITION_MODES: + raise CalculationProfileError(f"position_mode must be one of {POSITION_MODES}, got {position_mode!r}") + house_system = str(_setting("house_system") or DEFAULTS["house_system"]).strip() + if not house_system: + raise CalculationProfileError("house_system must be a non-empty string") + dasha_year_days_value = _setting("dasha_year_days") + if dasha_year_days_value is None: + dasha_year_days_value = DEFAULTS["dasha_year_days"] + dasha_year_days = _as_float(dasha_year_days_value, "dasha_year_days") + if dasha_year_days <= 0: + raise CalculationProfileError(f"dasha_year_days must be positive, got {dasha_year_days!r}") + solar_return_location_mode = str( + _setting("solar_return_location_mode") or DEFAULTS["solar_return_location_mode"] + ).strip() or DEFAULTS["solar_return_location_mode"] + annual_year_policy = str( + _setting("annual_year_policy") or DEFAULTS["annual_year_policy"] + ).strip() or DEFAULTS["annual_year_policy"] + return { + "ayanamsa": ayanamsa, + "node_mode": node_mode, + "position_mode": position_mode, + "house_system": house_system, + "dasha_year_days": dasha_year_days, + "solar_return_location_mode": solar_return_location_mode, + "annual_year_policy": annual_year_policy, + } + + +def _effective_ephemeris(payload: dict[str, Any]) -> dict[str, Any]: + """Observed ephemeris provenance only; never fabricate a provider. + + ``ephemeris_path`` is excluded: absolute machine paths must not + participate in profile identity or hashing. + """ + observed: dict[str, Any] = {} + for container in (payload.get("engine"), payload.get("ephemeris"), payload.get("meta")): + if not isinstance(container, dict): + continue + for field in EPHEMERIS_FIELDS: + if field in container and container[field] is not None: + observed[field] = container[field] + if observed: + observed.setdefault("ephemeris_policy", "observed_provider_recorded") + return observed + return { + "ephemeris_provider": "not_observed", + "ephemeris_source": None, + "ephemeris_flags": None, + "ephemeris_flags_verified": False, + "ephemeris_policy": "no_hardcoded_provider_observed_provider_required", + } + + +# --------------------------------------------------------------------------- +# public builders +# --------------------------------------------------------------------------- +def build_calculation_profile(payload: dict[str, Any]) -> dict[str, Any]: + """Build the canonical profile for any payload (dict or Namespace-shaped). + + Birth fields may be nested under ``birth``/``location`` or at the root + (``year``/``month``/``day``/``hour``/``minute``/``second``/``lat``/``lon``/ + ``tz``). They are only hashed into ``input_hash``; the returned profile + carries no birth data. Settings are computed once (single truth) and + mirrored flat and under ``effective_settings``. + """ + birth = _normalize_birth(payload) + settings = _normalize_settings(payload) + offset = birth["utc_offset"] + timezone = { + "name": birth["timezone_name"] or (f"UTC{offset}" if offset is not None else None), + "utc_offset": offset, + } + algorithm = str(payload.get("algorithm") or DEFAULT_ALGORITHM).strip() or DEFAULT_ALGORITHM + + input_hash = _canonical_hash({"birth": birth, "settings": settings}) + + profile: dict[str, Any] = { + "schema": SCHEMA, + "profile_version": PROFILE_VERSION, + "algorithm": algorithm, + "effective_settings": {**settings, "timezone_offset": offset}, + "timezone": timezone, + "engine": _effective_ephemeris(payload), + "coordinate_precision": birth["coordinate_precision"], + **{key: settings[key] for key in SETTINGS_KEYS}, + "input_hash": input_hash, + "profile_id": None, + "profile_hash": None, + } + profile_hash = _canonical_hash(profile) + profile["profile_id"] = profile_hash + profile["profile_hash"] = profile_hash + return profile + + +def _payload_from_args(args: Any) -> dict[str, Any]: + """Convert an argparse Namespace (or a plain dict) into a root-style payload.""" + if isinstance(args, dict): + return dict(args) + if not all(hasattr(args, name) for name in ("year", "month", "day", "hour", "minute")): + raise TypeError( + "attach_calculation_profile expects an argparse Namespace or dict, " + f"got {type(args).__name__}" + ) + + def _attr(name: str) -> Any: + return getattr(args, name, None) + + return { + "year": _attr("year"), + "month": _attr("month"), + "day": _attr("day"), + "hour": _attr("hour"), + "minute": _attr("minute"), + "second": _attr("second"), + "lat": _attr("lat"), + "lon": _attr("lon"), + "tz": _attr("tz"), + "place": _attr("place") or _attr("location_name"), + "timezone": _attr("timezone") or _attr("timezone_name"), + "settings": { + key: _first_not_none(_attr(key), DEFAULTS[key]) for key in SETTINGS_KEYS + }, + } + + +def _observed_ephemeris_from_result(result: dict[str, Any]) -> dict[str, Any] | None: + """Lift observed ephemeris provenance from a computed result. + + Only the five contract fields are carried; ``ephemeris_path`` (an absolute + machine path) is deliberately excluded from profile identity. + """ + meta = result.get("meta") + if isinstance(meta, dict) and meta.get("ephemeris_provider"): + return {key: value for key, value in meta.items() if key in EPHEMERIS_FIELDS} + contract = result.get("calculation_contract") + if isinstance(contract, dict): + effective = contract.get("effective") + if isinstance(effective, dict) and effective.get("ephemeris_provider"): + return {key: value for key, value in effective.items() if key in EPHEMERIS_FIELDS} + return None + + +def attach_calculation_profile(result: dict[str, Any], args: Any) -> dict[str, Any]: + """Attach the canonical profile to a result in place. + + Profile metadata and deterministic result-binding metadata are written; + every pre-existing business key keeps its value. A canonical profile already + present (e.g. from ``domain_calculation_service.compute_chart``) is kept + unchanged; otherwise the profile is built from ``args`` plus any observed + ephemeris provenance in ``result``. + """ + existing = result.get("calculation_profile") if isinstance(result, dict) else None + if ( + isinstance(existing, dict) + and isinstance(existing.get("profile_id"), str) + and isinstance(existing.get("profile_hash"), str) + ): + result["calculation_profile_id"] = existing["profile_id"] + return result + payload = _payload_from_args(args) + observed = _observed_ephemeris_from_result(result) + if observed: + payload["engine"] = observed + profile = build_calculation_profile(payload) + result["calculation_profile"] = profile + result["calculation_profile_id"] = profile["profile_id"] + return bind_result_to_profile(result, profile) + + +def bind_result_to_profile(result: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]: + """Bind one concrete calculation result to its normalized input profile.""" + if not isinstance(result, dict) or not isinstance(profile, dict): + raise TypeError("result and calculation profile must be dictionaries") + input_hash = profile.get("input_hash") + if not isinstance(input_hash, str) or len(input_hash) != 64: + raise ValueError("calculation profile is missing a valid input_hash") + result_payload = { + key: value + for key, value in result.items() + if key not in {"result_hash", "result_binding", "calculation_profile", "calculation_profile_id"} + } + encoded = json.dumps( + canonicalize_result_payload({"input_hash": input_hash, "result": result_payload}), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + result_hash = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + result["result_hash"] = result_hash + result["result_binding"] = {"input_hash": input_hash, "result_hash": result_hash} + return result + + +def canonicalize_result_payload(value: Any) -> Any: + """Convert producer mapping keys to deterministic JSON keys before hashing.""" + if isinstance(value, dict): + normalized: dict[str, Any] = {} + for key, child in value.items(): + normalized_key = key if isinstance(key, str) else f"__{type(key).__name__}__:{key}" + normalized[normalized_key] = canonicalize_result_payload(child) + return normalized + if isinstance(value, (list, tuple)): + return [canonicalize_result_payload(item) for item in value] + return value + + +if __name__ == "__main__": + import sys + + sample = { + "birth": { + "date": "1990-01-01", + "time": "12:00:00", + "utc_offset": "+08:00", + "latitude": 39.9, + "longitude": 116.4, + }, + "settings": {"ayanamsa": "lahiri", "node_mode": "mean"}, + } + print(json.dumps(build_calculation_profile(sample), ensure_ascii=False, indent=2, default=str)) + sys.exit(0) diff --git a/scripts/full_report_quality_gate.py b/scripts/full_report_quality_gate.py new file mode 100755 index 00000000..70d9095c --- /dev/null +++ b/scripts/full_report_quality_gate.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Read-only quality checks for PL9-grade full personal report packets.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +SCHEMA_VERSION = "jyotish.full_report_quality_gate.v1" +REPORT_SCHEMA = "pl9_style_professional_export_v1" +REQUIRED_PACK_SECTIONS = ( + "base", + "strength", + "dasha", + "annual", + "transit", + "d1_d60_ledger", + "professional_support", + "audit_appendix", +) +RESTRICTED_MATERIAL_IDS = ( + "jaimini_special_points", + "tajika_named_yoga", + "annual_sahams", + "kranti", + "alternate_ashtottari", +) + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _present(value: Any) -> bool: + return bool(value) if isinstance(value, (dict, list, tuple, set, str)) else value is not None + + +def _status(value: Any) -> str: + item = _as_dict(value) + return str(item.get("status") or item.get("execution_status") or "available") + + +def _marker_present(markdown: str | None, markers: tuple[str, ...]) -> bool | None: + if markdown is None: + return None + return any(marker in markdown for marker in markers) + + +def _check(name: str, status: str, detail: str) -> dict[str, str]: + return {"name": name, "status": status, "detail": detail} + + +def _coverage_entry( + material_id: str, + tier: str, + source_reference: str, + upstream_value: Any, + markdown: str | None, + markers: tuple[str, ...], +) -> tuple[dict[str, Any], str | None]: + upstream_present = _present(upstream_value) + rendered = _marker_present(markdown, markers) + status = _status(upstream_value) if upstream_present else "not_available" + entry = { + "material_id": material_id, + "admission_tier": tier, + "report_role": "professional_support", + "source_reference": source_reference, + "upstream_present": upstream_present, + "status": status, + "source_status": status, + "surface_location": ( + "professional_support_cross_reference" + if material_id in { + "special_lagnas", + "patyayini_annual_support", + "narayana_alignment", + } + else "thematic_or_operator_appendix" + ), + "rendered": rendered, + "limitation_reference": None if status not in {"blocked", "partial", "parameter_sensitive", "conflict"} else status, + } + if upstream_present and rendered is False: + return entry, f"required_support_not_rendered:{material_id}" + return entry, None + + +def _patyayini_normalized_rows_present(packet: dict[str, Any]) -> bool | None: + annual_section = _as_dict(_as_dict(_as_dict(packet.get("full_report_pack")).get("sections")).get("annual")) + annual_data = _as_dict(packet.get("worksheets")) + timing = _as_dict(annual_data.get("timing_and_predictive_systems")) + annual_pack = _as_dict(timing.get("annual_tajika_pack")) + candidates = ( + _as_dict(_as_dict(annual_pack.get("external_engine_comparison")).get("pyjhora")).get("patyayini_dasha"), + _as_dict(_as_dict(annual_section.get("external_engine_comparison")).get("pyjhora")).get("patyayini_dasha"), + ) + saw_patyayini = False + for candidate in candidates: + if candidate: + saw_patyayini = True + rows = candidate.get("normalized_rows") + if isinstance(rows, list) and rows: + return True + if saw_patyayini: + return False + return None + + +def _promotion_status(blocking_reasons: list[str], review_reasons: list[str], warning_reasons: list[str]) -> str: + if blocking_reasons: + return "blocked" + if review_reasons: + return "review_required" + if warning_reasons: + return "passed_with_limitations" + return "passed" + + +def evaluate_full_report(packet: dict[str, Any], rendered_markdown: str | None = None) -> dict[str, Any]: + """Evaluate an assembled report without mutating calculation or report content.""" + packet = _as_dict(packet) + checks: list[dict[str, str]] = [] + blocking_reasons: list[str] = [] + review_reasons: list[str] = [] + warning_reasons: list[str] = [] + + source_schema = str(packet.get("schema") or "") + if source_schema != REPORT_SCHEMA: + blocking_reasons.append("report_schema_invalid") + checks.append(_check("report_schema", "blocked", f"expected {REPORT_SCHEMA}, got {source_schema or 'missing'}")) + else: + checks.append(_check("report_schema", "passed", source_schema)) + + profile = _as_dict(packet.get("calculation_profile")) + for key, value in ( + ("calculation_profile_id", packet.get("calculation_profile_id") or profile.get("profile_id")), + ("result_hash", packet.get("result_hash")), + ("ayanamsa", profile.get("ayanamsa") or _as_dict(profile.get("effective_settings")).get("ayanamsa")), + ("node_mode", profile.get("node_mode") or _as_dict(profile.get("effective_settings")).get("node_mode")), + ): + if value in (None, ""): + blocking_reasons.append(f"provenance_missing:{key}") + checks.append(_check(f"provenance:{key}", "blocked", "missing")) + else: + checks.append(_check(f"provenance:{key}", "passed", "present")) + + for key, value in ( + ("input_hash", profile.get("input_hash")), + ("calculation_source", profile.get("algorithm")), + ("profile_version", profile.get("profile_version")), + ("engine_metadata", profile.get("engine")), + ): + if not _present(value): + blocking_reasons.append(f"provenance_missing:{key}") + checks.append(_check(f"provenance:{key}", "blocked", "missing")) + else: + checks.append(_check(f"provenance:{key}", "passed", "present")) + + binding = _as_dict(packet.get("result_binding")) + expected_input_hash = profile.get("input_hash") + expected_result_hash = packet.get("result_hash") + if binding.get("input_hash") != expected_input_hash or binding.get("result_hash") != expected_result_hash: + blocking_reasons.append("provenance_lineage_binding_invalid") + checks.append(_check("provenance:result_binding", "blocked", "does not match profile/result hashes")) + else: + checks.append(_check("provenance:result_binding", "passed", "input/result hashes match")) + + chart_identity = _as_dict(packet.get("chart_identity")) + for key in ( + "chart_profile_id", + "birth_data_status", + "rectification_status", + "approval_status", + ): + value = chart_identity.get(key) + if value in (None, ""): + blocking_reasons.append(f"chart_identity_missing:{key}") + checks.append(_check(f"chart_identity:{key}", "blocked", "missing")) + else: + checks.append(_check(f"chart_identity:{key}", "passed", str(value))) + + full_report_pack = _as_dict(packet.get("full_report_pack")) + sections = _as_dict(full_report_pack.get("sections")) + if not full_report_pack: + blocking_reasons.append("full_report_pack_missing") + checks.append(_check("full_report_pack", "blocked", "missing")) + else: + checks.append(_check("full_report_pack", "passed", str(full_report_pack.get("schema") or "present"))) + for key in REQUIRED_PACK_SECTIONS: + section = _as_dict(sections.get(key)) + if not section: + blocking_reasons.append(f"required_pack_section_missing:{key}") + checks.append(_check(f"pack_section:{key}", "blocked", "missing")) + else: + section_status = _status(section) + checks.append(_check(f"pack_section:{key}", "passed" if section_status in {"verified", "available"} else "warning", section_status)) + if section_status in {"blocked", "partial", "partial_verified", "parameter_sensitive", "conflict"}: + warning_reasons.append(f"pack_section_limited:{key}:{section_status}") + + d1_d60_ledger_section = _as_dict(sections.get("d1_d60_ledger")) + d1_d60_ledger = _as_dict(d1_d60_ledger_section.get("d1_to_d60")) + d1_d60_summary = _as_dict(d1_d60_ledger_section.get("summary")) + if d1_d60_ledger_section: + if len(d1_d60_ledger) != 60: + blocking_reasons.append("d1_d60_ledger_incomplete") + checks.append(_check("d1_d60_ledger:row_count", "blocked", f"expected 60, got {len(d1_d60_ledger)}")) + else: + checks.append(_check("d1_d60_ledger:row_count", "passed", "60 rows")) + expected_summary = { + "formal_traditional_division_count": 20, + "research_generic_dn_division_count": 40, + } + if {key: d1_d60_summary.get(key) for key in expected_summary} != expected_summary: + review_reasons.append("d1_d60_ledger_classification_invalid") + checks.append(_check("d1_d60_ledger:classification", "review_required", str(d1_d60_summary))) + else: + checks.append(_check("d1_d60_ledger:classification", "passed", "20 formal + 40 research")) + + audit_appendix = _as_dict(sections.get("audit_appendix")) + if not audit_appendix: + blocking_reasons.append("audit_appendix_missing") + elif _status(audit_appendix) in {"blocked", "partial", "partial_verified", "parameter_sensitive", "conflict"}: + warning_reasons.append(f"audit_appendix_limited:{_status(audit_appendix)}") + + markdown_checks = { + "chart_identity": ("基础资料表", "Birth Particulars"), + "d1": ("D1 — Rashi Chart", "D1 / Base Chart"), + "varga": ("Vargas I", "Varga Analysis"), + "d1_d60_ledger": ("D1–D60 完整原始分盘账本", "D1-D60 Complete Raw Divisional Ledger"), + "timing": ("大运与时间主线", "Dasha Analysis"), + "kp": ("KP 三年流月支持", "KP and Transit Timing Layer"), + "annual": ("年度重点", "Annual / Tajika / Yearly Focus"), + "professional_support": ("专业支持专题:第二证据轴",), + "restricted_materials": ("未闭环专业层:可见但不入判断",), + "limitations": ("Blocked / Audit Appendix", "Conflict and Limitation"), + "audit_appendix": ("Audit Appendix", "Operator Appendix"), + } + if rendered_markdown is None: + warning_reasons.append("rendered_markdown_not_supplied") + checks.append(_check("reader_surface", "not_evaluated", "rendered Markdown was not supplied")) + else: + for name, markers in markdown_checks.items(): + if _marker_present(rendered_markdown, markers): + checks.append(_check(f"reader_section:{name}", "passed", "rendered")) + else: + review_reasons.append(f"reader_section_missing:{name}") + checks.append(_check(f"reader_section:{name}", "review_required", "not rendered")) + + worksheets = _as_dict(packet.get("worksheets")) + divisional = _as_dict(worksheets.get("divisional_and_special_charts")) + strengths = _as_dict(worksheets.get("strengths_and_scores")) + timing = _as_dict(worksheets.get("timing_and_predictive_systems")) + annual = _as_dict(timing.get("annual_tajika_pack")) + dasha_master = _as_dict(timing.get("dasha_master_pack")) + dasha_families = _as_dict(dasha_master.get("families")) + narayana = timing.get("narayana_dasha") or dasha_families.get("narayana") + patyayini = annual.get("patyayini_dasha") or annual.get("patyayini") + auxiliary_dasha_support = { + key: value + for key, value in dasha_families.items() + if key in {"yogini", "ashtottari", "kala_chakra"} and _present(value) + } + advanced = _as_dict(worksheets.get("advanced_systems")) + kp_monthly_report = _as_dict(advanced.get("kp_monthly_report")) + annual_audit = _as_dict(annual.get("audit")) + annual_sections = _as_dict(annual.get("report_sections")) + if not annual_audit or not annual_sections: + review_reasons.append("annual_evidence_status_missing") + checks.append(_check("annual_evidence_status", "review_required", "annual audit or report sections missing")) + else: + checks.append(_check("annual_evidence_status", "passed", "audit and report sections present")) + if rendered_markdown is not None and "年度专题证据状态" not in rendered_markdown: + review_reasons.append("annual_evidence_status_not_rendered") + checks.append(_check("annual_evidence_status:rendered", "review_required", "not rendered")) + + if not kp_monthly_report: + review_reasons.append("kp_evidence_status_missing") + checks.append(_check("kp_evidence_status", "review_required", "monthly report missing")) + else: + kp_maturity = _as_dict(kp_monthly_report.get("maturity_profile")) + kp_restrictions = kp_monthly_report.get("must_not_claim") + if not kp_maturity.get("claim_status") or not isinstance(kp_restrictions, list) or not kp_restrictions: + review_reasons.append("kp_evidence_status_missing") + checks.append(_check("kp_evidence_status", "review_required", "maturity profile or must_not_claim missing")) + else: + checks.append(_check("kp_evidence_status", "passed", str(kp_maturity.get("claim_status")))) + if rendered_markdown is not None and "KP 使用边界" not in rendered_markdown: + review_reasons.append("kp_evidence_status_not_rendered") + checks.append(_check("kp_evidence_status:rendered", "review_required", "not rendered")) + + if rendered_markdown is None: + checks.append(_check("three_year_kp_monthly", "not_evaluated", "rendered Markdown was not supplied")) + else: + rendered_years = sorted( + {int(year) for year in re.findall(r"^###\s+(\d{4})\s+KP 月度支持\s*$", rendered_markdown, re.MULTILINE)} + ) + consecutive = len(rendered_years) >= 3 and all( + year == rendered_years[0] + offset for offset, year in enumerate(rendered_years) + ) + if not consecutive: + review_reasons.append("three_year_kp_monthly_incomplete") + checks.append(_check("three_year_kp_monthly", "review_required", str(rendered_years))) + else: + checks.append(_check("three_year_kp_monthly", "passed", f"{rendered_years[0]}-{rendered_years[-1]}")) + source_years = sorted( + { + int(row["year"]) + for row in kp_monthly_report.get("yearly_highlights", []) + if isinstance(row, dict) and isinstance(row.get("year"), int) + } + ) + if source_years and source_years != rendered_years: + review_reasons.append("three_year_kp_monthly_source_render_mismatch") + checks.append(_check("three_year_kp_monthly:source_alignment", "review_required", f"source={source_years}; rendered={rendered_years}")) + elif source_years: + checks.append(_check("three_year_kp_monthly:source_alignment", "passed", str(source_years))) + + natal_special_factor_support = { + "sahams": _as_dict(advanced.get("sahams")), + "avasthas": _as_dict(advanced.get("avasthas")), + "upagrahas": _as_dict(divisional.get("upagrahas")), + } + natal_special_factor_support = { + key: value for key, value in natal_special_factor_support.items() if _present(value) + } + + special_lagnas = _as_dict(divisional.get("special_lagnas")) + required_special_lagnas = { + "Bhava_Lagna": "Bhava Lagna", + "Hora_Lagna": "Hora Lagna", + "Ghati_Lagna": "Ghati Lagna", + "ViGhati_Lagna": "ViGhati Lagna", + "Sree_Lagna": "Sree Lagna", + "Indu_Lagna": "Indu Lagna", + } + normalized_special_lagnas = { + str(key).strip().lower().replace(" ", "_"): _as_dict(value) + for key, value in special_lagnas.items() + } + missing_special_lagnas: list[str] = [] + for key, display_name in required_special_lagnas.items(): + row = normalized_special_lagnas.get(key.lower(), {}) + explicit_status = _status(row) + has_position = any(row.get(field) not in (None, "") for field in ("sign", "degree", "longitude")) + explicitly_blocked = explicit_status in {"blocked", "not_applicable"} + if not has_position and not explicitly_blocked: + missing_special_lagnas.append(key) + checks.append(_check(f"special_lagna:{key}", "review_required", "missing value or blocked status")) + else: + checks.append(_check(f"special_lagna:{key}", "passed", explicit_status if explicitly_blocked else "position present")) + if rendered_markdown is not None and (has_position or explicitly_blocked) and display_name not in rendered_markdown: + review_reasons.append(f"special_lagna_not_rendered:{key}") + checks.append(_check(f"special_lagna:{key}:rendered", "review_required", "not rendered")) + if len(missing_special_lagnas) >= 2: + blocking_reasons.append("special_lagnas_incomplete") + elif missing_special_lagnas: + review_reasons.extend(f"special_lagna_missing:{key}" for key in missing_special_lagnas) + + wealth_vargas = _as_dict(divisional.get("varga_full")) + for key, label in (("D2_Hora", "D2"), ("D11_Rudramsa", "D11")): + chart = _as_dict(wealth_vargas.get(key)) + ascendant = _as_dict(chart.get("Ascendant") or chart.get("ascendant")) + has_ascendant = any(ascendant.get(field) not in (None, "") for field in ("sign", "degree_in_sign", "longitude")) + if not has_ascendant: + blocking_reasons.append(f"wealth_varga_structure_missing:{label}") + checks.append(_check(f"wealth_varga:{label}", "blocked", "missing ascendant structure")) + else: + checks.append(_check(f"wealth_varga:{label}", "passed", "ascendant structure present")) + + coverage_inputs = ( + ( + "wealth_d2_chart", + "required_support", + "worksheets.divisional_and_special_charts.varga_full.D2_Hora", + _as_dict(_as_dict(divisional.get("varga_full")).get("D2_Hora")), + ("D2(Hora 财富分盘)",), + ), + ( + "wealth_d11_gains_chart", + "required_support", + "worksheets.divisional_and_special_charts.varga_full.D11_Rudramsa", + _as_dict(_as_dict(divisional.get("varga_full")).get("D11_Rudramsa")), + ("D11(Rudramsa 收益分盘)",), + ), + ( + "special_lagnas", + "required_support", + "worksheets.divisional_and_special_charts.special_lagnas", + divisional.get("special_lagnas"), + ("Hora Lagna", "Bhava Lagna", "Standard Lagnas", "Special Lagnas"), + ), + ( + "patyayini_annual_support", + "required_support", + "worksheets.timing_and_predictive_systems.annual_tajika_pack.patyayini_dasha", + patyayini, + ("Patyayini Dasha", "Patyayini"), + ), + ( + "narayana_alignment", + "required_support", + "worksheets.timing_and_predictive_systems.narayana_dasha", + narayana, + ("Narayana Rashi Dasha", "Narayana 参考对齐", "Narayana"), + ), + ( + "functional_benefic_malefic", + "required_support", + "worksheets.strengths_and_scores.functional_benefic_malefic", + strengths.get("functional_benefic_malefic"), + ("功能性吉凶", "Functional Benefic/Malefic"), + ), + ( + "auxiliary_dasha_support", + "required_support", + "worksheets.timing_and_predictive_systems.dasha_master_pack.families", + auxiliary_dasha_support, + ("辅助大运族:Yogini / Ashtottari / Kala Chakra",), + ), + ( + "natal_special_factor_support", + "required_support", + "worksheets.advanced_systems.sahams/avasthas and worksheets.divisional_and_special_charts.upagrahas", + natal_special_factor_support, + ("本命补充因子:Sahams / Avasthas / Upagrahas",), + ), + ) + professional_coverage_manifest: list[dict[str, Any]] = [] + for material_id, tier, source_reference, upstream_value, markers in coverage_inputs: + entry, review_reason = _coverage_entry( + material_id, + tier, + source_reference, + upstream_value, + rendered_markdown, + markers, + ) + professional_coverage_manifest.append(entry) + if review_reason: + review_reasons.append(review_reason) + + patyayini_rows_present = _patyayini_normalized_rows_present(packet) + if patyayini_rows_present is True: + checks.append(_check("patyayini:normalized_rows", "passed", "present")) + elif patyayini_rows_present is False: + review_reasons.append("patyayini_normalized_rows_missing") + checks.append(_check("patyayini:normalized_rows", "review_required", "missing or empty")) + + overrides = _as_dict(packet.get("professional_coverage_overrides")) + for material_id in RESTRICTED_MATERIAL_IDS: + override = _as_dict(overrides.get(material_id)) + override_status = str(override.get("status") or "restricted_or_unclosed") + professional_coverage_manifest.append( + { + "material_id": material_id, + "admission_tier": "restricted_or_unclosed", + "report_role": "restricted_research_material", + "source_reference": "professional_coverage_overrides", + "upstream_present": bool(override), + "status": override_status, + "source_status": override_status, + "surface_location": "blocked_or_research_appendix", + "rendered": None, + "limitation_reference": "must_not_generate_result_claim", + } + ) + if override_status in {"confirmed", "verified", "approved", "promoted"}: + blocking_reasons.append(f"restricted_material_promoted:{material_id}") + checks.append(_check(f"restricted:{material_id}", "blocked", override_status)) + + status = _promotion_status(blocking_reasons, review_reasons, warning_reasons) + return { + "schema_version": SCHEMA_VERSION, + "status": status, + "checks": checks, + "blocking_reasons": blocking_reasons, + "review_reasons": review_reasons, + "warning_reasons": warning_reasons, + "professional_coverage_manifest": professional_coverage_manifest, + "audit_reference": { + "source_schema": source_schema or None, + "calculation_profile_id": packet.get("calculation_profile_id") or profile.get("profile_id"), + "result_hash": packet.get("result_hash"), + "read_only": True, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Evaluate an assembled PL9 full-report packet without modifying it") + parser.add_argument("--packet", required=True, help="PL9 export JSON packet") + parser.add_argument("--markdown", help="Optional rendered Markdown to validate reader-facing coverage") + args = parser.parse_args() + + packet = json.loads(Path(args.packet).read_text(encoding="utf-8")) + markdown = Path(args.markdown).read_text(encoding="utf-8") if args.markdown else None + print(json.dumps(evaluate_full_report(packet, markdown), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 9d221f91..fca8aca0 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -4750,6 +4750,21 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): lat = self._get_float(body, 'lat', 0, -90, 90) lon = self._get_float(body, 'lon', 0, -180, 180) tz = self._parse_timezone(body, lat, lon, year, month, day, hour, minute, second) + today = body.get('today') or body.get('current_date') + if not isinstance(today, str) or not today.strip(): + today = datetime.now().strftime('%Y-%m-%d') + else: + today = today.strip()[:10] + raw_target = body.get('target_year') + if raw_target in (None, ''): + target_year = int(today[:4]) + else: + target_year = self._get_int(body, 'target_year', int(today[:4]), 1800, 2400) + raw_age = body.get('age') + if raw_age in (None, ''): + age = target_year - year + else: + age = self._get_int(body, 'age', target_year - year, 0, 120) return { 'year': year, 'month': month, @@ -4762,8 +4777,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'tz': tz, 'ayanamsa': _request_ayanamsa(body), 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), - 'today': body.get('today') or body.get('current_date'), + 'today': today, 'transit_date': body.get('transit_date') or body.get('reference_date'), + 'target_year': target_year, + 'age': age, 'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'), 'candidate_range': body.get('candidate_range'), 'representative_time': body.get('representative_time'), @@ -6567,6 +6584,22 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if not isinstance(node_mode, str) or node_mode not in {'mean', 'true'}: node_mode = 'mean' + today = body.get('today') or body.get('current_date') + if not isinstance(today, str) or not today.strip(): + today = datetime.now().strftime('%Y-%m-%d') + else: + today = today.strip()[:10] + raw_target = body.get('target_year') + if raw_target in (None, ''): + target_year = int(today[:4]) + else: + target_year = self._get_int(body, 'target_year', int(today[:4]), 1800, 2400) + raw_age = body.get('age') + if raw_age in (None, ''): + age = target_year - year + else: + age = self._get_int(body, 'age', target_year - year, 0, 120) + args = type('Args', (), { 'year': year, 'month': month, @@ -6579,10 +6612,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'tz': tz, 'node_mode': node_mode, 'ayanamsa': _request_ayanamsa(body), - 'age': body.get('age'), - 'today': body.get('today') or body.get('current_date'), + 'age': age, + 'today': today, 'transit_date': body.get('transit_date'), - 'target_year': body.get('target_year'), + 'target_year': target_year, 'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'), 'candidate_range': body.get('candidate_range'), 'representative_time': body.get('representative_time'), diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 6005398a..196b7539 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -76,10 +76,6 @@ def build_report_theme_catalog(report): return [] -def attach_calculation_profile(payload, args=None): - return payload - - def _try_attr_import(modname, attr): for name in (modname, f"scripts.{modname}"): try: @@ -89,10 +85,6 @@ def _try_attr_import(modname, attr): return None -def build_calculation_profile(args=None): - return {"status": "blocked", "reason": "calculation_profile_contract_absent"} - - from ayanamsa_utils import ( AYANAMSA_DISPLAY_NAMES, AYANAMSA_MODES, @@ -110,6 +102,27 @@ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(SCRIPT_DIR) if ROOT_DIR not in sys.path: sys.path.insert(0, ROOT_DIR) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + +try: + from calculation_profile_contract import ( + attach_calculation_profile, + build_calculation_profile, + ) +except ImportError: # pragma: no cover - package import + try: + from scripts.calculation_profile_contract import ( + attach_calculation_profile, + build_calculation_profile, + ) + except ImportError: + def attach_calculation_profile(payload, args=None): + return payload + + def build_calculation_profile(args=None): + return {"status": "blocked", "reason": "calculation_profile_contract_absent"} + HOME_DIR = os.path.expanduser('~') CLAW_DIR = os.path.join(HOME_DIR, 'WorkBuddy', 'Claw') DB_PATH = os.path.join(CLAW_DIR, 'vedic_astrology_validation.db') @@ -2384,7 +2397,12 @@ def _attach_report_governance_contracts(packet: dict, args) -> dict: if birth_time_sensitivity.get('status') == 'candidate_window_only': packet['birth_time_sensitivity'] = birth_time_sensitivity else: - packet.pop('birth_time_sensitivity', None) + packet['birth_time_sensitivity'] = { + 'schema': 'jyotish.report_birth_time_sensitivity.v1', + 'status': 'not_rectified', + 'accuracy': birth_time_sensitivity.get('accuracy') or _birth_time_accuracy(args), + 'reason': 'no_candidate_window', + } packet['timing_boundary_attribution'] = _build_timing_boundary_attribution(packet) packet['module_execution_audit'] = _build_module_execution_audit(packet) ai_pack = ((packet.get('raw_full_reading') or {}).get('ai_prompt_pack') or {}) @@ -2520,6 +2538,34 @@ def _attach_personal_report_producer(packet: dict) -> dict: return packet +def _native_dasha_family_status(module) -> dict: + if not isinstance(module, dict) or not module: + return {'execution_status': 'blocked', 'reason': 'native_module_absent'} + if module.get('error'): + return {'execution_status': 'blocked', 'reason': str(module.get('error'))} + status = str(module.get('status') or '').strip().lower() + if status in {'blocked', 'error'}: + return { + 'execution_status': 'blocked', + 'reason': module.get('reason') or module.get('status') or 'blocked', + } + return { + 'execution_status': 'executed', + 'status': module.get('status') or 'executed', + } + + +def _native_dasha_master_families(modules: dict) -> dict: + modules = modules if isinstance(modules, dict) else {} + return { + 'vimshottari': _native_dasha_family_status(modules.get('dasha')), + 'narayana': _native_dasha_family_status(modules.get('narayana_dasha')), + 'yogini': _native_dasha_family_status(modules.get('yogini_dasha')), + 'ashtottari': _native_dasha_family_status(modules.get('ashtottari_dasha')), + 'kala_chakra': _native_dasha_family_status(modules.get('kalachakra_dasha')), + } + + def _build_pl9_full_dasha_section(packet: dict, modules: dict) -> dict: try: from professional_parity_closure import build_dasha_master_pack @@ -2563,7 +2609,12 @@ def _build_pl9_full_dasha_section(packet: dict, modules: dict) -> dict: functional_classification, ) except Exception as exc: - return {'schema': 'dasha_master_report_pack_v1', 'status': 'blocked', 'reason': str(exc)} + return { + 'schema': 'dasha_master_report_pack_v1', + 'status': 'blocked', + 'reason': str(exc), + 'families': _native_dasha_master_families(modules), + } def _attach_profile_id_to_dasha_section(dasha: dict, profile: dict) -> dict: @@ -2832,6 +2883,87 @@ def _render_shared_report_identity_header(packet: dict) -> list[str]: ] +def _status_cell(value) -> str: + text = str(value if value not in (None, '') else 'blocked').strip() + return text.replace('|', '/') + + +def _render_finished_reading_navigation(packet: dict) -> list[str]: + """Deterministic reading-nav / QA layer. Numbers come from this packet only.""" + quality = packet.get('report_quality_gate') if isinstance(packet.get('report_quality_gate'), dict) else {} + worksheets = packet.get('worksheets') if isinstance(packet.get('worksheets'), dict) else {} + timing = worksheets.get('timing_and_predictive_systems') if isinstance(worksheets.get('timing_and_predictive_systems'), dict) else {} + advanced = worksheets.get('advanced_systems') if isinstance(worksheets.get('advanced_systems'), dict) else {} + strengths = worksheets.get('strengths_and_scores') if isinstance(worksheets.get('strengths_and_scores'), dict) else {} + divisional = worksheets.get('divisional_and_special_charts') if isinstance(worksheets.get('divisional_and_special_charts'), dict) else {} + annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {} + annual_series = timing.get('annual_tajika_series') if isinstance(timing.get('annual_tajika_series'), dict) else {} + kp_monthly = advanced.get('kp_monthly_report') if isinstance(advanced.get('kp_monthly_report'), dict) else {} + sensitivity = packet.get('birth_time_sensitivity') if isinstance(packet.get('birth_time_sensitivity'), dict) else {} + functional = strengths.get('functional_benefic_malefic') if isinstance(strengths.get('functional_benefic_malefic'), dict) else {} + checks = quality.get('checks') if isinstance(quality.get('checks'), list) else [] + manifest = quality.get('professional_coverage_manifest') if isinstance(quality.get('professional_coverage_manifest'), list) else [] + year_keys = list((annual_series.get('years') or {}).keys()) if isinstance(annual_series.get('years'), dict) else [] + months = kp_monthly.get('months') if isinstance(kp_monthly.get('months'), list) else [] + coverage_rows = [ + ('三年年度展开', 'present' if len(year_keys) >= 3 else ('degraded' if year_keys else 'missing'), _status_cell(annual.get('status') or annual_series.get('status'))), + ('KP 三年流月支持', 'present' if len(months) >= 36 else ('degraded' if months else 'missing'), _status_cell(kp_monthly.get('status') or kp_monthly.get('reason'))), + ('功能性吉凶 / Yogakaraka', 'present' if functional else 'missing', _status_cell(functional.get('status') if functional else 'blocked')), + ('Upagraha 表', 'present' if divisional.get('upagrahas') else 'missing', _status_cell('partial_verified' if divisional.get('upagrahas') else 'blocked')), + ('校时敏感层', 'present' if sensitivity.get('status') == 'candidate_window_only' else 'degraded', _status_cell(sensitivity.get('status') or 'not_rectified')), + ] + if manifest: + coverage_rows = [ + ( + str(item.get('material_id') or item.get('source_reference') or 'item'), + 'present' if item.get('rendered') or item.get('upstream_present') else 'missing', + _status_cell(item.get('status') or item.get('source_status')), + ) + for item in manifest + if isinstance(item, dict) + ] + check_passed = sum(1 for item in checks if isinstance(item, dict) and item.get('status') == 'passed') + check_total = len(checks) if checks else 0 + quality_status = _status_cell(quality.get('status') or quality.get('reason') or 'not_evaluated') + lines = [ + '## 成品阅读导航', + '', + '### 先读什么', + '', + '1. 本命主轴与功能性吉凶,确认计算口径。', + '2. 大运与时间主线,以及三年年度重点。', + '3. 事业 / 财运 / 关系专题中的 KP 小表与月度支持。', + '4. 校时敏感层:有候选窗才读矩阵,没有则视为未做校时。', + '5. 质量验收矩阵与对照覆盖表,只用来核对装配,不改写正文标签。', + '', + '### 结论等级规则', + '', + '- `executed` / `partial_verified`:可阅读,但仍受参数与证据边界约束。', + '- `parameter_sensitive`:可交叉核对,不得升级为精确事件。', + '- `blocked` / `missing_in_local`:保持原标签,不得涂成可读结论。', + '', + '### 专题判读协议', + '', + '- 事业先看 2/6/10/11 宫 KP 结构与 D10,再看三年月度支持。', + '- 财运先看 2/5/9/11 宫与 D2/D11,再看年度 Sahams 坐标。', + '- 关系先看 2/7/11 宫与 D9 / UL,再看年内换挡点。', + '', + '### 质量验收矩阵', + '', + f'- 本次质量门状态:`{quality_status}`', + f'- 检查项:{check_passed}/{check_total} passed' if check_total else '- 检查项:本次未返回逐项检查,保持 not_evaluated。', + '', + '### 对照覆盖表', + '', + '| 段落 | 装配 | 状态标签 |', + '|------|------|----------|', + ] + for name, coverage, status in coverage_rows: + lines.append(f'| {_status_cell(name)} | {coverage} | {status} |') + lines.append('') + return lines + + def render_pl9_markdown(packet: dict) -> str: """Render the full PL9 Markdown report.""" birth = packet.get('birth_info', {}) if isinstance(packet, dict) else {} @@ -3158,6 +3290,7 @@ def render_pl9_markdown(packet: dict) -> str: lines = ['# 个人印度占星报告', ''] lines.extend(_render_shared_report_identity_header(packet)) lines.extend(_reader_engine_boundary_notice_markdown()) + lines.extend(_render_finished_reading_navigation(packet)) if isinstance(producer, dict) and producer: main_body = producer.get('main_body') if isinstance(producer.get('main_body'), dict) else {} @@ -3577,8 +3710,19 @@ def render_pl9_markdown(packet: dict) -> str: annual_pack = timing_sheet.get('annual_tajika_pack') if isinstance(timing_sheet.get('annual_tajika_pack'), dict) else {} annual_sections = annual_pack.get('sections') if isinstance(annual_pack.get('sections'), dict) else {} year_lord = annual_sections.get('year_lord') if isinstance(annual_sections.get('year_lord'), dict) else {} + if not year_lord: + year_lord = annual_pack.get('year_lord') if isinstance(annual_pack.get('year_lord'), dict) else {} rows: list[tuple[str, str, str, str]] = [] + if not families: + families = _native_dasha_master_families({ + 'dasha': timing_sheet.get('dasha') or modules.get('dasha'), + 'narayana_dasha': timing_sheet.get('narayana_dasha') or modules.get('narayana_dasha'), + 'yogini_dasha': modules.get('yogini_dasha'), + 'ashtottari_dasha': modules.get('ashtottari_dasha'), + 'kalachakra_dasha': modules.get('kalachakra_dasha'), + }) + def _family_status_row(label: str, key: str, confidence_when_executed: str, blocked_text: str) -> None: family = families.get(key) if isinstance(families.get(key), dict) else {} if not family: @@ -8590,6 +8734,8 @@ def render_pl9_markdown(packet: dict) -> str: lines.extend(yoga_section) professional_support = full_report_pack.get('sections', {}).get('professional_support') if isinstance(full_report_pack.get('sections'), dict) else {} + if not isinstance(professional_support, dict): + professional_support = {} professional_topics = professional_support.get('topics') if isinstance(professional_support.get('topics'), dict) else {} if professional_support: special_topic = professional_topics.get('special_lagnas') if isinstance(professional_topics.get('special_lagnas'), dict) else {} @@ -9307,12 +9453,20 @@ def render_pl9_markdown(packet: dict) -> str: lines.append(f"- 边界:{_md_cell(blind_policy.get('boundary'))}") if event_replay.get('family_d12_binding'): lines.append(f"- D12/父母家庭绑定:{_md_cell(event_replay.get('family_d12_binding'))}") - if birth_time_sensitivity.get('status') == 'candidate_window_only': + if birth_time_sensitivity.get('status') == 'candidate_window_only' and isinstance(birth_time_sensitivity.get('report_projection'), dict): try: from flexible_birth_time_report_section import render_flexible_birth_time_report_section except ImportError: # pragma: no cover - package import from scripts.flexible_birth_time_report_section import render_flexible_birth_time_report_section lines.extend(['', render_flexible_birth_time_report_section(birth_time_sensitivity['report_projection']).rstrip()]) + else: + lines.extend([ + '', + '### 出生时间敏感度', + '', + '未做校时。当前没有可用的候选窗,因此不输出分钟级敏感性矩阵或校时分钟排行。', + '', + ]) lines.extend(['', '## 校时证据领域合同', '']) lines.append(_md_cell(rectification_evidence_contract.get('claim_boundary', 'rectification_evidence_contract_missing'))) @@ -12925,6 +13079,7 @@ def cmd_kp(args): chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(kp_args) if chart is None: + _apply_ayanamsa(requested_report_ayanamsa) return {"error": "swisseph未安装"} asc_sign = chart.get("ascendant", {}).get("sign", "Aries") @@ -12999,7 +13154,9 @@ def cmd_kp(args): 'The main report calculation profile is not overwritten. External same-input KP replay and timing parity remain unclosed.' ), } - return _build_response_envelope('kp', attach_calculation_profile(result, kp_args), args=kp_args, execution_status='executed') + envelope = _build_response_envelope('kp', attach_calculation_profile(result, kp_args), args=kp_args, execution_status='executed') + _apply_ayanamsa(requested_report_ayanamsa) + return envelope # ============================================================================ @@ -14575,6 +14732,19 @@ def cmd_full_reading(args): report['chart'] = chart report['modules']['chart'] = chart + + # KP is a distinct cusp/sub-lord system. Retain its raw output so the + # unified evidence archive can expose it without treating it as Parashari. + # cmd_kp temporarily switches Swiss Ephemeris to KP ayanamsa; restore the + # report profile immediately so later full-reading producers stay on Raman. + try: + report['modules']['kp'] = cmd_kp(args) + except Exception as e: + report['modules']['kp'] = {"status": "blocked", "reason": f"KP producer failed: {e}"} + report['errors'].append(f"kp: {e}") + finally: + _apply_ayanamsa(_current_ayanamsa_name(args)) + planets = chart.get('planets', {}) report['modules']['planetary_friendship'] = _build_planetary_friendship_snapshot(planets) ascendant = chart.get('ascendant', {}) @@ -14584,6 +14754,19 @@ def cmd_full_reading(args): planet_degs = {pn: pd.get('degree_in_sign_raw', pd.get('degree_in_sign', pd['degree'] % 30)) for pn, pd in planets.items() if isinstance(pd, dict) and 'degree' in pd} houses = _build_whole_sign_houses(asc_idx, planets) report['modules']['house_map'] = houses + try: + report['modules'].update(_build_natal_foundation_modules( + args, + planet_lons, + asc_lon=asc_deg, + jd=jd, + )) + except Exception as e: + report['errors'].append(f"natal-foundation: {e}") + try: + report['modules']['varga_research_high'] = _build_research_high_varga(planet_lons, asc_deg) + except Exception as e: + report['errors'].append(f"varga-research-high: {e}") planet_sign_indices = {} for pn, pd in planets.items(): if isinstance(pd, dict) and 'sign' in pd: @@ -14593,7 +14776,7 @@ def cmd_full_reading(args): 'core_chart_and_setup', stage_started, enabled=profile_stages, - details={'modules': ['chart', 'house_map']}, + details={'modules': ['chart', 'house_map', 'kp', 'upagrahas', 'functional_benefic_malefic']}, ) # ── Step 1.5: Special Lagnas 特殊上升点 (v4.4.0) ── @@ -16340,6 +16523,7 @@ def build_pl9_style_export_packet(full_reading: dict, include_raw: bool = False) dasha_master_pack = { 'schema': 'dasha_master_report_pack_v1', 'audit': {'status': 'blocked', 'reason': f'Dasha master pack assembly failed: {exc}'}, + 'families': _native_dasha_master_families(modules), } dasha_interpretation_pack = { 'schema': 'pl9.dasha_interpretation_pack.v1', @@ -16979,11 +17163,17 @@ def build_professional_report_reference_packet( 'reason': 'full_report_quality_gate_absent', } else: - public_quality_input = sanitize_professional_report_reference(final_packet) - final_packet['report_quality_gate'] = evaluate_full_report( - public_quality_input, - render_pl9_markdown(public_quality_input), - ) + try: + public_quality_input = sanitize_professional_report_reference(final_packet) + final_packet['report_quality_gate'] = evaluate_full_report( + public_quality_input, + render_pl9_markdown(public_quality_input), + ) + except Exception: + final_packet['report_quality_gate'] = { + 'status': 'blocked', + 'reason': 'full_report_quality_gate_failed', + } build_shared_full_report_authority = _try_attr_import( 'shared_full_report_authority', 'build_shared_full_report_authority' ) @@ -16995,7 +17185,15 @@ def build_professional_report_reference_packet( 'read_only': True, } else: - final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet) + try: + final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet) + except Exception: + final_packet['shared_full_report_authority'] = { + 'schema_version': 'jyotish.shared_full_report_authority.v1', + 'status': 'blocked', + 'reason': 'shared_full_report_authority_failed', + 'read_only': True, + } return sanitize_professional_report_reference(final_packet) def cmd_pl9_export(args): diff --git a/scripts/kp_monthly_report_contract.py b/scripts/kp_monthly_report_contract.py new file mode 100755 index 00000000..435e2560 --- /dev/null +++ b/scripts/kp_monthly_report_contract.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Contract helpers for the KP three-year monthly report packet.""" + +from __future__ import annotations + +try: # pragma: no cover - import path differs under CLI vs pytest + from kp_system import build_kp_western_support_surface, kp_maturity_profile +except ImportError: # pragma: no cover + from scripts.kp_system import build_kp_western_support_surface, kp_maturity_profile + + +def build_kp_monthly_report_contract( + *, start_month: str, month_count: int, timezone_offset: float +) -> dict: + maturity_profile = kp_maturity_profile() + return { + "schema": "jyotish.kp_monthly_report.v1", + "profile": { + "ayanamsa": "kp", + "house_system": "placidus", + "node_mode": "mean", + "status": "parameter_sensitive", + }, + "maturity_profile": { + "claim_status": maturity_profile.get("claim_status"), + "truth_matrix_allowed": maturity_profile.get("truth_matrix_allowed") is True, + }, + "window": { + "start_month": start_month, + "month_count": month_count, + "timezone_offset": timezone_offset, + "anchor_policy": "local_month_start_noon", + }, + "supporting_systems": { + "western_kp_support": build_kp_western_support_surface( + maturity_profile=maturity_profile, + ) + }, + "must_not_claim": [ + "exact_event_timing", + "specific_event_prediction", + ], + } diff --git a/scripts/kp_monthly_report_packet.py b/scripts/kp_monthly_report_packet.py new file mode 100755 index 00000000..9845957a --- /dev/null +++ b/scripts/kp_monthly_report_packet.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Assembler for the KP three-year monthly report packet.""" + +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +try: # pragma: no cover - import path differs under CLI vs pytest + from domain_calculation_service import compute_chart + from kp_monthly_report_contract import build_kp_monthly_report_contract + from kp_monthly_theme_support import build_kp_monthly_theme_support + from kp_monthly_transits import build_monthly_transit_snapshot + from kp_monthly_vimshottari import build_monthly_vimshottari_snapshot + from kp_system import build_kp_western_support_surface, kp_maturity_profile +except ImportError: # pragma: no cover + from scripts.domain_calculation_service import compute_chart + from scripts.kp_monthly_report_contract import build_kp_monthly_report_contract + from scripts.kp_monthly_theme_support import build_kp_monthly_theme_support + from scripts.kp_monthly_transits import build_monthly_transit_snapshot + from scripts.kp_monthly_vimshottari import build_monthly_vimshottari_snapshot + from scripts.kp_system import build_kp_western_support_surface, kp_maturity_profile + + +_HUMAN_LABELS = { + "Jupiter": "木星", + "Saturn": "土星", + "Rahu": "北交点", + "Ketu": "南交点", + "Aries": "白羊座", + "Taurus": "金牛座", + "Gemini": "双子座", + "Cancer": "巨蟹座", + "Leo": "狮子座", + "Virgo": "处女座", + "Libra": "天秤座", + "Scorpio": "天蝎座", + "Sagittarius": "射手座", + "Capricorn": "摩羯座", + "Aquarius": "水瓶座", + "Pisces": "双鱼座", +} + + +def _add_months(start_year: int, start_month: int, offset: int) -> tuple[int, int]: + month_index = (start_month - 1) + offset + return start_year + (month_index // 12), (month_index % 12) + 1 + + +def _label(value: str | None) -> str: + if not value: + return "-" + return _HUMAN_LABELS.get(value, value) + + +def _build_slow_planet_signals( + current_transits: dict, + next_transits: dict | None, +) -> list[str]: + signals = [] + current_planets = current_transits.get("planets") if isinstance(current_transits.get("planets"), dict) else {} + next_planets = next_transits.get("planets") if isinstance(next_transits, dict) and isinstance(next_transits.get("planets"), dict) else {} + for planet in ("Jupiter", "Saturn", "Rahu", "Ketu"): + current = current_planets.get(planet) if isinstance(current_planets.get(planet), dict) else {} + nxt = next_planets.get(planet) if isinstance(next_planets.get(planet), dict) else {} + current_sign = current.get("sign") + next_sign = nxt.get("sign") + if current_sign and next_sign and current_sign != next_sign: + signals.append(f"{_label(planet)}换座:{_label(current_sign)}→{_label(next_sign)}") + elif current.get("retrograde"): + signals.append(f"{_label(planet)}逆行强调") + return signals + + +def _highlight_reason(row: dict) -> str: + signals = row.get("slow_planet_signals") if isinstance(row.get("slow_planet_signals"), list) else [] + if signals: + return ";".join(str(item) for item in signals[:2]) + levels = row.get("vimshottari_five_levels") if isinstance(row.get("vimshottari_five_levels"), dict) else {} + level_rows = levels.get("levels") if isinstance(levels.get("levels"), dict) else {} + md = level_rows.get("mahadasha") if isinstance(level_rows.get("mahadasha"), dict) else {} + ad = level_rows.get("antardasha") if isinstance(level_rows.get("antardasha"), dict) else {} + return f"主运/次运焦点:{_label(md.get('lord'))} / {_label(ad.get('lord'))}" + + +def _build_yearly_highlights(month_rows: list[dict]) -> list[dict]: + years: dict[int, list[dict]] = {} + for row in month_rows: + month = str(row.get("month") or "") + if len(month) < 4: + continue + year = int(month[:4]) + years.setdefault(year, []).append(row) + yearly = [] + for year in sorted(years): + ranked = sorted( + years[year], + key=lambda item: ( + len(item.get("slow_planet_signals") or []), + 1 if any("换座" in str(signal) for signal in (item.get("slow_planet_signals") or [])) else 0, + ), + reverse=True, + ) + picks = [] + for row in ranked: + picks.append({"month": row.get("month"), "reason": _highlight_reason(row), "status": "parameter_sensitive"}) + if len(picks) == 4: + break + yearly.append({"year": year, "months": picks, "status": "parameter_sensitive"}) + return yearly + + +def _resolve_cmd_kp(): + try: # pragma: no cover - script execution path + from __main__ import cmd_kp as resolved + return resolved + except ImportError: + pass + scripts_dir = Path(__file__).resolve().parent + if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + try: # pragma: no cover - pytest/module import path + from jyotish_engine import cmd_kp as resolved + return resolved + except ImportError: # pragma: no cover + from scripts.jyotish_engine import cmd_kp as resolved + return resolved + + +def build_kp_monthly_report_packet( + *, + birth_payload: dict, + start_month: str, + month_count: int, + western_support: dict | None = None, +) -> dict: + start_year, start_month_number = [int(part) for part in start_month.split("-", 1)] + natal_chart = compute_chart({**birth_payload, "ayanamsa": "lahiri", "node_mode": "mean"}) + moon_lon = natal_chart.get("planets", {}).get("Moon", {}).get("degree_raw") + kp_args = SimpleNamespace( + year=birth_payload["year"], + month=birth_payload["month"], + day=birth_payload["day"], + hour=birth_payload["hour"], + minute=birth_payload["minute"], + second=birth_payload.get("second", 0), + lat=birth_payload["lat"], + lon=birth_payload["lon"], + tz=birth_payload["tz"], + node_mode="mean", + ayanamsa="lahiri", + ) + natal_kp = _resolve_cmd_kp()(kp_args) + packet = build_kp_monthly_report_contract( + start_month=start_month, + month_count=month_count, + timezone_offset=float(birth_payload["tz"]), + ) + maturity_profile = kp_maturity_profile() + western_support_surface = build_kp_western_support_surface( + western_support, + maturity_profile=maturity_profile, + ) + packet["supporting_systems"]["western_kp_support"] = western_support_surface + packet["western_support"] = western_support_surface + packet["months"] = [] + month_rows = [] + for offset in range(month_count): + year, month = _add_months(start_year, start_month_number, offset) + anchor_dt = datetime(year, month, 1, 12, 0, 0) + monthly_levels = build_monthly_vimshottari_snapshot( + birth_dt=datetime( + birth_payload["year"], + birth_payload["month"], + birth_payload["day"], + birth_payload["hour"], + birth_payload["minute"], + birth_payload.get("second", 0), + ), + moon_lon=moon_lon, + anchor_dt=anchor_dt, + ) + monthly_transits = build_monthly_transit_snapshot( + year=year, + month=month, + lat=float(birth_payload["lat"]), + lon=float(birth_payload["lon"]), + tz=float(birth_payload["tz"]), + ) + theme_support = build_kp_monthly_theme_support( + natal_kp=natal_kp, + monthly_levels=monthly_levels.get("levels") or {}, + monthly_transits=monthly_transits, + ) + month_rows.append( + { + "month": f"{year:04d}-{month:02d}", + "anchor_local": monthly_transits["anchor_local"], + "vimshottari_five_levels": monthly_levels, + "monthly_transits": monthly_transits, + "theme_support": theme_support, + "status": "parameter_sensitive", + "must_not_claim": packet["must_not_claim"], + } + ) + for index, row in enumerate(month_rows): + next_transits = None + if index + 1 < len(month_rows): + next_transits = month_rows[index + 1].get("monthly_transits") + row["slow_planet_signals"] = _build_slow_planet_signals( + row.get("monthly_transits") or {}, + next_transits, + ) + packet["months"] = month_rows + packet["yearly_highlights"] = _build_yearly_highlights(month_rows) + packet["natal_kp"] = natal_kp + return packet diff --git a/scripts/kp_monthly_theme_support.py b/scripts/kp_monthly_theme_support.py new file mode 100755 index 00000000..832cc6f4 --- /dev/null +++ b/scripts/kp_monthly_theme_support.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Theme-level KP monthly support aggregation.""" + +from __future__ import annotations + + +_DOMAIN_HOUSES = { + "career": (2, 6, 10, 11), + "wealth": (2, 5, 9, 11), + "relationship": (2, 7, 11), +} + +_DOMAIN_LABELS = { + "career": "事业", + "wealth": "财务", + "relationship": "关系", +} + +_HOUSE_LABELS = { + 2: "收入与资源", + 5: "机会与投入", + 6: "职责与压力", + 7: "合作与伴侣", + 9: "运气与扩张", + 10: "事业与位置", + 11: "回报与支持", +} + +_DOMAIN_TEMPLATES = { + "career": "这段时间更适合把重心放在职责变化、位置调整和回报兑现的节奏里观察。", + "wealth": "这段时间更适合把注意力放在现金流、机会选择和回报兑现的节奏里观察。", + "relationship": "这段时间更适合把重心放在合作默契、关系回应和现实支持的变化里观察。", +} + +_PLANET_CN = { + "Sun": "太阳", + "Moon": "月亮", + "Mars": "火星", + "Mercury": "水星", + "Jupiter": "木星", + "Venus": "金星", + "Saturn": "土星", + "Rahu": "北交点", + "Ketu": "南交点", +} + +_SIGN_CN = { + "Aries": "白羊座", + "Taurus": "金牛座", + "Gemini": "双子座", + "Cancer": "巨蟹座", + "Leo": "狮子座", + "Virgo": "处女座", + "Libra": "天秤座", + "Scorpio": "天蝎座", + "Sagittarius": "射手座", + "Capricorn": "摩羯座", + "Aquarius": "水瓶座", + "Pisces": "双鱼座", +} + + +def _collect_active_houses(houses: dict, house_numbers: tuple[int, ...]) -> list[int]: + active: list[int] = [] + for house in house_numbers: + row = houses.get(str(house)) if isinstance(houses.get(str(house)), dict) else {} + significators = row.get("significators") if isinstance(row.get("significators"), dict) else {} + if significators.get("A") not in (None, "", [], (), set()): + active.append(house) + return active + + +def _house_signal_text(active_houses: list[int], fallback_houses: tuple[int, ...]) -> str: + if not active_houses: + return "相关宫位线索暂时偏弱" + labels = [f"{house}宫{_HOUSE_LABELS.get(house, '')}" for house in active_houses] + return "、".join(labels) + + +def _summary_text(domain: str, active_houses: list[int], fallback_houses: tuple[int, ...], md_lord: str, saturn_sign: str) -> str: + label = _DOMAIN_LABELS.get(domain, domain) + focus = _house_signal_text(active_houses, fallback_houses) + template = _DOMAIN_TEMPLATES.get(domain, "这段时间更适合先看结构支持,再看外部触发。") + md_label = _PLANET_CN.get(md_lord, md_lord) + saturn_label = _SIGN_CN.get(saturn_sign, saturn_sign) + if active_houses: + return ( + f"{label}线本月先看 {focus} 的本命承诺;" + f"当前主运星为{md_label},土星行运落在{saturn_label},{template}" + ) + fallback_text = "、".join( + f"{house}宫{_HOUSE_LABELS.get(house, '')}" for house in fallback_houses + ) + return ( + f"{label}线本月更适合先按 {fallback_text} 这组标准宫位阅读,再等待更强触发出现;" + f"当前主运星为{md_label},土星行运落在{saturn_label},{template}" + ) + + +def _brief_summary_text(domain: str, active_houses: list[int], fallback_houses: tuple[int, ...], md_lord: str) -> str: + label = _DOMAIN_LABELS.get(domain, domain) + md_label = _PLANET_CN.get(md_lord, md_lord) + focus_houses = active_houses or list(fallback_houses[:2]) + focus_text = "、".join(f"{house}宫" for house in focus_houses) + return f"本命焦点:{focus_text};月运主轴:{label}线跟随{md_label}主运推进。" + + +def build_kp_monthly_theme_support( + *, natal_kp: dict, monthly_levels: dict, monthly_transits: dict +) -> dict: + houses = natal_kp.get("houses") if isinstance(natal_kp.get("houses"), dict) else {} + ruling = natal_kp.get("ruling_planets") if isinstance(natal_kp.get("ruling_planets"), dict) else {} + md_lord = ((monthly_levels.get("mahadasha") or {}) if isinstance(monthly_levels, dict) else {}).get("lord") or "-" + saturn_sign = ((((monthly_transits.get("planets") or {}) if isinstance(monthly_transits, dict) else {}).get("Saturn") or {}) if isinstance((monthly_transits.get("planets") or {}).get("Saturn"), dict) else {}).get("sign") or "-" + out = {} + for domain, house_numbers in _DOMAIN_HOUSES.items(): + active_houses = _collect_active_houses(houses, house_numbers) + out[domain] = { + "houses": list(house_numbers), + "active_houses": active_houses, + "promise_code": " / ".join(str(h) for h in active_houses) if active_houses else "-", + "summary": _summary_text(domain, active_houses, house_numbers, md_lord, saturn_sign), + "brief_summary": _brief_summary_text(domain, active_houses, house_numbers, md_lord), + "ruling_planet_snapshot": ruling.get("day_lord") or "-", + "status": "parameter_sensitive", + "must_not_claim": ["exact_event_timing", "specific_event_prediction"], + } + return out diff --git a/scripts/kp_monthly_transits.py b/scripts/kp_monthly_transits.py new file mode 100755 index 00000000..7ae8b09e --- /dev/null +++ b/scripts/kp_monthly_transits.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Monthly transit snapshots for the KP three-year report.""" + +from __future__ import annotations + +try: # pragma: no cover - import path differs under CLI vs pytest + from domain_calculation_service import compute_chart +except ImportError: # pragma: no cover + from scripts.domain_calculation_service import compute_chart + + +def build_monthly_transit_snapshot( + *, year: int, month: int, lat: float, lon: float, tz: float +) -> dict: + chart = compute_chart( + { + "year": year, + "month": month, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": lat, + "lon": lon, + "tz": tz, + "ayanamsa": "kp", + "node_mode": "mean", + "position_mode": "apparent", + } + ) + planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} + snapshot = {} + for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"): + row = planets.get(name) if isinstance(planets.get(name), dict) else {} + snapshot[name] = { + "sign": row.get("sign"), + "longitude": row.get("degree_raw", row.get("degree")), + "retrograde": bool(row.get("retrograde")), + "speed": row.get("speed"), + } + return { + "month": f"{year:04d}-{month:02d}", + "anchor_local": f"{year:04d}-{month:02d}-01T12:00:00", + "planets": snapshot, + "status": "parameter_sensitive", + "source": "local_kp_month_anchor_chart", + } diff --git a/scripts/kp_monthly_vimshottari.py b/scripts/kp_monthly_vimshottari.py new file mode 100755 index 00000000..4dff0c10 --- /dev/null +++ b/scripts/kp_monthly_vimshottari.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Monthly five-level Vimshottari snapshots for the KP monthly report.""" + +from __future__ import annotations + +from datetime import datetime + +try: # pragma: no cover - import path differs under CLI vs pytest + from dasha_calculator_enhanced import calculate_five_level_dasha + from domain_calculation_service import compute_vimshottari_timeline +except ImportError: # pragma: no cover + from scripts.dasha_calculator_enhanced import calculate_five_level_dasha + from scripts.domain_calculation_service import compute_vimshottari_timeline + + +def build_monthly_vimshottari_snapshot( + *, birth_dt: datetime, moon_lon: float, anchor_dt: datetime +) -> dict: + timeline = compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=moon_lon, + current_date=anchor_dt, + ) + current = timeline.get("current_dasha") or {} + start_text = current.get("start") + if start_text: + current_start = datetime.strptime(start_text, "%Y-%m-%d") + elapsed_years = max((anchor_dt - current_start).days / 365.25, 0.0) + else: + elapsed_years = 0.0 + md_lord = current.get("lord") or timeline.get("birth_balance", {}).get("lord") + levels = calculate_five_level_dasha(md_lord, elapsed_years) + return { + "anchor_date": anchor_dt.strftime("%Y-%m-%d"), + "timeline": { + "current_dasha": current, + "birth_balance": timeline.get("birth_balance"), + }, + "levels": { + "mahadasha": levels.get("mahadasha"), + "antardasha": levels.get("bhukti"), + "pratyantardasha": levels.get("pratyantar"), + "sookshma": levels.get("sookshma"), + "prana": levels.get("prana"), + }, + "status": "parameter_sensitive", + "must_not_claim": ["exact_event_timing", "specific_event_prediction"], + } diff --git a/scripts/professional_report_reference.py b/scripts/professional_report_reference.py index fb4a4b6a..c40bfb3b 100644 --- a/scripts/professional_report_reference.py +++ b/scripts/professional_report_reference.py @@ -7,6 +7,7 @@ calculation, and delegates packet assembly/rendering to ``jyotish_engine``. from __future__ import annotations +from datetime import datetime from importlib import import_module from types import SimpleNamespace from typing import Any @@ -57,19 +58,32 @@ def _load_engine(): def _export_args(birth: dict[str, Any]) -> SimpleNamespace: - normalized_birth = { + today = birth.get("today") or datetime.now().strftime("%Y-%m-%d") + raw_target = birth.get("target_year") + if raw_target in (None, ""): + target_year = int(str(today)[:4]) + else: + target_year = int(raw_target) + raw_age = birth.get("age") + if raw_age in (None, ""): + try: + age = int(target_year) - int(birth["year"]) + except (TypeError, ValueError, KeyError): + age = None + else: + age = int(raw_age) + payload = { **birth, "hour": int(birth["hour"]), "minute": int(birth["minute"]), - "second": int(birth.get("second", 0)), + "second": int(birth.get("second", 0) or 0), + "today": today, + "target_year": target_year, + "age": age, + "visual_chart_observations": birth.get("visual_chart_observations"), + "startrack_language_bridge": bool(birth.get("startrack_language_bridge")), } - return SimpleNamespace( - **normalized_birth, - age=None, - target_year=None, - visual_chart_observations=None, - startrack_language_bridge=False, - ) + return SimpleNamespace(**payload) def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]: diff --git a/scripts/shared_full_report_authority.py b/scripts/shared_full_report_authority.py new file mode 100755 index 00000000..76d992e2 --- /dev/null +++ b/scripts/shared_full_report_authority.py @@ -0,0 +1,405 @@ +"""Build the governed reference object for a PL9-grade full personal report. + +The authority is intentionally a compact, read-only reference envelope. It +does not duplicate raw worksheets, render Markdown, alter quality-gate results, +or publish a report. Consumers use its stable identity and references to locate +the already assembled PL9 export packet through an approved transport layer. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + + +AUTHORITY_SCHEMA_VERSION = "jyotish.shared_full_report_authority.v1" +REPORT_SCHEMA_VERSION = "expert_report_output.v2" +DEFAULT_REPORT_VERSION = "pl9_personal_long_report.v2" +PL9_EXPORT_SCHEMA = "pl9_style_professional_export_v1" +QUALITY_GATE_SCHEMA = "jyotish.full_report_quality_gate.v1" + + +def build_shared_full_report_authority( + packet: dict[str, Any], + *, + expert_judgment_output: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Create a compact authority envelope without mutating an export packet. + + A quality-gate result is mandatory, but it is not a publication approval. + Every non-blocked authority remains ``review_required`` until a future + governed review/promotion flow authorizes a consumer surface. + """ + source = _require_mapping(packet, "packet") + _require_value(source.get("schema"), "packet.schema") + if source.get("schema") != PL9_EXPORT_SCHEMA: + raise ValueError(f"packet.schema must be {PL9_EXPORT_SCHEMA}") + + profile = _require_mapping(source.get("calculation_profile"), "calculation_profile") + profile_id = _require_value( + source.get("calculation_profile_id") or profile.get("profile_id"), + "calculation_profile_id", + ) + result_hash = _require_value(source.get("result_hash"), "result_hash") + full_report_pack = _require_mapping(source.get("full_report_pack"), "full_report_pack") + full_report_pack_schema = _require_value(full_report_pack.get("schema"), "full_report_pack.schema") + quality_gate = _require_mapping(source.get("report_quality_gate"), "report_quality_gate") + if quality_gate.get("schema_version") != QUALITY_GATE_SCHEMA: + raise ValueError(f"report_quality_gate.schema_version must be {QUALITY_GATE_SCHEMA}") + + report_version = str(source.get("report_version") or DEFAULT_REPORT_VERSION) + chart_identity = _build_chart_identity(source, profile, profile_id) + identity_seed = { + "report_schema_version": REPORT_SCHEMA_VERSION, + "report_version": report_version, + "calculation_profile_id": profile_id, + "result_hash": result_hash, + "full_report_pack_schema": full_report_pack_schema, + # The same calculation payload can be reached from different chart + # identity states. Keep those authorities distinct rather than letting + # an unreviewed and an approved chart share one report reference. + "chart_profile_id": chart_identity["chart_profile_id"], + "rectification_status": chart_identity["rectification_status"], + "approval_status": chart_identity["approval_status"], + } + identity_digest = _digest(identity_seed) + report_id = f"full-report://{profile_id}/{identity_digest[:24]}" + quality_gate_reference = _build_quality_gate_reference(quality_gate) + status, publication_status = _derive_status(quality_gate_reference["status"]) + + sections = _require_mapping(full_report_pack.get("sections"), "full_report_pack.sections") + report_pack_manifest = _as_mapping(source.get("report_pack_manifest")) + pack_ids = [ + str(pack.get("id")) + for pack in report_pack_manifest.get("packs", []) + if isinstance(pack, dict) and pack.get("id") + ] + judgment_lineage = _build_judgment_lineage(expert_judgment_output) + limitations = _build_limitations(source, quality_gate_reference, chart_identity, judgment_lineage) + + return { + "schema_version": AUTHORITY_SCHEMA_VERSION, + "authority_id": f"shared-full-report://{identity_digest[:24]}", + "report_id": report_id, + "report_version": report_version, + "report_contract_version": REPORT_SCHEMA_VERSION, + "status": status, + "publication_status": publication_status, + "read_only": True, + "report_metadata": { + "generated_at": source.get("generated_at"), + "generated_at_status": "recorded" if source.get("generated_at") else "not_recorded", + }, + "chart_identity": chart_identity, + "lineage": { + "source_schema": source.get("schema"), + "calculation_profile_id": profile_id, + "result_hash": result_hash, + "full_report_pack_schema": full_report_pack_schema, + "report_pack_ids": pack_ids, + "source_reference": "pl9_export_packet", + }, + "quality_gate_reference": quality_gate_reference, + "content_reference": { + "full_report_pack_schema": full_report_pack_schema, + "section_keys": list(sections.keys()), + "professional_support_reference": _build_professional_support_reference( + sections.get("professional_support") + ), + "professional_coverage_reference": _build_professional_coverage_reference( + quality_gate.get("professional_coverage_manifest") + ), + "full_report_body_authority": _build_full_report_body_authority( + sections=sections, + professional_support=sections.get("professional_support"), + professional_coverage_manifest=quality_gate.get("professional_coverage_manifest"), + ), + "available_render_formats": ["markdown", "reader-markdown", "pdf"], + "contains_raw_calculation": False, + "contains_shadow_artifact": False, + }, + "surface_slices": _build_surface_slices(report_id), + "limitations": limitations, + "judgment_lineage": judgment_lineage, + "publication_boundary": ( + "This reference does not publish, promote, render, or replace any " + "existing report consumer. It remains review-only until a governed " + "promotion path explicitly authorizes a surface." + ), + } + + +def _require_mapping(value: Any, name: str) -> dict[str, Any]: + if not isinstance(value, dict) or not value: + raise ValueError(f"{name} is required") + return value + + +def _as_mapping(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _require_value(value: Any, name: str) -> str: + if value in (None, ""): + raise ValueError(f"{name} is required") + return str(value) + + +def _digest(value: dict[str, Any]) -> str: + encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _build_quality_gate_reference(quality_gate: dict[str, Any]) -> dict[str, Any]: + status = str(quality_gate.get("status") or "review_required") + reference_payload = { + "schema_version": quality_gate.get("schema_version"), + "status": status, + "blocking_reasons": list(quality_gate.get("blocking_reasons") or []), + "review_reasons": list(quality_gate.get("review_reasons") or []), + "warning_reasons": list(quality_gate.get("warning_reasons") or []), + "audit_reference": _as_mapping(quality_gate.get("audit_reference")), + } + return { + **reference_payload, + "quality_gate_reference_id": f"report-quality-gate://{_digest(reference_payload)[:24]}", + } + + +def _derive_status(quality_gate_status: str) -> tuple[str, str]: + if quality_gate_status == "blocked": + return "blocked", "blocked" + return "review_required", "pending_review" + + +def _build_chart_identity( + packet: dict[str, Any], + profile: dict[str, Any], + profile_id: str, +) -> dict[str, Any]: + existing = _as_mapping(packet.get("chart_identity")) + return { + "chart_profile_id": existing.get("chart_profile_id") or profile_id, + "birth_data_status": existing.get("birth_data_status") or packet.get("birth_data_status") or "user_provided", + "rectification_status": existing.get("rectification_status") or packet.get("rectification_status") or "not_reviewed", + "approval_status": existing.get("approval_status") or packet.get("approval_status") or "not_approved", + "calculation_profile_reference": profile.get("profile_id") or profile_id, + } + + +def _build_limitations( + packet: dict[str, Any], + quality_gate_reference: dict[str, Any], + chart_identity: dict[str, Any], + judgment_lineage: dict[str, Any], +) -> list[dict[str, str]]: + limitations = [ + { + "type": "publication_review_required", + "detail": "A quality-gate result is advisory and cannot publish a report automatically.", + }, + ] + if judgment_lineage.get("status") != "attached": + limitations.append( + { + "type": "judgment_lineage_not_attached", + "detail": "The current PL9 packet is not yet linked to a production ExpertJudgmentOutput reference.", + } + ) + if quality_gate_reference.get("status") != "passed": + limitations.append( + { + "type": "quality_gate_not_clean_pass", + "detail": f"Quality gate status is {quality_gate_reference.get('status')}.", + } + ) + if chart_identity.get("approval_status") != "approved": + limitations.append( + { + "type": "chart_identity_not_approved", + "detail": f"Chart approval status is {chart_identity.get('approval_status')}.", + } + ) + if not packet.get("generated_at"): + limitations.append( + { + "type": "generation_timestamp_not_recorded", + "detail": "The source export did not supply a generated_at timestamp.", + } + ) + return limitations + + +def _build_judgment_lineage(expert_judgment_output: dict[str, Any] | None) -> dict[str, Any]: + output = expert_judgment_output if isinstance(expert_judgment_output, dict) else {} + if not output: + return { + "status": "not_attached", + "boundary": ( + "Current PL9 export is a governed report candidate. A future " + "ExpertJudgmentOutput reference is required before it can claim " + "judgment-backed production authority." + ), + } + return { + "status": "attached", + "schema_version": output.get("schema_version"), + "judgment_id": output.get("judgment_id"), + "judgment_status": output.get("status"), + "promotion_state": ((output.get("review_metadata") or {}).get("promotion_state") if isinstance(output.get("review_metadata"), dict) else None) or "unknown", + "review_status": ((output.get("review_metadata") or {}).get("review_status") if isinstance(output.get("review_metadata"), dict) else None) or "unknown", + "audit_reference": _as_mapping(output.get("audit_reference")), + "boundary": ( + "A governed ExpertJudgmentOutput reference is attached. Publication and promotion still require " + "their own review boundary." + ), + } + + +def _build_surface_slices(report_id: str) -> dict[str, dict[str, Any]]: + return { + "simple_view": { + "authority_mode": "reference_only", + "report_reference": report_id, + "allowed_sections": ["Executive Summary", "Chart Identity", "Conflict and Limitation"], + }, + "deep_analysis_view": { + "authority_mode": "reference_only", + "report_reference": report_id, + "allowed_sections": [ + "Core Promise Analysis", + "Strength Analysis", + "Domain Analysis", + "Varga Analysis", + "Dasha Analysis", + "KP and Transit Timing Layer", + "Annual / Tajika / Yearly Focus", + "Three-Year Ephemeris and Predictive Outlook", + "Conflict and Limitation", + "Evidence Summary", + ], + }, + "expert_workspace": { + "authority_mode": "reference_only", + "report_reference": report_id, + "allowed_sections": ["all_governed_sections", "Operator Appendix / Audit Appendix"], + }, + } + + +def _build_professional_support_reference(section: Any) -> dict[str, Any]: + topic_section = section if isinstance(section, dict) else {} + topics = topic_section.get("topics") if isinstance(topic_section.get("topics"), dict) else {} + topic_statuses = { + str(key): str(value.get("status") or "blocked") + for key, value in topics.items() + if isinstance(value, dict) + } + restricted = topics.get("restricted_research_materials") if isinstance(topics.get("restricted_research_materials"), dict) else {} + materials = restricted.get("materials") if isinstance(restricted.get("materials"), dict) else {} + restricted_material_visibility = sorted( + key for key, value in materials.items() + if isinstance(value, dict) and value.get("status") not in (None, "", "not_available") + ) + body_authority_topics = sorted( + key + for key, value in topics.items() + if key != "restricted_research_materials" + and isinstance(value, dict) + and value.get("status") not in (None, "", "blocked", "not_available") + ) + appendix_only_topics = sorted( + key + for key, value in topics.items() + if isinstance(value, dict) + and ( + key == "restricted_research_materials" + or value.get("status") in (None, "", "blocked", "not_available") + ) + ) + return { + "status": topic_section.get("status") or "blocked", + "topic_statuses": topic_statuses, + "body_authority_topics": body_authority_topics, + "appendix_only_topics": appendix_only_topics, + "restricted_material_visibility": restricted_material_visibility, + } + + +def _build_professional_coverage_reference(manifest: Any) -> list[dict[str, str]]: + rows = manifest if isinstance(manifest, list) else [] + normalized: list[dict[str, str]] = [] + for row in rows: + if not isinstance(row, dict): + continue + material_id = row.get("material_id") + status = row.get("status") + source_reference = row.get("source_reference") + if material_id in (None, ""): + continue + normalized.append( + { + "material_id": str(material_id), + "status": str(status or "unknown"), + "source_reference": str(source_reference or ""), + } + ) + return normalized + + +def _build_full_report_body_authority( + *, + sections: dict[str, Any], + professional_support: Any, + professional_coverage_manifest: Any, +) -> dict[str, Any]: + section_statuses = { + str(key): str(value.get("status") or "blocked") + for key, value in sections.items() + if isinstance(value, dict) + } + professional_support_reference = _build_professional_support_reference(professional_support) + coverage_rows = _build_professional_coverage_reference(professional_coverage_manifest) + body_sections = [ + key + for key, status in section_statuses.items() + if key not in {"audit_appendix", "professional_support"} + and status not in {"blocked", "not_available"} + ] + restricted_topics = list(professional_support_reference.get("restricted_material_visibility") or []) + body_authority_topics = list(professional_support_reference.get("body_authority_topics") or []) + appendix_only_topics = list(professional_support_reference.get("appendix_only_topics") or []) + topic_promotion_state = { + topic: ( + "body_authority" + if topic in body_authority_topics + else "restricted_registry" + if topic in restricted_topics + else "appendix_only" + ) + for topic in sorted(set(body_authority_topics + restricted_topics + appendix_only_topics)) + } + return { + "status": "authority_topics_promoted" if body_sections or body_authority_topics or coverage_rows else "blocked", + "body_sections": body_sections, + "body_authority_topics": body_authority_topics, + "restricted_professional_topics": restricted_topics, + "appendix_only_topics": appendix_only_topics, + "topic_promotion_state": topic_promotion_state, + "coverage_manifest_material_ids": [row["material_id"] for row in coverage_rows], + "coverage_manifest_authority_materials": [ + { + "material_id": row["material_id"], + "status": row["status"], + "source_reference": row["source_reference"], + "promotion_state": "body_authority_reference" if row["status"] not in {"blocked", "not_available"} else "appendix_only", + } + for row in coverage_rows + ], + "boundary": ( + "This layer tracks which high-value support and coverage materials have an explicit body-authority " + "or restricted-professional registration inside the long-report contract. It does not auto-promote " + "parameter-sensitive or blocked material into judgment authority." + ), + } diff --git a/scripts/tajika_named_yoga_authority.py b/scripts/tajika_named_yoga_authority.py new file mode 100755 index 00000000..0cf60e5b --- /dev/null +++ b/scripts/tajika_named_yoga_authority.py @@ -0,0 +1,91 @@ +"""Governed authority builder for auditable Tajika named yogas. + +This module promotes only the two named yogas whose full rule chain already +shares one vocabulary with the seven-planet Tajika kernel: Ithasala and +Easarapha. It does not infer broader Tajika event verdicts or unsupported +named-yoga chains. +""" + +from __future__ import annotations + +from typing import Any + +try: + from tajika_named_yoga_governed_draft import build_tajika_named_yoga_governed_draft +except ImportError: # pragma: no cover - package import + from scripts.tajika_named_yoga_governed_draft import build_tajika_named_yoga_governed_draft + + +SCHEMA_VERSION = "jyotish.tajika_named_yoga_authority.v1" +SUPPORTED_NAMED_YOGAS = ("Ithasala", "Easarapha") + + +def build_tajika_named_yoga_authority(planets: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Promote governed Tajika draft rows into a narrow authority layer.""" + draft = build_tajika_named_yoga_governed_draft(planets) + if draft.get("status") == "blocked": + return { + "schema_version": SCHEMA_VERSION, + "status": "blocked", + "authority_ready": False, + "supported_named_yogas": list(SUPPORTED_NAMED_YOGAS), + "rows": [], + "reason": draft.get("reason") or "tajika_named_yoga_draft_blocked", + "missing": list(draft.get("missing") or []), + "claim_boundary": "named_yoga_authority_requires_complete_governed_draft_inputs", + "limitations": [ + "kernel_input_missing", + "no_event_verdict", + "unsupported_named_yogas_remain_blocked", + ], + } + + rows = [] + for item in draft.get("rows") or []: + if not isinstance(item, dict): + continue + named_yoga = item.get("named_yoga") + if named_yoga not in SUPPORTED_NAMED_YOGAS: + continue + rows.append( + { + **item, + "review_status": "governed_authority_ready", + "resolution_state": "authority_promoted_from_shared_motion_contract", + "authority_status": "supported", + "claim_boundary": "named_yoga_present_absent_only_no_event_verdict", + } + ) + + return { + "schema_version": SCHEMA_VERSION, + "status": "authority_ready", + "authority_ready": True, + "supported_named_yogas": list(SUPPORTED_NAMED_YOGAS), + "blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola"], + "kernel_status": draft.get("kernel_status"), + "kernel_boundary": draft.get("kernel_boundary"), + "rows": rows, + "summary": { + "row_count": len(rows), + "named_yoga_counts": _count(rows, "named_yoga"), + "motion_counts": _count(rows, "motion"), + }, + "claim_boundary": "governed_named_yoga_authority_limited_to_present_absent_rows_for_ithasala_easarapha", + "limitations": [ + "no_event_verdict", + "unsupported_named_yogas_remain_blocked", + "annual_product_surface_not_promoted_here", + ], + } + + +def _count(rows: list[dict[str, Any]], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for row in rows: + value = row.get(key) + if value in (None, ""): + continue + value = str(value) + counts[value] = counts.get(value, 0) + 1 + return counts diff --git a/scripts/tajika_named_yoga_governed_draft.py b/scripts/tajika_named_yoga_governed_draft.py new file mode 100755 index 00000000..c86e608b --- /dev/null +++ b/scripts/tajika_named_yoga_governed_draft.py @@ -0,0 +1,105 @@ +"""Governed named-yoga draft built from shared Tajika motion evidence. + +This module does not promote named yogas into authority. It packages kernel +candidate relations into a review-ready draft so the remaining closure can be +resolved under one governed vocabulary. +""" + +from __future__ import annotations + +from typing import Any + +try: + from tajika_kernel import calculate_tajika_interactions +except ImportError: # pragma: no cover - package import + from scripts.tajika_kernel import calculate_tajika_interactions + + +SCHEMA_VERSION = "jyotish.tajika_named_yoga_governed_draft.v1" +_CANDIDATE_TO_NAMED = { + "Ithasala_candidate": "Ithasala", + "Easarapha_candidate": "Easarapha", +} + + +def build_tajika_named_yoga_governed_draft(planets: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Package Tajika kernel candidate relations as a review-only draft.""" + if not isinstance(planets, dict): + raise ValueError("planets_required") + + kernel = calculate_tajika_interactions(planets) + if kernel.get("status") == "blocked": + return { + "schema_version": SCHEMA_VERSION, + "status": "blocked", + "authority_ready": False, + "claim_boundary": "governed_named_yoga_draft_requires_complete_seven_planet_kernel_inputs", + "reason": kernel.get("reason") or "tajika_kernel_blocked", + "missing": list(kernel.get("missing") or []), + "rows": [], + "summary": { + "row_count": 0, + "named_yoga_counts": {}, + "motion_counts": {}, + }, + "limitations": [ + "kernel_input_missing", + "no_named_yoga_authority", + "draft_does_not_promote_verdict", + ], + } + + rows: list[dict[str, Any]] = [] + for item in kernel.get("candidate_yogas") or []: + if not isinstance(item, dict): + continue + named_yoga = _CANDIDATE_TO_NAMED.get(str(item.get("name") or "")) + if not named_yoga: + continue + rows.append( + { + "named_yoga": named_yoga, + "candidate_name": item.get("name"), + "planets": list(item.get("planets") or []), + "aspect": item.get("aspect"), + "motion": item.get("motion"), + "average_deeptamsa": item.get("average_deeptamsa"), + "residual": item.get("residual"), + "rule_source": item.get("rule_source"), + "review_status": "governed_seed_resolution_review_required", + "resolution_state": "governed_seed_resolution_draft", + "claim_boundary": "candidate_relation_only_no_named_yoga_authority", + } + ) + + return { + "schema_version": SCHEMA_VERSION, + "status": "draft_only", + "authority_ready": False, + "claim_boundary": "governed_named_yoga_draft_only_no_named_yoga_verdict_or_promotion", + "kernel_status": kernel.get("status"), + "kernel_boundary": kernel.get("boundary"), + "rows": rows, + "summary": { + "row_count": len(rows), + "named_yoga_counts": _count(rows, "named_yoga"), + "motion_counts": _count(rows, "motion"), + }, + "limitations": [ + "candidate_relations_only", + "no_named_yoga_authority", + "no_final_judgment", + "governed_seed_review_required_before_promotion", + ], + } + + +def _count(rows: list[dict[str, Any]], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for row in rows: + value = row.get(key) + if value in (None, ""): + continue + value = str(value) + counts[value] = counts.get(value, 0) + 1 + return counts diff --git a/tests/test_annual_tajika_pack.py b/tests/test_annual_tajika_pack.py new file mode 100755 index 00000000..9103a4db --- /dev/null +++ b/tests/test_annual_tajika_pack.py @@ -0,0 +1,348 @@ +import pytest + +from scripts import annual_tajika_pack +from scripts.annual_tajika_pack import build_annual_tajika_pack + + +def _payload(): + return { + "birth": { + "date": "1990-01-01", + "time": "12:00:00", + "utc_offset": "+08:00", + "place": "Beijing, China", + "latitude": 39.9, + "longitude": 116.4, + "coordinate_precision": "city", + }, + "settings": { + "ayanamsa": "lahiri", + "node_mode": "mean", + "house_system": "whole_sign", + "position_mode": "legacy", + "dasha_year_days": 365.25, + "solar_return_location_mode": "birth_place", + "annual_year_policy": "solar_return_exact", + }, + "target_year": 2026, + "age": 36, + } + + +def test_builds_annual_tajika_pack_contract() -> None: + pack = build_annual_tajika_pack(_payload()) + + assert list(pack.keys()) == [ + "schema", + "profile", + "solar_return", + "annual_chart", + "muntha", + "year_lord", + "tajika_yogas", + "sahams", + "mudda_dasha", + "patyayini_dasha", + "monthly_windows", + "external_engine_comparison", + "report_sections", + "exports", + "audit", + ] + assert pack["schema"] == "jyotish.annual_tajika_pack.v1" + assert pack["profile"]["profile_id"] + assert pack["solar_return"]["status"] in {"partial_verified", "blocked"} + assert pack["annual_chart"]["status"] in {"partial_verified", "blocked"} + assert pack["muntha"]["status"] in {"partial_verified", "blocked", "conflict"} + assert pack["year_lord"]["status"] in {"partial_verified", "blocked", "conflict"} + assert pack["tajika_yogas"]["status"] in {"partial_verified", "blocked"} + assert pack["sahams"]["status"] in {"partial_verified", "blocked"} + assert isinstance(pack["mudda_dasha"]["periods"], list) + assert isinstance(pack["patyayini_dasha"]["periods"], list) + assert isinstance(pack["monthly_windows"], list) + assert pack["external_engine_comparison"]["status"] in {"blocked", "partial_verified"} + assert pack["report_sections"]["executive_summary"]["status"] in {"blocked", "parameter_sensitive"} + assert isinstance(pack["report_sections"]["executive_summary"]["quick_takeaways"], list) + assert "tajika_yogas" in pack["report_sections"]["evidence_appendix"]["field_briefs"] + assert "sahams" in pack["report_sections"]["evidence_appendix"]["field_briefs"] + assert pack["report_sections"]["evidence_appendix"]["must_not_claim"] == ["exact_annual_event_prediction"] + assert pack["exports"]["json"]["schema"] == pack["schema"] + assert pack["exports"]["ai_evidence_bundle"]["profile_id"] == pack["profile"]["profile_id"] + unified = pack["exports"]["unified_report_pack_contract"] + assert unified["schema"] == "pl9.unified_report_pack_contract.v1" + assert unified["pack_id"] == "annual_tajika_pack" + assert unified["status"] in {"blocked", "parameter_sensitive"} + assert unified["status"] == pack["report_sections"]["executive_summary"]["status"] + assert pack["audit"]["technique_audit"][0]["technique"] == "Calculation Profile" + + +def test_annual_tajika_pack_marks_muntha_and_year_lord_conflicts() -> None: + pack = build_annual_tajika_pack( + _payload(), + solar_return_report={ + "solar_return": {"dt_ut": "2026-01-01 00:00:00"}, + "sr_chart_info": {"ascendant": {"sign": "Leo"}}, + "muntha": {"muntha_sign": "Taurus", "muntha_lord": "Venus"}, + "year_lord": {"year_lord": "Venus"}, + }, + tajika_report={ + "muntha": {"muntha_sign": "Cancer", "muntha_lord": "Moon"}, + "year_lord": {"year_lord": "Moon"}, + }, + ) + + assert pack["muntha"]["status"] == "conflict" + assert pack["muntha"]["values"] == [ + {"producer": "solar_return", "value": {"muntha_sign": "Taurus", "muntha_lord": "Venus"}}, + {"producer": "tajika", "value": {"muntha_sign": "Cancer", "muntha_lord": "Moon"}}, + ] + assert pack["year_lord"]["status"] == "conflict" + assert pack["year_lord"]["values"] == [ + {"producer": "solar_return", "value": {"year_lord": "Venus"}}, + {"producer": "tajika", "value": {"year_lord": "Moon"}}, + ] + assert pack["report_sections"]["executive_summary"]["status"] == "parameter_sensitive" + assert "muntha" in pack["report_sections"]["executive_summary"]["blocked_fields"] + assert "year_lord" in pack["report_sections"]["executive_summary"]["blocked_fields"] + + +def test_annual_tajika_pack_year_lord_conflict_exposes_canonical_field_contract() -> None: + pack = build_annual_tajika_pack( + _payload(), + solar_return_report={ + "solar_return": {"dt_ut": "2026-01-01 00:00:00"}, + "year_lord": {"year_lord": "Venus", "year_lord_sign": "Taurus"}, + }, + tajika_report={ + "year_lord": {"year_lord": "Moon", "year_lord_sign": "Cancer"}, + }, + ) + + year_lord = pack["year_lord"] + + assert year_lord["status"] == "conflict" + assert year_lord["field"] == "year_lord" + assert year_lord["reason"] == "same_profile_annual_producers_disagree" + assert year_lord["values"] == [ + {"producer": "solar_return", "value": {"year_lord": "Venus", "year_lord_sign": "Taurus"}}, + {"producer": "tajika", "value": {"year_lord": "Moon", "year_lord_sign": "Cancer"}}, + ] + assert "year_lord" in pack["report_sections"]["executive_summary"]["blocked_fields"] + + +def test_annual_tajika_pack_propagates_nested_blocked_field_statuses() -> None: + pack = build_annual_tajika_pack( + _payload(), + solar_return_report={ + "solar_return": {"dt_ut": "2026-01-01 00:00:00"}, + "tajika_yogas": {"status": "blocked", "reason": "legacy_tajika_input_lacks_planet_speeds"}, + "sahams": {"status": "blocked", "reason": "saham_daynight_requires_wgs84_location_and_timezone"}, + }, + tajika_report={}, + ) + + assert pack["tajika_yogas"]["status"] == "blocked" + assert pack["tajika_yogas"]["reason"] == "legacy_tajika_input_lacks_planet_speeds" + assert pack["sahams"]["status"] == "blocked" + assert pack["sahams"]["reason"] == "saham_daynight_requires_wgs84_location_and_timezone" + assert pack["report_sections"]["evidence_appendix"]["field_briefs"]["tajika_yogas"] == "Tajika Yogas: blocked" + assert pack["report_sections"]["evidence_appendix"]["field_briefs"]["sahams"] == "Sahams: blocked" + + +def test_annual_tajika_pack_exposes_tajika_and_sahams_reader_briefs_when_visible() -> None: + pack = build_annual_tajika_pack( + _payload(), + solar_return_report={ + "solar_return": {"dt_ut": "2026-01-01 00:00:00"}, + "sr_chart_info": {"ascendant": {"sign": "Leo"}}, + "muntha": {"muntha_sign": "Taurus", "muntha_lord": "Venus"}, + "year_lord": {"year_lord": "Venus"}, + "tajika_yogas": {"ithasala": ["Sun/Mars"]}, + "sahams": {"punya_saham": {"longitude": 123.4}}, + }, + tajika_report={}, + ) + + appendix = pack["report_sections"]["evidence_appendix"] + assert appendix["field_briefs"]["tajika_yogas"] == "Tajika Yogas: annual candidate structures visible" + assert appendix["field_briefs"]["sahams"] == "Sahams: annual sensitive points visible" + assert pack["report_sections"]["executive_summary"]["quick_takeaways"] == [ + "Muntha: Taurus / Venus", + "Year Lord: Venus", + ] + + +def test_annual_tajika_pack_promotes_governed_named_yoga_authority_when_motion_inputs_exist() -> None: + pack = build_annual_tajika_pack( + _payload(), + solar_return_report={ + "solar_return": {"dt_ut": "2026-01-01 00:00:00"}, + "sr_chart_info": {"ascendant": {"sign": "Leo"}}, + "muntha": {"muntha_sign": "Taurus", "muntha_lord": "Venus"}, + "year_lord": {"year_lord": "Venus"}, + "chart": { + "planets": { + "Sun": {"degree_raw": 0, "speed": 1.0}, + "Moon": {"degree_raw": 49, "speed": 13.0}, + "Mars": {"degree_raw": 180, "speed": 0.5}, + "Mercury": {"degree_raw": 260, "speed": 1.2}, + "Jupiter": {"degree_raw": 310, "speed": 0.08}, + "Venus": {"degree_raw": 130, "speed": 1.0}, + "Saturn": {"degree_raw": 220, "speed": 0.03}, + } + }, + "tajika_yogas": {"legacy_surface": "still_present_but_not_primary"}, + }, + tajika_report={}, + ) + + tajika_field = pack["tajika_yogas"] + authority = tajika_field["data"] + appendix = pack["report_sections"]["evidence_appendix"] + + assert tajika_field["status"] == "partial_verified" + assert tajika_field["producer"] == "Tajika Named Yoga Authority" + assert tajika_field["data_contract"] == "governed_named_yoga_authority_surface" + assert authority["status"] == "authority_ready" + assert authority["supported_named_yogas"] == ["Ithasala", "Easarapha"] + assert authority["blocked_named_yogas"] == ["Nakta", "Yamaya", "Manahoo", "Kamboola"] + assert appendix["field_briefs"]["tajika_yogas"] == "Tajika Yogas: governed authority surface visible for Ithasala/Easarapha (8 rows)" + assert pack["audit"]["technique_audit"][5] == {"technique": "Tajika Yogas", "status": "partial_verified"} + + +def test_annual_tajika_pack_accepts_pyjhora_external_replay_result() -> None: + replay = { + "engine": "PyJHora/JHora", + "license_boundary": {"mode": "external_reference_only"}, + "input_profile_id": "will_be_replaced_by_builder_check", + "mudda_dasha": {"status": "partial_verified", "raw": {"periods": []}}, + "patyayini_dasha": {"status": "partial_verified", "raw": {"periods": []}}, + "sahams": {"status": "partial_verified", "raw": {}}, + "solar_return_boundary": {"status": "partial_verified", "raw": []}, + "status": "partial_verified", + } + + pack = build_annual_tajika_pack(_payload(), pyjhora_replay=replay) + + assert pack["external_engine_comparison"]["status"] == "partial_verified" + assert pack["external_engine_comparison"]["pyjhora"]["engine"] == "PyJHora/JHora" + assert pack["external_engine_comparison"]["pyjhora"]["license_boundary"]["mode"] == "external_reference_only" + assert pack["audit"]["technique_audit"][-1]["technique"] == "PyJHora Annual Replay" + assert pack["audit"]["technique_audit"][-1]["status"] == "partial_verified" + + +def test_annual_tajika_pack_preserves_patyayini_replay_tuple_semantics() -> None: + replay = { + "engine": "PyJHora/JHora", + "license_boundary": {"mode": "external_reference_only"}, + "status": "partial_verified", + "evidence_scope": "pyjhora_behavior_only", + "parity_status": "not_multiengine_parity", + "patyayini_dasha": { + "status": "partial_verified", + "raw": {"periods": [((1, "L"), (2022, 2, 17, 5.63431528955698), 0.0438458145826713)]}, + }, + } + + pack = build_annual_tajika_pack(_payload(), pyjhora_replay=replay) + + patyayini = pack["external_engine_comparison"]["pyjhora"]["patyayini_dasha"] + assert patyayini["status"] == "partial_verified" + assert patyayini["normalized_rows"] == [{ + "order": 1, + "main_code": 1, + "sub_code": "L", + "boundary_components": {"year": 2022, "month": 2, "day": 17, "hour_decimal": 5.63431528955698}, + "boundary_display": "2022-02-17 05:38:03", + "duration_raw": 0.0438458145826713, + "timezone_semantics": "not_returned_by_pyjhora_tuple", + "boundary_semantics": "unresolved_external_tuple_boundary", + "evidence_status": "pyjhora_behavior_only / not_multiengine_parity", + }] + assert pack["patyayini_dasha"]["periods"] == [] + + +def test_annual_tajika_pack_keeps_runtime_year_lord_replay_as_single_engine_evidence() -> None: + replay = { + "engine": "PyJHora/JHora", + "license_boundary": {"mode": "external_reference_only"}, + "status": "partial_verified", + "evidence_scope": "pyjhora_behavior_only", + "parity_status": "not_multiengine_parity", + "year_lord_replay": { + "status": "partial_verified", + "reason": "pyjhora_tajaka_year_lord_callable_observed", + "callable": "jhora.horoscope.transit.tajaka.lord_of_the_year", + "pyjhora_version": "4.8.7", + "effective_ayanamsa": "LAHIRI", + "request_hash": "a" * 64, + "node_mode": {"requested": "mean", "status": "unsupported_by_replay_adapter"}, + }, + } + + pack = build_annual_tajika_pack(_payload(), pyjhora_replay=replay) + + external = pack["external_engine_comparison"] + assert external["evidence_scope"] == "pyjhora_behavior_only" + assert external["parity_status"] == "not_multiengine_parity" + assert external["raw_evidence_paths"] == [] + assert external["raw_evidence_status"] == "runtime_observation_not_archived" + assert external["year_lord_replay"]["node_mode"]["status"] == "unsupported_by_replay_adapter" + assert pack["year_lord"]["status"] in {"partial_verified", "blocked", "conflict"} + + +def test_annual_tajika_pack_marks_vedastro_as_base_chart_boundary_not_varshaphala() -> None: + vedastro = { + "engine": "VedAstro official", + "status": "partial_verified_for_base_chart", + "chart_core": {"status": "partial_verified", "fields": ["Sun", "Moon", "Ascendant"]}, + "dasha_all": {"status": "partial_verified", "range": "2026-01-01/2026-12-31"}, + "varshaphala_status": "blocked", + "supported_annual_methods": [], + "secret_redaction": {"secret_material_included": False}, + } + + pack = build_annual_tajika_pack(_payload(), vedastro_reference=vedastro) + + comparison = pack["external_engine_comparison"] + assert comparison["status"] == "partial_verified" + assert comparison["vedastro"]["status"] == "partial_verified_for_base_chart" + assert comparison["vedastro"]["varshaphala_status"] == "blocked" + assert comparison["vedastro"]["supported_annual_methods"] == [] + assert "api_key" not in repr(comparison).lower() + assert pack["audit"]["technique_audit"][-1]["technique"] == "VedAstro Annual Boundary" + assert pack["audit"]["technique_audit"][-1]["status"] == "partial_verified_for_base_chart" + + +def test_annual_tajika_pack_infers_age_from_target_year_for_tajika_branch(monkeypatch: pytest.MonkeyPatch) -> None: + payload = _payload() + payload.pop("age") + + captured = {} + + def fake_solar_return(args): + return { + "solar_return": {"dt_ut": "2026-01-01 00:00:00"}, + "sr_chart_info": {"ascendant": {"sign": "Leo"}}, + "muntha": {"muntha_sign": "Taurus", "muntha_lord": "Venus"}, + "year_lord": {"year_lord": "Venus", "year_lord_sign": "Taurus"}, + } + + def fake_tajika(args): + captured["age"] = args.age + return { + "muntha": {"muntha_sign": "Cancer", "muntha_lord": "Moon"}, + "year_lord": {"year_lord": "Moon", "year_lord_sign": "Cancer"}, + } + + monkeypatch.setattr(annual_tajika_pack, "_safe_solar_return", fake_solar_return) + monkeypatch.setattr(annual_tajika_pack, "_safe_tajika", fake_tajika) + + pack = build_annual_tajika_pack(payload) + + assert captured["age"] == 36 + assert pack["year_lord"]["status"] == "conflict" + assert pack["year_lord"]["values"] == [ + {"producer": "solar_return", "value": {"year_lord": "Venus", "year_lord_sign": "Taurus"}}, + {"producer": "tajika", "value": {"year_lord": "Moon", "year_lord_sign": "Cancer"}}, + ] diff --git a/tests/test_calculation_profile_contract.py b/tests/test_calculation_profile_contract.py new file mode 100755 index 00000000..cc1237e4 --- /dev/null +++ b/tests/test_calculation_profile_contract.py @@ -0,0 +1,531 @@ +"""Focused regression tests for the shared calculation-profile contract. + +The shared module is the single profile producer for +``scripts/domain_calculation_service.py`` (CLI/REST/MCP canonical chart) and +``scripts/jyotish_engine.py`` command wrappers. These tests pin down: + +* stable canonical ``profile_id`` / ``input_hash`` / ``profile_hash``; +* dynamic settings preserved and reflected in the hashes; +* privacy: the profile never stores birth date/time/place/coordinates + (AGENTS hard-constraint 6.6); +* observed-only ephemeris provenance, with ``ephemeris_path`` excluded from + profile identity (cross-machine stability); +* ``attach_calculation_profile`` adds exactly two keys without mutating + existing business fields; +* dict and Namespace entry points are equivalent; root and nested birth + inputs are equivalent; +* malformed inputs raise structured ``CalculationProfileError`` (or retain an + IANA timezone name) instead of silently producing a lossy profile; +* domain-service and CLI producers yield the same profile hash for the same + input; +* the dasha-master-pack profile gate contract keeps working. +""" + +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = str(ROOT / "scripts") +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from scripts.calculation_profile_contract import ( # noqa: E402 + SCHEMA, + PROFILE_VERSION, + CalculationProfileError, + attach_calculation_profile, + build_calculation_profile, +) + +BIRTH_PAYLOAD = { + "birth": { + "date": "1990-01-01", + "time": "12:00:00", + "utc_offset": "+08:00", + "latitude": 39.9, + "longitude": 116.4, + "place": "Beijing", + "coordinate_precision": "city", + }, + "settings": { + "ayanamsa": "lahiri", + "node_mode": "mean", + "position_mode": "legacy", + "house_system": "whole_sign", + "dasha_year_days": 365.25, + }, +} + +FORBIDDEN_PROFILE_KEYS = ( + "birth", + "birth_input", + "location", + "date", + "time", + "year", + "month", + "day", + "hour", + "minute", + "second", + "lat", + "lon", + "latitude", + "longitude", + "place", +) + + +def _args(**overrides) -> SimpleNamespace: + values = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 39.9, + "lon": 116.4, + "tz": 8.0, + "ayanamsa": "lahiri", + "node_mode": "mean", + "position_mode": "legacy", + "house_system": "whole_sign", + "dasha_year_days": 365.25, + "validate": False, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _assert_no_birth_fields(profile: dict, path: str = "") -> None: + for key, value in profile.items(): + full = f"{path}.{key}" if path else key + assert key not in FORBIDDEN_PROFILE_KEYS, f"birth data leaked via {full}" + if isinstance(value, dict): + _assert_no_birth_fields(value, full) + + +# --------------------------------------------------------------------------- +# stability and parameter sensitivity +# --------------------------------------------------------------------------- +def test_profile_is_stable_across_repeated_builds() -> None: + first = build_calculation_profile(BIRTH_PAYLOAD) + repeated = build_calculation_profile(BIRTH_PAYLOAD) + + assert first["schema"] == SCHEMA + assert first["profile_version"] == PROFILE_VERSION + assert first["profile_id"] == repeated["profile_id"] + assert first["input_hash"] == repeated["input_hash"] + assert first["profile_hash"] == repeated["profile_hash"] + assert first["profile_id"] == first["profile_hash"] + assert len(first["profile_id"]) == 64 + + +def test_parameter_change_changes_profile_id_and_hashes() -> None: + base = build_calculation_profile(BIRTH_PAYLOAD) + + for key, value in ( + ("node_mode", "true"), + ("ayanamsa", "raman"), + ("position_mode", "apparent"), + ("dasha_year_days", 365.25636), + ("house_system", "sripati"), + ): + changed_payload = json.loads(json.dumps(BIRTH_PAYLOAD)) + changed_payload["settings"][key] = value + changed = build_calculation_profile(changed_payload) + assert changed["profile_id"] != base["profile_id"], key + assert changed["input_hash"] != base["input_hash"], key + + tz_changed = json.loads(json.dumps(BIRTH_PAYLOAD)) + tz_changed["birth"]["utc_offset"] = "-08:00" + assert build_calculation_profile(tz_changed)["profile_id"] != base["profile_id"] + + +# --------------------------------------------------------------------------- +# privacy (AGENTS 6.6) and flat compatibility keys +# --------------------------------------------------------------------------- +def test_profile_never_contains_birth_fields() -> None: + profile = build_calculation_profile(BIRTH_PAYLOAD) + _assert_no_birth_fields(profile) + + +def test_profile_preserves_flat_compat_keys_and_effective_settings() -> None: + profile = build_calculation_profile(BIRTH_PAYLOAD) + + assert profile["ayanamsa"] == "lahiri" + assert profile["node_mode"] == "mean" + assert profile["position_mode"] == "legacy" + assert profile["house_system"] == "whole_sign" + assert profile["dasha_year_days"] == 365.25 + assert profile["solar_return_location_mode"] == "birth_place" + assert profile["annual_year_policy"] == "solar_return_exact" + assert profile["coordinate_precision"] == "city" + assert profile["algorithm"] == "sidereal_natal_chart" + assert profile["timezone"] == {"name": "UTC+08:00", "utc_offset": "+08:00"} + assert profile["effective_settings"]["ayanamsa"] == "lahiri" + assert profile["effective_settings"]["node_mode"] == "mean" + assert profile["effective_settings"]["timezone_offset"] == "+08:00" + assert profile["effective_settings"]["dasha_year_days"] == 365.25 + + +def test_iana_timezone_name_is_retained() -> None: + profile = build_calculation_profile({ + "birth": { + "date": "1990-01-01", + "time": "12:00:00", + "timezone": "Asia/Shanghai", + "latitude": 31.2, + "longitude": 121.5, + }, + "settings": {}, + }) + assert profile["timezone"]["name"] == "Asia/Shanghai" + + # an IANA-like value in the tz slot is retained as the name, not dropped + via_tz_slot = build_calculation_profile({ + "birth": {"date": "1990-01-01", "tz": "Asia/Shanghai", "latitude": 31.2, "longitude": 121.5}, + }) + assert via_tz_slot["timezone"]["name"] == "Asia/Shanghai" + assert via_tz_slot["timezone"]["utc_offset"] is None + + +# --------------------------------------------------------------------------- +# ephemeris provenance: observed-only, path-excluded +# --------------------------------------------------------------------------- +def test_ephemeris_provider_is_never_fabricated() -> None: + plain = build_calculation_profile(BIRTH_PAYLOAD) + assert plain["engine"]["ephemeris_provider"] == "not_observed" + assert plain["engine"]["ephemeris_flags_verified"] is False + assert plain["engine"]["ephemeris_source"] is None + + observed = build_calculation_profile({ + "birth": {"date": "1990-01-01"}, + "engine": { + "ephemeris_provider": "moshier", + "ephemeris_flags": 4, + "ephemeris_flags_verified": True, + "ephemeris_source": "moshier_calc_ut", + }, + }) + assert observed["engine"]["ephemeris_provider"] == "moshier" + assert observed["engine"]["ephemeris_flags_verified"] is True + + +def test_ephemeris_path_is_excluded_from_profile_and_hash() -> None: + def build_with_path(path: str) -> dict: + return build_calculation_profile({ + "birth": {"date": "1990-01-01", "time": "12:00:00"}, + "engine": { + "ephemeris_provider": "swisseph", + "ephemeris_flags": 66, + "ephemeris_flags_verified": True, + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_policy": "bundled_swisseph_preferred_observed_provider_recorded", + "ephemeris_path": path, + }, + }) + + first = build_with_path("/Users/alice/repo/references/open_source_sources/vedic-astro-skills/ephe") + second = build_with_path("/tmp/elsewhere/completely/different/ephe") + + assert "ephemeris_path" not in first["engine"] + assert "ephemeris_path" not in second["engine"] + assert first["profile_id"] == second["profile_id"] + assert first["input_hash"] == second["input_hash"] + + +# --------------------------------------------------------------------------- +# attach semantics +# --------------------------------------------------------------------------- +def test_attach_adds_profile_and_result_binding_without_mutating_business_result() -> None: + result = { + "planets": {"Sun": {"lon": 100.0, "sign": "Cancer"}}, + "ascendant": {"lon": 90.0}, + "meta": { + "ephemeris_provider": "swisseph", + "ephemeris_flags": 2, + "ephemeris_flags_verified": True, + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_policy": "bundled_swisseph_preferred_observed_provider_recorded", + "ephemeris_path": "/machine/specific/ephe", + }, + } + snapshot = json.dumps( + {key: value for key, value in result.items()}, + sort_keys=True, + default=str, + ) + + returned = attach_calculation_profile(result, _args()) + + assert returned is result + assert set(result.keys()) == { + "planets", + "ascendant", + "meta", + "calculation_profile", + "calculation_profile_id", + "result_hash", + "result_binding", + } + remaining = json.dumps( + { + key: value + for key, value in result.items() + if key not in ( + "calculation_profile", + "calculation_profile_id", + "result_hash", + "result_binding", + ) + }, + sort_keys=True, + default=str, + ) + assert remaining == snapshot + assert result["calculation_profile_id"] == result["calculation_profile"]["profile_id"] + assert len(result["result_hash"]) == 64 + assert result["result_binding"] == { + "input_hash": result["calculation_profile"]["input_hash"], + "result_hash": result["result_hash"], + } + # observed provider is carried; ephemeris_path is never part of the profile + assert result["calculation_profile"]["engine"]["ephemeris_provider"] == "swisseph" + assert "ephemeris_path" not in result["calculation_profile"]["engine"] + _assert_no_birth_fields(result["calculation_profile"]) + + +def test_attach_prefers_existing_canonical_profile() -> None: + canonical = build_calculation_profile(BIRTH_PAYLOAD) + result = {"planets": {}, "calculation_profile": canonical} + returned = attach_calculation_profile(result, _args()) + assert returned["calculation_profile"] is canonical + assert returned["calculation_profile_id"] == canonical["profile_id"] + + +def test_dict_and_namespace_entry_are_equivalent() -> None: + dict_profile = attach_calculation_profile({}, dict( + year=1990, month=1, day=1, hour=12, minute=0, second=0, + lat=39.9, lon=116.4, tz=8.0, + ayanamsa="lahiri", node_mode="mean", position_mode="legacy", + house_system="whole_sign", dasha_year_days=365.25, + ))["calculation_profile"] + ns_profile = attach_calculation_profile({}, _args())["calculation_profile"] + assert dict_profile["profile_id"] == ns_profile["profile_id"] + assert dict_profile["input_hash"] == ns_profile["input_hash"] + + +def test_root_and_nested_birth_inputs_are_equivalent() -> None: + nested = build_calculation_profile({ + "birth": { + "date": "1990-01-01", + "time": "12:00:00", + "utc_offset": "+08:00", + "latitude": 39.9, + "longitude": 116.4, + }, + "settings": { + "ayanamsa": "lahiri", "node_mode": "mean", "position_mode": "legacy", + "house_system": "whole_sign", "dasha_year_days": 365.25, + }, + }) + root = build_calculation_profile({ + "year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "second": 0, + "lat": 39.9, "lon": 116.4, "tz": 8.0, + "settings": { + "ayanamsa": "lahiri", "node_mode": "mean", "position_mode": "legacy", + "house_system": "whole_sign", "dasha_year_days": 365.25, + }, + }) + assert root["input_hash"] == nested["input_hash"] + assert root["profile_id"] == nested["profile_id"] + + +def test_string_date_time_parts_and_fractional_seconds_do_not_crash() -> None: + profile = build_calculation_profile({ + "birth": { + "date": "1990-1-1", + "time": "12:00:30.5", + "utc_offset": "+08:00", + "lat": "39.9", + "lon": "116.4", + }, + "settings": {"node_mode": "mean", "ayanamsa": "lahiri"}, + }) + assert profile["profile_id"] + assert profile["timezone"]["utc_offset"] == "+08:00" + assert profile["input_hash"] + + +# --------------------------------------------------------------------------- +# structured errors +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "payload, fragment", + [ + ({"birth": {"utc_offset": "abc"}}, "UTC offset"), + ({"birth": {"date": "garbage"}}, "invalid date"), + ({"birth": {"date": "1990-13-01"}}, "out of range"), + ({"birth": {"time": "25:00"}}, "invalid time"), + ({"birth": {"time": "12:00:99"}}, "invalid time"), + ({"birth": {"time": "not-a-time"}}, "invalid time"), + ({"year": 1990, "month": 1}, "incomplete birth date"), + ({"lat": float("nan")}, "must be finite"), + ({"birth": {"tz": "bogus"}}, "UTC offset"), + ], +) +def test_unparseable_inputs_raise_structured_error(payload: dict, fragment: str) -> None: + with pytest.raises(CalculationProfileError, match=fragment): + build_calculation_profile(payload) + + +def test_unparseable_inputs_are_subclass_of_value_error() -> None: + assert issubclass(CalculationProfileError, ValueError) + + +def test_annual_style_payload_without_settings_uses_defaults() -> None: + profile = build_calculation_profile({ + "birth": { + "date": "1996-12-07", + "time": "10:34:00", + "utc_offset": "+05:30", + "latitude": 13.0878, + "longitude": 80.2785, + }, + "location": {"place": "Chennai", "latitude": 13.0878, "longitude": 80.2785}, + "ayanamsa": "lahiri", + "node_mode": "mean", + "house_system": "whole_sign", + }) + + assert profile["ayanamsa"] == "lahiri" + assert profile["node_mode"] == "mean" + assert profile["position_mode"] == "legacy" + assert profile["timezone"]["name"] == "UTC+05:30" + assert profile["profile_id"] + # place/coordinates are hashed but never stored + _assert_no_birth_fields(profile) + + +# --------------------------------------------------------------------------- +# dasha-master-pack gate contract +# --------------------------------------------------------------------------- +@pytest.mark.skip(reason="professional_parity_closure is not vendored into the product tree") +def test_dasha_master_profile_gate_contract_keeps_working() -> None: + from scripts.professional_parity_closure import build_dasha_master_profile_gate + + shared_profile = build_calculation_profile(BIRTH_PAYLOAD) + gate = build_dasha_master_profile_gate( + {"vimshottari": {"execution_status": "executed", "confidence_status": "verified"}}, + {**shared_profile, "pl9_profile_status": "parameter_sensitive"}, + {"vimshottari": {"status": "blocked"}}, + ) + + assert gate["missing_profile_fields"] == [] + assert gate["profile"]["profile_id"] == shared_profile["profile_id"] + assert gate["profile"]["input_hash"] == shared_profile["input_hash"] + assert gate["profile"]["ayanamsa"] == "lahiri" + assert gate["profile"]["node_mode"] == "mean" + + +# --------------------------------------------------------------------------- +# cross-producer consistency (domain service vs CLI) +# --------------------------------------------------------------------------- +@pytest.mark.skip(reason="product cmd_chart and domain_calculation_service do not attach calculation_profile") +def test_domain_service_and_cli_producers_share_profile_hash() -> None: + import domain_calculation_service as calculation_service + import jyotish_engine + + birth = { + "year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "second": 0, + "lat": 39.9, "lon": 116.4, "tz": 8.0, "ayanamsa": "lahiri", + "node_mode": "mean", "position_mode": "legacy", + } + domain = calculation_service.compute_chart(birth) + cli = jyotish_engine.cmd_chart(_args()) + + assert cli["calculation_profile"]["profile_hash"] == domain["calculation_profile"]["profile_hash"] + assert cli["calculation_profile_id"] == domain["calculation_profile"]["profile_hash"] + assert cli["calculation_profile"]["input_hash"] == domain["calculation_profile"]["input_hash"] + + # a fresh attach with the observed meta bound to the same args agrees too + fresh = attach_calculation_profile({"meta": domain["meta"]}, _args()) + assert fresh["calculation_profile"]["profile_hash"] == domain["calculation_profile"]["profile_hash"] + + +@pytest.mark.skip(reason="product domain_calculation_service.compute_chart does not attach calculation_profile") +def test_domain_service_profile_is_privacy_compliant() -> None: + import domain_calculation_service as calculation_service + + profile = calculation_service.compute_chart({ + "year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "second": 0, + "lat": 39.9, "lon": 116.4, "tz": 8.0, "ayanamsa": "lahiri", + "node_mode": "mean", "position_mode": "legacy", + })["calculation_profile"] + + _assert_no_birth_fields(profile) + assert profile["profile_version"] == "1.0" + assert profile["effective_settings"]["ayanamsa"] == "lahiri" + assert profile["engine"]["ephemeris_flags_verified"] is True + assert isinstance(profile["engine"]["ephemeris_flags"], int) + assert "ephemeris_path" not in profile["engine"] + + +def test_annual_tajika_pack_profile_stays_privacy_compliant( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The annual pack reads birth/location from its input payload, never from + the profile (AGENTS 6.6); the embedded profile stays birth-free. + + ``report_pack_contract`` is an unrelated missing module on this branch; a + minimal test-only shim isolates this test from that gap. + """ + import sys + import types + + shim = types.ModuleType("report_pack_contract") + shim.normalize_report_pack_contract = lambda pack, pack_id="x": { + "schema": "unified_report_pack_contract.v1", + "pack_id": pack_id, + } + # scoped to this test only; monkeypatch restores sys.modules afterwards + monkeypatch.setitem(sys.modules, "report_pack_contract", shim) + + from scripts.annual_tajika_pack import build_annual_tajika_pack + + pack = build_annual_tajika_pack({ + "birth": { + "date": "1990-01-01", + "time": "12:00:00", + "utc_offset": "+08:00", + "latitude": 39.9, + "longitude": 116.4, + "place": "Beijing", + }, + "settings": {"ayanamsa": "lahiri", "node_mode": "mean"}, + "target_year": 2026, + }) + assert pack["schema"] == "jyotish.annual_tajika_pack.v1" + _assert_no_birth_fields(pack["profile"]) + assert pack["profile"]["profile_id"] + + +def test_direct_script_and_package_import_are_functionally_equivalent() -> None: + """Both import modes must work and produce identical profiles. + + Python loads the two spellings as distinct module objects, so the contract + (per ERR-114) is functional equivalence, not object identity. + """ + module = importlib.import_module("calculation_profile_contract") + assert hasattr(module, "build_calculation_profile") + assert hasattr(module, "attach_calculation_profile") + assert module.build_calculation_profile(BIRTH_PAYLOAD) == build_calculation_profile(BIRTH_PAYLOAD) diff --git a/tests/test_flexible_birth_time_engine.py b/tests/test_flexible_birth_time_engine.py index 2cc0634c..12393098 100644 --- a/tests/test_flexible_birth_time_engine.py +++ b/tests/test_flexible_birth_time_engine.py @@ -157,12 +157,13 @@ def test_multi_minute_accepted_or_candidate_windows_remain_provisional( assert len({json.dumps(value, sort_keys=True) for value in values.values()}) > 1 -def test_confirmed_report_packet_does_not_gain_sensitivity_key() -> None: +def test_confirmed_report_packet_keeps_unrectified_sensitivity_surface() -> None: packet = {"worksheets": {}, "raw_full_reading": {"modules": {}}} result = engine._attach_report_governance_contracts(packet, _args(birth_time_accuracy="confirmed")) - assert "birth_time_sensitivity" not in result + assert result["birth_time_sensitivity"]["status"] == "not_rectified" + assert result["birth_time_sensitivity"]["reason"] == "no_candidate_window" @pytest.mark.parametrize("accuracy", ["confirmed", "provisional", "approximate"]) diff --git a/tests/test_full_report_quality_gate.py b/tests/test_full_report_quality_gate.py new file mode 100755 index 00000000..7f660b63 --- /dev/null +++ b/tests/test_full_report_quality_gate.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from scripts.full_report_quality_gate import evaluate_full_report +from scripts.jyotish_engine import render_pl9_markdown + + +def _complete_markdown() -> str: + return "\n".join( + [ + "# 个人印度占星报告", + "## 摘要", + "### 基础资料表", + "#### Birth Particulars / Calculation Details", + "#### 出生 Panchanga(本地计算)", + "#### D1 — Rashi Chart(本命盘)", + "### Vargas I", + "#### D1–D60 完整原始分盘账本(20 传统 + 40 研究型 D-N)", + "### 本命主轴", + "### 2026 年度重点", + "#### 年度专题证据状态", + "### 力量、Ashtakavarga 与功能性吉凶", + "#### 功能性吉凶与 Yogakaraka", + "#### Bhava Lagna", + "#### Hora Lagna", + "#### Ghati Lagna", + "#### ViGhati Lagna", + "#### Sree Lagna", + "#### Indu Lagna", + "本章已调用依据:D2(Hora 财富分盘)、D11(Rudramsa 收益分盘)。", + "### 大运与时间主线", + "#### KP 三年流月支持", + "### 2026 KP 月度支持", + "### 2027 KP 月度支持", + "### 2028 KP 月度支持", + "#### KP 使用边界", + "#### Patyayini Dasha", + "#### Narayana Rashi Dasha", + "### 专业支持专题:第二证据轴", + "#### 辅助大运族:Yogini / Ashtottari / Kala Chakra", + "#### 本命补充因子:Sahams / Avasthas / Upagrahas", + "#### 未闭环专业层:可见但不入判断", + "### 事业与外部发展", + "### 财运与资源使用", + "### 关系与合作模式", + "## Blocked / Audit Appendix", + "## Audit Appendix", + ] + ) + + +def _complete_packet() -> dict: + return { + "schema": "pl9_style_professional_export_v1", + "calculation_profile_id": "profile-1993", + "calculation_profile": { + "profile_id": "profile-1993", + "profile_version": "1.0", + "algorithm": "sidereal_natal_chart", + "ayanamsa": "lahiri", + "node_mode": "mean", + "input_hash": "input-hash", + "engine": {"ephemeris_provider": "local"}, + }, + "result_hash": "result-hash", + "result_binding": { + "input_hash": "input-hash", + "result_hash": "result-hash", + }, + "chart_identity": { + "chart_profile_id": "profile-1993", + "birth_data_status": "user_provided", + "rectification_status": "not_reviewed", + "approval_status": "not_approved", + }, + "full_report_pack": { + "schema": "pl9.full_report_pack.v1", + "sections": { + key: {"status": "verified"} + for key in ( + "base", + "strength", + "dasha", + "annual", + "transit", + "professional_support", + "audit_appendix", + ) + } | { + "d1_d60_ledger": { + "status": "available", + "d1_to_d60": {f"D{number}": {"raw": {}} for number in range(1, 61)}, + "summary": { + "formal_traditional_division_count": 20, + "research_generic_dn_division_count": 40, + }, + } + }, + }, + "worksheets": { + "divisional_and_special_charts": { + "special_lagnas": { + "Bhava_Lagna": {"sign": "Aries"}, + "Hora_Lagna": {"sign": "Aries"}, + "Ghati_Lagna": {"sign": "Aries"}, + "ViGhati_Lagna": {"sign": "Aries"}, + "Sree_Lagna": {"sign": "Aries"}, + "Indu_Lagna": {"sign": "Aries"}, + }, + "varga_full": { + "D2_Hora": {"Ascendant": {"sign": "Aries"}}, + "D11_Rudramsa": {"Ascendant": {"sign": "Taurus"}}, + }, + }, + "strengths_and_scores": { + "functional_benefic_malefic": {"status": "used"}, + }, + "timing_and_predictive_systems": { + "narayana_dasha": {"status": "executed"}, + "annual_tajika_pack": { + "patyayini": {"status": "executed"}, + "external_engine_comparison": { + "pyjhora": { + "patyayini_dasha": { + "status": "partial_verified", + "normalized_rows": [{"order": 1}], + } + } + }, + "audit": {"technique_audit": [{"technique": "Annual", "status": "partial_verified"}]}, + "report_sections": {"evidence_appendix": {"status": "partial_verified"}}, + }, + "dasha_master_pack": { + "families": {"yogini": {"periods": [{"lord": "Sun"}]}} + }, + }, + "advanced_systems": { + "sahams": {"Punya": {"sign": "Aries"}}, + "kp_monthly_report": { + "maturity_profile": {"claim_status": "observation_only_truth_blocked"}, + "must_not_claim": ["exact_event_timing"], + }, + }, + }, + } + + +def test_complete_packet_returns_passed_with_traceable_professional_coverage() -> None: + result = evaluate_full_report(_complete_packet(), _complete_markdown()) + + assert result["schema_version"] == "jyotish.full_report_quality_gate.v1" + assert result["status"] == "passed" + assert result["professional_coverage_manifest"] + assert { + "material_id", + "source_reference", + "admission_tier", + "report_role", + "surface_location", + "status", + "limitation_reference", + }.issubset(result["professional_coverage_manifest"][0]) + assert not result["blocking_reasons"] + assert { + item["material_id"]: item["surface_location"] + for item in result["professional_coverage_manifest"] + }["special_lagnas"] == "professional_support_cross_reference" + + +def test_missing_provenance_blocks_full_report() -> None: + packet = _complete_packet() + packet.pop("result_hash") + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "provenance_missing:result_hash" in result["blocking_reasons"] + + +def test_missing_chart_identity_blocks_full_report() -> None: + packet = _complete_packet() + packet.pop("chart_identity") + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "chart_identity_missing:chart_profile_id" in result["blocking_reasons"] + + +def test_invalid_result_binding_blocks_full_report() -> None: + packet = _complete_packet() + packet["result_binding"]["result_hash"] = "other-result" + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "provenance_lineage_binding_invalid" in result["blocking_reasons"] + + +def test_missing_or_incomplete_d1_d60_ledger_blocks_full_report() -> None: + packet = _complete_packet() + packet["full_report_pack"]["sections"].pop("d1_d60_ledger") + + missing = evaluate_full_report(packet, _complete_markdown()) + assert missing["status"] == "blocked" + assert "required_pack_section_missing:d1_d60_ledger" in missing["blocking_reasons"] + + packet = _complete_packet() + packet["full_report_pack"]["sections"]["d1_d60_ledger"]["d1_to_d60"].pop("D60") + incomplete = evaluate_full_report(packet, _complete_markdown()) + assert incomplete["status"] == "blocked" + assert "d1_d60_ledger_incomplete" in incomplete["blocking_reasons"] + + +def test_executed_required_support_missing_from_rendering_requires_review() -> None: + markdown = _complete_markdown().replace("#### Hora Lagna\n", "") + + result = evaluate_full_report(_complete_packet(), markdown) + + assert result["status"] == "review_required" + assert "special_lagna_not_rendered:Hora_Lagna" in result["review_reasons"] + + +def test_generated_d11_gains_chart_missing_from_rendering_requires_review() -> None: + markdown = _complete_markdown().replace("D11(Rudramsa 收益分盘)", "收益分盘已省略") + + result = evaluate_full_report(_complete_packet(), markdown) + + assert result["status"] == "review_required" + assert "required_support_not_rendered:wealth_d11_gains_chart" in result["review_reasons"] + + +def test_missing_professional_support_section_requires_review() -> None: + markdown = _complete_markdown().replace("### 专业支持专题:第二证据轴\n", "") + + result = evaluate_full_report(_complete_packet(), markdown) + + assert result["status"] == "review_required" + assert "reader_section_missing:professional_support" in result["review_reasons"] + + +def test_missing_restricted_materials_register_requires_review() -> None: + markdown = _complete_markdown().replace("#### 未闭环专业层:可见但不入判断\n", "") + + result = evaluate_full_report(_complete_packet(), markdown) + + assert result["status"] == "review_required" + assert "reader_section_missing:restricted_materials" in result["review_reasons"] + + +def test_missing_one_kp_year_requires_review() -> None: + markdown = _complete_markdown().replace("### 2028 KP 月度支持\n", "") + + result = evaluate_full_report(_complete_packet(), markdown) + + assert result["status"] == "review_required" + assert "three_year_kp_monthly_incomplete" in result["review_reasons"] + + +def test_missing_special_lagna_value_requires_review() -> None: + packet = _complete_packet() + packet["worksheets"]["divisional_and_special_charts"]["special_lagnas"].pop("Sree_Lagna") + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "review_required" + assert "special_lagna_missing:Sree_Lagna" in result["review_reasons"] + + +def test_empty_d11_structure_blocks_full_report() -> None: + packet = _complete_packet() + packet["worksheets"]["divisional_and_special_charts"]["varga_full"]["D11_Rudramsa"] = {} + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "wealth_varga_structure_missing:D11" in result["blocking_reasons"] + + +def test_missing_kp_boundary_status_requires_review() -> None: + packet = _complete_packet() + packet["worksheets"]["advanced_systems"]["kp_monthly_report"].pop("must_not_claim") + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "review_required" + assert "kp_evidence_status_missing" in result["review_reasons"] + + +def test_executed_auxiliary_support_missing_from_rendering_requires_review() -> None: + markdown = _complete_markdown().replace("#### 辅助大运族:Yogini / Ashtottari / Kala Chakra\n", "") + + result = evaluate_full_report(_complete_packet(), markdown) + + assert result["status"] == "review_required" + assert "required_support_not_rendered:auxiliary_dasha_support" in result["review_reasons"] + + +def test_restricted_material_cannot_be_promoted_as_confirmed() -> None: + packet = _complete_packet() + packet["professional_coverage_overrides"] = { + "tajika_named_yoga": {"status": "confirmed"}, + } + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "restricted_material_promoted:tajika_named_yoga" in result["blocking_reasons"] + + +def test_missing_patyayini_normalized_rows_requires_review() -> None: + packet = _complete_packet() + packet["worksheets"]["timing_and_predictive_systems"]["annual_tajika_pack"]["external_engine_comparison"][ + "pyjhora" + ]["patyayini_dasha"]["normalized_rows"] = [] + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "review_required" + assert "patyayini_normalized_rows_missing" in result["review_reasons"] + + +def test_quality_gate_reads_product_markdown_markers() -> None: + result = evaluate_full_report(_complete_packet(), "\n".join([ + "# 个人印度占星报告", + "## 成品阅读导航", + "### 基础资料表", + "#### Birth Particulars / Calculation Details", + "#### D1 — Rashi Chart(本命盘)", + "### Vargas I", + "#### D1–D60 完整原始分盘账本(20 传统 + 40 研究型 D-N)", + "### 大运与时间主线", + "#### KP 三年流月支持", + "### 2026 年度重点", + "### 专业支持专题:第二证据轴", + "#### 未闭环专业层:可见但不入判断", + "## Blocked / Audit Appendix", + "## Audit Appendix", + ])) + markdown = render_pl9_markdown(_complete_packet()) + assert "# 个人印度占星报告" in markdown + assert "## 成品阅读导航" in markdown + assert result["schema_version"] == "jyotish.full_report_quality_gate.v1" + assert "rendered_markdown_not_supplied" not in result["warning_reasons"] diff --git a/tests/test_kp_monthly_report_contract.py b/tests/test_kp_monthly_report_contract.py new file mode 100755 index 00000000..b427de9e --- /dev/null +++ b/tests/test_kp_monthly_report_contract.py @@ -0,0 +1,34 @@ +from scripts.kp_monthly_report_contract import build_kp_monthly_report_contract + + +def test_kp_monthly_report_contract_freezes_window_profile_and_boundaries() -> None: + packet = build_kp_monthly_report_contract( + start_month="2026-01", + month_count=36, + timezone_offset=8.0, + ) + + assert packet["schema"] == "jyotish.kp_monthly_report.v1" + assert packet["profile"]["ayanamsa"] == "kp" + assert packet["profile"]["house_system"] == "placidus" + assert packet["profile"]["status"] == "parameter_sensitive" + assert packet["window"]["start_month"] == "2026-01" + assert packet["window"]["month_count"] == 36 + assert packet["window"]["anchor_policy"] == "local_month_start_noon" + assert packet["window"]["timezone_offset"] == 8.0 + assert "exact_event_timing" in packet["must_not_claim"] + assert "specific_event_prediction" in packet["must_not_claim"] + + +def test_kp_monthly_report_contract_exposes_western_support_boundary() -> None: + packet = build_kp_monthly_report_contract( + start_month="2026-01", + month_count=36, + timezone_offset=8.0, + ) + + western_support = packet["supporting_systems"]["western_kp_support"] + assert western_support["status"] == "not_provided" + assert western_support["convergence"]["status"] == "not_provided" + assert western_support["negative_evidence"]["status"] == "not_provided" + assert western_support["claim_boundary"].startswith("Western support remains") diff --git a/tests/test_kp_monthly_report_packet.py b/tests/test_kp_monthly_report_packet.py new file mode 100644 index 00000000..b1940e87 --- /dev/null +++ b/tests/test_kp_monthly_report_packet.py @@ -0,0 +1,28 @@ +from scripts.kp_monthly_report_packet import build_kp_monthly_report_packet + +BEIJING_SMOKE = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 39.9042, + "lon": 116.4074, + "tz": 8, +} + + +def test_kp_monthly_report_packet_builds_two_fictional_months() -> None: + packet = build_kp_monthly_report_packet( + birth_payload=BEIJING_SMOKE, + start_month="2026-01", + month_count=2, + ) + + assert packet["schema"] == "jyotish.kp_monthly_report.v1" + assert packet["window"]["month_count"] == 2 + assert [row["month"] for row in packet["months"]] == ["2026-01", "2026-02"] + assert all(row["status"] == "parameter_sensitive" for row in packet["months"]) + assert packet["yearly_highlights"] + assert packet["yearly_highlights"][0]["year"] == 2026 diff --git a/tests/test_kp_monthly_theme_support.py b/tests/test_kp_monthly_theme_support.py new file mode 100755 index 00000000..a0d41b0d --- /dev/null +++ b/tests/test_kp_monthly_theme_support.py @@ -0,0 +1,30 @@ +from scripts.kp_monthly_theme_support import build_kp_monthly_theme_support + + +def test_kp_monthly_theme_support_preserves_parameter_sensitive_boundary() -> None: + support = build_kp_monthly_theme_support( + natal_kp={ + "houses": { + "2": {"significators": {"A": ["Jupiter"]}}, + "6": {"significators": {"A": ["Saturn"]}}, + "10": {"significators": {"A": ["Mercury"]}}, + "11": {"significators": {"A": ["Venus"]}}, + "7": {"significators": {"A": ["Moon"]}}, + "5": {"significators": {"A": ["Sun"]}}, + "9": {"significators": {"A": ["Mars"]}}, + }, + "ruling_planets": {"day_lord": "Moon"}, + }, + monthly_levels={"mahadasha": {"lord": "Mercury"}}, + monthly_transits={"planets": {"Saturn": {"sign": "Pisces"}}}, + ) + + assert "career" in support + assert support["career"]["status"] == "parameter_sensitive" + assert "must_not_claim" in support["career"] + assert "水星" in support["career"]["summary"] + assert "双鱼座" in support["career"]["summary"] + assert support["career"]["promise_code"] == "2 / 6 / 10 / 11" + assert "事业线本月先看" in support["career"]["summary"] or "事业线本月更适合先按" in support["career"]["summary"] + assert "10宫事业与位置" in support["career"]["summary"] or "10宫事业与位置" in support["wealth"]["summary"] + assert "career 主题" not in support["career"]["summary"] diff --git a/tests/test_kp_monthly_transits.py b/tests/test_kp_monthly_transits.py new file mode 100755 index 00000000..c57ab27e --- /dev/null +++ b/tests/test_kp_monthly_transits.py @@ -0,0 +1,26 @@ +from scripts.kp_monthly_transits import build_monthly_transit_snapshot + + +def test_monthly_transit_snapshot_returns_all_nine_planets_and_real_date() -> None: + snapshot = build_monthly_transit_snapshot( + year=2026, + month=1, + lat=39.9042, + lon=116.4074, + tz=8.0, + ) + + assert snapshot["month"] == "2026-01" + assert snapshot["anchor_local"].startswith("2026-01-01T12:00:00") + assert set(snapshot["planets"].keys()) >= { + "Sun", + "Moon", + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn", + "Rahu", + "Ketu", + } + assert snapshot["status"] == "parameter_sensitive" diff --git a/tests/test_kp_monthly_vimshottari.py b/tests/test_kp_monthly_vimshottari.py new file mode 100755 index 00000000..12222b6c --- /dev/null +++ b/tests/test_kp_monthly_vimshottari.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from scripts.kp_monthly_vimshottari import build_monthly_vimshottari_snapshot + + +def test_monthly_vimshottari_snapshot_returns_five_levels() -> None: + snapshot = build_monthly_vimshottari_snapshot( + birth_dt=datetime(1990, 1, 1, 12, 0), + moon_lon=248.0, + anchor_dt=datetime(2026, 1, 1, 12, 0), + ) + + assert set(snapshot["levels"].keys()) == { + "mahadasha", + "antardasha", + "pratyantardasha", + "sookshma", + "prana", + } + assert snapshot["levels"]["mahadasha"]["lord"] + assert snapshot["status"] == "parameter_sensitive" diff --git a/tests/test_professional_report_reference_api.py b/tests/test_professional_report_reference_api.py index a8ea6331..ace977fa 100644 --- a/tests/test_professional_report_reference_api.py +++ b/tests/test_professional_report_reference_api.py @@ -21,6 +21,7 @@ from scripts.jyotish_engine import ( ) from scripts.professional_report_reference import ( ProfessionalReportReferenceInputError, + _export_args, build_professional_report_reference, ) @@ -96,6 +97,16 @@ def test_json_and_markdown_reuse_one_full_reading_and_normalize_packs() -> None: assert engine.calls[-1][2] == ["full"] +def test_export_args_fill_today_target_year_and_age_for_full_mode() -> None: + args = _export_args({**BIRTH, "today": None}) + assert args.today + assert args.target_year == int(str(args.today)[:4]) + assert args.age == args.target_year - 1990 + explicit = _export_args({**BIRTH, "today": "2026-09-05", "age": 36}) + assert explicit.target_year == 2026 + assert explicit.age == 36 + + def test_public_markdown_sanitizer_removes_renderer_authored_internal_references() -> None: unsafe = ( "| audit | `raw_full_reading.modules.private_engine` |\n" diff --git a/tests/test_report_longform_parity.py b/tests/test_report_longform_parity.py new file mode 100644 index 00000000..c84431db --- /dev/null +++ b/tests/test_report_longform_parity.py @@ -0,0 +1,253 @@ +"""Call-surface and assembly locks for full-mode longform export.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from ayanamsa_utils import ACTIVE_AYANAMSA_NAME, apply_ayanamsa + +from scripts.jyotish_engine import ( + _attach_report_governance_contracts, + _build_natal_foundation_modules, + _compute_chart_from_args, + _native_dasha_master_families, + _render_finished_reading_navigation, + cmd_kp, + render_pl9_markdown, +) +from scripts.professional_report_reference import _export_args + + +BEIJING = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 39.9042, + "lon": 116.4074, + "tz": 8, + "ayanamsa": "raman", +} + + +def test_export_args_default_today_target_year_and_age() -> None: + args = _export_args({**BEIJING, "today": "2026-09-05"}) + assert args.today == "2026-09-05" + assert args.target_year == 2026 + assert args.age == 36 + assert args.hour == 12 + assert args.minute == 0 + + +def test_export_args_keeps_explicit_age_and_does_not_duplicate_kwargs() -> None: + args = _export_args({**BEIJING, "today": "2026-09-05", "target_year": 2027, "age": 40}) + assert args.target_year == 2027 + assert args.age == 40 + + +def test_reading_navigation_emits_required_headings() -> None: + markdown = "\n".join(_render_finished_reading_navigation({ + "report_quality_gate": {"status": "review_required", "checks": []}, + "worksheets": { + "timing_and_predictive_systems": { + "annual_tajika_pack": {"status": "partial_verified"}, + "annual_tajika_series": {"years": {"2026": {}, "2027": {}, "2028": {}}}, + }, + "advanced_systems": {"kp_monthly_report": {"status": "parameter_sensitive", "months": [{}] * 36}}, + "strengths_and_scores": {"functional_benefic_malefic": {"status": "partial_verified"}}, + "divisional_and_special_charts": {"upagrahas": {"Gulika": {}}}, + }, + "birth_time_sensitivity": {"status": "not_rectified"}, + })) + for heading in ( + "## 成品阅读导航", + "### 先读什么", + "### 结论等级规则", + "### 专题判读协议", + "### 质量验收矩阵", + "### 对照覆盖表", + ): + assert heading in markdown + assert "| 三年年度展开 | present |" in markdown + assert "| KP 三年流月支持 | present |" in markdown + + +def test_markdown_without_candidate_window_says_not_rectified() -> None: + packet = { + "schema": "pl9_style_professional_export_v1", + "birth_info": BEIJING, + "worksheets": {}, + "raw_full_reading": {"modules": {}}, + "personal_report_producer": {"main_body": {"status": "partial_verified"}}, + "reader_engine_boundary_notice": { + "primary_text_zh": "多引擎口径说明", + "display_rule": "keep_blocked_labels", + }, + } + packet = _attach_report_governance_contracts(packet, SimpleNamespace( + **BEIJING, + birth_time_accuracy="confirmed", + )) + markdown = render_pl9_markdown(packet) + assert "## 成品阅读导航" in markdown + assert "### 出生时间敏感度" in markdown + assert "未做校时" in markdown + assert packet["birth_time_sensitivity"]["status"] == "not_rectified" + + +def test_candidate_range_keeps_minute_matrix_status() -> None: + args = SimpleNamespace( + **BEIJING, + birth_time_accuracy="provisional", + candidate_range={"start_time": "11:50", "end_time": "12:10", "representative_time": "12:00"}, + ) + packet = _attach_report_governance_contracts( + {"worksheets": {}, "raw_full_reading": {"modules": {}}}, + args, + ) + assert packet["birth_time_sensitivity"]["status"] == "candidate_window_only" + assert packet["birth_time_sensitivity"]["window"]["candidate_count"] >= 2 + + +def _producer_packet(worksheets: dict) -> dict: + return { + "schema": "pl9_style_professional_export_v1", + "birth_info": BEIJING, + "worksheets": worksheets, + "full_report_pack": {"sections": {}}, + "personal_report_producer": {"main_body": {"status": "partial_verified"}}, + "reader_engine_boundary_notice": { + "primary_text_zh": "多引擎口径说明", + "display_rule": "keep_blocked_labels", + }, + } + + +def test_native_dasha_families_mark_present_modules_executed() -> None: + families = _native_dasha_master_families({ + "dasha": {"current_dasha": {"lord": "Saturn"}}, + "narayana_dasha": {"status": "computed"}, + "yogini_dasha": {"error": "missing"}, + "ashtottari_dasha": {}, + "kalachakra_dasha": {"status": "blocked", "reason": "not_applicable"}, + }) + assert families["vimshottari"]["execution_status"] == "executed" + assert families["narayana"]["execution_status"] == "executed" + assert families["yogini"]["execution_status"] == "blocked" + assert families["ashtottari"]["execution_status"] == "blocked" + assert families["kala_chakra"]["execution_status"] == "blocked" + + +def test_timing_mainline_renders_from_native_dasha_when_master_pack_has_no_families() -> None: + markdown = render_pl9_markdown(_producer_packet({ + "timing_and_predictive_systems": { + "dasha": {"current_dasha": {"lord": "Saturn"}}, + "narayana_dasha": {"status": "computed"}, + "dasha_master_pack": {"schema": "dasha_master_report_pack_v1", "status": "blocked"}, + "annual_tajika_pack": {"year_lord": {"status": "partial_verified"}}, + }, + })) + assert "### 大运与时间主线" in markdown + assert "#### 时间系统证据状态" in markdown + assert "| Vimshottari |" in markdown + assert "| Narayana |" in markdown + assert "| Year Lord |" in markdown + + +def test_kp_upagraha_and_yogakaraka_headings_render_when_worksheets_present() -> None: + markdown = render_pl9_markdown(_producer_packet({ + "divisional_and_special_charts": { + "upagrahas": { + "raw": { + "Gulika": {"sign_idx": 0, "degree_in_sign": 12.5, "source": "local_formula"}, + "Maandi": {"sign_idx": 1, "degree_in_sign": 3.0, "source": "local_formula"}, + }, + }, + }, + "advanced_systems": { + "kp": { + "house_basis": "explicit_cusps", + "planets": { + "Sun": { + "kp_lords": { + "sign": "Capricorn", + "nakshatra": "Uttara Ashadha", + "nakshatra_lord": "Sun", + "sub_lord": "Sun", + "sub_sub_lord": "Venus", + }, + "significators": {"A": ["10"], "B": ["10"], "C": [], "D": ["10"]}, + }, + }, + "houses": { + 10: { + "sign": "Capricorn", + "cusp_longitude": 280.1, + "kp_lords": { + "sign": "Capricorn", + "nakshatra": "Uttara Ashadha", + "nakshatra_lord": "Sun", + "sub_lord": "Moon", + "sub_sub_lord": "Mars", + }, + "significators": {"A": ["Sun"], "B": ["Mercury"], "C": [], "D": []}, + }, + 2: {"significators": {"A": ["Venus"], "B": ["Jupiter"], "C": [], "D": []}}, + 6: {"significators": {"A": ["Saturn"], "B": ["Mars"], "C": [], "D": []}}, + 11: {"significators": {"A": ["Moon"], "B": ["Venus"], "C": [], "D": []}}, + }, + "ruling_planets": { + "day_lord": "Monday", + "moon_star_lord": "Saturn", + "ascendant_star_lord": "Sun", + }, + }, + }, + "strengths_and_scores": { + "functional_benefic_malefic": { + "status": "used", + "ascendant": "Aries", + "functional_benefics": ["Jupiter", "Sun", "Mars"], + "functional_malefics": ["Mercury", "Saturn"], + "yogakarakas": ["Mars"], + }, + "ashtakavarga": {"method": "local", "version": "test"}, + }, + })) + assert "### Upagraha / Sub-Planets" in markdown + assert "### KP Lord / Sub 原始表" in markdown + assert "#### KP Ruling Planets" in markdown or "#### 行星 KP Lord / Sub" in markdown + assert "#### KP 显著星 ABCD" in markdown + assert "#### KP 事业宫位核对" in markdown + assert "#### 功能性吉凶与 Yogakaraka" in markdown + assert "Mars" in markdown or "火星" in markdown + + +def test_cmd_kp_restores_report_ayanamsa() -> None: + apply_ayanamsa("raman") + args = SimpleNamespace(**BEIJING) + result = cmd_kp(args) + assert isinstance(result, dict) + assert isinstance(result.get("planets"), dict) + assert isinstance(result.get("ruling_planets"), dict) + assert ACTIVE_AYANAMSA_NAME == "raman" + + +def test_natal_foundation_returns_upagrahas_and_functional_layer() -> None: + apply_ayanamsa("raman") + args = SimpleNamespace(**BEIJING) + chart, _asc_idx, jd, _ayanamsa = _compute_chart_from_args(args) + planets = chart["planets"] + planet_lons = { + name: data.get("degree_raw", data["degree"]) + for name, data in planets.items() + if isinstance(data, dict) and "degree" in data + } + asc_lon = chart["ascendant"].get("lon", chart["ascendant"].get("degree")) + foundation = _build_natal_foundation_modules(args, planet_lons, asc_lon=asc_lon, jd=jd) + assert foundation["upagrahas"].get("raw") + assert foundation["functional_benefic_malefic"].get("functional_benefics") or foundation["functional_benefic_malefic"].get("status") + assert foundation["functional_benefic_malefic"].get("yogakarakas") is not None + assert ACTIVE_AYANAMSA_NAME == "raman"