diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts
index 9c1a7694..bc4a68a0 100644
--- a/frontend/src/hooks/use-chart-page.ts
+++ b/frontend/src/hooks/use-chart-page.ts
@@ -6,7 +6,7 @@ import { flushSync } from "react-dom";
import { fetchChartView } from "@/lib/chart-view-client";
import { readCurrentSubjectId, useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject";
import type { ChartViewFailureStatus, ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract";
-import type { ChartViewLayer } from "@/lib/chart-view-engine";
+import { dashaSystemByLayer, type ChartViewLayer } from "@/lib/chart-view-engine";
import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure";
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels";
import {
@@ -42,11 +42,12 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie
if (current.accountId !== incoming.accountId || current.profileFingerprint !== incoming.profileFingerprint) return incoming;
const layerFailures = { ...current.layerFailures };
delete layerFailures[layer];
+ const dashaSystem = dashaSystemByLayer(layer);
return {
...current,
layerFailures,
...(layer === "varga" ? { vedic: incoming.vedic } : {}),
- ...(layer === "chara" ? { dasha: incoming.dasha } : {}),
+ ...(dashaSystem ? { dasha: { ...current.dasha, [dashaSystem.track]: incoming.dasha[dashaSystem.track] } } : {}),
...(layer === "western" ? { western: incoming.western } : {}),
...(layer === "bhava" ? { bhava: incoming.bhava } : {}),
...(layer === "transit" ? { transit: incoming.transit } : {}),
@@ -139,9 +140,11 @@ export function useChartPage() {
}
const failure = body.layerFailures?.[layer];
if (failure) { fail(failure); return; }
+ const dashaSystem = dashaSystemByLayer(layer);
const missing = layer === "varga" ? !VARGA_CHIP_ORDER.every((division) => body.vedic.vargas.some((varga) => varga.id === `D${division}`))
- : layer === "chara" ? body.dasha.chara.periods.length === 0
- : body[layer].status !== "ok";
+ : dashaSystem ? body.dasha[dashaSystem.track].periods.length === 0
+ : layer === "western" || layer === "bhava" || layer === "transit" ? body[layer].status !== "ok"
+ : true;
if (missing) { fail("unavailable"); return; }
const current = peekChartPage();
const merged = current?.kind === "view" && current.view.status === "ok"
diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts
index 28a7a012..14ebf67e 100644
--- a/frontend/src/lib/chart-view-contract.ts
+++ b/frontend/src/lib/chart-view-contract.ts
@@ -195,7 +195,7 @@ export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({
});
export const chartViewDashaTrackSchema = z.object({
- id: z.enum(["vimshottari", "chara"]),
+ id: z.enum(["vimshottari", "chara", "yogini"]),
title: z.string().min(1),
method: z.string().min(1),
currentLabel: z.string().min(1),
@@ -280,6 +280,7 @@ export const chartViewOkSchema = z.object({
layerFailures: z.object({
varga: chartViewFailureStatusSchema.optional(),
chara: chartViewFailureStatusSchema.optional(),
+ yogini: chartViewFailureStatusSchema.optional(),
western: chartViewFailureStatusSchema.optional(),
bhava: chartViewFailureStatusSchema.optional(),
transit: chartViewFailureStatusSchema.optional(),
@@ -301,6 +302,7 @@ export const chartViewOkSchema = z.object({
asOf: isoDate,
vimshottari: chartViewDashaTrackSchema,
chara: chartViewDashaTrackSchema,
+ yogini: chartViewDashaTrackSchema,
dualTrackNote: z.string().min(1),
}),
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
diff --git a/frontend/src/lib/chart-view-engine.ts b/frontend/src/lib/chart-view-engine.ts
index 418c245a..e398bd7b 100644
--- a/frontend/src/lib/chart-view-engine.ts
+++ b/frontend/src/lib/chart-view-engine.ts
@@ -2,9 +2,30 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000;
export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000;
const ENGINE_CACHE_MAX_ENTRIES = 200;
-export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "bhava", "transit"] as const;
+export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit"] as const;
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
+/**
+ * On-demand dasha columns. A new `/api/dasha` system is one row here plus a
+ * mapper. Chara keeps its own path, so its cache scope stays empty and the
+ * historical key does not move. Every `/api/dasha` row must set `cacheScope`,
+ * because those calls share one path.
+ */
+export const CHART_DASHA_SYSTEMS = [
+ { layer: "chara", dasha: "chara", cacheScope: null, track: "chara", path: "/api/dasha/chara" },
+ { layer: "yogini", dasha: "yogini", cacheScope: "dasha:yogini", track: "yogini", path: "/api/dasha" },
+] as const;
+
+export type ChartDashaSystem = (typeof CHART_DASHA_SYSTEMS)[number];
+
+export function dashaCacheScope(dashaKey: string): string {
+ return `dasha:${dashaKey}`;
+}
+
+export function dashaSystemByLayer(layer: string): ChartDashaSystem | null {
+ return CHART_DASHA_SYSTEMS.find((item) => item.layer === layer) ?? null;
+}
+
export type EngineCallResult =
| { readonly status: "ok"; readonly payload: Record
}
| {
diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts
index eb459335..34930479 100644
--- a/frontend/src/lib/chart-view-labels.ts
+++ b/frontend/src/lib/chart-view-labels.ts
@@ -173,6 +173,8 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
+export const YOGINI_ANTARDASHA_UNVERIFIED_NOTE = "小运未经外部软件核对。";
+
export const CHARA_ZERO_YEAR_LABEL = "0 年";
export const CHARA_ZERO_YEAR_NOTE = "本段时长为 0,不展开";
diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts
index 06dc13db..e35fe190 100644
--- a/frontend/src/lib/chart-view-load.ts
+++ b/frontend/src/lib/chart-view-load.ts
@@ -7,6 +7,8 @@ import {
type ChartViewResponse,
} from "./chart-view-contract.ts";
import {
+ CHART_DASHA_SYSTEMS,
+ dashaCacheScope,
enginePayload,
logEngineCallFailure,
type ChartViewLayer,
@@ -94,6 +96,26 @@ function wantedLayers(layers: readonly ChartViewLayer[] | undefined): Set): {
+ path: string;
+ body: Record;
+ cacheScope?: string;
+} {
+ const system = CHART_DASHA_SYSTEMS.find((item) => item.dasha === dashaKey);
+ if (system && system.path !== "/api/dasha") {
+ return {
+ path: system.path,
+ body: { ...followUp, antardasha: true, mode: "dasha" },
+ };
+ }
+ return {
+ path: "/api/dasha",
+ body: { ...followUp, dasha: dashaKey },
+ cacheScope: system?.cacheScope ?? dashaCacheScope(dashaKey),
+ };
+}
+
async function callEngine(
postEngine: ChartViewEnginePost,
path: string,
@@ -167,20 +189,23 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
return enginePayload(result);
};
const transitDate = input.asOf.slice(0, 10);
- const [varga, chara, western, bhava, transit] = await Promise.all([
+ const dashaPackets: Record<(typeof CHART_DASHA_SYSTEMS)[number]["track"], Record | null> = {
+ chara: null,
+ yogini: null,
+ };
+ const dashaReady = Promise.all(CHART_DASHA_SYSTEMS.map(async (system) => {
+ if (!layers.has(system.layer)) return;
+ const request = chartDashaLayerRequest(system.dasha, followUp);
+ const result = await callEngine(input.postEngine, request.path, request.body, request.cacheScope);
+ dashaPackets[system.track] = layerPayload(system.layer)(result);
+ }));
+ const [varga, western, bhava, transit] = await Promise.all([
layers.has("varga")
? callEngine(input.postEngine, "/api/varga_full", {
...followUp,
divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`),
}).then(layerPayload("varga"))
: Promise.resolve(null),
- layers.has("chara")
- ? callEngine(input.postEngine, "/api/dasha/chara", {
- ...followUp,
- antardasha: true,
- mode: "dasha",
- }).then(layerPayload("chara"))
- : Promise.resolve(null),
layers.has("western")
? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western"))
: Promise.resolve(null),
@@ -209,12 +234,14 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
).then(layerPayload("transit"))
: Promise.resolve(null),
]);
+ await dashaReady;
try {
const body = buildChartView({
chart,
varga,
- chara,
+ chara: dashaPackets.chara,
+ yogini: dashaPackets.yogini,
western,
bhava,
transit,
diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts
index f8ae6134..5f004df7 100644
--- a/frontend/src/lib/chart-view-mapper.ts
+++ b/frontend/src/lib/chart-view-mapper.ts
@@ -14,6 +14,7 @@ import {
VIMSHOTTARI_ORDER,
VIMSHOTTARI_YEARS,
WESTERN_UNAVAILABLE_COPY,
+ YOGINI_ANTARDASHA_UNVERIFIED_NOTE,
ASPECT_ZH,
ELEMENT_ZH,
MODE_ZH,
@@ -58,6 +59,7 @@ export type ChartViewEngineBundle = {
chart: Record;
varga: Record | null;
chara: Record | null;
+ yogini?: Record | null;
western: Record | null;
bhava?: Record | null;
transit?: Record | null;
@@ -700,6 +702,68 @@ function charaTrack(charaPacket: Record | null, birthDate: stri
};
}
+function yoginiPieceLabel(yogini: string, planet: string): string {
+ return `${yogini} / ${planetZh(planet)}`;
+}
+
+function yoginiTrack(packet: Record | null, asOf: string) {
+ const rows = Array.isArray(packet?.periods) ? packet.periods : [];
+ const periods = rows.flatMap((entry) => {
+ const row = record(entry);
+ if (!row) return [];
+ const planet = text(row.planet) ?? text(row.lord);
+ const yogini = text(row.yogini);
+ const start = text(row.start_date);
+ const end = text(row.end_date);
+ if (!planet || !yogini || !start || !end) return [];
+ const startIso = isoDay(start);
+ const endIso = isoDay(end);
+ const rawAntar = Array.isArray(row.antardasha) ? row.antardasha : [];
+ const antardashas = rawAntar.flatMap((antar) => {
+ const item = record(antar);
+ if (!item) return [];
+ const antarPlanet = text(item.planet) ?? text(item.lord);
+ const antarYogini = text(item.yogini);
+ const antarStart = text(item.start_date);
+ const antarEnd = text(item.end_date);
+ if (!antarPlanet || !antarYogini || !antarStart || !antarEnd) return [];
+ const subStart = isoDay(antarStart);
+ const subEnd = isoDay(antarEnd);
+ return [{
+ lord: antarPlanet,
+ label: yoginiPieceLabel(antarYogini, antarPlanet),
+ start: subStart,
+ end: subEnd,
+ current: containsDay(subStart, subEnd, asOf),
+ derived: false,
+ }];
+ });
+ return [{
+ lord: planet,
+ label: yoginiPieceLabel(yogini, planet),
+ sign: null,
+ start: startIso,
+ end: endIso,
+ current: containsDay(startIso, endIso, asOf),
+ derived: false,
+ antardashas,
+ }];
+ });
+ const current = periods.find((item) => item.current);
+ const currentAntar = current?.antardashas.find((item) => item.current);
+ const currentLabel = current
+ ? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}`
+ : "未落入已返回的时段";
+ return {
+ id: "yogini" as const,
+ title: "Yogini",
+ method: "Yogini",
+ currentLabel,
+ antardashaNote: YOGINI_ANTARDASHA_UNVERIFIED_NOTE,
+ periods,
+ };
+}
+
function unavailableWestern(reason: string): ChartViewUnavailableLayer {
return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY };
}
@@ -789,7 +853,7 @@ function mapWestern(raw: Record | null, ayanamsaDisplay: string
}
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
- const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle;
+ const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf } = bundle;
const planets = asPlanetMap(chart.planets);
const moon = planets.Moon ?? {};
const ascendant = record(chart.ascendant);
@@ -801,6 +865,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
if (vargas.length === 0) throw new Error("vedic_chart_unavailable");
const vimshottari = vimshottariTrack(chart, asOf);
const charaMapped = charaTrack(chara, profile.date, asOf);
+ const yoginiMapped = yoginiTrack(yogini, asOf);
const placeLabel = profile.placeLabel?.trim()
|| formatCoord(profile.latitude, profile.longitude);
const view = {
@@ -839,6 +904,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
asOf: isoDay(asOf),
vimshottari,
chara: charaMapped,
+ yogini: yoginiMapped,
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
},
western: mapWestern(western, ayanamsaDisplay),
diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts
index 07bbe32d..41220d2f 100644
--- a/frontend/src/lib/secondary-page-data.ts
+++ b/frontend/src/lib/secondary-page-data.ts
@@ -1,6 +1,7 @@
import { fetchChartView } from "@/lib/chart-view-client";
import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject";
-import type { ChartViewResponse } from "@/lib/chart-view-contract";
+import type { ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract";
+import { CHART_DASHA_SYSTEMS } from "@/lib/chart-view-engine";
import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure";
import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
@@ -185,6 +186,15 @@ function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean {
return sameChartIdentity(identity, pinnedChartIdentity);
}
+function dashaKeepingArrivedTracks(prior: ChartViewOk["dasha"], next: ChartViewOk["dasha"]): ChartViewOk["dasha"] {
+ const dasha = { ...next };
+ for (const system of CHART_DASHA_SYSTEMS) {
+ const track = prior[system.track];
+ if (track && track.periods.length > 0) dasha[system.track] = track;
+ }
+ return dasha;
+}
+
async function loadChartSnapshot(started: number, signal: AbortSignal): Promise {
const subjectId = readCurrentSubjectId();
if (!pinnedChartIdentity || subjectOf(pinnedChartIdentity) !== subjectId) {
@@ -214,7 +224,7 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<
snapshot = { kind: "view", view: {
...snapshot.view,
...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}),
- dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) },
+ dasha: dashaKeepingArrivedTracks(prior.dasha, snapshot.view.dasha),
...(prior.western.status === "ok" ? { western: prior.western } : {}),
...(prior.bhava.status === "ok" ? { bhava: prior.bhava } : {}),
...(prior.transit.status === "ok" ? { transit: prior.transit } : {}),
diff --git a/frontend/src/lib/synastry-display-order.ts b/frontend/src/lib/synastry-display-order.ts
new file mode 100644
index 00000000..07b5b916
--- /dev/null
+++ b/frontend/src/lib/synastry-display-order.ts
@@ -0,0 +1,15 @@
+/** Selection order for a synastry report. The pair key stays sorted for dedup. */
+
+export const SYNASTRY_ORDER_SECTION = "synastry_order";
+
+export function synastryDisplaySubjectIds(low: string, high: string, order: unknown): [string, string] {
+ const row = order && typeof order === "object" && !Array.isArray(order)
+ ? order as Record
+ : null;
+ const first = typeof row?.first === "string" ? row.first : "";
+ const second = typeof row?.second === "string" ? row.second : "";
+ if (first !== second && (first === low || first === high) && (second === low || second === high)) {
+ return [first, second];
+ }
+ return [low, high];
+}
diff --git a/frontend/src/lib/synastry-full-create.ts b/frontend/src/lib/synastry-full-create.ts
index 9526927a..8e4599cf 100644
--- a/frontend/src/lib/synastry-full-create.ts
+++ b/frontend/src/lib/synastry-full-create.ts
@@ -5,6 +5,7 @@ import type { DataQueryBuilder } from "@/lib/db/data-client";
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service";
+import { SYNASTRY_ORDER_SECTION } from "@/lib/synastry-display-order";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import { readPersonalReportDailyLimit } from "@/lib/personal-report-entitlement";
@@ -52,21 +53,27 @@ function selectReports(admin: unknown, columns: string): Query {
return (admin as { from(table: string): { select(columns: string): Query } }).from("personal_reports").select(columns);
}
-export async function createSynastryFullReport(input: Readonly<{
+type OpenedPair = {
+ userId: string;
+ subjectA: string;
+ subjectB: string;
+ pair: ReturnType;
+};
+
+async function openSynastryPair(input: Readonly<{
userId: string | null;
- body: unknown;
supabase: unknown;
- admin: Parameters[0];
-}>): Promise {
- if (!input.userId) return { status: 401, body: { error: "请先登录" } };
+ subjectA: unknown;
+ subjectB: unknown;
+}>): Promise<{ response: SynastryCreateResponse } | OpenedPair> {
+ if (!input.userId) return { response: { status: 401, body: { error: "请先登录" } } };
if (!await isProductEnabled("compatibility") || !await isProductEnabled("report_center")) {
- return { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } };
+ return { response: { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } } };
}
- const body = input.body && typeof input.body === "object" ? input.body as Record : {};
- const subjectA = subjectId(body.subjectA ?? "self");
- const subjectB = subjectId(body.subjectB ?? body.with);
- if (!subjectA || !subjectB) return { status: 400, body: { error: "请选择两个人", code: "subject_required" } };
- if (subjectA === subjectB) return { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } };
+ const subjectA = subjectId(input.subjectA ?? "self");
+ const subjectB = subjectId(input.subjectB);
+ if (!subjectA || !subjectB) return { response: { status: 400, body: { error: "请选择两个人", code: "subject_required" } } };
+ if (subjectA === subjectB) return { response: { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } } };
let rowA: Record;
let rowB: Record;
@@ -76,44 +83,90 @@ export async function createSynastryFullReport(input: Readonly<{
loadSubjectBirth(input.supabase, input.userId, subjectB),
]);
if (!personA.chartable || !personB.chartable) {
- return { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } };
+ return { response: { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } } };
}
rowA = personA.row;
rowB = personB.row;
} catch (error) {
if (error instanceof ConsultationSubjectError) {
const failure = consultationSubjectFailureResponse(error);
- return { status: failure.status, body: failure.body };
+ return { response: { status: failure.status, body: failure.body } };
}
throw error;
}
+ return {
+ userId: input.userId,
+ subjectA,
+ subjectB,
+ pair: pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB)),
+ };
+}
- const pair = pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB));
- const requestFingerprint = createHash("sha256").update(JSON.stringify({
- ...pair,
- rules: MARRIAGE_MATCH_RULES_VERSION,
- })).digest("hex");
- const existing = await selectReports(input.admin, "id,request_id,status,created_at,completed_at")
- .eq("user_id", input.userId)
+function pairQuery(admin: unknown, pair: OpenedPair["pair"], userId: string) {
+ return selectReports(admin, "id,request_id,status,created_at,completed_at")
+ .eq("user_id", userId)
.eq("report_type", "synastry_full")
.eq("subject_low", pair.low)
.eq("subject_high", pair.high)
.eq("fingerprint_low", pair.fingerprintLow)
.eq("fingerprint_high", pair.fingerprintHigh)
- .eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION)
- .in("status", ["ready", "generating"])
+ .eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION);
+}
+
+export async function readSynastryPairState(input: Readonly<{
+ userId: string | null;
+ supabase: unknown;
+ admin: Parameters[0];
+ subjectA: unknown;
+ subjectB: unknown;
+}>): Promise {
+ const opened = await openSynastryPair(input);
+ if ("response" in opened) return opened.response;
+ const found = await pairQuery(input.admin, opened.pair, opened.userId).in("status", ["ready", "generating"]);
+ if (found.error) throw new Error(found.error.message ?? "synastry pair lookup failed");
+ const rows = Array.isArray(found.data) ? found.data as Row[] : [];
+ return {
+ status: 200,
+ body: {
+ readyCount: rows.filter((row) => row.status === "ready").length,
+ generating: rows.some((row) => row.status === "generating"),
+ },
+ };
+}
+
+export async function createSynastryFullReport(input: Readonly<{
+ userId: string | null;
+ body: unknown;
+ supabase: unknown;
+ admin: Parameters[0];
+}>): Promise {
+ const body = input.body && typeof input.body === "object" ? input.body as Record : {};
+ const opened = await openSynastryPair({
+ userId: input.userId,
+ supabase: input.supabase,
+ subjectA: body.subjectA ?? "self",
+ subjectB: body.subjectB ?? body.with,
+ });
+ if ("response" in opened) return opened.response;
+ const { userId, subjectA, subjectB, pair } = opened;
+ const requestFingerprint = createHash("sha256").update(JSON.stringify({
+ ...pair,
+ rules: MARRIAGE_MATCH_RULES_VERSION,
+ })).digest("hex");
+ const existing = await pairQuery(input.admin, pair, userId)
+ .eq("status", "generating")
.limit(1)
.maybeSingle();
if (existing.error) throw new Error(existing.error.message ?? "synastry lookup failed");
- if (existing.data?.status === "ready" || existing.data?.status === "generating") {
+ if (existing.data?.status === "generating") {
return {
- status: existing.data.status === "ready" ? 200 : 202,
- body: { report: reportView(stubRecord(input.userId, existing.data)), reused: true },
+ status: 202,
+ body: { report: reportView(stubRecord(userId, existing.data)), reused: true },
};
}
const inFlight = await selectReports(input.admin, "id")
- .eq("user_id", input.userId)
+ .eq("user_id", userId)
.eq("status", "generating")
.limit(1)
.maybeSingle();
@@ -123,7 +176,7 @@ export async function createSynastryFullReport(input: Readonly<{
}
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
- const today = await selectReports(input.admin, "created_at").eq("user_id", input.userId).gte("created_at", todayStart.toISOString());
+ const today = await selectReports(input.admin, "created_at").eq("user_id", userId).gte("created_at", todayStart.toISOString());
if (today.error) throw new Error(today.error.message ?? "synastry daily lookup failed");
if ((today.data ?? []).length >= readPersonalReportDailyLimit(process.env)) {
return { status: 429, body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited } };
@@ -132,7 +185,7 @@ export async function createSynastryFullReport(input: Readonly<{
const catalog = await loadLanguageModelCatalog();
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
if (!model) return { status: 502, body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable } };
- const limited = consumeUserRateLimitSet(["reportGenerateHour"], input.userId);
+ const limited = consumeUserRateLimitSet(["reportGenerateHour"], userId);
if (!limited.ok) {
return {
status: 429,
@@ -146,7 +199,7 @@ export async function createSynastryFullReport(input: Readonly<{
try {
const pricing = await resolveFeaturePricing(input.admin as never, "synastry.full", model.id);
const authorization = await authorizeUsage(input.admin as never, {
- userId: input.userId,
+ userId,
requestId,
featureKey: "synastry.full",
requestedModelId: model.id,
@@ -180,7 +233,7 @@ export async function createSynastryFullReport(input: Readonly<{
const release = async (reason: string) => {
if (!reserved) return;
reserved = false;
- try { await releaseUsage(input.admin as never, input.userId as string, requestId, reason); } catch { /* the RPC is idempotent */ }
+ try { await releaseUsage(input.admin as never, userId, requestId, reason); } catch { /* the RPC is idempotent */ }
};
const persistence = createSupabasePersonalReportService(input.admin);
@@ -190,7 +243,7 @@ export async function createSynastryFullReport(input: Readonly<{
let created;
try {
created = await persistence.createGenerating({
- userId: input.userId,
+ userId,
requestId,
requestFingerprint,
reportType: "synastry_full",
@@ -215,15 +268,33 @@ export async function createSynastryFullReport(input: Readonly<{
await release(REPORT_STABLE_CODES.generationInProgress);
return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
}
+ const orderSaved = await (input.admin as {
+ from(table: string): {
+ insert(row: Record): Promise<{ error: { message?: string } | null }>;
+ };
+ }).from("personal_report_sections").insert({
+ user_id: userId,
+ request_id: requestId,
+ section_id: SYNASTRY_ORDER_SECTION,
+ status: "ready",
+ payload: { first: subjectA, second: subjectB },
+ attempt_count: 1,
+ max_attempts: 1,
+ });
+ if (orderSaved.error) {
+ await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
+ await release(REPORT_STABLE_CODES.generationFailed);
+ return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
+ }
try {
const enqueued = await createSupabasePersonalReportJobService(input.admin as never).enqueue({
- userId: input.userId,
+ userId,
requestId,
requestFingerprint,
});
return { status: 202, body: { report: reportView(created.record), jobId: enqueued.job.id } };
} catch {
- await persistence.markFailed(input.userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
+ await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
await release(REPORT_STABLE_CODES.generationFailed);
return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
}
diff --git a/frontend/src/lib/synastry-full-generate.ts b/frontend/src/lib/synastry-full-generate.ts
index 2e870d32..161cb725 100644
--- a/frontend/src/lib/synastry-full-generate.ts
+++ b/frontend/src/lib/synastry-full-generate.ts
@@ -113,6 +113,36 @@ function deliveryFrom(parts: ReadonlyMap parts.get(id) ?? { id }) };
}
+/** A body that opens with its own section title, including a repeated title. */
+export function startsWithSectionTitle(text: string, title: string): boolean {
+ const heading = title.trim();
+ const body = text.replace(/^\s+/, "");
+ if (!heading || !body.startsWith(heading)) return false;
+ const rest = body.slice(heading.length);
+ return rest.length === 0 || /^[\s\u3000]/.test(rest) || rest.startsWith(heading);
+}
+
+export function stripLeadingSectionTitle(text: string, title: string): string {
+ const heading = title.trim();
+ if (!heading) return text;
+ let rest = text.replace(/^\s+/, "");
+ for (let guard = 0; guard < 4 && startsWithSectionTitle(rest, heading); guard += 1) {
+ rest = rest.slice(heading.length).replace(/^\s+/, "");
+ }
+ return rest;
+}
+
+function headingFailures(parts: ReadonlyMap>): { section: string; code: string }[] {
+ const failures: { section: string; code: string }[] = [];
+ for (const id of SYNASTRY_PROSE_IDS) {
+ const row = parts.get(id);
+ const title = typeof row?.title === "string" ? row.title : "";
+ const text = typeof row?.text === "string" ? row.text : "";
+ if (startsWithSectionTitle(text, title)) failures.push({ section: id, code: "repeated_heading" });
+ }
+ return failures;
+}
+
export async function writeSynastryLanguage(input: Readonly<{
language: "zh" | "en";
names: Readonly<{ a: string; b: string }>;
@@ -195,16 +225,35 @@ export async function writeSynastryLanguage(input: Readonly<{
await input.onProgress?.("checking_report");
let parts = materialize();
let gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
+ const firstHeadings = headingFailures(parts);
+ if (firstHeadings.length > 0) {
+ const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
+ gated = {
+ ok: false,
+ failures: [...gated.failures, ...firstHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
+ };
+ }
if (!gated.ok) {
const failed = new Set(gated.failures.map((failure) => failure.section).filter((section) => SYNASTRY_SECTION_IDS.includes(section as SynastrySectionId)));
const targets = failed.size > 0 ? [...failed] as SynastrySectionId[] : [...SYNASTRY_SECTION_IDS];
await pool(targets, 3, (sectionId) => writeOne(sectionId, gated.failures.filter((failure) => failure.section === sectionId).map((failure) => failure.code)));
parts = materialize();
gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
+ const secondHeadings = headingFailures(parts);
+ if (secondHeadings.length > 0) {
+ const seen = new Set(gated.failures.map((failure) => `${failure.section}:${failure.code}`));
+ gated = {
+ ok: false,
+ failures: [...gated.failures, ...secondHeadings.filter((failure) => !seen.has(`${failure.section}:${failure.code}`))],
+ };
+ }
}
if (!gated.ok) throw new Error("synastry_gate_rejected");
- const sections = SYNASTRY_PROSE_IDS.map((id) => parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] });
+ const sections = SYNASTRY_PROSE_IDS.map((id) => {
+ const section = parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] };
+ return { ...section, text: stripLeadingSectionTitle(section.text, section.title) };
+ });
const timeline = (parts.get("timeline")?.segments ?? []) as SynastrySnapshot["timeline"];
const practices = (parts.get("practices")?.items ?? []) as SynastrySnapshot["practices"];
const technical = buildTechnicalBasis({
diff --git a/frontend/src/lib/synastry-full-run.ts b/frontend/src/lib/synastry-full-run.ts
index 3262a35c..c047b8d2 100644
--- a/frontend/src/lib/synastry-full-run.ts
+++ b/frontend/src/lib/synastry-full-run.ts
@@ -14,6 +14,7 @@ import { PersonalReportWorkerError } from "@/lib/personal-report-worker-core";
import type { PersonalReportRecord } from "@/lib/personal-report-service-core";
import { loadSubjectBirth } from "@/lib/subject-birth";
import { marriageMatchBirthBody } from "@/lib/marriage-match-service";
+import { SYNASTRY_ORDER_SECTION, synastryDisplaySubjectIds } from "@/lib/synastry-display-order";
import { timelineForPair, writeSynastryLanguage, type SynastrySnapshot } from "@/lib/synastry-full-generate";
import type { TimingPeriod } from "@/lib/synastry-full-timeline";
@@ -160,9 +161,21 @@ export async function runSynastryFullReport(input: Readonly<{
}
await input.onProgress?.({ phase: "computing_charts", completed: 0, total: 9 });
const today = new Date().toISOString().slice(0, 10);
+ const orderRow = await input.admin.from("personal_report_sections")
+ .select("payload")
+ .eq("user_id", input.report.userId)
+ .eq("request_id", input.report.requestId)
+ .eq("section_id", SYNASTRY_ORDER_SECTION)
+ .maybeSingle();
+ const orderPayload = orderRow.error ? null : asRecord(orderRow.data)?.payload ?? null;
+ const [firstId, secondId] = synastryDisplaySubjectIds(
+ input.report.subjectLow,
+ input.report.subjectHigh,
+ orderPayload,
+ );
const [personA, personB] = await Promise.all([
- loadSubjectBirth(input.admin, input.report.userId, input.report.subjectLow),
- loadSubjectBirth(input.admin, input.report.userId, input.report.subjectHigh),
+ loadSubjectBirth(input.admin, input.report.userId, firstId),
+ loadSubjectBirth(input.admin, input.report.userId, secondId),
]);
const packet = asRecord(await postJson("/api/synastry_full_dossier", {
person_a: marriageMatchBirthBody(personA.row, today),
diff --git a/frontend/src/lib/synastry-full-sections.ts b/frontend/src/lib/synastry-full-sections.ts
index 1312f804..d14a83df 100644
--- a/frontend/src/lib/synastry-full-sections.ts
+++ b/frontend/src/lib/synastry-full-sections.ts
@@ -197,7 +197,7 @@ const ZH_SYSTEM = `你在写一份婚恋合盘的关系画像。把盘面翻译
不要报分数。不要用「克 / 煞 / 凶 / 吉」贴标签。不要说「注定」「必然」「命中」。不要把一方说成问题源。不要替当事人做决定。
示例:${ZH_EXAMPLE}
每个判断给两到三条分析。盘面行必须逐字来自证据里的某个完整字段,不要改写。证据互相冲突时把 label 降为 parameter_sensitive 或 blocked,并写成条件,不要写成应期。不要点名补充方法。
-结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」。
+结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」,正文也不要以这一节的标题开头。
质检硬规则:label 只能是 ${EVIDENCE_LABELS.join("、")}。中文每一节结论至少 ${SYNASTRY_LENGTH.zhSection} 字,七节合计至少 ${SYNASTRY_LENGTH.zhTotal} 字。evidence 必须等于该节证据 JSON 里的某个完整字符串字段。`;
const EN_SYSTEM = `You write a relationship portrait of two people. Translate the evidence into how the relationship feels. Do not judge the people.
@@ -207,7 +207,7 @@ Five rules: speak about the pair, not about who someone is. Speak in tendencies,
Do not quote a score. Do not call one person the problem. Do not decide for them.
Example: ${EN_EXAMPLE}
Give two or three supports for each judgment. The chart line must copy one complete evidence field verbatim. When supports conflict, lower the label to parameter_sensitive or blocked and write a condition, not a dated event. Do not name supplementary methods.
-The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not".
+The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year. Do not open with "The core of this relationship is not". Do not start the body with the section title.
Gate: label must be one of ${EVIDENCE_LABELS.join(", ")}. Each English section is at least ${SYNASTRY_LENGTH.enSection} words, and the seven sections together are at least ${SYNASTRY_LENGTH.enTotal} words. evidence must equal one complete string field from that section's evidence JSON.`;
export function systemPrompt(language: "zh" | "en"): string {
@@ -223,14 +223,20 @@ export function userPrompt(input: Readonly<{
}>): string {
const title = SECTION_TITLES[input.sectionId][input.language];
const evidence = JSON.stringify(input.evidence);
+ const notes = (input.failures ?? []).map((code) => {
+ if (code !== "repeated_heading") return code;
+ return input.language === "en"
+ ? "repeated_heading (do not start the body with the section title)"
+ : "repeated_heading(正文不要以节标题开头)";
+ });
if (input.language === "en") {
- const repair = input.failures && input.failures.length > 0
- ? `\nThe previous draft failed this section only: ${input.failures.join("; ")}`
+ const repair = notes.length > 0
+ ? `\nThe previous draft failed this section only: ${notes.join("; ")}`
: "";
return [`Names: ${input.names.a} and ${input.names.b}`, `Section: ${title}`, `Evidence: ${evidence}`, repair].filter(Boolean).join("\n");
}
- const repair = input.failures && input.failures.length > 0
- ? `\n上次没有通过,只改这一节:${input.failures.join(";")}`
+ const repair = notes.length > 0
+ ? `\n上次没有通过,只改这一节:${notes.join(";")}`
: "";
return [
`两人称呼:${input.names.a}、${input.names.b}`,
diff --git a/frontend/src/lib/synastry-report-href.ts b/frontend/src/lib/synastry-report-href.ts
new file mode 100644
index 00000000..51fb1e9f
--- /dev/null
+++ b/frontend/src/lib/synastry-report-href.ts
@@ -0,0 +1,34 @@
+export type ReportSurface = "personal" | "synastry";
+
+export function reportReaderHref(surface: ReportSurface, reportId: string, language: "zh" | "en"): string {
+ const path = surface === "synastry"
+ ? `/synastry/${encodeURIComponent(reportId)}`
+ : `/reports/${encodeURIComponent(reportId)}`;
+ return language === "en" ? `${path}?lang=en` : path;
+}
+
+/**
+ * Old shared links still open /reports/:id. A synastry report there moves to
+ * /synastry/:id, and a personal report opened on the synastry URL moves back.
+ * Unknown types stay put. English keeps ?lang=en; Chinese keeps a clean path.
+ */
+export function reportSurfaceRedirect(input: {
+ surface: ReportSurface;
+ reportType: string | null;
+ reportId: string;
+ language: "zh" | "en";
+}): string | null {
+ if (!input.reportType) return null;
+ const synastry = input.reportType === "synastry_full";
+ if (input.surface === "personal" && synastry) return reportReaderHref("synastry", input.reportId, input.language);
+ if (input.surface === "synastry" && !synastry) return reportReaderHref("personal", input.reportId, input.language);
+ return null;
+}
+
+export function reportTypeForSurface(state: { phase: string; reportType?: string }): string | null {
+ if (state.phase === "synastry-ready") return "synastry_full";
+ if (state.phase === "generating") return state.reportType ?? "personal_full";
+ if (state.phase === "markdown-ready" || state.phase === "legacy-unavailable") return "personal_full";
+ if (state.phase === "failed") return state.reportType ?? null;
+ return null;
+}
diff --git a/frontend/supabase/migrations/20261021120000_synastry_ready_reports_allow_repeat.sql b/frontend/supabase/migrations/20261021120000_synastry_ready_reports_allow_repeat.sql
new file mode 100644
index 00000000..58772853
--- /dev/null
+++ b/frontend/supabase/migrations/20261021120000_synastry_ready_reports_allow_repeat.sql
@@ -0,0 +1,4 @@
+-- A ready synastry report used to be unique per pair, so a second reading
+-- could not be saved. Product decision K6 keeps the old report and allows
+-- another one. Engine rows in synastry_reports stay unique and are reused.
+drop index if exists public.personal_reports_synastry_pair_ready_uidx;
diff --git a/frontend/tests/chart-dasha-switch-layout.test.ts b/frontend/tests/chart-dasha-switch-layout.test.ts
new file mode 100644
index 00000000..a610d1b7
--- /dev/null
+++ b/frontend/tests/chart-dasha-switch-layout.test.ts
@@ -0,0 +1,20 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import { cssDeclarations } from "./css-contract-test-support.ts";
+
+const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
+
+test("the dasha switch stays inside the column and keeps a 44px hit area", () => {
+ const row = cssDeclarations(".chart-page-dasha-switch", globalStyles);
+ assert.match(row, /display:\s*flex/);
+ assert.match(row, /overflow-x:\s*hidden/);
+ assert.doesNotMatch(row, /overflow-x:\s*auto/);
+ const option = cssDeclarations(".chart-page-dasha-switch .chart-page-tab", globalStyles);
+ assert.match(option, /min-height:\s*44px/);
+ assert.match(option, /min-width:\s*44px/);
+ assert.match(option, /flex:\s*1 1 0/);
+ assert.match(globalStyles, /@media \(min-width: 1024px\)/);
+ assert.match(globalStyles, /\.chart-page-dasha-columns/);
+});
diff --git a/frontend/tests/chart-dasha-yogini.test.ts b/frontend/tests/chart-dasha-yogini.test.ts
new file mode 100644
index 00000000..325ffe24
--- /dev/null
+++ b/frontend/tests/chart-dasha-yogini.test.ts
@@ -0,0 +1,152 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import test from "node:test";
+
+import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
+import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
+import {
+ chartViewEngineCacheKey,
+ chartViewEngineCachePath,
+ dashaCacheScope,
+} from "../src/lib/chart-view-engine.ts";
+import { assembleChartView, chartDashaLayerRequest } from "../src/lib/chart-view-load.ts";
+import { ANTARDASHA_DERIVED_NOTE, CHARA_ANTARDASHA_DATE_NOTE, YOGINI_ANTARDASHA_UNVERIFIED_NOTE } from "../src/lib/chart-view-labels.ts";
+
+Object.assign(globalThis, { React });
+
+const fixtureText = readFileSync(new URL("./fixtures/yogini-dasha-api-1990-01-01.golden.json", import.meta.url), "utf8");
+const yoginiFixture = JSON.parse(fixtureText) as {
+ sourceCommand: string;
+ ayanamsa: string;
+ node_mode: string;
+ response: Record;
+};
+const golden = JSON.parse(
+ readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
+) as { chart: Record; chara: Record };
+
+const profile = {
+ name: "示例",
+ date: "1990-06-15",
+ time: "12:00",
+ placeLabel: "北京",
+ latitude: 39.9042,
+ longitude: 116.4074,
+ timezoneOffset: 8,
+ timezoneId: "Asia/Shanghai",
+ ayanamsa: "raman",
+ birthTimeStatus: "confirmed" as const,
+};
+
+const person = {
+ userId: "user-1",
+ subjectId: "self",
+ date: "1990-01-01",
+ time: "12:00",
+ latitude: 28.6139,
+ longitude: 77.209,
+ timezoneOffset: 5.5,
+ ayanamsa: "lahiri",
+ nodeMode: "mean",
+};
+
+test("the yogini fixture is a real dasha response, not a hand-built shape", () => {
+ assert.match(yoginiFixture.sourceCommand, /_compute_dasha_system/);
+ assert.match(yoginiFixture.sourceCommand, /dasha=yogini/);
+ assert.equal(yoginiFixture.ayanamsa, "lahiri");
+ assert.equal(yoginiFixture.node_mode, "mean");
+ const periods = yoginiFixture.response.periods;
+ assert.ok(Array.isArray(periods) && periods.length > 0);
+});
+
+test("the same person gets a different cache key for each dasha system", () => {
+ const followUp = { date: person.date, time: person.time };
+ const chara = chartDashaLayerRequest("chara", followUp);
+ const yogini = chartDashaLayerRequest("yogini", followUp);
+ const kalachakra = chartDashaLayerRequest("kalachakra", followUp);
+ assert.equal(chara.path, "/api/dasha/chara");
+ assert.equal(chara.cacheScope, undefined);
+ assert.equal(yogini.path, "/api/dasha");
+ assert.equal(yogini.cacheScope, "dasha:yogini");
+ assert.equal(yogini.body.dasha, "yogini");
+ assert.equal(kalachakra.path, "/api/dasha");
+ assert.equal(kalachakra.cacheScope, dashaCacheScope("kalachakra"));
+ assert.equal(kalachakra.body.dasha, "kalachakra");
+ const key = (path: string, cacheScope?: string) => chartViewEngineCacheKey({
+ ...person,
+ path: chartViewEngineCachePath(path, cacheScope),
+ });
+ const keys = [
+ key(chara.path, chara.cacheScope),
+ key(yogini.path, yogini.cacheScope),
+ key(kalachakra.path, kalachakra.cacheScope),
+ ];
+ assert.equal(new Set(keys).size, 3);
+ assert.notEqual(JSON.stringify(chara.body), JSON.stringify(yogini.body));
+ assert.notEqual(JSON.stringify(yogini.body), JSON.stringify(kalachakra.body));
+});
+
+async function yoginiView(): Promise {
+ const scopes: Array = [];
+ const result = await assembleChartView({
+ userId: "user-1",
+ profile,
+ asOf: "1990-06-01",
+ layers: ["chara", "yogini"],
+ postEngine: async (path, body, cacheScope) => {
+ if (path === "/api/chart") return { status: "ok", payload: golden.chart };
+ if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara };
+ if (path === "/api/dasha") {
+ scopes.push(cacheScope);
+ assert.equal(body.dasha, "yogini");
+ return { status: "ok", payload: yoginiFixture.response };
+ }
+ return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
+ },
+ });
+ assert.deepEqual(scopes, ["dasha:yogini"]);
+ return chartViewOkSchema.parse(result.body);
+}
+
+test("a real yogini response maps dates and labels without deriving them", async () => {
+ const view = await yoginiView();
+ const track = view.dasha.yogini;
+ assert.equal(track.id, "yogini");
+ const first = track.periods[0];
+ assert.ok(first);
+ assert.equal(first.label, "Pingala / 太阳");
+ assert.equal(first.derived, false);
+ assert.equal(first.start, "1990-01-01");
+ assert.equal(first.end, "1990-01-12");
+ assert.equal(first.antardashas.length, 1);
+ assert.equal(first.antardashas[0]?.label, "Mangala / 月亮");
+ assert.equal(first.antardashas[0]?.derived, false);
+ assert.equal(first.antardashas[0]?.start, "1990-01-01");
+ const dhanya = track.periods.find((period) => period.label === "Dhanya / 木星");
+ assert.ok(dhanya);
+ assert.equal(dhanya.derived, false);
+ assert.equal(dhanya.antardashas.length, 8);
+ assert.equal(dhanya.antardashas.every((item) => item.derived === false), true);
+ assert.equal(dhanya.current, true);
+ assert.equal(dhanya.antardashas.some((item) => item.current), true);
+ assert.equal(track.antardashaNote, YOGINI_ANTARDASHA_UNVERIFIED_NOTE);
+});
+
+test("the yogini note sits under that track only", async () => {
+ const view = await yoginiView();
+ const yogini = renderToStaticMarkup(React.createElement(ChartDashaTab, { view, system: "yogini" }));
+ assert.equal((yogini.match(/小运未经外部软件核对。/g) ?? []).length, 1);
+ assert.match(yogini, /大运<\/dt>/);
+ assert.match(yogini, /小运<\/dt>/);
+ assert.match(yogini, /小运剩余<\/dt>\d+ 个月<\/dd>/);
+ assert.match(yogini, /Dhanya \/ 木星/);
+ assert.match(yogini, new RegExp(ANTARDASHA_DERIVED_NOTE));
+ assert.doesNotMatch(yogini, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
+ const chara = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
+ assert.equal((chara.match(/小运未经外部软件核对。/g) ?? []).length, 0);
+ assert.match(chara, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
+ assert.match(chara, new RegExp(ANTARDASHA_DERIVED_NOTE));
+ assert.match(chara, /aria-selected="true"[^>]*>Chara);
+});
diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts
index 803a6ca5..58b8de34 100644
--- a/frontend/tests/chart-page-hook.test.ts
+++ b/frontend/tests/chart-page-hook.test.ts
@@ -17,13 +17,22 @@ import { createClientLifecycleHarness } from "./react-client-lifecycle-test-supp
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const bhavaGolden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-bhava-golden.json", import.meta.url), "utf8"));
const transitGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
+const yoginiGolden = JSON.parse(readFileSync(new URL("./fixtures/yogini-dasha-api-1990-01-01.golden.json", import.meta.url), "utf8")).response;
const profile = { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" };
async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) {
// 原值: `/api/chart` 一律回本命 golden;新值: 带 cacheScope 的 `/api/chart`(行运盘)走 failure / 行运 golden
// 原因: 行运层也打 `/api/chart`,只按 cacheScope 区分(TASK-chart-types T4)
- return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path, _body, cacheScope) => {
+ return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path, body, cacheScope) => {
if (path === "/api/chart" && !cacheScope) return { status: "ok", payload: golden.chart };
if (failure) return failure;
+ // 原值: `/api/dasha` 没有分支,落到西洋包
+ // 新值: 按 body.dasha 分派;yogini 用真实接口响应,其它键回空 periods
+ // 原因: Chara 与 Yogini 都会打大运接口,不能按 path 共用一份包(issue #88)
+ if (path === "/api/dasha") {
+ return body.dasha === "yogini"
+ ? { status: "ok", payload: yoginiGolden }
+ : { status: "ok", payload: { periods: [] } };
+ }
const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : path === "/api/bhava_chalit" ? bhavaGolden.bhava_chalit : golden.western;
return { status: "ok", payload };
} });
@@ -88,9 +97,10 @@ for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "respon
assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]);
}));
-// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 bhava 与 transit
-// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层(T4)与 Bhava 层(T5)新增
-for (const layer of ["varga", "chara", "western", "bhava", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
+// 原值: ["varga", "chara", "western", "bhava", "transit"]
+// 新值: ["varga", "chara", "yogini", "western", "bhava", "transit"]
+// 原因: Yogini 是按需大运层,429 只记在这一层。更早一次是去掉 qizheng、加上 bhava 与 transit(TASK-chart-types)
+for (const layer of ["varga", "chara", "yogini", "western", "bhava", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
const initial = await packet();
const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 });
globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial);
@@ -258,3 +268,109 @@ test("profile pin invalidation ignores an older layer completion", async () => h
assert.deepEqual(peekChartPage(), { kind: "view", view: fresh });
assert.equal(state().layerFailures.size, 0);
}));
+
+test("chara and yogini replace only their own dasha track", async () => harness(async (h, state) => {
+ const initial = await packet();
+ const chara = await packet("chara");
+ const yogini = await packet("yogini");
+ writeChartPage({ kind: "view", view: initial.body });
+ globalThis.fetch = async (url) => {
+ const text = String(url);
+ if (text.includes("layers=yogini")) return response(yogini);
+ if (text.includes("layers=chara")) return response(chara);
+ return response(initial);
+ };
+ await h.render(null);
+ await h.update(() => state().requestLayer("chara"));
+ let view = state().view as ChartViewOk;
+ const charaPeriods = view.dasha.chara.periods.length;
+ assert.ok(charaPeriods > 0);
+ assert.equal(view.dasha.yogini.periods.length, 0);
+ await h.update(() => state().requestLayer("yogini"));
+ view = state().view as ChartViewOk;
+ assert.equal(view.dasha.chara.periods.length, charaPeriods);
+ assert.ok(view.dasha.yogini.periods.length > 0);
+}));
+
+test("yogini then chara keeps the yogini track", async () => harness(async (h, state) => {
+ const initial = await packet();
+ const chara = await packet("chara");
+ const yogini = await packet("yogini");
+ writeChartPage({ kind: "view", view: initial.body });
+ globalThis.fetch = async (url) => {
+ const text = String(url);
+ if (text.includes("layers=yogini")) return response(yogini);
+ if (text.includes("layers=chara")) return response(chara);
+ return response(initial);
+ };
+ await h.render(null);
+ await h.update(() => state().requestLayer("yogini"));
+ const yoginiPeriods = (state().view as ChartViewOk).dasha.yogini.periods.length;
+ assert.ok(yoginiPeriods > 0);
+ await h.update(() => state().requestLayer("chara"));
+ const view = state().view as ChartViewOk;
+ assert.equal(view.dasha.yogini.periods.length, yoginiPeriods);
+ assert.ok(view.dasha.chara.periods.length > 0);
+}));
+
+test("yogini 429 leaves a loaded chara track in place", async () => harness(async (h, state) => {
+ const initial = await packet();
+ const chara = await packet("chara");
+ const failed = await packet("yogini", { status: "busy", path: "/api/dasha", elapsedMs: 1, httpStatus: 429 });
+ writeChartPage({ kind: "view", view: initial.body });
+ globalThis.fetch = async (url) => {
+ const text = String(url);
+ if (text.includes("layers=yogini")) return response(failed);
+ if (text.includes("layers=chara")) return response(chara);
+ return response(initial);
+ };
+ await h.render(null);
+ await h.update(() => state().requestLayer("chara"));
+ const charaPeriods = (state().view as ChartViewOk).dasha.chara.periods.length;
+ await h.update(() => state().requestLayer("yogini"));
+ const view = state().view as ChartViewOk;
+ assert.equal(state().layerFailures.get("yogini"), "engine_busy");
+ assert.equal(state().layerFailures.has("chara"), false);
+ assert.equal(view.dasha.chara.periods.length, charaPeriods);
+}));
+
+test("a warm D1 refresh keeps an arrived yogini track", async () => harness(async (h, state) => {
+ const initial = await packet();
+ const yogini = await packet("yogini");
+ const refresh = deferred();
+ writeChartPage({ kind: "view", view: initial.body });
+ globalThis.fetch = async (url) => String(url).includes("layers=yogini") ? response(yogini) : refresh.promise;
+ await h.render(null);
+ await h.update(() => state().requestLayer("yogini"));
+ assert.ok((state().view as ChartViewOk).dasha.yogini.periods.length > 0);
+ await h.update(() => refresh.resolve(response(initial)));
+ assert.ok((state().view as ChartViewOk).dasha.yogini.periods.length > 0, "a D1-only refresh does not erase yogini");
+}));
+
+test("empty yogini periods fail only that layer and the same request can run again", async () => harness(async (h, state) => {
+ const initial = await packet();
+ const yogini = await packet("yogini");
+ assert.equal(yogini.body.status, "ok");
+ if (yogini.body.status !== "ok") return;
+ const emptyBody = {
+ ...yogini.body,
+ dasha: {
+ ...yogini.body.dasha,
+ yogini: { ...yogini.body.dasha.yogini, periods: [] },
+ },
+ };
+ let yoginiCalls = 0;
+ writeChartPage({ kind: "view", view: initial.body });
+ globalThis.fetch = async (url) => {
+ if (!String(url).includes("layers=yogini")) return response(initial);
+ yoginiCalls += 1;
+ return response({ ...yogini, body: emptyBody });
+ };
+ await h.render(null);
+ await h.update(() => state().requestLayer("yogini"));
+ assert.equal(state().layerFailures.get("yogini"), "unavailable");
+ assert.equal(state().layerFailures.has("chara"), false);
+ assert.equal((state().view as ChartViewOk).dasha.vimshottari.periods.length, (initial.body as ChartViewOk).dasha.vimshottari.periods.length);
+ await h.update(() => state().requestLayer("yogini"));
+ assert.equal(yoginiCalls, 2);
+}));
diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx
index 54a9a758..09a32d9a 100644
--- a/frontend/tests/chart-page-view.test.tsx
+++ b/frontend/tests/chart-page-view.test.tsx
@@ -11,14 +11,15 @@ import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/perso
import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
-import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
-import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
+import { CHART_VIEW_TABS, type ChartViewFailureStatus, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
+import { CHART_VIEW_LAYERS, type ChartViewLayer } from "../src/lib/chart-view-engine.ts";
import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
import { goldenSky } from "./birth-sky-test-support.ts";
+import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
@@ -551,3 +552,70 @@ test("birth-sky prefetch follows the current subject and the dialog is a lazy ch
assert.match(cover, /await import\("\.\/render\.ts"\)/);
assert.doesNotMatch(chartView, /birth-sky-dialog|lib\/birth-sky\/(draw|render)/);
});
+
+function buttonByLabel(h: ReturnType, label: string) {
+ const node = h.elements().find((item) => item.tagName === "BUTTON" && item.text === label);
+ assert.ok(node, `missing button ${label}`);
+ return node;
+}
+
+test("opening dasha requests only chara, and Yogini is a separate request", async () => {
+ const view = await okView();
+ const calls: string[] = [];
+ const h = createClientLifecycleHarness();
+ try {
+ await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
+ view,
+ onNeedLayer: (layer: string) => { calls.push(layer); },
+ })));
+ await h.event(buttonByLabel(h, "大运"));
+ assert.deepEqual(calls, ["chara"]);
+ assert.match(h.container.text, /Chara Dasha(kn_rao 变体)/);
+ assert.match(h.container.text, /Vimshottari/);
+ await h.event(buttonByLabel(h, "Yogini"));
+ assert.deepEqual(calls, ["chara", "yogini"]);
+ } finally {
+ await h.close();
+ }
+});
+
+test("a yogini failure stays in the right column, and clicking Yogini again asks again", async () => {
+ const view = await okView();
+ const calls: string[] = [];
+ const h = createClientLifecycleHarness();
+ try {
+ await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
+ view,
+ layerFailures: new Map([["yogini", "engine_busy"]]),
+ onNeedLayer: (layer: string) => { calls.push(layer); },
+ })));
+ await h.event(buttonByLabel(h, "大运"));
+ await h.event(buttonByLabel(h, "Yogini"));
+ assert.match(h.container.text, /Vimshottari/);
+ assert.match(h.container.text, new RegExp(CHART_VIEW_COPY.busy));
+ assert.doesNotMatch(h.container.text, /小运未经外部软件核对。/);
+ assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
+ await h.event(buttonByLabel(h, "Yogini"));
+ assert.deepEqual(calls, ["chara", "yogini", "yogini"]);
+ } finally {
+ await h.close();
+ }
+});
+
+test("yogini pending reuses the orbit sentence and does not draw a skeleton", async () => {
+ const view = await okView();
+ const h = createClientLifecycleHarness();
+ try {
+ await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
+ view,
+ pendingLayers: new Set(["yogini"]),
+ })));
+ await h.event(buttonByLabel(h, "大运"));
+ await h.event(buttonByLabel(h, "Yogini"));
+ assert.match(h.container.text, /这一栏还没拿到/);
+ assert.match(h.container.text, /Vimshottari/);
+ assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
+ } finally {
+ await h.close();
+ }
+});
diff --git a/frontend/tests/chart-view-route.test.ts b/frontend/tests/chart-view-route.test.ts
index c2e39b11..c72883ab 100644
--- a/frontend/tests/chart-view-route.test.ts
+++ b/frontend/tests/chart-view-route.test.ts
@@ -94,7 +94,13 @@ test("the chart-view BFF never bills and never calls a model", () => {
assert.match(serviceSource, /loadSubjectBirth/);
assert.doesNotMatch(serviceSource, /clientBirth|body\.profile/);
assert.match(loadSource, /\/api\/chart/);
- assert.match(loadSource, /\/api\/dasha\/chara/);
+ // 原值: loadSource 匹配 /api/dasha/chara
+ // 新值: 路径写在 CHART_DASHA_SYSTEMS,loadSource 经 chartDashaLayerRequest 取用
+ // 原因: Chara 与 Yogini 共用一张大运层表,Yogini 走 /api/dasha 且缓存 scope 是 dasha:yogini(issue #88)
+ const engineSource = readFileSync(new URL("../src/lib/chart-view-engine.ts", import.meta.url), "utf8");
+ assert.match(engineSource, /\/api\/dasha\/chara/);
+ assert.match(engineSource, /dasha:yogini/);
+ assert.match(loadSource, /chartDashaLayerRequest/);
assert.match(loadSource, /\/api\/varga_full/);
assert.match(loadSource, /planets/);
assert.match(loadSource, /ascendant/);
diff --git a/frontend/tests/database-synastry-full-create.test.ts b/frontend/tests/database-synastry-full-create.test.ts
index 73ae711a..dc570742 100644
--- a/frontend/tests/database-synastry-full-create.test.ts
+++ b/frontend/tests/database-synastry-full-create.test.ts
@@ -105,7 +105,7 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the
});
catalog.invalidateLanguageModelCatalog();
- const { createSynastryFullReport } = await import("../src/lib/synastry-full-create.ts");
+ const { createSynastryFullReport, readSynastryPairState } = await import("../src/lib/synastry-full-create.ts");
const user = createLocalPostgresDataClient(appUrl, { id: userId, email: `${userId}@example.invalid` });
const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role");
const created = await createSynastryFullReport({
@@ -123,6 +123,65 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the
assert.equal(fixture.psql(`select status from public.personal_report_jobs where user_id = '${userId}'`), "queued");
assert.equal(fixture.psql(`select status from public.usage_reservations where user_id = '${userId}'`), "reserved");
assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81");
+ assert.equal(fixture.psql(`select payload->>'first' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), "self");
+ assert.equal(fixture.psql(`select payload->>'second' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), personId);
+ assert.equal(fixture.psql(`select subject_low from public.personal_reports where id = '${report?.id}'`), personId);
+ assert.equal(fixture.psql(`select subject_high from public.personal_reports where id = '${report?.id}'`), "self");
+
+ const again = await createSynastryFullReport({
+ userId,
+ body: { reportType: "synastry_full", subjectA: "self", subjectB: personId },
+ supabase: user,
+ admin,
+ });
+ assert.equal(again.status, 202, JSON.stringify(again.body));
+ assert.equal(again.body.reused, true);
+ assert.equal((again.body.report as { id?: string }).id, report?.id);
+ assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81");
+
+ const hash = "d".repeat(64);
+ fixture.psql(`
+ update public.personal_reports
+ set status = 'ready', completed_at = now(), report_document = '{}'::jsonb,
+ calculation_hash = '${hash}', evidence_hash = '${hash}'
+ where id = '${report?.id}';
+ update public.personal_report_jobs
+ set status = 'cancelled', finished_at = now()
+ where user_id = '${userId}' and status = 'queued';
+ `);
+ const readyState = await readSynastryPairState({
+ userId,
+ supabase: user,
+ admin,
+ subjectA: "self",
+ subjectB: personId,
+ });
+ assert.equal(readyState.status, 200, JSON.stringify(readyState.body));
+ assert.equal(readyState.body.readyCount, 1);
+ assert.equal(readyState.body.generating, false);
+
+ const second = await createSynastryFullReport({
+ userId,
+ body: { reportType: "synastry_full", subjectA: "self", subjectB: personId },
+ supabase: user,
+ admin,
+ });
+ assert.equal(second.status, 202, JSON.stringify(second.body));
+ assert.equal(second.body.reused, undefined);
+ const secondId = (second.body.report as { id?: string }).id;
+ assert.equal(typeof secondId, "string");
+ assert.notEqual(secondId, report?.id);
+ assert.equal(fixture.psql(`select status from public.personal_reports where id = '${secondId}'`), "generating");
+ assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "62");
+ const generatingState = await readSynastryPairState({
+ userId,
+ supabase: user,
+ admin,
+ subjectA: "self",
+ subjectB: personId,
+ });
+ assert.equal(generatingState.body.readyCount, 1);
+ assert.equal(generatingState.body.generating, true);
catalog.setModelCatalogIoForTests(null);
catalog.invalidateLanguageModelCatalog();
} finally {
diff --git a/frontend/tests/database-synastry-full-report.test.ts b/frontend/tests/database-synastry-full-report.test.ts
index cb1e875e..da165b0d 100644
--- a/frontend/tests/database-synastry-full-report.test.ts
+++ b/frontend/tests/database-synastry-full-report.test.ts
@@ -81,7 +81,10 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast
where id = '${reportId}';
${closeJob}
`);
- assert.throws(() => fixture.psql(`
+ // 原值:第二份 ready 插入被 personal_reports_synastry_pair_ready_uidx 拒绝,count 为 1
+ // 新值:同一对可以再有一份 ready,count 为 2
+ // 原因:K6 推翻复用,旧报告保留(BUG-1408)
+ fixture.psql(`
begin;
${insertReport(duplicateReportId, secondRequestId, fingerprint)}
update public.personal_reports
@@ -89,14 +92,12 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast
calculation_hash = '${skillHash}', evidence_hash = '${skillHash}'
where id = '${duplicateReportId}';
commit;
- `), (error: unknown) => {
- const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr?: unknown }).stderr) : "";
- return /personal_reports_synastry_pair_ready_uidx/.test(`${stderr}\n${String(error)}`);
- });
+ `);
assert.equal(lastLine(fixture.psql(`
select count(*) from public.personal_reports
where user_id = '${userId}' and fingerprint_low = '${fingerprint}' and status = 'ready';
- `)), "1");
+ `)), "2");
+ fixture.psql(closeJob);
fixture.psql(insertReport(changedReportId, changedRequestId, changedFingerprint));
fixture.psql(`
diff --git a/frontend/tests/fixtures/yogini-dasha-api-1990-01-01.golden.json b/frontend/tests/fixtures/yogini-dasha-api-1990-01-01.golden.json
new file mode 100644
index 00000000..5a125efc
--- /dev/null
+++ b/frontend/tests/fixtures/yogini-dasha-api-1990-01-01.golden.json
@@ -0,0 +1,1970 @@
+{
+ "sourceCommand": "agent-loop/.venv/Scripts/python.exe: scripts.domain_calculation_service.compute_chart then tests/test_api_server_security.py _handler()._compute_dasha_system(dasha=yogini); fictional chart 1990-01-01 12:00 lat 28.6139 lon 77.2090 tz 5.5; ayanamsa lahiri; node_mode mean",
+ "chart": "fictional 1990-01-01 12:00 lat 28.6139 lon 77.2090 tz 5.5",
+ "ayanamsa": "lahiri",
+ "node_mode": "mean",
+ "response": {
+ "success": true,
+ "key": "yogini",
+ "name": "Yogini",
+ "type": "nakshatra",
+ "cycle_years": 36,
+ "precision": "engine_module",
+ "periods": [
+ {
+ "yogini": "Pingala",
+ "planet": "Sun",
+ "full_years": 2,
+ "years": 0.030205765412230336,
+ "start_date": "1990-01-01T12:00:00",
+ "end_date": "1990-01-12T12:47:01",
+ "unclipped_start_date": "1988-01-13T00:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.03020575075417649,
+ "start_date": "1990-01-01T12:00:00",
+ "end_date": "1990-01-12T12:47:01",
+ "unclipped_start_date": "1989-12-23T05:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Dhanya",
+ "planet": "Jupiter",
+ "full_years": 3,
+ "years": 3.0,
+ "start_date": "1990-01-12T12:47:01",
+ "end_date": "1993-01-12T06:47:01",
+ "unclipped_start_date": "1990-01-12T12:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.25,
+ "start_date": "1990-01-12T12:47:01",
+ "end_date": "1990-04-13T20:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.3333333333333333,
+ "start_date": "1990-04-13T20:17:01",
+ "end_date": "1990-08-13T14:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.4166666666666667,
+ "start_date": "1990-08-13T14:17:01",
+ "end_date": "1991-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.5,
+ "start_date": "1991-01-12T18:47:01",
+ "end_date": "1991-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.5833333333333334,
+ "start_date": "1991-07-14T09:47:01",
+ "end_date": "1992-02-12T11:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.6666666666666666,
+ "start_date": "1992-02-12T11:17:01",
+ "end_date": "1992-10-12T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.08333333333333333,
+ "start_date": "1992-10-12T23:17:01",
+ "end_date": "1992-11-12T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.16666666666666666,
+ "start_date": "1992-11-12T09:47:01",
+ "end_date": "1993-01-12T06:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Bhramari",
+ "planet": "Mars",
+ "full_years": 4,
+ "years": 4.0,
+ "start_date": "1993-01-12T06:47:01",
+ "end_date": "1997-01-12T06:47:01",
+ "unclipped_start_date": "1993-01-12T06:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.4444444444444444,
+ "start_date": "1993-01-12T06:47:01",
+ "end_date": "1993-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.5555555555555556,
+ "start_date": "1993-06-23T14:47:01",
+ "end_date": "1994-01-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.6666666666666666,
+ "start_date": "1994-01-12T12:47:01",
+ "end_date": "1994-09-13T00:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.7777777777777778,
+ "start_date": "1994-09-13T00:47:01",
+ "end_date": "1995-06-24T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.8888888888888888,
+ "start_date": "1995-06-24T02:47:01",
+ "end_date": "1996-05-13T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.1111111111111111,
+ "start_date": "1996-05-13T18:47:01",
+ "end_date": "1996-06-23T08:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.2222222222222222,
+ "start_date": "1996-06-23T08:47:01",
+ "end_date": "1996-09-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.3333333333333333,
+ "start_date": "1996-09-12T12:47:01",
+ "end_date": "1997-01-12T06:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Bhadrika",
+ "planet": "Mercury",
+ "full_years": 5,
+ "years": 5.0,
+ "start_date": "1997-01-12T06:47:01",
+ "end_date": "2002-01-12T12:47:01",
+ "unclipped_start_date": "1997-01-12T06:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.6944444444444444,
+ "start_date": "1997-01-12T06:47:01",
+ "end_date": "1997-09-22T22:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.8333333333333334,
+ "start_date": "1997-09-22T22:17:01",
+ "end_date": "1998-07-24T07:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.9722222222222222,
+ "start_date": "1998-07-24T07:17:01",
+ "end_date": "1999-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.1111111111111112,
+ "start_date": "1999-07-14T09:47:01",
+ "end_date": "2000-08-23T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.1388888888888889,
+ "start_date": "2000-08-23T05:47:01",
+ "end_date": "2000-10-12T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.2777777777777778,
+ "start_date": "2000-10-12T23:17:01",
+ "end_date": "2001-01-22T10:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.4166666666666667,
+ "start_date": "2001-01-22T10:17:01",
+ "end_date": "2001-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.5555555555555556,
+ "start_date": "2001-06-23T14:47:01",
+ "end_date": "2002-01-12T12:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Ulka",
+ "planet": "Saturn",
+ "full_years": 6,
+ "years": 6.0,
+ "start_date": "2002-01-12T12:47:01",
+ "end_date": "2008-01-13T00:47:01",
+ "unclipped_start_date": "2002-01-12T12:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.0,
+ "start_date": "2002-01-12T12:47:01",
+ "end_date": "2003-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.1666666666666667,
+ "start_date": "2003-01-12T18:47:01",
+ "end_date": "2004-03-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.3333333333333333,
+ "start_date": "2004-03-13T21:47:01",
+ "end_date": "2005-07-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.16666666666666666,
+ "start_date": "2005-07-13T21:47:01",
+ "end_date": "2005-09-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.3333333333333333,
+ "start_date": "2005-09-12T18:47:01",
+ "end_date": "2006-01-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.5,
+ "start_date": "2006-01-12T12:47:01",
+ "end_date": "2006-07-14T03:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.6666666666666666,
+ "start_date": "2006-07-14T03:47:01",
+ "end_date": "2007-03-14T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.8333333333333334,
+ "start_date": "2007-03-14T15:47:01",
+ "end_date": "2008-01-13T00:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Siddha",
+ "planet": "Venus",
+ "full_years": 7,
+ "years": 7.0,
+ "start_date": "2008-01-13T00:47:01",
+ "end_date": "2015-01-12T18:47:01",
+ "unclipped_start_date": "2008-01-13T00:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.3611111111111112,
+ "start_date": "2008-01-13T00:47:01",
+ "end_date": "2009-05-24T04:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.5555555555555556,
+ "start_date": "2009-05-24T04:17:01",
+ "end_date": "2010-12-13T08:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.19444444444444445,
+ "start_date": "2010-12-13T08:17:01",
+ "end_date": "2011-02-22T08:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.3888888888888889,
+ "start_date": "2011-02-22T08:47:01",
+ "end_date": "2011-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.5833333333333334,
+ "start_date": "2011-07-14T09:47:01",
+ "end_date": "2012-02-12T11:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.7777777777777778,
+ "start_date": "2012-02-12T11:17:01",
+ "end_date": "2012-11-22T13:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.9722222222222222,
+ "start_date": "2012-11-22T13:17:01",
+ "end_date": "2013-11-12T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.1666666666666667,
+ "start_date": "2013-11-12T15:47:01",
+ "end_date": "2015-01-12T18:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Sankata",
+ "planet": "Rahu",
+ "full_years": 8,
+ "years": 8.0,
+ "start_date": "2015-01-12T18:47:01",
+ "end_date": "2023-01-12T18:47:01",
+ "unclipped_start_date": "2015-01-12T18:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.7777777777777777,
+ "start_date": "2015-01-12T18:47:01",
+ "end_date": "2016-10-23T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.2222222222222222,
+ "start_date": "2016-10-23T02:47:01",
+ "end_date": "2017-01-12T06:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.4444444444444444,
+ "start_date": "2017-01-12T06:47:01",
+ "end_date": "2017-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.6666666666666666,
+ "start_date": "2017-06-23T14:47:01",
+ "end_date": "2018-02-22T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.8888888888888888,
+ "start_date": "2018-02-22T02:47:01",
+ "end_date": "2019-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 1.1111111111111112,
+ "start_date": "2019-01-12T18:47:01",
+ "end_date": "2020-02-22T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.3333333333333333,
+ "start_date": "2020-02-22T14:47:01",
+ "end_date": "2021-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.5555555555555556,
+ "start_date": "2021-06-23T14:47:01",
+ "end_date": "2023-01-12T18:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Mangala",
+ "planet": "Moon",
+ "full_years": 1,
+ "years": 1.0,
+ "start_date": "2023-01-12T18:47:01",
+ "end_date": "2024-01-13T00:47:01",
+ "unclipped_start_date": "2023-01-12T18:47:01",
+ "cycle_number": 1,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.027777777777777776,
+ "start_date": "2023-01-12T18:47:01",
+ "end_date": "2023-01-22T22:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.05555555555555555,
+ "start_date": "2023-01-22T22:17:01",
+ "end_date": "2023-02-12T05:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.08333333333333333,
+ "start_date": "2023-02-12T05:17:01",
+ "end_date": "2023-03-14T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.1111111111111111,
+ "start_date": "2023-03-14T15:47:01",
+ "end_date": "2023-04-24T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.1388888888888889,
+ "start_date": "2023-04-24T05:47:01",
+ "end_date": "2023-06-13T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.16666666666666666,
+ "start_date": "2023-06-13T23:17:01",
+ "end_date": "2023-08-13T20:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.19444444444444445,
+ "start_date": "2023-08-13T20:17:01",
+ "end_date": "2023-10-23T20:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.2222222222222222,
+ "start_date": "2023-10-23T20:47:01",
+ "end_date": "2024-01-13T00:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Pingala",
+ "planet": "Sun",
+ "full_years": 2,
+ "years": 2.0,
+ "start_date": "2024-01-13T00:47:01",
+ "end_date": "2026-01-12T12:47:01",
+ "unclipped_start_date": "2024-01-13T00:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.1111111111111111,
+ "start_date": "2024-01-13T00:47:01",
+ "end_date": "2024-02-22T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.16666666666666666,
+ "start_date": "2024-02-22T14:47:01",
+ "end_date": "2024-04-23T11:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.2222222222222222,
+ "start_date": "2024-04-23T11:47:01",
+ "end_date": "2024-07-13T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.2777777777777778,
+ "start_date": "2024-07-13T15:47:01",
+ "end_date": "2024-10-23T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.3333333333333333,
+ "start_date": "2024-10-23T02:47:01",
+ "end_date": "2025-02-21T20:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.3888888888888889,
+ "start_date": "2025-02-21T20:47:01",
+ "end_date": "2025-07-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.4444444444444444,
+ "start_date": "2025-07-13T21:47:01",
+ "end_date": "2025-12-23T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.05555555555555555,
+ "start_date": "2025-12-23T05:47:01",
+ "end_date": "2026-01-12T12:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Dhanya",
+ "planet": "Jupiter",
+ "full_years": 3,
+ "years": 3.0,
+ "start_date": "2026-01-12T12:47:01",
+ "end_date": "2029-01-12T06:47:01",
+ "unclipped_start_date": "2026-01-12T12:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.25,
+ "start_date": "2026-01-12T12:47:01",
+ "end_date": "2026-04-13T20:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.3333333333333333,
+ "start_date": "2026-04-13T20:17:01",
+ "end_date": "2026-08-13T14:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.4166666666666667,
+ "start_date": "2026-08-13T14:17:01",
+ "end_date": "2027-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.5,
+ "start_date": "2027-01-12T18:47:01",
+ "end_date": "2027-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.5833333333333334,
+ "start_date": "2027-07-14T09:47:01",
+ "end_date": "2028-02-12T11:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.6666666666666666,
+ "start_date": "2028-02-12T11:17:01",
+ "end_date": "2028-10-12T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.08333333333333333,
+ "start_date": "2028-10-12T23:17:01",
+ "end_date": "2028-11-12T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.16666666666666666,
+ "start_date": "2028-11-12T09:47:01",
+ "end_date": "2029-01-12T06:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Bhramari",
+ "planet": "Mars",
+ "full_years": 4,
+ "years": 4.0,
+ "start_date": "2029-01-12T06:47:01",
+ "end_date": "2033-01-12T06:47:01",
+ "unclipped_start_date": "2029-01-12T06:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.4444444444444444,
+ "start_date": "2029-01-12T06:47:01",
+ "end_date": "2029-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.5555555555555556,
+ "start_date": "2029-06-23T14:47:01",
+ "end_date": "2030-01-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.6666666666666666,
+ "start_date": "2030-01-12T12:47:01",
+ "end_date": "2030-09-13T00:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.7777777777777778,
+ "start_date": "2030-09-13T00:47:01",
+ "end_date": "2031-06-24T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.8888888888888888,
+ "start_date": "2031-06-24T02:47:01",
+ "end_date": "2032-05-13T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.1111111111111111,
+ "start_date": "2032-05-13T18:47:01",
+ "end_date": "2032-06-23T08:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.2222222222222222,
+ "start_date": "2032-06-23T08:47:01",
+ "end_date": "2032-09-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.3333333333333333,
+ "start_date": "2032-09-12T12:47:01",
+ "end_date": "2033-01-12T06:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Bhadrika",
+ "planet": "Mercury",
+ "full_years": 5,
+ "years": 5.0,
+ "start_date": "2033-01-12T06:47:01",
+ "end_date": "2038-01-12T12:47:01",
+ "unclipped_start_date": "2033-01-12T06:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.6944444444444444,
+ "start_date": "2033-01-12T06:47:01",
+ "end_date": "2033-09-22T22:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.8333333333333334,
+ "start_date": "2033-09-22T22:17:01",
+ "end_date": "2034-07-24T07:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.9722222222222222,
+ "start_date": "2034-07-24T07:17:01",
+ "end_date": "2035-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.1111111111111112,
+ "start_date": "2035-07-14T09:47:01",
+ "end_date": "2036-08-23T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.1388888888888889,
+ "start_date": "2036-08-23T05:47:01",
+ "end_date": "2036-10-12T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.2777777777777778,
+ "start_date": "2036-10-12T23:17:01",
+ "end_date": "2037-01-22T10:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.4166666666666667,
+ "start_date": "2037-01-22T10:17:01",
+ "end_date": "2037-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.5555555555555556,
+ "start_date": "2037-06-23T14:47:01",
+ "end_date": "2038-01-12T12:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Ulka",
+ "planet": "Saturn",
+ "full_years": 6,
+ "years": 6.0,
+ "start_date": "2038-01-12T12:47:01",
+ "end_date": "2044-01-13T00:47:01",
+ "unclipped_start_date": "2038-01-12T12:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.0,
+ "start_date": "2038-01-12T12:47:01",
+ "end_date": "2039-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.1666666666666667,
+ "start_date": "2039-01-12T18:47:01",
+ "end_date": "2040-03-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.3333333333333333,
+ "start_date": "2040-03-13T21:47:01",
+ "end_date": "2041-07-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.16666666666666666,
+ "start_date": "2041-07-13T21:47:01",
+ "end_date": "2041-09-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.3333333333333333,
+ "start_date": "2041-09-12T18:47:01",
+ "end_date": "2042-01-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.5,
+ "start_date": "2042-01-12T12:47:01",
+ "end_date": "2042-07-14T03:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.6666666666666666,
+ "start_date": "2042-07-14T03:47:01",
+ "end_date": "2043-03-14T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.8333333333333334,
+ "start_date": "2043-03-14T15:47:01",
+ "end_date": "2044-01-13T00:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Siddha",
+ "planet": "Venus",
+ "full_years": 7,
+ "years": 7.0,
+ "start_date": "2044-01-13T00:47:01",
+ "end_date": "2051-01-12T18:47:01",
+ "unclipped_start_date": "2044-01-13T00:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.3611111111111112,
+ "start_date": "2044-01-13T00:47:01",
+ "end_date": "2045-05-24T04:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.5555555555555556,
+ "start_date": "2045-05-24T04:17:01",
+ "end_date": "2046-12-13T08:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.19444444444444445,
+ "start_date": "2046-12-13T08:17:01",
+ "end_date": "2047-02-22T08:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.3888888888888889,
+ "start_date": "2047-02-22T08:47:01",
+ "end_date": "2047-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.5833333333333334,
+ "start_date": "2047-07-14T09:47:01",
+ "end_date": "2048-02-12T11:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.7777777777777778,
+ "start_date": "2048-02-12T11:17:01",
+ "end_date": "2048-11-22T13:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.9722222222222222,
+ "start_date": "2048-11-22T13:17:01",
+ "end_date": "2049-11-12T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.1666666666666667,
+ "start_date": "2049-11-12T15:47:01",
+ "end_date": "2051-01-12T18:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Sankata",
+ "planet": "Rahu",
+ "full_years": 8,
+ "years": 8.0,
+ "start_date": "2051-01-12T18:47:01",
+ "end_date": "2059-01-12T18:47:01",
+ "unclipped_start_date": "2051-01-12T18:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.7777777777777777,
+ "start_date": "2051-01-12T18:47:01",
+ "end_date": "2052-10-23T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.2222222222222222,
+ "start_date": "2052-10-23T02:47:01",
+ "end_date": "2053-01-12T06:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.4444444444444444,
+ "start_date": "2053-01-12T06:47:01",
+ "end_date": "2053-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.6666666666666666,
+ "start_date": "2053-06-23T14:47:01",
+ "end_date": "2054-02-22T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.8888888888888888,
+ "start_date": "2054-02-22T02:47:01",
+ "end_date": "2055-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 1.1111111111111112,
+ "start_date": "2055-01-12T18:47:01",
+ "end_date": "2056-02-22T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.3333333333333333,
+ "start_date": "2056-02-22T14:47:01",
+ "end_date": "2057-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.5555555555555556,
+ "start_date": "2057-06-23T14:47:01",
+ "end_date": "2059-01-12T18:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Mangala",
+ "planet": "Moon",
+ "full_years": 1,
+ "years": 1.0,
+ "start_date": "2059-01-12T18:47:01",
+ "end_date": "2060-01-13T00:47:01",
+ "unclipped_start_date": "2059-01-12T18:47:01",
+ "cycle_number": 2,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.027777777777777776,
+ "start_date": "2059-01-12T18:47:01",
+ "end_date": "2059-01-22T22:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.05555555555555555,
+ "start_date": "2059-01-22T22:17:01",
+ "end_date": "2059-02-12T05:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.08333333333333333,
+ "start_date": "2059-02-12T05:17:01",
+ "end_date": "2059-03-14T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.1111111111111111,
+ "start_date": "2059-03-14T15:47:01",
+ "end_date": "2059-04-24T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.1388888888888889,
+ "start_date": "2059-04-24T05:47:01",
+ "end_date": "2059-06-13T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.16666666666666666,
+ "start_date": "2059-06-13T23:17:01",
+ "end_date": "2059-08-13T20:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.19444444444444445,
+ "start_date": "2059-08-13T20:17:01",
+ "end_date": "2059-10-23T20:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.2222222222222222,
+ "start_date": "2059-10-23T20:47:01",
+ "end_date": "2060-01-13T00:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Pingala",
+ "planet": "Sun",
+ "full_years": 2,
+ "years": 2.0,
+ "start_date": "2060-01-13T00:47:01",
+ "end_date": "2062-01-12T12:47:01",
+ "unclipped_start_date": "2060-01-13T00:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.1111111111111111,
+ "start_date": "2060-01-13T00:47:01",
+ "end_date": "2060-02-22T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.16666666666666666,
+ "start_date": "2060-02-22T14:47:01",
+ "end_date": "2060-04-23T11:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.2222222222222222,
+ "start_date": "2060-04-23T11:47:01",
+ "end_date": "2060-07-13T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.2777777777777778,
+ "start_date": "2060-07-13T15:47:01",
+ "end_date": "2060-10-23T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.3333333333333333,
+ "start_date": "2060-10-23T02:47:01",
+ "end_date": "2061-02-21T20:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.3888888888888889,
+ "start_date": "2061-02-21T20:47:01",
+ "end_date": "2061-07-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.4444444444444444,
+ "start_date": "2061-07-13T21:47:01",
+ "end_date": "2061-12-23T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.05555555555555555,
+ "start_date": "2061-12-23T05:47:01",
+ "end_date": "2062-01-12T12:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Dhanya",
+ "planet": "Jupiter",
+ "full_years": 3,
+ "years": 3.0,
+ "start_date": "2062-01-12T12:47:01",
+ "end_date": "2065-01-12T06:47:01",
+ "unclipped_start_date": "2062-01-12T12:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.25,
+ "start_date": "2062-01-12T12:47:01",
+ "end_date": "2062-04-13T20:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.3333333333333333,
+ "start_date": "2062-04-13T20:17:01",
+ "end_date": "2062-08-13T14:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.4166666666666667,
+ "start_date": "2062-08-13T14:17:01",
+ "end_date": "2063-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.5,
+ "start_date": "2063-01-12T18:47:01",
+ "end_date": "2063-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.5833333333333334,
+ "start_date": "2063-07-14T09:47:01",
+ "end_date": "2064-02-12T11:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.6666666666666666,
+ "start_date": "2064-02-12T11:17:01",
+ "end_date": "2064-10-12T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.08333333333333333,
+ "start_date": "2064-10-12T23:17:01",
+ "end_date": "2064-11-12T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.16666666666666666,
+ "start_date": "2064-11-12T09:47:01",
+ "end_date": "2065-01-12T06:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Bhramari",
+ "planet": "Mars",
+ "full_years": 4,
+ "years": 4.0,
+ "start_date": "2065-01-12T06:47:01",
+ "end_date": "2069-01-12T06:47:01",
+ "unclipped_start_date": "2065-01-12T06:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.4444444444444444,
+ "start_date": "2065-01-12T06:47:01",
+ "end_date": "2065-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.5555555555555556,
+ "start_date": "2065-06-23T14:47:01",
+ "end_date": "2066-01-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.6666666666666666,
+ "start_date": "2066-01-12T12:47:01",
+ "end_date": "2066-09-13T00:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.7777777777777778,
+ "start_date": "2066-09-13T00:47:01",
+ "end_date": "2067-06-24T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.8888888888888888,
+ "start_date": "2067-06-24T02:47:01",
+ "end_date": "2068-05-13T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.1111111111111111,
+ "start_date": "2068-05-13T18:47:01",
+ "end_date": "2068-06-23T08:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.2222222222222222,
+ "start_date": "2068-06-23T08:47:01",
+ "end_date": "2068-09-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.3333333333333333,
+ "start_date": "2068-09-12T12:47:01",
+ "end_date": "2069-01-12T06:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Bhadrika",
+ "planet": "Mercury",
+ "full_years": 5,
+ "years": 5.0,
+ "start_date": "2069-01-12T06:47:01",
+ "end_date": "2074-01-12T12:47:01",
+ "unclipped_start_date": "2069-01-12T06:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.6944444444444444,
+ "start_date": "2069-01-12T06:47:01",
+ "end_date": "2069-09-22T22:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.8333333333333334,
+ "start_date": "2069-09-22T22:17:01",
+ "end_date": "2070-07-24T07:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.9722222222222222,
+ "start_date": "2070-07-24T07:17:01",
+ "end_date": "2071-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.1111111111111112,
+ "start_date": "2071-07-14T09:47:01",
+ "end_date": "2072-08-23T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.1388888888888889,
+ "start_date": "2072-08-23T05:47:01",
+ "end_date": "2072-10-12T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.2777777777777778,
+ "start_date": "2072-10-12T23:17:01",
+ "end_date": "2073-01-22T10:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.4166666666666667,
+ "start_date": "2073-01-22T10:17:01",
+ "end_date": "2073-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.5555555555555556,
+ "start_date": "2073-06-23T14:47:01",
+ "end_date": "2074-01-12T12:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Ulka",
+ "planet": "Saturn",
+ "full_years": 6,
+ "years": 6.0,
+ "start_date": "2074-01-12T12:47:01",
+ "end_date": "2080-01-13T00:47:01",
+ "unclipped_start_date": "2074-01-12T12:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.0,
+ "start_date": "2074-01-12T12:47:01",
+ "end_date": "2075-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.1666666666666667,
+ "start_date": "2075-01-12T18:47:01",
+ "end_date": "2076-03-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.3333333333333333,
+ "start_date": "2076-03-13T21:47:01",
+ "end_date": "2077-07-13T21:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.16666666666666666,
+ "start_date": "2077-07-13T21:47:01",
+ "end_date": "2077-09-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.3333333333333333,
+ "start_date": "2077-09-12T18:47:01",
+ "end_date": "2078-01-12T12:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.5,
+ "start_date": "2078-01-12T12:47:01",
+ "end_date": "2078-07-14T03:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.6666666666666666,
+ "start_date": "2078-07-14T03:47:01",
+ "end_date": "2079-03-14T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.8333333333333334,
+ "start_date": "2079-03-14T15:47:01",
+ "end_date": "2080-01-13T00:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Siddha",
+ "planet": "Venus",
+ "full_years": 7,
+ "years": 7.0,
+ "start_date": "2080-01-13T00:47:01",
+ "end_date": "2087-01-12T18:47:01",
+ "unclipped_start_date": "2080-01-13T00:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.3611111111111112,
+ "start_date": "2080-01-13T00:47:01",
+ "end_date": "2081-05-24T04:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.5555555555555556,
+ "start_date": "2081-05-24T04:17:01",
+ "end_date": "2082-12-13T08:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.19444444444444445,
+ "start_date": "2082-12-13T08:17:01",
+ "end_date": "2083-02-22T08:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.3888888888888889,
+ "start_date": "2083-02-22T08:47:01",
+ "end_date": "2083-07-14T09:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.5833333333333334,
+ "start_date": "2083-07-14T09:47:01",
+ "end_date": "2084-02-12T11:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.7777777777777778,
+ "start_date": "2084-02-12T11:17:01",
+ "end_date": "2084-11-22T13:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.9722222222222222,
+ "start_date": "2084-11-22T13:17:01",
+ "end_date": "2085-11-12T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.1666666666666667,
+ "start_date": "2085-11-12T15:47:01",
+ "end_date": "2087-01-12T18:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Sankata",
+ "planet": "Rahu",
+ "full_years": 8,
+ "years": 8.0,
+ "start_date": "2087-01-12T18:47:01",
+ "end_date": "2095-01-12T18:47:01",
+ "unclipped_start_date": "2087-01-12T18:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 1.7777777777777777,
+ "start_date": "2087-01-12T18:47:01",
+ "end_date": "2088-10-23T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.2222222222222222,
+ "start_date": "2088-10-23T02:47:01",
+ "end_date": "2089-01-12T06:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.4444444444444444,
+ "start_date": "2089-01-12T06:47:01",
+ "end_date": "2089-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.6666666666666666,
+ "start_date": "2089-06-23T14:47:01",
+ "end_date": "2090-02-22T02:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.8888888888888888,
+ "start_date": "2090-02-22T02:47:01",
+ "end_date": "2091-01-12T18:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 1.1111111111111112,
+ "start_date": "2091-01-12T18:47:01",
+ "end_date": "2092-02-22T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 1.3333333333333333,
+ "start_date": "2092-02-22T14:47:01",
+ "end_date": "2093-06-23T14:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 1.5555555555555556,
+ "start_date": "2093-06-23T14:47:01",
+ "end_date": "2095-01-12T18:47:01"
+ }
+ ]
+ },
+ {
+ "yogini": "Mangala",
+ "planet": "Moon",
+ "full_years": 1,
+ "years": 1.0,
+ "start_date": "2095-01-12T18:47:01",
+ "end_date": "2096-01-13T00:47:01",
+ "unclipped_start_date": "2095-01-12T18:47:01",
+ "cycle_number": 3,
+ "antardasha": [
+ {
+ "level": "antardasha",
+ "planet": "Moon",
+ "yogini": "Mangala",
+ "lord": "Moon",
+ "years": 0.027777777777777776,
+ "start_date": "2095-01-12T18:47:01",
+ "end_date": "2095-01-22T22:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Sun",
+ "yogini": "Pingala",
+ "lord": "Sun",
+ "years": 0.05555555555555555,
+ "start_date": "2095-01-22T22:17:01",
+ "end_date": "2095-02-12T05:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Jupiter",
+ "yogini": "Dhanya",
+ "lord": "Jupiter",
+ "years": 0.08333333333333333,
+ "start_date": "2095-02-12T05:17:01",
+ "end_date": "2095-03-14T15:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mars",
+ "yogini": "Bhramari",
+ "lord": "Mars",
+ "years": 0.1111111111111111,
+ "start_date": "2095-03-14T15:47:01",
+ "end_date": "2095-04-24T05:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Mercury",
+ "yogini": "Bhadrika",
+ "lord": "Mercury",
+ "years": 0.1388888888888889,
+ "start_date": "2095-04-24T05:47:01",
+ "end_date": "2095-06-13T23:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Saturn",
+ "yogini": "Ulka",
+ "lord": "Saturn",
+ "years": 0.16666666666666666,
+ "start_date": "2095-06-13T23:17:01",
+ "end_date": "2095-08-13T20:17:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Venus",
+ "yogini": "Siddha",
+ "lord": "Venus",
+ "years": 0.19444444444444445,
+ "start_date": "2095-08-13T20:17:01",
+ "end_date": "2095-10-23T20:47:01"
+ },
+ {
+ "level": "antardasha",
+ "planet": "Rahu",
+ "yogini": "Sankata",
+ "lord": "Rahu",
+ "years": 0.2222222222222222,
+ "start_date": "2095-10-23T20:47:01",
+ "end_date": "2096-01-13T00:47:01"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts
index d35b63ff..31b2e3df 100644
--- a/frontend/tests/personal-report-view.test.ts
+++ b/frontend/tests/personal-report-view.test.ts
@@ -63,9 +63,13 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
assert.equal(readyV2.phase, "markdown-ready");
assert.deepEqual(classifyReportEnvelope(200, { report: view("generating") }), { phase: "generating" });
+ // 原值:失败态只有 phase 和 failureCode(以及摘要、附录错误码)
+ // 新值:失败态带上 reportType
+ // 原因:合盘地址要能把个人报告换回 /reports,失败的报告也要认出类型
assert.deepEqual(classifyReportEnvelope(200, { report: view("failed", "model_unavailable") }), {
phase: "failed",
failureCode: "model_unavailable",
+ reportType: "personal_full",
});
assert.deepEqual(
classifyReportEnvelope(200, {
@@ -75,6 +79,7 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
phase: "failed",
failureCode: "report_schema_invalid",
failureSummary: "4 个主题中 4 个写作失败:输出被截断",
+ reportType: "personal_full",
},
);
assert.deepEqual(
@@ -90,9 +95,14 @@ test("GET envelope classification: 401/404/ready/generating/failed/invalid (real
failureCode: "appendix_persist_failed",
failureSummary: "报告正文未能保存,请稍后重试",
appendixLastErrorCode: "appendix_persist_failed",
+ reportType: "personal_full",
},
);
- assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), { phase: "failed", failureCode: null });
+ assert.deepEqual(classifyReportEnvelope(200, { report: view("failed") }), {
+ phase: "failed",
+ failureCode: null,
+ reportType: "personal_full",
+ });
// Server-side error envelopes carry a stable code at the top level.
assert.deepEqual(classifyReportEnvelope(503, { error: "x", code: "report_persistence_unavailable" }), {
diff --git a/frontend/tests/synastry-display-order.test.ts b/frontend/tests/synastry-display-order.test.ts
new file mode 100644
index 00000000..3a246691
--- /dev/null
+++ b/frontend/tests/synastry-display-order.test.ts
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFileSync } from "node:fs";
+
+import { synastryDisplaySubjectIds } from "../src/lib/synastry-display-order.ts";
+import { synastryFollowupTitle } from "../src/lib/synastry-report-session.ts";
+
+test("picker order outranks the sorted pair key for the title and the chat", () => {
+ const ids = synastryDisplaySubjectIds("person-yi", "self", { first: "self", second: "person-yi" });
+ assert.deepEqual(ids, ["self", "person-yi"]);
+ const labels: Record = { self: "甲", "person-yi": "乙" };
+ const names = { a: labels[ids[0]] ?? "", b: labels[ids[1]] ?? "" };
+ assert.equal(`${names.a} × ${names.b} · 婚恋合盘`, "甲 × 乙 · 婚恋合盘");
+ assert.equal(synastryFollowupTitle(names), "甲 × 乙 · 合盘追问");
+ assert.deepEqual(synastryDisplaySubjectIds("person-yi", "self", null), ["person-yi", "self"]);
+ const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
+ const run = readFileSync(new URL("../src/lib/synastry-full-run.ts", import.meta.url), "utf8");
+ assert.match(wait, /\$\{a\} × \$\{b\}/);
+ assert.match(run, /synastryDisplaySubjectIds\(/);
+ assert.match(run, /cardSummary: `\$\{names\.a\} × \$\{names\.b\} · 婚恋合盘`/);
+});
diff --git a/frontend/tests/synastry-full-create-route.test.ts b/frontend/tests/synastry-full-create-route.test.ts
index d29bc1ef..07770c97 100644
--- a/frontend/tests/synastry-full-create-route.test.ts
+++ b/frontend/tests/synastry-full-create-route.test.ts
@@ -31,7 +31,7 @@ function runRoute(script: string): { status: number; body: Record; charged: boolean };
}
-function script(input: { product: boolean; same: boolean; missingOther: boolean; reused: boolean }): string {
+function script(input: { product: boolean; same: boolean; missingOther: boolean; existing: "none" | "generating" }): string {
return String.raw`
import { mock } from "node:test";
import { pathToFileURL } from "node:url";
@@ -60,7 +60,7 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
const q = {
select() { return q; },
eq(column, value) { filters.push([column, value]); return q; },
- in() { return q; },
+ in(column, values) { filters.push([column, values]); return q; },
gte() { return q; },
is() { return q; },
or() { return q; },
@@ -72,7 +72,11 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
return Promise.resolve({ data: ${input.missingOther ? "null" : "{ ...selfRow, id: otherId, user_id: 'user-1', role: 'other' }"}, error: null });
}
if (table === "personal_reports") {
- return Promise.resolve({ data: ${input.reused ? "{ id: 'ready-1', request_id: 'req-1', status: 'ready', created_at: '2026-10-19T00:00:00Z' }" : "null"}, error: null });
+ const wantsGenerating = filters.some(([column, value]) => column === "status" && (value === "generating" || (Array.isArray(value) && value.includes("generating"))));
+ if (${input.existing === "generating" ? "true" : "false"} && wantsGenerating) {
+ return Promise.resolve({ data: { id: "gen-1", request_id: "req-1", status: "generating", created_at: "2026-10-19T00:00:00Z" }, error: null });
+ }
+ return Promise.resolve({ data: null, error: null });
}
return Promise.resolve({ data: null, error: null });
},
@@ -98,28 +102,31 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean;
}
test("the same person is rejected before billing", () => {
- const result = runRoute(script({ product: true, same: true, missingOther: false, reused: false }));
+ const result = runRoute(script({ product: true, same: true, missingOther: false, existing: "none" }));
assert.equal(result.status, 400);
assert.equal(result.body.code, "same_subject");
assert.equal(result.charged, false);
});
test("someone else's id is rejected before billing", () => {
- const result = runRoute(script({ product: true, same: false, missingOther: true, reused: false }));
+ const result = runRoute(script({ product: true, same: false, missingOther: true, existing: "none" }));
assert.equal(result.status, 409);
assert.equal(result.charged, false);
assert.match(JSON.stringify(result.body), /不会扣点/);
});
-test("an unchanged pair is reused without a new reservation", () => {
- const result = runRoute(script({ product: true, same: false, missingOther: false, reused: true }));
- assert.equal(result.status, 200);
+test("a generating pair is reused without a new reservation", () => {
+ // 原值:已有 ready 报告时 status 200、reused、不扣点
+ // 新值:只有 generating 复用并返回 202;ready 不再走这条
+ // 原因:K6 允许同一对人重新生成(BUG-1408)
+ const result = runRoute(script({ product: true, same: false, missingOther: false, existing: "generating" }));
+ assert.equal(result.status, 202);
assert.equal(result.body.reused, true);
assert.equal(result.charged, false);
});
test("a closed product switch rejects the report before billing", () => {
- const result = runRoute(script({ product: false, same: false, missingOther: true, reused: false }));
+ const result = runRoute(script({ product: false, same: false, missingOther: true, existing: "none" }));
assert.equal(result.status, 503);
assert.equal(result.body.code, "compatibility_product_disabled");
assert.equal(result.charged, false);
diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts
index c67137f0..fd7f098a 100644
--- a/frontend/tests/synastry-full-report.test.ts
+++ b/frontend/tests/synastry-full-report.test.ts
@@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
+import { SynastryGoNotes, SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx";
-import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
-import { writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
+import { stripLeadingSectionTitle, writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
import { EVIDENCE_LABELS, PRIVATE_KEY_NAMES, sectionEvidence, SYNASTRY_LENGTH, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts";
import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
import { buildTimelineSegments } from "../src/lib/synastry-full-timeline.ts";
@@ -98,6 +98,47 @@ test("a failed gate rewrites only the failed section once", async () => {
assert.match(result.snapshotParts.technical[5]?.lines.at(-1) ?? "", /外部资料与真实案例核对:未进行/);
});
+test("a section that opens with its title is saved without the duplicate", async () => {
+ const calls: SynastrySectionId[] = [];
+ let checks = 0;
+ const clean = prose();
+ const result = await writeSynastryLanguage({
+ language: "zh",
+ names: { a: "甲", b: "乙" },
+ dossier: {},
+ segments: [
+ { id: "s1", start: "2026-01", end: "2027-01", basis: "甲:木星大运 2026-01 至 2027-01。乙:金星大运 2026-01 至 2027-01。" },
+ { id: "s2", start: "2027-01", end: "2028-01", basis: "甲:木星大运 2027-01 至 2028-01。乙:金星大运 2027-01 至 2028-01。" },
+ { id: "s3", start: "2028-01", end: "2029-01", basis: "甲:木星大运 2028-01 至 2029-01。乙:金星大运 2028-01 至 2029-01。" },
+ ],
+ signal: new AbortController().signal,
+ timeoutMs: 1000,
+ write: async ({ sectionId }) => {
+ calls.push(sectionId);
+ if (sectionId === "timeline") {
+ return { body: { segments: [{ id: "s1", phase: "靠近", text: "这段时间先把日常节奏说清楚。" }, { id: "s2", phase: "磨合", text: "这段时间把分歧说完再放下。" }, { id: "s3", phase: "协作", text: "这段时间一起把安排写下来。" }] }, inputTokens: 1, outputTokens: 1 };
+ }
+ if (sectionId === "practices") {
+ return { body: { items: [{ text: "每周留一次不被打断的谈话。", evidence: "证据甲" }] }, inputTokens: 1, outputTokens: 1 };
+ }
+ const titled = sectionId === "communication" && calls.filter((id) => id === "communication").length === 1;
+ return { body: titled ? { ...clean, text: `沟通与理解 沟通与理解 ${clean.text}` } : clean, inputTokens: 1, outputTokens: 1 };
+ },
+ gate: async () => {
+ checks += 1;
+ return { ok: true, failures: [] };
+ },
+ });
+ assert.equal(checks, 2);
+ assert.equal(calls.filter((id) => id === "communication").length, 2);
+ const saved = result.snapshotParts.sections.find((section) => section.id === "communication");
+ assert.equal(saved?.text, clean.text);
+ assert.equal(saved?.text.startsWith("沟通与理解"), false);
+ assert.equal(stripLeadingSectionTitle("沟通与理解 沟通与理解 沟通的天气很顺。", "沟通与理解"), "沟通的天气很顺。");
+ assert.equal(stripLeadingSectionTitle("Communication and Understanding The two of you talk.", "Communication and Understanding"), "The two of you talk.");
+ assert.equal(stripLeadingSectionTitle("沟通与理解力还在。", "沟通与理解"), "沟通与理解力还在。");
+});
+
test("technical appendix has six program blocks and the fixed closing line", () => {
const blocks = buildTechnicalBasis({
dossier: { execution_receipt: { pair_execution_tasks: [{ task: "Kuja Dosha", status: "not_applicable" }] } },
@@ -131,6 +172,22 @@ test("the synastry page is a static shell and reads the flag in the browser", ()
assert.match(picker, /开始合盘/);
assert.match(picker, /+ 新增星盘/);
assert.match(picker, /生成一份完整合盘报告,用掉/);
+ const again = renderToStaticMarkup(createElement(SynastryGoNotes, {
+ creditCost: 19,
+ pricingNote: "",
+ readyCount: 2,
+ generating: false,
+ }));
+ assert.match(again, /这两人已经合过 2 次,再生成会另扣 19 点/);
+ assert.match(again, /生成一份完整合盘报告,用掉 19 点/);
+ const quiet = renderToStaticMarkup(createElement(SynastryGoNotes, {
+ creditCost: 19,
+ pricingNote: "",
+ readyCount: 2,
+ generating: true,
+ }));
+ assert.doesNotMatch(quiet, /再生成会另扣/);
+ assert.match(picker, /pair: "1"/);
assert.doesNotMatch(picker, /男方|女方|合不合|会不会在一起/);
const dialog = readFileSync(new URL("../src/components/synastry/synastry-add-dialog.tsx", import.meta.url), "utf8");
const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
@@ -555,3 +612,22 @@ test("a public pair dossier fills every section without birth keys", { timeout:
}
rmSync(output, { force: true });
});
+
+test("the synastry report scrolls in a full-width outer container", () => {
+ const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
+ const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
+ const scroll = css.slice(css.indexOf(".synastry-report-scroll {"), css.indexOf(".synastry-report {"));
+ const column = css.slice(css.indexOf(".synastry-report {"), css.indexOf(".synastry-report-back"));
+ assert.match(scroll, /width:\s*100%/);
+ assert.match(scroll, /height:\s*100%/);
+ assert.match(scroll, /overflow-y:\s*auto/);
+ assert.match(scroll, /overflow-x:\s*hidden/);
+ assert.match(column, /width:\s*min\(720px, 100%\)/);
+ assert.match(column, /margin-inline:\s*auto/);
+ assert.doesNotMatch(column, /height:\s*100%/);
+ assert.doesNotMatch(column, /overflow:\s*auto/);
+ assert.match(page, /\s*
css.indexOf("@media print"));
+ assert.match(css.slice(printAt, printAt + 180), /overflow: visible !important/);
+});
diff --git a/frontend/tests/synastry-report-surface.test.ts b/frontend/tests/synastry-report-surface.test.ts
new file mode 100644
index 00000000..12425b94
--- /dev/null
+++ b/frontend/tests/synastry-report-surface.test.ts
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFileSync } from "node:fs";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { PathnameContext } from "next/dist/shared/lib/hooks-client-context.shared-runtime";
+
+import { AppSidebar } from "../src/components/app-sidebar.tsx";
+import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
+import { reportSurfaceRedirect } from "../src/lib/synastry-report-href.ts";
+
+function renderSidebar(pathname: string) {
+ return renderToStaticMarkup(
+ createElement(
+ PathnameContext.Provider,
+ { value: pathname },
+ createElement(
+ SidebarProvider,
+ null,
+ createElement(AppSidebar, {
+ sessions: [],
+ activeSessionId: null,
+ compatibilityEnabled: true,
+ account: {
+ name: "示例甲",
+ email: "fictional@example.invalid",
+ credits: 12,
+ initial: "示",
+ avatar: null,
+ },
+ }),
+ ),
+ ),
+ );
+}
+
+function navAnchor(markup: string, label: string) {
+ const anchors = markup.match(/]*\breport-nav-button\b[^>]*>[\s\S]*?<\/a>/g) ?? [];
+ const hit = anchors.find((anchor) => anchor.includes(`>${label}<`));
+ assert.ok(hit, `sidebar nav link missing: ${label}`);
+ return hit;
+}
+
+const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
+
+test("an old synastry url moves to the synastry route and keeps the language", () => {
+ assert.equal(
+ reportSurfaceRedirect({ surface: "personal", reportType: "synastry_full", reportId: "abc", language: "en" }),
+ "/synastry/abc?lang=en",
+ );
+ assert.equal(
+ reportSurfaceRedirect({ surface: "synastry", reportType: "personal_full", reportId: "abc", language: "zh" }),
+ "/reports/abc",
+ );
+ assert.equal(
+ reportSurfaceRedirect({ surface: "synastry", reportType: "synastry_full", reportId: "abc", language: "zh" }),
+ null,
+ );
+ assert.equal(
+ reportSurfaceRedirect({ surface: "personal", reportType: "personal_full", reportId: "abc", language: "en" }),
+ null,
+ );
+});
+
+test("the synastry route reuses the reader and the sidebar stays on 婚恋合盘", () => {
+ const page = read("../src/app/(app)/synastry/[reportId]/page.tsx");
+ const reader = read("../src/components/personal-report/personal-report-page.tsx");
+ const sidebar = read("../src/components/app-sidebar.tsx");
+ const picker = read("../src/components/synastry/synastry-picker.tsx");
+ const center = read("../src/components/personal-report/personal-report-center.tsx");
+ assert.match(page, /title: "婚恋合盘 · Jyotisha"/);
+ assert.match(page, /readReportLanguage\(lang\)/);
+ assert.match(page, /surface="synastry"/);
+ assert.match(reader, /window\.location\.replace\(redirectTo\)/);
+ assert.match(sidebar, /\{ href: "\/synastry", label: "婚恋合盘"/);
+ assert.match(sidebar, /pathname === href \|\| pathname\.startsWith\(`\$\{href\}\/`\)/);
+ // 原值: assert.equal("/synastry/abc".startsWith("/synastry/"), true)
+ // 新值: pathname 为 /synastry/ 时渲染侧栏,当前项(data-active="true")是「婚恋合盘」,不是「我的报告」
+ // 原因: 原断言永远成立,没有渲染侧栏(issue #85 G3)
+ const markup = renderSidebar("/synastry/abc");
+ const synastry = navAnchor(markup, "婚恋合盘");
+ const reports = navAnchor(markup, "我的报告");
+ assert.match(synastry, /\bdata-active="true"/);
+ assert.doesNotMatch(reports, /\bdata-active="true"/);
+ assert.doesNotMatch(reports, /aria-current="page"/);
+ assert.match(picker, /window\.location\.assign\(`\/synastry\/\$\{payload\.report\.id\}`\)/);
+ assert.match(picker, /href=\{`\/synastry\/\$\{report\.id\}`\}/);
+ assert.doesNotMatch(picker, /\/reports\/\$\{/);
+ assert.match(center, /reportType === "synastry_full" \? `\/synastry\/\$\{id\}` : `\/reports\/\$\{id\}`/);
+});
diff --git a/scripts/dasha_registry.py b/scripts/dasha_registry.py
index 87aaa1e4..e36aac5e 100644
--- a/scripts/dasha_registry.py
+++ b/scripts/dasha_registry.py
@@ -12,6 +12,7 @@ This module must not import `jyotish_api_server` or PyJHora.
from __future__ import annotations
+import copy
from datetime import datetime
from typing import Any
@@ -287,8 +288,22 @@ def compute_dasha(
body = body or {}
alt, conditional, planet_lons = build_reading_birth_info(birth_dt, planets or {}, ascendant, body)
if key == "yogini":
- payload = _load("yogini_dasha").calculate_yogini_dasha({**alt, "moon_longitude": conditional["moon_longitude"]})
- return {"periods": _major_rows(payload)}
+ module = _load("yogini_dasha")
+ payload = module.calculate_yogini_dasha({**alt, "moon_longitude": conditional["moon_longitude"]})
+ year_days = 365.25
+ if isinstance(payload, dict) and payload.get("dasha_year_days") is not None:
+ year_days = float(payload["dasha_year_days"])
+ periods = []
+ for row in _major_rows(payload):
+ copied = copy.deepcopy(row)
+ if isinstance(copied, dict):
+ copied["antardasha"] = module.yogini_antardashas(
+ copied,
+ birth_dt=birth_dt,
+ year_days=year_days,
+ )
+ periods.append(copied)
+ return {"periods": periods}
if key == "ashtottari":
payload = _load("ashtottari_dasha").calculate_ashtottari_dasha({
**alt,
diff --git a/scripts/goel_jaimini_strength.py b/scripts/goel_jaimini_strength.py
new file mode 100644
index 00000000..d13b3f78
--- /dev/null
+++ b/scripts/goel_jaimini_strength.py
@@ -0,0 +1,137 @@
+"""Goel's full Jaimini strength and special-point profile, not Bhava Bala.
+
+Source: Predicting through Jaimini Astrology, printed pp10-17 (PDF pp14-21).
+The published p15 example is the numeric regression. Dignity degree ranges
+reuse the explicitly named Raman manual profile; no universal lineage claim.
+"""
+import math
+from scripts.trikona_raman import CLASSICAL, DIGNITY_PROFILE, _dignity, _validate_positions
+from scripts.narayana_dasha import _rasi_aspects
+from scripts.sthira_brahma_goel_short_method import SIGNS, SIGN_LORDS
+
+PROFILE = 'goel_full_jaimini_strength_ch1_v1'
+SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'
+KARAKA_POINTS = (60, 45, 30, 22.5, 15, 7.5, 3.75)
+MALEFICS = ('Sun', 'Mars', 'Saturn', 'Rahu', 'Ketu')
+
+
+def calculate_goel_strength(planet_longitudes, *, node_occupancy_policy='include_nodes_text'):
+ if node_occupancy_policy not in {'include_nodes_text', 'exclude_nodes_worked_example'}:
+ raise ValueError('explicit Goel node occupancy policy required')
+ _validate_positions(planet_longitudes)
+ signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
+ degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
+ ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
+ if any(math.isclose(degrees[a], degrees[b], rel_tol=0, abs_tol=1e-10)
+ for i, a in enumerate(ranked) for b in ranked[i + 1:]):
+ return dict(status='blocked', reason='equal_degree_seven_karaka_rank_requires_finer_positions', profile_id=PROFILE)
+ ak = ranked[0]
+ karakas = dict(zip(ranked, KARAKA_POINTS))
+
+ def contacts(sign, candidates):
+ return [p for p in candidates if signs[p] == sign or _rasi_aspects(signs[p], sign)]
+
+ planets = {}
+ for p in CLASSICAL:
+ sign = signs[p]
+ dignity, moola = _dignity(p, planet_longitudes[p])
+ kendra = (60, 30, 15)[(sign - signs[ak]) % 3]
+ before = [q for q, s in signs.items() if s == (sign - 1) % 12]
+ after = [q for q, s in signs.items() if s == (sign + 1) % 12]
+ kartari = 60 if sign % 2 == 0 and before and after else 0
+ malefic_contacts = contacts(sign, [q for q in MALEFICS if q != p])
+ adhipati = 60 if p != 'Moon' and malefic_contacts else 0
+ planets[p] = dict(sign_idx=sign, degree_in_sign=degrees[p],
+ dignity=dignity, mooltrikona=moola, karaka=karakas[p], kendriya=kendra,
+ kartari=kartari, adhipati=adhipati, malefic_contacts=malefic_contacts,
+ kartari_before=before, kartari_after=after,
+ total=moola + karakas[p] + kendra + kartari + adhipati)
+ rows = []
+ for sign in range(12):
+ lord = SIGN_LORDS[sign]
+ occupants = [p for p, s in signs.items() if s == sign]
+ permanent = (15, 30, 60)[sign % 3]
+ karaka = sum(karakas.get(p, 0) for p in occupants)
+ counted = occupants if node_occupancy_policy == 'include_nodes_text' else [p for p in occupants if p in CLASSICAL]
+ sthana = 45 + 15 * len(counted) if counted else 0
+ # Three roles, not distinct planets: Goel p103 explicitly explains
+ # double credit when Mercury/Jupiter is also the sign lord.
+ aspect_roles = [role for role, p in [('Jupiter', 'Jupiter'), ('Mercury', 'Mercury'), ('lord', lord)]
+ if contacts(sign, [p])]
+ aspect = 60 * len(aspect_roles)
+ rows.append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
+ permanent=permanent, karaka=karaka, sthana=sthana, occupants=occupants, counted_occupants=counted,
+ aspect=aspect, aspect_roles=aspect_roles, lord_strength=planets[lord]['total'],
+ total=permanent + karaka + sthana + aspect + planets[lord]['total']))
+ return dict(status='executed', confidence_status='parameter_sensitive', profile_id=PROFILE,
+ source=SOURCE, source_locator='PDF pp14-21; printed pp10-17; lines461-736',
+ dignity_profile=DIGNITY_PROFILE, malefic_profile='Sun_Mars_Saturn_Rahu_Ketu_excluding_self',
+ node_occupancy_policy=node_occupancy_policy,
+ aspect_profile='jaimini_rasi_drishti', aspect_role_policy='distinct_roles',
+ atmakaraka=ak, planets=planets, rashi_strengths=rows,
+ claim_boundary='Named Goel full method with declared dignity ranges; not PL9 or all-lineage parity.')
+
+
+def build_goel_chart_evidence(planet_longitudes, ascendant_longitude):
+ if (type(ascendant_longitude) not in (int, float)
+ or not math.isfinite(ascendant_longitude)
+ or not 0 <= ascendant_longitude < 360):
+ raise ValueError('ascendant longitude must be a finite number in [0,360)')
+ strength = calculate_goel_strength(planet_longitudes)
+ result = dict(strength=strength, special_points={})
+ if strength['status'] != 'executed':
+ result['special_points'] = {
+ name: dict(status='blocked', reason=strength['reason'], profile_id=PROFILE)
+ for name in ('Brahma', 'Maheshwara', 'Rudra')
+ }
+ return result
+ strength['worked_example_node_exclusion'] = calculate_goel_strength(
+ planet_longitudes, node_occupancy_policy='exclude_nodes_worked_example')
+ strength['source_discrepancy'] = (
+ 'Printed p14 includes nodes in Sthana; printed p15 worked totals omit them. '
+ 'Both numeric alternatives are preserved; the explicit text is the active profile.')
+ lagna = int(float(ascendant_longitude) % 360 // 30)
+ seventh = (lagna + 6) % 12
+ planets = strength['planets']
+ rashis = strength['rashi_strengths']
+ ak_sign = planets[strength['atmakaraka']]['sign_idx']
+
+ def choose(candidates, rule):
+ candidates = list(dict.fromkeys(candidates))
+ scores = {p: planets[p]['total'] for p in candidates}
+ winners = [p for p in candidates if scores[p] == max(scores.values())]
+ return dict(status='executed' if len(winners) == 1 else 'blocked',
+ value=winners[0] if len(winners) == 1 else None,
+ candidates=scores, rule=rule,
+ reason=None if len(winners) == 1 else 'equal_Goel_strength_no_unique_selector',
+ profile_id=PROFILE, confidence_status='parameter_sensitive',
+ source=SOURCE, source_locator='PDF pp20-21; printed pp16-17; lines701-736')
+
+ eighth_ak = SIGN_LORDS[(ak_sign + 7) % 12]
+ if planets[eighth_ak]['sign_idx'] == (ak_sign + 7) % 12:
+ brahma = choose([eighth_ak], 'eighth_lord_from_AK_occupies_eighth_from_AK')
+ elif rashis[lagna]['total'] == rashis[seventh]['total']:
+ brahma = dict(status='blocked', reason='equal_Lagna_seventh_Goel_strength', profile_id=PROFILE)
+ else:
+ anchor = lagna if rashis[lagna]['total'] > rashis[seventh]['total'] else seventh
+ brahma = choose([SIGN_LORDS[(anchor + h - 1) % 12] for h in (6, 8, 12)
+ if SIGN_LORDS[(anchor + h - 1) % 12] != 'Saturn'],
+ 'strongest_6_8_12_lord_from_stronger_Lagna_seventh_excluding_Saturn')
+ brahma['anchor_sign_idx'] = anchor
+ maheswara = choose([eighth_ak] + ([SIGN_LORDS[(ak_sign + 11) % 12]]
+ if planets[eighth_ak]['dignity'] == 'exalted' else []),
+ 'eighth_lord_from_AK_compare_twelfth_if_exalted')
+ rudra_candidates = list(dict.fromkeys([SIGN_LORDS[(lagna + 7) % 12], SIGN_LORDS[(lagna + 1) % 12]]))
+ afflicted = [p for p in rudra_candidates if planets[p]['malefic_contacts']]
+ rudra = choose(afflicted if len(afflicted) == 1 else rudra_candidates,
+ 'stronger_8th_2nd_lord_unless_only_weaker_has_malefic_contact')
+ result['special_points'] = dict(Brahma=brahma, Maheshwara=maheswara, Rudra=rudra)
+ result['lagna_seventh_strength'] = dict(lagna=rashis[lagna]['total'],
+ seventh=rashis[seventh]['total'], profile_id=PROFILE, source=SOURCE,
+ comparison='lagna_vs_seventh')
+ # Frozen-export compatibility only. PVR Niryaana is second/eighth Ayur
+ # comparison; callers must not route these Goel totals into that selector.
+ result['niryaana_shoola_strength'] = dict(result['lagna_seventh_strength'],
+ deprecated=True, replacement='lagna_seventh_strength',
+ not_for='pvr_niryaana_shoola_ayur_rasi_strength_v1')
+ return result
diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py
index d3332577..ff8e4de8 100644
--- a/scripts/jyotish_engine.py
+++ b/scripts/jyotish_engine.py
@@ -14339,6 +14339,39 @@ def cmd_aspects(args):
# ============================================================================
# 17. Jaimini系统(v3.7新增)
# ============================================================================
+def _goel_jaimini_evidence(planet_longitudes, ascendant_longitude):
+ """Shared CLI/full-reading view of the source-bounded Goel producer.
+
+ Failures stay inside this helper. A Goel error must not escape into the
+ full-reading ``except Exception`` that drops the whole jaimini module.
+ """
+ try:
+ from scripts.goel_jaimini_strength import build_goel_chart_evidence
+ goel = build_goel_chart_evidence(planet_longitudes, ascendant_longitude)
+ fields = {
+ 'goel_full_strength': goel['strength'],
+ 'goel_special_points': goel['special_points'],
+ }
+ for key in ('lagna_seventh_strength', 'niryaana_shoola_strength'):
+ if key in goel:
+ fields[key] = goel[key]
+ return fields
+ except (ImportError, KeyError, TypeError, ValueError) as exc:
+ logger.warning(
+ "Goel Jaimini strength failed: %s: %s",
+ exc.__class__.__name__,
+ exc,
+ )
+ reason = f'{exc.__class__.__name__}: {exc}'
+ return {
+ 'goel_full_strength': {'status': 'blocked', 'reason': reason},
+ 'goel_special_points': {
+ name: {'status': 'blocked', 'reason': reason}
+ for name in ('Brahma', 'Maheshwara', 'Rudra')
+ },
+ }
+
+
def cmd_jaimini(args):
chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(args)
if chart is None:
@@ -14411,6 +14444,11 @@ def cmd_jaimini(args):
result['graha_padas'] = calc_graha_padas(planet_lons)
if mode in ('all', 'special'):
result['special_lagnas'] = calc_special_lagnas(asc_idx, args.hour, args.minute + _arg_second(args) / 60.0)
+ goel_lons = {}
+ for pn, pd in planets.items():
+ if isinstance(pd, dict) and 'degree' in pd:
+ goel_lons[pn] = pd.get('degree_raw', pd['degree'])
+ result.update(_goel_jaimini_evidence(goel_lons, asc_deg))
return _build_response_envelope('jaimini', result, args=args, execution_status='executed')
@@ -16283,6 +16321,7 @@ def cmd_full_reading(args):
except Exception as dk_e:
jaimini_result['darakaraka'] = {'error': str(dk_e)}
+ jaimini_result.update(_goel_jaimini_evidence(planet_lons, asc_deg))
report['modules']['jaimini'] = jaimini_result
except Exception as e:
report['errors'].append(f"jaimini: {e}")
diff --git a/scripts/marriage_match_service/synastry_full_reader_gate.py b/scripts/marriage_match_service/synastry_full_reader_gate.py
index decaea1c..dae06820 100644
--- a/scripts/marriage_match_service/synastry_full_reader_gate.py
+++ b/scripts/marriage_match_service/synastry_full_reader_gate.py
@@ -225,6 +225,15 @@ def _scan_prose(text: str, language: str, section: str, *, opening: bool) -> lis
return failures
+def _opens_with_title(text: str, title: str) -> bool:
+ heading = title.strip()
+ body = text.lstrip()
+ if not heading or not body.startswith(heading):
+ return False
+ rest = body[len(heading):]
+ return rest == "" or rest[:1].isspace() or rest.startswith(heading)
+
+
def validate_synastry_reader(
delivery: dict[str, Any],
section_inputs: dict[str, Any] | None = None,
@@ -254,6 +263,9 @@ def validate_synastry_reader(
failures.append(_fail(section_id, "missing_section", section_id))
continue
text = str(row.get("text") or "")
+ title = str(row.get("title") or "")
+ if _opens_with_title(text, title):
+ failures.append(_fail(section_id, "repeated_heading", title))
prose_characters += len(re.sub(r"\s+", "", text))
prose_words += len(re.findall(r"\b[\w']+\b", text))
minimum = ZH_SECTION_MINIMUM if language == "zh" else EN_SECTION_MINIMUM
diff --git a/scripts/sthira_brahma_goel_short_method.py b/scripts/sthira_brahma_goel_short_method.py
new file mode 100644
index 00000000..6e010b54
--- /dev/null
+++ b/scripts/sthira_brahma_goel_short_method.py
@@ -0,0 +1,112 @@
+"""Source-bounded Brahma selector for the Goel/Rao short-method profile.
+
+This is intentionally explicit-input only. It implements the short method
+described in the local Jaimini transcriptions: choose the stronger of Lagna
+and the seventh sign, inspect the 6th/8th/12th lords from that anchor, exclude
+Saturn, then rank the remaining candidates by declared strength and degree.
+It does not implement the separate odd/back-rising or BPHS override clauses.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+
+SIGNS = (
+ "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
+ "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
+)
+SIGN_LORDS = {
+ 0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon",
+ 4: "Sun", 5: "Mercury", 6: "Venus", 7: "Mars",
+ 8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter",
+}
+PROFILE_ID = "goel_rao_short_brahma_method_v1"
+
+
+def _candidate(
+ *,
+ house: int,
+ anchor_sign_index: int,
+ planets: dict[str, dict[str, Any]],
+ strengths: dict[str, float],
+) -> dict[str, Any]:
+ sign_index = (anchor_sign_index + house - 1) % 12
+ lord = SIGN_LORDS[sign_index]
+ planet = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
+ degree = planet.get("degree_in_sign", planet.get("degree"))
+ return {
+ "source_house": house,
+ "source_sign_index": sign_index,
+ "source_sign": SIGNS[sign_index],
+ "planet": lord,
+ "planet_sign_index": planet.get("sign_index", planet.get("sign_idx")),
+ "planet_sign": planet.get("sign"),
+ "degree_in_sign": float(degree) if isinstance(degree, (int, float)) else None,
+ "declared_strength": strengths.get(lord),
+ "saturn_excluded": lord == "Saturn",
+ }
+
+
+def select_brahma_goel_short_method(
+ *,
+ lagna_sign_index: int,
+ planets: dict[str, dict[str, Any]],
+ planet_strengths: dict[str, float],
+ lagna_strength: float,
+ seventh_strength: float,
+) -> dict[str, Any]:
+ """Select Brahma under the explicit Goel/Rao short-method profile."""
+ if not 0 <= int(lagna_sign_index) < 12:
+ return {"status": "blocked", "reason": "lagna_sign_index must be 0..11", "candidates": []}
+ if not isinstance(planets, dict) or not isinstance(planet_strengths, dict):
+ return {"status": "blocked", "reason": "planets and planet_strengths are required", "candidates": []}
+ if not isinstance(lagna_strength, (int, float)) or not isinstance(seventh_strength, (int, float)):
+ return {"status": "blocked", "reason": "lagna_strength and seventh_strength are required", "candidates": []}
+
+ anchor = int(lagna_sign_index) if float(lagna_strength) >= float(seventh_strength) else (int(lagna_sign_index) + 6) % 12
+ candidates = [
+ _candidate(house=house, anchor_sign_index=anchor, planets=planets, strengths=planet_strengths)
+ for house in (6, 8, 12)
+ ]
+ eligible = [row for row in candidates if not row["saturn_excluded"] and row["declared_strength"] is not None]
+ if not eligible:
+ return {
+ "profile_id": PROFILE_ID,
+ "status": "blocked",
+ "confidence_status": "parameter_sensitive",
+ "reason": "No non-Saturn candidate has a declared strength.",
+ "anchor": {"sign_index": anchor, "sign": SIGNS[anchor], "selected_by": "lagna_vs_seventh_strength"},
+ "candidates": candidates,
+ }
+ eligible.sort(
+ key=lambda row: (
+ float(row["declared_strength"]),
+ float(row["degree_in_sign"]) if row["degree_in_sign"] is not None else -1.0,
+ ),
+ reverse=True,
+ )
+ selected = eligible[0]
+ return {
+ "profile_id": PROFILE_ID,
+ "status": "executed",
+ "confidence_status": "parameter_sensitive",
+ "verification_status": "source_bounded_explicit_input",
+ "anchor": {
+ "sign_index": anchor,
+ "sign": SIGNS[anchor],
+ "selected_by": "stronger_of_lagna_and_seventh",
+ "lagna_strength": float(lagna_strength),
+ "seventh_strength": float(seventh_strength),
+ },
+ "candidates": candidates,
+ "selected": selected,
+ "source_rule": (
+ "Goel/Rao short method: strongest of 6th, 8th, and 12th lords from "
+ "the stronger Lagna/7th anchor, excluding Saturn."
+ ),
+ "unresolved": [
+ "odd_sign_and_back_rising_filter_not_applied",
+ "BPHS_verse_172_karaka_and_saturn_node_override_not_applied",
+ "strength_source_is_declared_input_not_recomputed_here",
+ ],
+ }
diff --git a/scripts/trikona_dasha.py b/scripts/trikona_dasha.py
new file mode 100644
index 00000000..e6c8a27c
--- /dev/null
+++ b/scripts/trikona_dasha.py
@@ -0,0 +1,166 @@
+"""Separate Trikona profiles: Raghava Bhatta MD and Raman confirmed-parent AD."""
+import argparse
+from copy import deepcopy
+from datetime import datetime, timedelta, timezone
+import json
+import math
+from pathlib import Path
+import sys
+
+if __package__ in (None, ''):
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from scripts.narayana_dasha import SIGNS, SIGN_LORDS, _NARAYANA_DIGNITY
+
+PROFILE = 'trikona_raghava_bhatta_explicit_seed_md_v1'
+SOURCE = 'https://saptarishisastrology.com/trikona-dasa-a-critical-review-by-iranganti-rangacharya/'
+RAMAN_AD_PROFILE = 'trikona_raman_94_confirmed_full_md_ad_v1'
+RAMAN_SOURCE = 'https://www.scribd.com/doc/127930441/Studies-in-Jaimini-Astrology-by-b-v-Raman'
+
+
+def build_trikona_raman_antardasa_report(*, md_sign_idx, full_md_years,
+ full_md_start, parent_source, parent_profile, year_days, profile):
+ """Raman section94 AD only; a confirmed, untruncated parent is input."""
+ if profile != RAMAN_AD_PROFILE or parent_profile != 'raman_studies_jaimini_92_93':
+ raise ValueError('explicit Raman AD and Raman sections92/93 parent profiles required')
+ if type(md_sign_idx) is not int or not 0 <= md_sign_idx < 12:
+ raise ValueError('md_sign_idx must be integer 0..11')
+ if not isinstance(parent_source, str) or not parent_source.strip():
+ raise ValueError('parent_source must identify the confirmed full MD, not birth balance')
+ for name, value, low, high in [('full_md_years', full_md_years, 0, 12),
+ ('year_days', year_days, 300, 400)]:
+ if (isinstance(value, bool) or not isinstance(value, (int, float)) or
+ not math.isfinite(value) or not low <= value <= high or value == 0):
+ raise ValueError(f'{name} outside supported finite range')
+ if not isinstance(full_md_start, str):
+ raise ValueError('full_md_start must be aware ISO datetime')
+ anchor = datetime.fromisoformat(full_md_start.replace('Z', '+00:00'))
+ if anchor.tzinfo is None or anchor.utcoffset() is None:
+ raise ValueError('full_md_start must include UTC offset')
+ anchor = anchor.astimezone(timezone.utc)
+ # Raman section155 specifies these groups; neither parity nor ninth-from-parent.
+ direction = 1 if md_sign_idx in (0, 1, 2, 6, 7, 8) else -1
+ offsets = [group + trine for group in range(4) for trine in (0, 4, 8)]
+ try:
+ edges = [(anchor + timedelta(days=full_md_years * year_days * i / 12)).isoformat()
+ for i in range(13)]
+ except OverflowError as exc:
+ raise ValueError('calendar boundary outside supported datetime range') from exc
+ periods = [dict(sign_idx=(md_sign_idx + direction * offset) % 12,
+ sign=SIGNS[(md_sign_idx + direction * offset) % 12],
+ years=full_md_years / 12, start_utc=edges[i], end_utc=edges[i + 1],
+ start_age=full_md_years * i / 12, end_age=full_md_years * (i + 1) / 12)
+ for i, offset in enumerate(offsets)]
+ result = dict(status='parameter_sensitive', variant_profile_id=profile,
+ tradition_profile='bv_raman_studies_in_jaimini_astrology', source=RAMAN_SOURCE,
+ source_locator='section94 pp61-62; Savya groups section155 p127',
+ parent=dict(sign_idx=md_sign_idx, sign=SIGNS[md_sign_idx], years=full_md_years,
+ start_utc=edges[0], end_utc=edges[-1], source=parent_source,
+ profile=parent_profile, status='supplied_confirmed_full_parent'),
+ year_days=year_days, direction=direction, periods=periods,
+ child_periods=dict(level='AD', status='executed', count=12),
+ pratyantardasa=dict(status='blocked', reason='section94_does_not_establish_PD'),
+ limits=['Parent seed, duration and full start supplied, not calculated or independently verified',
+ 'No birth-balance clipping; do not supply a residual MD as a full parent',
+ 'Fixed year_days is explicit caller convention, not a source date oracle',
+ 'Raman AD only; never attach to Raghava Bhatta or PyJHora parents automatically',
+ 'No PD, event inference or default customer export'])
+ lines = ['# Trikona — Raman confirmed full MD / AD', '', f'Profile: {profile}',
+ f'Source: {RAMAN_SOURCE} — {result["source_locator"]}', '',
+ '| AD | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
+ lines += [f"| {p['sign']} | {p['years']} | {p['start_utc']} | {p['end_utc']} |" for p in periods]
+ lines += ['', *result['limits'], '', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
+ return dict(status=result['status'], customer_export_admitted=False,
+ evidence_appendix=[result], exports=dict(markdown='\n'.join(lines),
+ ai_evidence_bundle=deepcopy(result)))
+
+
+def build_trikona_native_report(*, lagna_sign_idx, seed_sign_idx, seed_source,
+ planet_longitudes, epoch, year_days, profile):
+ if profile != PROFILE:
+ raise ValueError('explicit Raghava Bhatta profile required')
+ if any(type(v) is not int or not 0 <= v < 12 for v in (lagna_sign_idx, seed_sign_idx)):
+ raise ValueError('sign indices must be integers 0..11')
+ if (seed_sign_idx - lagna_sign_idx) % 12 not in (0, 4, 8):
+ raise ValueError('seed must be Lagna, fifth or ninth')
+ if not isinstance(seed_source, str) or not seed_source.strip():
+ raise ValueError('seed_source must explain the supplied strength decision')
+ required = set(SIGN_LORDS.values())
+ if not isinstance(planet_longitudes, dict) or set(planet_longitudes) != required:
+ raise ValueError('exactly seven classical planetary longitudes required; no node co-lords')
+ if any(isinstance(v, bool) or not isinstance(v, (int, float)) or
+ not math.isfinite(v) or not 0 <= v < 360 for v in planet_longitudes.values()):
+ raise ValueError('planetary longitudes must be finite in [0,360)')
+ if isinstance(year_days, bool) or not isinstance(year_days, (int, float)) or not math.isfinite(year_days) or not 300 <= year_days <= 400:
+ raise ValueError('explicit year_days 300..400 required')
+ if not isinstance(epoch, str):
+ raise ValueError('aware ISO epoch required')
+ anchor = datetime.fromisoformat(epoch.replace('Z', '+00:00'))
+ if anchor.tzinfo is None or anchor.utcoffset() is None:
+ raise ValueError('epoch must include UTC offset')
+ anchor = anchor.astimezone(timezone.utc)
+ direction = 1 if seed_sign_idx % 2 == 0 else -1
+ if seed_sign_idx % 3 == 0:
+ offsets = list(range(12))
+ elif seed_sign_idx % 3 == 1:
+ offsets = [5 * i for i in range(12)]
+ else:
+ offsets = [group + trine for group in range(4) for trine in (0, 4, 8)]
+ periods = []
+ age = 0
+ for offset in offsets:
+ sign = (seed_sign_idx + direction * offset) % 12
+ lord = SIGN_LORDS[SIGNS[sign]]
+ lord_sign = int(planet_longitudes[lord] // 30)
+ count_direction = 1 if sign % 2 == 0 else -1
+ count = ((lord_sign - sign) * count_direction) % 12 + 1
+ dignity = _NARAYANA_DIGNITY[lord]
+ adjustment = int(lord_sign in dignity['exalted']) - int(lord_sign in dignity['debilitated'])
+ years = 12 if lord_sign == sign else min(12, count + adjustment)
+ end = age + years
+ try:
+ start_utc = (anchor + timedelta(days=age * year_days)).isoformat()
+ end_utc = (anchor + timedelta(days=end * year_days)).isoformat()
+ except OverflowError as exc:
+ raise ValueError('calendar boundary outside supported datetime range') from exc
+ periods.append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
+ lord_sign_idx=lord_sign, inclusive_count=count, dignity_adjustment=adjustment,
+ years=years, start_age=age, end_age=end, start_utc=start_utc, end_utc=end_utc))
+ age = end
+ result = dict(status='parameter_sensitive', variant_profile_id=PROFILE,
+ tradition_profile='raghava_bhatta_as_presented_by_rangacharya', source=SOURCE,
+ lagna_sign_idx=lagna_sign_idx, seed_sign_idx=seed_sign_idx,
+ seed_selection=dict(status='supplied_not_calculated', source=seed_source),
+ epoch_utc=anchor.isoformat(), year_days=year_days, periods=periods,
+ child_periods=dict(status='blocked', reason='method_specific_AD_PD_rules_not_established'),
+ limits=['Supplied strongest trine; strength selection is not calculated',
+ 'Full first MD begins at explicit epoch; no fractional birth balance inferred',
+ 'Fixed-day calendar is caller convention, not verified source dates',
+ 'First cycle only; no automatic customer export or event certainty'])
+ lines = ['# Trikona — Raghava Bhatta / Rangacharya method 2', '',
+ 'Status: parameter_sensitive', f'Source: {SOURCE}', f'Profile: {PROFILE}', '',
+ '| Sign | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
+ lines += [f"| {p['sign']} | {p['years']} | {p['start_utc']} | {p['end_utc']} |" for p in periods]
+ lines += ['', *result['limits'], 'AD/PD: blocked — method-specific rules not established',
+ '', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
+ return dict(status=result['status'], customer_export_admitted=False,
+ evidence_appendix=[result], exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--request', type=Path, required=True)
+ parser.add_argument('--mode', choices=('method2-md', 'raman-confirmed-md-ad', 'raman-native'), default='method2-md')
+ parser.add_argument('--format', choices=('json', 'markdown'), default='json')
+ args = parser.parse_args()
+ builder = (build_trikona_raman_antardasa_report if args.mode == 'raman-confirmed-md-ad'
+ else build_trikona_native_report)
+ if args.mode == 'raman-native':
+ from scripts.trikona_raman import build_trikona_raman_report
+ builder = build_trikona_raman_report
+ report = builder(**json.loads(args.request.read_text()))
+ print(report['exports']['markdown'] if args.format == 'markdown' else json.dumps(report, ensure_ascii=False, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/trikona_raman.py b/scripts/trikona_raman.py
new file mode 100644
index 00000000..1fc92833
--- /dev/null
+++ b/scripts/trikona_raman.py
@@ -0,0 +1,233 @@
+"""Raman's numerical Rasi strength and Trikona MD/AD, explicit conventions.
+
+Studies in Jaimini Astrology sections62-72,79-86,92-94. This is Raman's
+adaptation, not a universal Jaimini comparator or the Raghava Bhatta method.
+"""
+from copy import deepcopy
+from datetime import datetime, timedelta, timezone
+import json
+import math
+
+from scripts.jaimini import FRIENDSHIP
+from scripts.narayana_dasha import SIGNS, SIGN_LORDS, _NARAYANA_DIGNITY, _rasi_aspects
+from scripts.trikona_dasha import RAMAN_AD_PROFILE, RAMAN_SOURCE, build_trikona_raman_antardasa_report
+
+PROFILE = 'trikona_raman_numeric_seed_md_ad_candidate_v1'
+STRENGTH_PROFILE = 'raman_studies_62_72_numeric_v1'
+DIGNITY_PROFILE = 'raman_manual_degree_mt_exaltation_first_v1'
+CLASSICAL = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn')
+SAVYA = {0, 1, 2, 6, 7, 8}
+AMSA = (60, 45, 30, 22.5, 15, 7.5, 3.75)
+# Raman, A Manual of Hindu Astrology, section29 printed p12. These differ
+# from the repository Shadbala table; never silently substitute that table.
+MT = {'Sun': (4, 0, 20), 'Moon': (1, 4, 30), 'Mars': (0, 0, 18),
+ 'Mercury': (5, 16, 20), 'Jupiter': (8, 0, 13), 'Venus': (6, 0, 10),
+ 'Saturn': (10, 0, 20)}
+
+
+def _validate_positions(positions):
+ if not isinstance(positions, dict) or set(positions) != set(CLASSICAL) | {'Rahu', 'Ketu'}:
+ raise ValueError('exactly seven classical planets plus Rahu and Ketu required')
+ if any(type(v) not in (int, float) or not math.isfinite(v) or not 0 <= v < 360
+ for v in positions.values()):
+ raise ValueError('longitudes must be finite numbers in [0,360)')
+ if not math.isclose((positions['Rahu'] - positions['Ketu']) % 360, 180, abs_tol=1e-7, rel_tol=0):
+ raise ValueError('Rahu and Ketu must be opposite in the same position profile')
+
+
+def _dignity(planet, longitude):
+ sign, degree = int(longitude // 30), longitude % 30
+ if sign in _NARAYANA_DIGNITY[planet]['exalted']:
+ return 'exalted', 60
+ if sign in _NARAYANA_DIGNITY[planet]['debilitated']:
+ return 'debilitated', 3.75
+ mt_sign, lo, hi = MT[planet]
+ if sign == mt_sign and lo <= degree < hi:
+ return 'moolatrikona', 45
+ owner = SIGN_LORDS[SIGNS[sign]]
+ if owner == planet:
+ return 'own', 30
+ if owner in FRIENDSHIP[planet]['friend']:
+ return 'friend', 22.5
+ if owner in FRIENDSHIP[planet]['enemy']:
+ return 'enemy', 7.5
+ return 'neutral', 15
+
+
+def calculate_raman_rasi_strength(*, planet_longitudes, strength_profile,
+ dignity_profile, druk_role_policy):
+ if strength_profile != STRENGTH_PROFILE or dignity_profile != DIGNITY_PROFILE:
+ raise ValueError('explicit Raman strength and dignity profiles required')
+ if druk_role_policy not in ('distinct_planets', 'distinct_roles'):
+ raise ValueError('druk_role_policy must be distinct_planets or distinct_roles')
+ _validate_positions(planet_longitudes)
+ signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
+ degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
+ ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
+ ties = [[p for p in CLASSICAL if degrees[p] == value] for value in set(degrees.values())]
+ ties = sorted((group for group in ties if len(group) > 1), key=lambda group: degrees[group[0]], reverse=True)
+ result = dict(profile=strength_profile, dignity_profile=dignity_profile,
+ druk_role_policy=druk_role_policy, source=RAMAN_SOURCE, locator='sections62-72',
+ status='blocked' if ties else 'executed', rank_ties=ties, planet_strengths={}, rasi_strengths=[])
+ # Section65 does not specify equal-rank scores. Preserve own-sign strengths
+ # below, but never fabricate a total/rank/Atmakaraka when a degree tie exists.
+ ak = None if ties else ranked[0]
+ result['atmakaraka'] = ak
+ if not ties:
+ for rank, planet in enumerate(ranked):
+ label, dignity = _dignity(planet, planet_longitudes[planet])
+ kendra = (60, 30, 15)[(signs[planet] - signs[ak]) % 3]
+ result['planet_strengths'][planet] = dict(dignity_label=label,
+ moolatrikona_bala=dignity, amsa_bala=AMSA[rank], kendra_bala=kendra,
+ total=dignity + AMSA[rank] + kendra)
+ for sign in range(12):
+ lord = SIGN_LORDS[SIGNS[sign]]
+ role_planets = ['Jupiter', 'Mercury', lord]
+ if druk_role_policy == 'distinct_planets':
+ role_planets = list(dict.fromkeys(role_planets))
+ hits = [p for p in role_planets if signs[p] == sign or _rasi_aspects(signs[p], sign)]
+ occupants = [p for p, position in signs.items() if position == sign]
+ chara = (15, 30, 60)[sign % 3]
+ sthira = 45 + 15 * len(occupants) if occupants else 0
+ own = chara + 60 * len(hits) + sthira
+ special = 60 if ak and signs[ak] == sign else (None if ties else 0)
+ lord_strength = None if ties else result['planet_strengths'][lord]['total']
+ result['rasi_strengths'].append(dict(sign_idx=sign, sign=SIGNS[sign], lord=lord,
+ chara_bala=chara, druk_bala=60 * len(hits), druk_contributors=hits,
+ sthira_bala=sthira, occupants=occupants, own_strength=own,
+ lord_strength=lord_strength, special_strength=special,
+ total=None if ties else own + lord_strength + special))
+ if ties:
+ result['reason'] = 'equal_degree_AMSA_rank_rule_not_established; provide finer positions or a sourced tie profile'
+ return result
+
+
+def _duration(sign, signs, *, node_dignity_profile):
+ """Sections79-86, retaining the selected lord and decision trace."""
+ direction = 1 if sign in SAVYA else -1
+ classical = SIGN_LORDS[SIGNS[sign]]
+ lords = [classical] + ({7: ['Ketu'], 10: ['Rahu']}.get(sign, []))
+ base = {p: ((signs[p] - sign) * direction) % 12 or 12 for p in lords}
+ adjustment = {p: (int(signs[p] in _NARAYANA_DIGNITY[p]['exalted']) -
+ int(signs[p] in _NARAYANA_DIGNITY[p]['debilitated']))
+ if p in CLASSICAL else 0 for p in lords}
+ trace = ['section79_savya_count_minus_one']
+ selected = lords[:]
+ if len(lords) == 2:
+ if signs[lords[0]] == signs[lords[1]]:
+ trace.append('section82_same_own' if signs[lords[0]] == sign else 'sections83_84_conjunction')
+ # Conjunction outside own sign explicitly refers to section79 only.
+ years = base[lords[0]]
+ return dict(status='executed', years=years, lords=selected, trace=trace,
+ count_direction=direction, candidates=base, node_dignity_profile=node_dignity_profile)
+ away = [p for p in lords if signs[p] != sign]
+ if len(away) == 1:
+ selected = away
+ trace.append('section85_prefer_lord_away_from_own')
+ else:
+ associates = {p: sum(s == signs[p] for name, s in signs.items() if name != p) for p in lords}
+ selected = [p for p in selected if associates[p] == max(associates.values())]
+ trace.append('section86_associate_count')
+ if len(selected) > 1:
+ modes = {p: signs[p] % 3 for p in selected}
+ selected = [p for p in selected if modes[p] == max(modes.values())]
+ trace.append('section86_dual_fixed_movable')
+ if len(selected) > 1:
+ selected = [p for p in selected if base[p] == max(base[q] for q in selected)]
+ trace.append('section86_larger_span')
+ if classical in selected and adjustment[classical] == -1:
+ return dict(status='blocked', reason='section86_over_debilitated_and_node_exaltation_exception_unresolved',
+ lords=selected, candidates=base, trace=trace)
+ years_by_lord = {p: (12 if signs[p] == sign else base[p] + adjustment[p]) for p in selected}
+ if len(set(years_by_lord.values())) != 1:
+ return dict(status='blocked', reason='equal_colord_rank_with_different_adjusted_years',
+ lords=selected, candidates=years_by_lord, trace=trace)
+ years = next(iter(years_by_lord.values()))
+ return dict(status='executed' if years > 0 else 'blocked', years=years,
+ reason=None if years > 0 else 'nonpositive_adjusted_span_no_clipping_rule',
+ lords=selected, trace=trace, candidates=base, dignity_adjustments=adjustment,
+ count_direction=direction, node_dignity_profile=node_dignity_profile)
+
+
+def build_trikona_raman_report(*, lagna_sign_idx, planet_longitudes, epoch, year_days,
+ profile, strength_profile, dignity_profile, druk_role_policy, node_dignity_profile):
+ if profile != PROFILE:
+ raise ValueError('explicit Raman native profile required')
+ if type(lagna_sign_idx) is not int or not 0 <= lagna_sign_idx < 12:
+ raise ValueError('lagna_sign_idx must be integer 0..11')
+ if node_dignity_profile != 'unassigned_no_adjustment':
+ raise ValueError('node_dignity_profile must explicitly select unassigned_no_adjustment')
+ if type(year_days) not in (int, float) or not math.isfinite(year_days) or not 300 <= year_days <= 400:
+ raise ValueError('year_days must be finite in 300..400')
+ if not isinstance(epoch, str):
+ raise ValueError('aware ISO epoch required')
+ anchor = datetime.fromisoformat(epoch.replace('Z', '+00:00'))
+ if anchor.tzinfo is None or anchor.utcoffset() is None:
+ raise ValueError('epoch must include UTC offset')
+ anchor = anchor.astimezone(timezone.utc)
+ strength = calculate_raman_rasi_strength(planet_longitudes=planet_longitudes,
+ strength_profile=strength_profile, dignity_profile=dignity_profile, druk_role_policy=druk_role_policy)
+ signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
+ trines = [(lagna_sign_idx + offset) % 12 for offset in (0, 4, 8)]
+ candidates = [strength['rasi_strengths'][s] for s in trines]
+ seeds = [] if strength['status'] == 'blocked' else [r['sign_idx'] for r in candidates
+ if r['total'] == max(c['total'] for c in candidates)]
+ durations = [dict(sign_idx=s, sign=SIGNS[s], **_duration(s, signs,
+ node_dignity_profile=node_dignity_profile)) for s in range(12)]
+ branches = []
+ for seed in seeds:
+ age = 0
+ dated = True
+ periods = []
+ direction = 1 if seed in SAVYA else -1
+ for offset in [group + trine for group in range(4) for trine in (0, 4, 8)]:
+ sign = (seed + direction * offset) % 12
+ row = deepcopy(durations[sign])
+ if row['status'] != 'executed':
+ dated = False
+ if dated:
+ try:
+ start = (anchor + timedelta(days=age * year_days)).isoformat()
+ end = (anchor + timedelta(days=(age + row['years']) * year_days)).isoformat()
+ except OverflowError as exc:
+ raise ValueError('calendar boundary outside supported datetime range') from exc
+ row.update(start_age=age, end_age=age + row['years'], start_utc=start, end_utc=end,
+ calendar_status='calculated_with_caller_fixed_year')
+ child = build_trikona_raman_antardasa_report(md_sign_idx=sign,
+ full_md_years=row['years'], full_md_start=start,
+ parent_source='Native Raman sections79-86/92-93; see duration trace',
+ parent_profile='raman_studies_jaimini_92_93', year_days=year_days, profile=RAMAN_AD_PROFILE)
+ row['antardasas'] = child['evidence_appendix'][0]['periods']
+ # Parent and child formulas can round sub-microsecond floating
+ # days differently; share the exact already-calculated edge.
+ row['antardasas'][-1]['end_utc'] = end
+ age += row['years']
+ else:
+ row.update(calendar_status='blocked_by_unresolved_prior_or_current_duration', antardasas=[])
+ periods.append(row)
+ branches.append(dict(seed_sign_idx=seed, direction=direction,
+ status='parameter_sensitive' if dated else 'partial', periods=periods))
+ result = dict(status='blocked' if not seeds else 'parameter_sensitive', variant_profile_id=profile,
+ tradition_profile='bv_raman_studies_in_jaimini_astrology', source=RAMAN_SOURCE,
+ strength=strength, seed_selection=dict(status='blocked' if not seeds else 'parallel_tie' if len(seeds)>1 else 'calculated',
+ candidates=candidates, selected_signs=seeds), duration_evidence=durations, branches=branches,
+ epoch_utc=anchor.isoformat(), year_days=year_days, node_dignity_profile=node_dignity_profile,
+ pratyantardasa=dict(status='blocked', reason='Raman_section94_does_not_establish_PD'),
+ limits=['Raman numerical adaptation, not universal Jaimini strength',
+ 'Druk role deduplication is explicit; source does not resolve duplicate roles',
+ 'Degree MT follows Raman Manual section29; exaltation checked first by explicit profile',
+ 'Node dignity left unassigned by caller convention, not a universal no-exaltation doctrine',
+ 'Full first MD starts at caller epoch; no fractional birth-balance inferred',
+ 'Fixed year_days not source-date verified; first cycle only; no customer admission'])
+ lines = ['# Trikona — Raman numerical seed / MD / AD', '', f'Profile: {profile}',
+ f'Source: {RAMAN_SOURCE}', '', '## Strength and seed',
+ '| Trine sign | Strength |', '| --- | --- |']
+ lines += [f"| {r['sign']} | {r['total']} |" for r in candidates]
+ for branch in branches:
+ lines += ['', f"## Seed {SIGNS[branch['seed_sign_idx']]}",
+ '| MD | Years | Start UTC | End UTC |', '| --- | --- | --- | --- |']
+ lines += [f"| {r['sign']} | {r.get('years', 'blocked')} | {r.get('start_utc', 'blocked')} | {r.get('end_utc', 'blocked')} |"
+ for r in branch['periods']]
+ lines += ['', *result['limits'], '', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
+ return dict(status=result['status'], customer_export_admitted=False, evidence_appendix=[result],
+ exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
diff --git a/scripts/yogini_dasha.py b/scripts/yogini_dasha.py
index 00ddf62f..8be0a416 100644
--- a/scripts/yogini_dasha.py
+++ b/scripts/yogini_dasha.py
@@ -61,10 +61,63 @@ def calculate_yogini_dasha(birth_info: dict) -> dict:
moon_longitude: sidereal degrees, required
as_of / today: observation instant; report callers should pass it
dasha_year_days: fixed-day year, default 365.25
+
+ Major rows stay free of sub-periods. Callers that need antardashas use
+ ``yogini_antardashas`` on a copy.
"""
return _calculate_moon_balanced_yogini(birth_info)
+def _as_datetime(value):
+ if isinstance(value, datetime):
+ return value
+ return datetime.fromisoformat(str(value))
+
+
+def yogini_antardashas(major_row: dict, *, birth_dt, year_days: float) -> list:
+ """Proportional antardashas inside one Yogini mahadasha.
+
+ Eight sub-periods start at this mahadasha's own Yogini and follow Goel
+ Table I. Each length is ``full_years * sub_years / 36``. They are laid out
+ from ``unclipped_start_date``. Sub-periods that end at or before birth are
+ dropped. The one that crosses birth keeps ``unclipped_start_date`` and
+ starts at birth. The last sub-period ends on the mahadasha ``end_date``.
+ """
+ birth = _as_datetime(birth_dt)
+ year_length = float(year_days)
+ if year_length <= 0:
+ raise ValueError('dasha_year_days must be positive')
+ start_idx = next(
+ index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == major_row['yogini']
+ )
+ full_years = float(major_row['full_years'])
+ cursor = _as_datetime(major_row['unclipped_start_date'])
+ major_end = _as_datetime(major_row['end_date'])
+ rows = []
+ for offset in range(len(YOGINI_SEQUENCE)):
+ item = YOGINI_SEQUENCE[(start_idx + offset) % len(YOGINI_SEQUENCE)]
+ span_days = full_years * item['years'] / TOTAL_CYCLE * year_length
+ end = major_end if offset == len(YOGINI_SEQUENCE) - 1 else cursor + timedelta(days=span_days)
+ if end <= birth:
+ cursor = end
+ continue
+ visible_start = birth if cursor < birth else cursor
+ row = {
+ 'level': 'antardasha',
+ 'planet': item['planet'],
+ 'yogini': item['name'],
+ 'lord': item['planet'],
+ 'years': (end - visible_start).total_seconds() / (86400 * year_length),
+ 'start_date': visible_start.isoformat(timespec='seconds'),
+ 'end_date': end.isoformat(timespec='seconds'),
+ }
+ if cursor < birth:
+ row['unclipped_start_date'] = cursor.isoformat(timespec='seconds')
+ rows.append(row)
+ cursor = end
+ return rows
+
+
def _calculate_moon_balanced_yogini(birth_info):
"""Goel Table I order, angular birth balance, explicit fixed-day year."""
birth_dt = birth_info.get("birth_datetime")
diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py
index 94dbbdb9..8548d2f1 100644
--- a/tests/test_api_server_security.py
+++ b/tests/test_api_server_security.py
@@ -1643,6 +1643,10 @@ def test_capability_audit_scans_registry_and_local_sources() -> None:
# 新值: 中间加入 synastry
# 原因: 婚恋合盘页 app/(app)/synastry 进入页面扫描(issue 82,2026-10-19)
'synastry',
+ # 原值: synastry 后面直接是 terms
+ # 新值: 加入 synastry/[reportId]
+ # 原因: issue #85 T7 合盘报告独立地址
+ 'synastry/[reportId]',
'terms',
]
# 原值: 期望集合含 'Case Validation'
diff --git a/tests/test_bphs_validation_runtime.py b/tests/test_bphs_validation_runtime.py
index cb1e405e..0550f91f 100644
--- a/tests/test_bphs_validation_runtime.py
+++ b/tests/test_bphs_validation_runtime.py
@@ -63,3 +63,12 @@ def test_varga_cli_d9_uses_same_navamsa_mapping_as_varga_full() -> None:
assert short["ascendant"] == full["Ascendant"]["sign"]
for planet in ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]:
assert short[planet]["sign"] == full[planet]["sign"]
+
+
+def test_full_reading_exposes_goel_jaimini_strength() -> None:
+ report = _run_engine("full-reading", *BASE_BIRTH_ARGS)
+ goel = report["modules"]["jaimini"]
+ assert goel["goel_full_strength"]["status"] == "executed"
+ assert len(goel["goel_full_strength"]["rashi_strengths"]) == 12
+ assert set(goel["goel_special_points"]) == {"Brahma", "Maheshwara", "Rudra"}
+ assert goel["niryaana_shoola_strength"]["deprecated"] is True
diff --git a/tests/test_dasha_api_engine_parity.py b/tests/test_dasha_api_engine_parity.py
index 991c9c75..82ba876b 100644
--- a/tests/test_dasha_api_engine_parity.py
+++ b/tests/test_dasha_api_engine_parity.py
@@ -16,7 +16,7 @@ import os
import random
import re
import sys
-from datetime import date, datetime
+from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
@@ -201,6 +201,18 @@ def _period_covering(majors, moment: datetime):
return None
+def _without_antardasha(rows):
+ cleaned = []
+ for row in rows:
+ if isinstance(row, dict) and "antardasha" in row:
+ item = dict(row)
+ item.pop("antardasha")
+ cleaned.append(item)
+ else:
+ cleaned.append(row)
+ return cleaned
+
+
def test_api_major_periods_match_full_reading_modules() -> None:
report = _fiction_reading()
modules = report["modules"]
@@ -210,7 +222,16 @@ def test_api_major_periods_match_full_reading_modules() -> None:
result = handler._compute_dasha_system(_chart_body(report, key))
assert result["precision"] == "engine_module", key
assert result["precision"] not in {"generic", "calculator"}
- assert result["periods"] == engine_rows, key
+ if key == "yogini":
+ # 原值:result["periods"] == engine_rows
+ # 新值:去掉 antardasha 后相等
+ # 原因:小运只挂在接口返回的副本上,完整解读的 Yogini 行保持原字段
+ assert _without_antardasha(result["periods"]) == engine_rows, key
+ assert result["periods"]
+ assert all(isinstance(row, dict) and row.get("antardasha") for row in result["periods"])
+ assert all(isinstance(row, dict) and "antardasha" not in row for row in engine_rows)
+ else:
+ assert result["periods"] == engine_rows, key
def test_unimplemented_dasha_keys_are_rejected() -> None:
@@ -425,6 +446,172 @@ def test_ashtottari_birth_balance_matches_pyjhora_within_one_day() -> None:
_public_case("spears", spears, "moon")
+YOGINI_PUBLIC_CHARTS = (
+ ("presley", {
+ "year": 1935,
+ "month": 1,
+ "day": 8,
+ "hour": 4,
+ "minute": 35,
+ "lat": 34.3,
+ "lon": -88.4,
+ "tz": -6.0,
+ }),
+ ("spears", {
+ "year": 1981,
+ "month": 12,
+ "day": 2,
+ "hour": 19,
+ "minute": 55,
+ "lat": 31.17,
+ "lon": -89.18,
+ "tz": 0.0,
+ }),
+ ("einstein", {
+ "year": 1879,
+ "month": 3,
+ "day": 14,
+ "hour": 11,
+ "minute": 30,
+ "lat": 48.4,
+ "lon": 9.98,
+ "tz": 0.89,
+ }),
+)
+
+
+def _api_yogini_periods(birth: dict) -> list:
+ from scripts.domain_calculation_service import compute_chart
+
+ chart = compute_chart({**birth, "second": 0, "node_mode": "mean", "ayanamsa": "lahiri"})
+ planets = {
+ name: {"lon": row["lon"]}
+ for name, row in chart["planets"].items()
+ if isinstance(row, dict) and isinstance(row.get("lon"), (int, float))
+ }
+ body = {
+ "dasha": "yogini",
+ "year": birth["year"],
+ "month": birth["month"],
+ "day": birth["day"],
+ "hour": birth["hour"],
+ "minute": birth["minute"],
+ "second": 0,
+ "lat": birth["lat"],
+ "lon": birth["lon"],
+ "tz": birth["tz"],
+ "today": "2026-10-22",
+ "ayanamsa": "lahiri",
+ "node_mode": "mean",
+ "planets": planets,
+ "ascendant": {"lon": chart["ascendant"]["lon"]},
+ }
+ result = _handler()._compute_dasha_system(body)
+ assert result["precision"] == "engine_module"
+ return result["periods"]
+
+
+def _pyjhora_yogini_end(row, following) -> date:
+ if following is not None:
+ return date(*following[1][:3])
+ from jhora import const
+
+ parts = row[1]
+ hours = float(parts[3]) if len(parts) > 3 else 0.0
+ start = datetime(int(parts[0]), int(parts[1]), int(parts[2])) + timedelta(hours=hours)
+ return (start + timedelta(days=float(row[2]) * float(const.sidereal_year))).date()
+
+
+def _assert_yogini_majors_against_pyjhora(name: str, periods: list, py_rows: list) -> None:
+ assert len(py_rows) >= 24, name
+ assert len(periods) >= 24, name
+ mismatches = []
+ for index, (engine, oracle) in enumerate(zip(periods[:24], py_rows[:24])):
+ engine_lord = engine.get("planet")
+ oracle_lord = PYJHORA_LORDS[oracle[0][0]]
+ engine_start = _as_date(engine["unclipped_start_date"])
+ oracle_start = date(*oracle[1][:3])
+ following = py_rows[index + 1] if index + 1 < 24 else None
+ engine_end = _as_date(engine["end_date"])
+ oracle_end = _pyjhora_yogini_end(oracle, following)
+ start_delta = abs((engine_start - oracle_start).days)
+ end_delta = abs((engine_end - oracle_end).days)
+ if engine_lord != oracle_lord or start_delta > 1 or end_delta > 1:
+ mismatches.append(
+ f"{name} major[{index}] engine={engine_lord} {engine_start}..{engine_end} "
+ f"pyjhora={oracle_lord} {oracle_start}..{oracle_end} "
+ f"start_delta_days={start_delta} end_delta_days={end_delta}"
+ )
+ assert not mismatches, "\n".join(mismatches)
+
+
+def _assert_yogini_antardasha_consistency(name: str, periods: list, birth: datetime) -> None:
+ from scripts.yogini_dasha import TOTAL_CYCLE, YOGINI_SEQUENCE
+
+ year_days = 365.25
+ problems = []
+ for index, major in enumerate(periods):
+ subs = major.get("antardasha") or []
+ if not subs:
+ problems.append(f"{name} major[{index}] {major.get('yogini')} has no antardasha")
+ continue
+ start_idx = next(item_index for item_index, item in enumerate(YOGINI_SEQUENCE) if item["name"] == major["yogini"])
+ expected = [YOGINI_SEQUENCE[(start_idx + offset) % 8] for offset in range(8)]
+ got = [row["yogini"] for row in subs]
+ suffixes = [[item["name"] for item in expected[cut:]] for cut in range(8)]
+ if got not in suffixes:
+ problems.append(f"{name} major[{index}] order {got} is not a Goel suffix of {major['yogini']}")
+ continue
+ matched = expected[8 - len(got):]
+ for row, item in zip(subs, matched):
+ if row.get("level") != "antardasha" or row.get("planet") != item["planet"] or row.get("lord") != item["planet"]:
+ problems.append(f"{name} major[{index}] {row.get('yogini')} fields {row.get('level')} {row.get('planet')} {row.get('lord')}")
+ raw_start = datetime.fromisoformat(row.get("unclipped_start_date") or row["start_date"])
+ raw_end = datetime.fromisoformat(row["end_date"])
+ expected_seconds = float(major["full_years"]) * item["years"] / TOTAL_CYCLE * year_days * 86400
+ delta_seconds = abs((raw_end - raw_start).total_seconds() - expected_seconds)
+ if delta_seconds > 2:
+ problems.append(
+ f"{name} major[{index}] {item['name']} span_delta_seconds={delta_seconds:.3f}"
+ )
+ for left, right in zip(subs, subs[1:]):
+ if left["end_date"] != right["start_date"]:
+ problems.append(f"{name} major[{index}] gap {left['end_date']} -> {right['start_date']}")
+ if subs[0]["start_date"] != major["start_date"]:
+ problems.append(f"{name} major[{index}] first sub {subs[0]['start_date']} != major {major['start_date']}")
+ if index == 0 and datetime.fromisoformat(subs[0]["start_date"]) != birth:
+ problems.append(f"{name} first sub {subs[0]['start_date']} != birth {birth.isoformat(timespec='seconds')}")
+ if index > 0 and any("unclipped_start_date" in row for row in subs):
+ problems.append(f"{name} major[{index}] later mahadasha was clipped")
+ visible = sum(
+ (datetime.fromisoformat(row["end_date"]) - datetime.fromisoformat(row["start_date"])).total_seconds()
+ for row in subs
+ )
+ major_span = (datetime.fromisoformat(major["end_date"]) - datetime.fromisoformat(major["start_date"])).total_seconds()
+ if abs(visible - major_span) > 86400:
+ problems.append(f"{name} major[{index}] sub_sum_delta_seconds={visible - major_span:.3f}")
+ if index == 0 and major["unclipped_start_date"] == major["start_date"]:
+ if len(subs) != 8:
+ problems.append(f"{name} unclipped first major has {len(subs)} subs")
+ for row, item in zip(subs, expected):
+ formula = float(major["full_years"]) * item["years"] / TOTAL_CYCLE
+ if abs(float(row["years"]) - formula) > 1e-6:
+ problems.append(f"{name} unclipped {item['name']} years {row['years']} != {formula}")
+ assert not problems, "\n".join(problems)
+
+
+def test_yogini_api_majors_match_pyjhora_and_antardashas_are_proportional() -> None:
+ import importlib.metadata as metadata
+
+ version = metadata.version("PyJHora")
+ assert version == "4.8.7", version
+ for name, birth in YOGINI_PUBLIC_CHARTS:
+ periods = _api_yogini_periods(birth)
+ born = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"])
+ _assert_yogini_majors_against_pyjhora(name, periods, _pyjhora_rows("yogini", birth))
+ _assert_yogini_antardasha_consistency(name, periods, born)
+
+
def test_placeholder_modules_are_gone() -> None:
assert not (SCRIPTS / "extended_dashas.py").exists()
assert not (SCRIPTS / "conditional_dashas.py").exists()
diff --git a/tests/test_goel_jaimini_strength.py b/tests/test_goel_jaimini_strength.py
new file mode 100644
index 00000000..78ce895c
--- /dev/null
+++ b/tests/test_goel_jaimini_strength.py
@@ -0,0 +1,209 @@
+"""Hand-checked synthetic positions, plus Goel's printed p15 worked table.
+
+No private birth data; these fixtures exercise supplied-position arithmetic.
+"""
+from copy import deepcopy
+import importlib
+import importlib.util
+import math
+
+import pytest
+
+
+SYNTHETIC = dict(Sun=29., Moon=58., Mars=87., Mercury=116.,
+ Jupiter=145., Venus=174., Saturn=203., Rahu=245., Ketu=65.)
+
+
+def module():
+ assert importlib.util.find_spec('scripts.goel_jaimini_strength'), 'Goel producer must be published'
+ return importlib.import_module('scripts.goel_jaimini_strength')
+
+
+def test_synthetic_five_component_totals_and_special_points():
+ original = deepcopy(SYNTHETIC)
+ result = module().build_goel_chart_evidence(SYNTHETIC, 0.)
+ strength = result['strength']
+ assert strength['atmakaraka'] == 'Sun'
+ assert {p: row['total'] for p, row in strength['planets'].items()} == dict(
+ Sun=180, Moon=135, Mars=172.5, Mercury=90, Jupiter=187.5,
+ Venus=86.25, Saturn=123.75)
+ assert {p: row['value'] for p, row in result['special_points'].items()} == dict(
+ Brahma='Jupiter', Maheshwara='Mars', Rudra='Mars')
+ assert strength['rashi_strengths'][0]['total'] == 367.5
+ assert strength['rashi_strengths'][6]['total'] == 225
+ assert SYNTHETIC == original
+
+
+def test_node_policy_only_changes_sthana_and_sign_total():
+ m = module()
+ text = m.calculate_goel_strength(SYNTHETIC)
+ example = m.calculate_goel_strength(SYNTHETIC, node_occupancy_policy='exclude_nodes_worked_example')
+ assert text['planets'] == example['planets']
+ # Gemini has Mars+Ketu; Sagittarius has only Rahu.
+ assert (text['rashi_strengths'][2]['sthana'], example['rashi_strengths'][2]['sthana']) == (75, 60)
+ assert (text['rashi_strengths'][8]['sthana'], example['rashi_strengths'][8]['sthana']) == (60, 0)
+ assert text['rashi_strengths'][2]['total'] - example['rashi_strengths'][2]['total'] == 15
+ assert text['rashi_strengths'][8]['total'] - example['rashi_strengths'][8]['total'] == 60
+
+
+def test_goel_printed_p15_numeric_example_without_birth_identity():
+ positions = dict(Sun=20+18/60, Moon=210+20+15/60, Mars=210+23+2/60,
+ Mercury=30+11+12/60, Jupiter=150+3+13/60,
+ Venus=330+17+28/60, Saturn=7+7/60,
+ Rahu=330+5+58/60, Ketu=150+5+58/60)
+ result = module().calculate_goel_strength(positions, node_occupancy_policy='exclude_nodes_worked_example')
+ assert {p: r['total'] for p, r in result['planets'].items()} == dict(
+ Sun=240, Moon=93.75, Mars=210, Mercury=97.5, Jupiter=101.25,
+ Venus=172.5, Saturn=146.25)
+ assert [r['total'] for r in result['rashi_strengths']] == [
+ 412.5, 337.5, 217.5, 228.75, 330, 281.25, 247.5, 465,
+ 281.25, 221.25, 236.25, 363.75]
+
+
+def test_equal_karaka_degrees_block_all_special_point_choices():
+ result = module().build_goel_chart_evidence(dict(SYNTHETIC, Moon=59.), 0.)
+ assert result['strength']['status'] == 'blocked'
+ assert all(result['special_points'][p]['status'] == 'blocked'
+ for p in ('Brahma', 'Maheshwara', 'Rudra'))
+
+
+@pytest.mark.parametrize('bad', [math.nan, math.inf, -1., 360., True, '0'])
+def test_invalid_ascendant_rejected(bad):
+ with pytest.raises(ValueError):
+ module().build_goel_chart_evidence(SYNTHETIC, bad)
+
+
+def test_legacy_lagna_seventh_alias_does_not_replace_pvr_second_eighth():
+ result = module().build_goel_chart_evidence(SYNTHETIC, 0.)
+ legacy = result['niryaana_shoola_strength']
+ assert (legacy['lagna'], legacy['seventh']) == (367.5, 225)
+ assert legacy['deprecated'] is True
+ assert legacy['comparison'] == 'lagna_vs_seventh'
+ from scripts.rashi_dashas_native import select_niryaana_shoola_start_sign
+ pvr = select_niryaana_shoola_start_sign(ascendant_longitude=0., planet_longitudes=SYNTHETIC)
+ assert pvr['selected_sign_idx'] == 1
+ assert pvr['profile'] == 'pvr_niryaana_shoola_ayur_rasi_strength_v1'
+
+
+def test_brahma_ak_eighth_occupancy_overrides_lagna_selector():
+ result = module().build_goel_chart_evidence(dict(SYNTHETIC, Sun=239., Mercury=86.), 0.)
+ assert result['special_points']['Brahma']['value'] == 'Mercury'
+ assert result['special_points']['Brahma']['rule'] == 'eighth_lord_from_AK_occupies_eighth_from_AK'
+
+
+def test_exalted_ak_eighth_lord_compares_twelfth_for_maheshwara():
+ result = module().build_goel_chart_evidence(dict(SYNTHETIC, Mars=297.), 0.)
+ assert result['special_points']['Maheshwara']['value'] == 'Jupiter'
+ assert result['special_points']['Maheshwara']['candidates'] == {'Mars': 150, 'Jupiter': 187.5}
+
+
+def test_rudra_weaker_malefic_contact_exception():
+ result = module().build_goel_chart_evidence(dict(SYNTHETIC, Sun=329., Rahu=125., Ketu=305.), 0.)
+ planets = result['strength']['planets']
+ assert planets['Mars']['total'] == 127.5
+ assert planets['Mars']['malefic_contacts'] == []
+ assert planets['Venus']['total'] == 101.25
+ assert planets['Venus']['malefic_contacts'] == ['Mars']
+ assert result['special_points']['Rudra']['value'] == 'Venus'
+
+
+def test_incomplete_chart_entry_preserves_blocked_points():
+ from scripts.jyotish_engine import _goel_jaimini_evidence
+ result = _goel_jaimini_evidence({}, 0.)
+ assert result['goel_full_strength']['status'] == 'blocked'
+ assert all(r['status'] == 'blocked' for r in result['goel_special_points'].values())
+ assert 'niryaana_shoola_strength' not in result
+
+
+@pytest.mark.parametrize('bad', [dict(SYNTHETIC, Sun=math.nan),
+ dict(SYNTHETIC, Ketu=66.), {p: v for p, v in SYNTHETIC.items() if p != 'Saturn'}])
+def test_incomplete_or_inconsistent_positions_rejected(bad):
+ with pytest.raises(ValueError):
+ module().calculate_goel_strength(bad)
+
+
+def test_jaimini_special_entry_exposes_real_goel_results(monkeypatch):
+ from types import SimpleNamespace
+ from scripts import jyotish_engine as engine
+ chart = {'planets': {p: {'degree': v} for p, v in SYNTHETIC.items()},
+ 'ascendant': {'degree': 0.}}
+ monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
+ result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
+ assert result.get('goel_full_strength', {}).get('atmakaraka') == 'Sun'
+ assert result['goel_special_points']['Brahma']['value'] == 'Jupiter'
+ assert result['niryaana_shoola_strength']['deprecated'] is True
+
+
+def test_wired_special_entry_keeps_both_node_policies(monkeypatch):
+ from types import SimpleNamespace
+ from scripts import jyotish_engine as engine
+ chart = {'planets': {p: {'degree': round(v, 4), 'degree_raw': v} for p, v in SYNTHETIC.items()},
+ 'ascendant': {'degree': 0., 'lon': 0.}}
+ chart['planets']['Sun']['degree'] = 1.0
+ monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
+ result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
+ strength = result['goel_full_strength']
+ assert strength['status'] == 'executed'
+ assert strength['node_occupancy_policy'] == 'include_nodes_text'
+ exclusion = strength['worked_example_node_exclusion']
+ assert exclusion['node_occupancy_policy'] == 'exclude_nodes_worked_example'
+ assert strength['atmakaraka'] == 'Sun'
+ assert abs(strength['planets']['Sun']['degree_in_sign'] - (SYNTHETIC['Sun'] % 30)) <= 1e-6
+
+
+def test_wired_equal_degrees_block_without_atmakaraka_or_rank(monkeypatch):
+ from types import SimpleNamespace
+ from scripts import jyotish_engine as engine
+ positions = dict(SYNTHETIC, Moon=59.)
+ chart = {'planets': {p: {'degree': v} for p, v in positions.items()},
+ 'ascendant': {'degree': 0.}}
+ monkeypatch.setattr(engine, '_compute_chart_from_args', lambda args: (chart, 0, 2451545., 24.))
+ result = engine.cmd_jaimini(SimpleNamespace(mode='special', hour=12, minute=0))
+ strength = result['goel_full_strength']
+ assert strength['status'] == 'blocked'
+ assert strength['reason'] == 'equal_degree_seven_karaka_rank_requires_finer_positions'
+ assert 'atmakaraka' not in strength
+ assert 'planets' not in strength
+ for name in ('Brahma', 'Maheshwara', 'Rudra'):
+ point = result['goel_special_points'][name]
+ assert point['status'] == 'blocked'
+ assert 'value' not in point
+ assert 'candidates' not in point
+
+
+def test_full_reading_feeds_goel_degree_raw(monkeypatch):
+ import scripts.goel_jaimini_strength as goel
+ from scripts.jyotish_engine import cmd_full_reading
+ captured = []
+ real = goel.build_goel_chart_evidence
+
+ def wrapped(longitudes, ascendant):
+ captured.append(dict(longitudes))
+ return real(longitudes, ascendant)
+
+ monkeypatch.setattr(goel, 'build_goel_chart_evidence', wrapped)
+ report = cmd_full_reading(type('Args', (), {
+ 'name': 'fictional',
+ 'year': 1990, 'month': 1, 'day': 1, 'hour': 12, 'minute': 0, 'second': 0,
+ 'lat': 39.9, 'lon': 116.4, 'tz': 8.0,
+ 'node_mode': 'mean', 'ayanamsa': 'lahiri',
+ 'today': '2026-10-22', 'transit_date': '2026-10-22',
+ 'target_year': 2026, 'age': 36,
+ 'birth_time_accuracy': 'confirmed',
+ })())
+ assert captured, 'full-reading did not call Goel'
+ planets = report['modules']['chart']['planets']
+ expected = {
+ name: pdata['degree_raw']
+ for name, pdata in planets.items()
+ if isinstance(pdata, dict) and 'degree_raw' in pdata
+ }
+ assert expected
+ for longitudes in captured:
+ assert set(longitudes) == set(expected)
+ for name, raw in expected.items():
+ assert abs(float(longitudes[name]) - float(raw)) <= 1e-6
+ strength = report['modules']['jaimini']['goel_full_strength']
+ assert strength['status'] == 'executed'
+ assert strength['node_occupancy_policy'] == 'include_nodes_text'
+ assert strength['worked_example_node_exclusion']['node_occupancy_policy'] == 'exclude_nodes_worked_example'
diff --git a/tests/test_python_hygiene_budget.py b/tests/test_python_hygiene_budget.py
index 85942e7c..f12d4da9 100644
--- a/tests/test_python_hygiene_budget.py
+++ b/tests/test_python_hygiene_budget.py
@@ -18,11 +18,12 @@ SCRIPTS = ROOT / "scripts"
# 本地新写的静默处理仍不得出现;允许的命中只有这一处。
SILENT_EXCEPT_BUDGET = 1
UPSTREAM_SILENT_EXCEPT = "scripts/classical_astrology.py:12558"
-# 原值:446
-# 新值:448
-# 原因:产品 2026-10-18(issue #81 选 A):上游原样文件按上游路径放在 scripts/ 根目录,
-# classical_astrology.py、unified_western_reading.py 两个;本地新写的文件仍不得增加。
-ROOT_PY_BUDGET = 448
+# 原值:448
+# 新值:452
+# 原因:产品 2026-10-18 #81 决策 A 先例,issue #94:上游原样文件 goel_jaimini_strength、
+# trikona_raman、sthira_brahma_goel_short_method、trikona_dasha 按上游路径放根目录;
+# 本地新写的文件仍不得增加。
+ROOT_PY_BUDGET = 452
def _silent_handlers() -> list[str]:
diff --git a/tests/test_synastry_full_reader_gate.py b/tests/test_synastry_full_reader_gate.py
index b7a3963a..08a2a741 100644
--- a/tests/test_synastry_full_reader_gate.py
+++ b/tests/test_synastry_full_reader_gate.py
@@ -180,6 +180,16 @@ def test_english_kuja_fails():
assert "banned_term" in _one(delivery, inputs, "en")
+def test_a_body_that_opens_with_the_section_title_fails():
+ delivery, inputs = _passing("zh")
+ delivery["sections"][2]["text"] = "沟通与理解 沟通与理解 " + delivery["sections"][2]["text"]
+ assert "repeated_heading" in _one(delivery, inputs)
+ delivery, inputs = _passing("en")
+ delivery["sections"][2]["title"] = "Communication and Understanding"
+ delivery["sections"][2]["text"] = "Communication and Understanding " + delivery["sections"][2]["text"]
+ assert "repeated_heading" in _one(delivery, inputs, "en")
+
+
def test_english_banned_word_fails():
delivery, inputs = _passing("en")
delivery["sections"][1]["text"] += " Venus "
diff --git a/tests/test_yogini_seed_profile.py b/tests/test_yogini_seed_profile.py
index 52a7e85d..caeaa6e0 100644
--- a/tests/test_yogini_seed_profile.py
+++ b/tests/test_yogini_seed_profile.py
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
import pytest
-from scripts.yogini_dasha import YOGINI_SEQUENCE, calculate_yogini_dasha
+from scripts.yogini_dasha import YOGINI_SEQUENCE, TOTAL_CYCLE, calculate_yogini_dasha, yogini_antardashas
def test_yogini_classical_sequence_and_balance():
@@ -57,3 +57,77 @@ def test_yogini_explicit_reference_controls_current_cycle():
# Ashwini start: (0 + 3) % 8 is Bhramari / Mars, and the balance is the whole period.
assert first['starting_planet'] == 'Mars'
assert first['birth_balance_years'] == pytest.approx(4.0)
+
+
+def _sub_names(start_name):
+ start = next(index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == start_name)
+ return [YOGINI_SEQUENCE[(start + offset) % 8]['name'] for offset in range(8)]
+
+
+def test_yogini_antardasha_proportional_on_unclipped_seed():
+ # Moon at Ashwini 0°: Bhramari opens on the birth instant, so nothing is clipped.
+ birth = datetime(2000, 1, 1)
+ year_days = 360
+ result = calculate_yogini_dasha({
+ 'birth_datetime': birth,
+ 'moon_longitude': 0.0,
+ 'as_of': '2000-01-02',
+ 'dasha_year_days': year_days,
+ })
+ assert all('antardasha' not in row for row in result['major'])
+ major = result['major'][0]
+ assert major['unclipped_start_date'] == major['start_date']
+ subs = yogini_antardashas(major, birth_dt=birth, year_days=year_days)
+ assert [row['yogini'] for row in subs] == _sub_names('Bhramari')
+ assert all('unclipped_start_date' not in row for row in subs)
+ assert subs[0]['start_date'] == major['start_date']
+ assert subs[-1]['end_date'] == major['end_date']
+ assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:]))
+ for row, item in zip(subs, YOGINI_SEQUENCE[3:] + YOGINI_SEQUENCE[:3]):
+ assert row['level'] == 'antardasha'
+ assert row['planet'] == row['lord'] == item['planet']
+ assert row['years'] == pytest.approx(major['full_years'] * item['years'] / TOTAL_CYCLE)
+ visible = sum(
+ (datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds()
+ for row in subs
+ )
+ major_span = (
+ datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date'])
+ ).total_seconds()
+ assert abs(visible - major_span) <= 86400
+
+
+def test_yogini_antardasha_clips_the_birth_crossing_subperiod():
+ # Mid-Pushya is the same seed as test_yogini_classical_sequence_and_balance,
+ # shifted so the elapsed balance falls inside Siddha rather than on a boundary.
+ birth = datetime(2000, 1, 1, 12)
+ year_days = 360
+ moon = (7 + 0.6) * (360 / 27)
+ result = calculate_yogini_dasha({
+ 'birth_datetime': birth,
+ 'moon_longitude': moon,
+ 'as_of': '2000-01-02',
+ 'dasha_year_days': year_days,
+ })
+ first = result['major'][0]
+ later = result['major'][1]
+ first_subs = yogini_antardashas(first, birth_dt=birth, year_days=year_days)
+ later_subs = yogini_antardashas(later, birth_dt=birth, year_days=year_days)
+ assert first_subs[0]['yogini'] == 'Siddha'
+ assert first_subs[0]['start_date'] == first['start_date'] == birth.isoformat(timespec='seconds')
+ assert datetime.fromisoformat(first_subs[0]['unclipped_start_date']) < birth
+ assert [row['yogini'] for row in first_subs] == _sub_names('Dhanya')[-4:]
+ assert later_subs[0]['start_date'] == later['start_date']
+ assert [row['yogini'] for row in later_subs] == _sub_names(later['yogini'])
+ assert 'unclipped_start_date' not in later_subs[0]
+ for major, subs in ((first, first_subs), (later, later_subs)):
+ assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:]))
+ assert subs[-1]['end_date'] == major['end_date']
+ visible = sum(
+ (datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds()
+ for row in subs
+ )
+ major_span = (
+ datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date'])
+ ).total_seconds()
+ assert abs(visible - major_span) == 0