From a7041529c11a354a7966d0209469b372251f86a9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 4 Sep 2026 02:11:43 +0800 Subject: [PATCH] feat(reports): add birth time sensitivity --- frontend/src/app/api/reports/route.ts | 38 +- .../personal-report-document-view.tsx | 21 +- frontend/src/lib/personal-report-contract.ts | 24 ++ .../src/lib/personal-report-generation.ts | 79 ++++- .../src/lib/personal-report-route-core.ts | 57 +++ frontend/src/lib/personal-report-worker.ts | 62 +++- frontend/src/lib/report-evidence-bundle-v2.ts | 60 ++++ frontend/src/mastra/consultation-workflow.ts | 11 + frontend/tests/personal-report-api.test.ts | 73 ++++ frontend/tests/personal-report-view.test.ts | 30 ++ frontend/tests/personal-report-worker.test.ts | 4 + .../tests/report-interpretive-facts.test.ts | 79 +++++ ...xible_birth_time_full_report_projection.py | 53 +++ scripts/flexible_birth_time_profile.py | 323 +++++++++++++++++ scripts/flexible_birth_time_report_section.py | 63 ++++ scripts/flexible_birth_time_report_support.py | 88 +++++ scripts/jyotish_api_server.py | 26 +- scripts/jyotish_engine.py | 328 +++++++----------- tests/test_flexible_birth_time_engine.py | 235 +++++++++++++ tests/test_flexible_birth_time_profile.py | 86 +++++ ...est_flexible_birth_time_report_contract.py | 95 +++++ 21 files changed, 1630 insertions(+), 205 deletions(-) create mode 100644 scripts/flexible_birth_time_full_report_projection.py create mode 100644 scripts/flexible_birth_time_profile.py create mode 100644 scripts/flexible_birth_time_report_section.py create mode 100644 scripts/flexible_birth_time_report_support.py create mode 100644 tests/test_flexible_birth_time_engine.py create mode 100644 tests/test_flexible_birth_time_profile.py create mode 100644 tests/test_flexible_birth_time_report_contract.py diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 33becccb..739a9612 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -164,7 +164,7 @@ export async function POST(request: Request) { if (userId) { const result = await supabase .from("profiles") - .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,latitude,longitude,timezone_offset,birth_place_label,ayanamsa") + .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,rectification_case_id,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset,birth_place_label,ayanamsa") .eq("id", userId) .maybeSingle(); profile = result.data ?? null; @@ -182,6 +182,42 @@ export async function POST(request: Request) { userId, rawBody: await request.json().catch(() => null), profile, + loadCandidateRange: async () => { + const profileRow = profile && typeof profile === "object" ? profile as Record : {}; + const caseId = typeof profileRow.rectification_case_id === "string" + ? profileRow.rectification_case_id + : null; + if (caseId) { + const { data, error } = await admin + .from("birth_time_rectification_cases") + .select("candidate_start,candidate_end") + .eq("id", caseId) + .eq("user_id", userId as string) + .in("status", ["confirmed", "completed"]) + .maybeSingle(); + if (error) throw error; + const row = data && typeof data === "object" ? data as Record : null; + if (row && typeof row.candidate_start === "string" && typeof row.candidate_end === "string") { + return { startTime: row.candidate_start, endTime: row.candidate_end }; + } + } + const { data, error } = await admin + .from("agentic_rectification_cases") + .select("candidate_range,updated_at") + .eq("user_id", userId as string) + .eq("status", "candidate_accepted") + .order("updated_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw error; + const row = data && typeof data === "object" ? data as Record : null; + const range = row?.candidate_range && typeof row.candidate_range === "object" + ? row.candidate_range as Record + : null; + return range && typeof range.start_time === "string" && typeof range.end_time === "string" + ? { startTime: range.start_time, endTime: range.end_time } + : null; + }, checkSessionOwned: async (sessionId) => { const { data, error } = await supabase .from("chat_sessions") diff --git a/frontend/src/components/personal-report/personal-report-document-view.tsx b/frontend/src/components/personal-report/personal-report-document-view.tsx index 39e56678..469d381a 100644 --- a/frontend/src/components/personal-report/personal-report-document-view.tsx +++ b/frontend/src/components/personal-report/personal-report-document-view.tsx @@ -517,7 +517,8 @@ function EvidenceAppendixSection({ document }: { document: ReportDocument }) { const appendix = document.evidenceAppendix; const expandedByDefault = appendix.expandedByDefault || document.presentationMode === "research"; const hasContent = appendix.techniqueAudit.length > 0 || appendix.conflicts.length > 0 - || appendix.calculationEvidence.length > 0 || appendix.blockedTechniques.length > 0; + || appendix.calculationEvidence.length > 0 || appendix.blockedTechniques.length > 0 + || Boolean(appendix.birthTimeSensitivity); return (
@@ -556,6 +557,24 @@ function EvidenceAppendixSection({ document }: { document: ReportDocument }) { {appendix.blockedTechniques.map((name, index) =>
  • {name}
  • )} )} + {appendix.birthTimeSensitivity && ( +
    +

    出生时间敏感度

    +

    可信区间:{appendix.birthTimeSensitivity.window.startTime}–{appendix.birthTimeSensitivity.window.endTime};代表分钟:{appendix.birthTimeSensitivity.window.representativeTime}。

    +
      + {appendix.birthTimeSensitivity.themes.map((theme) => ( +
    • + {themeLabel(theme.theme)}:{theme.status === "sensitive" ? "敏感" : "稳定"} + {theme.sensitiveLayers.length > 0 ? `(变化层:${theme.sensitiveLayers.join("、")})` : ""} + {theme.minuteVariations.map((variation) => ( + {variation.layer}:{variation.values.map((entry) => `${entry.minute}=${entry.value}`).join(";")} + ))} +
    • + ))} +
    +

    {appendix.birthTimeSensitivity.claimBoundary}

    +
    + )} )} diff --git a/frontend/src/lib/personal-report-contract.ts b/frontend/src/lib/personal-report-contract.ts index 8522845f..9043a4f8 100644 --- a/frontend/src/lib/personal-report-contract.ts +++ b/frontend/src/lib/personal-report-contract.ts @@ -141,12 +141,36 @@ const calculationEvidenceRowSchema = z.strictObject({ source: text(200), }); +const birthTimeSensitivitySchema = z.strictObject({ + window: z.strictObject({ + startTime: text(5), + endTime: text(5), + representativeTime: text(5), + candidateCount: z.number().int().min(2).max(15), + }), + themes: z.array(z.strictObject({ + theme: themeIdSchema, + status: z.enum(["stable", "sensitive"]), + stableLayers: textArray(24, 100), + sensitiveLayers: textArray(24, 100), + minuteVariations: z.array(z.strictObject({ + layer: text(100), + values: z.array(z.strictObject({ + minute: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), + value: text(300), + })).min(2).max(15), + })).max(24), + })).max(12), + claimBoundary: text(500), +}); + const evidenceAppendixSchema = z.strictObject({ expandedByDefault: z.boolean(), techniqueAudit: z.array(techniqueAuditRowSchema).max(100), conflicts: z.array(conflictRowSchema).max(50), calculationEvidence: z.array(calculationEvidenceRowSchema).max(100), blockedTechniques: textArray(100, 120), + birthTimeSensitivity: birthTimeSensitivitySchema.optional(), }); const subjectSchema = z.strictObject({ diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index c7d1c8f7..3ccacf2c 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -29,6 +29,7 @@ import type { ReportFunctionalRole, ReportFunctionalRoleFact, ReportInterpretiveFacts, + ReportBirthTimeSensitivityFact, ReportPlanetaryFriendshipFact, ReportSavScoreFact, ReportShadbalaRankFact, @@ -1343,6 +1344,52 @@ function readSavFacts( }; } +function readBirthTimeSensitivity(workflow: JsonRecord): ReportBirthTimeSensitivityFact | undefined { + const packet = record(workflow.birth_time_sensitivity); + const window = record(packet?.window); + const rawThemes = record(packet?.theme_sensitivity); + const sensitiveLayers = record(record(packet?.sensitive_evidence)?.layers); + const candidateCount = finiteNumber(window?.candidate_count); + const startTime = text(window?.start_time); + const endTime = text(window?.end_time); + const representativeTime = text(window?.representative_time); + const claimBoundary = text(packet?.claim_boundary); + if (packet?.status !== "candidate_window_only" || !startTime || !endTime || !representativeTime + || candidateCount === null || candidateCount < 2 || candidateCount > 15 || !claimBoundary) return undefined; + const themeAliases: Readonly> = { health: "health_pressure" }; + const themes = Object.entries(rawThemes ?? {}).flatMap(([rawTheme, value]) => { + const row = record(value); + if (row?.status !== "stable" && row?.status !== "sensitive") return []; + const requestedSensitiveLayers = stringArray(row.sensitive_layers).slice(0, 24); + const minuteVariations = requestedSensitiveLayers.flatMap((layer) => { + const valuesByMinute = record(sensitiveLayers?.[layer]); + const values = Object.entries(valuesByMinute ?? {}).flatMap(([minute, rawValue]) => { + if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(minute)) return []; + const value = rawValue !== null && typeof rawValue === "object" + ? canonicalSerialize(rawValue) + : String(rawValue ?? ""); + const cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 300); + return cleaned && /[\p{L}\p{N}]/u.test(cleaned) ? [{ minute, value: cleaned }] : []; + }).sort((a, b) => a.minute.localeCompare(b.minute)); + return values.length >= 2 && new Set(values.map((entry) => entry.value)).size >= 2 + ? [{ layer, values }] + : []; + }).sort((a, b) => a.layer.localeCompare(b.layer)); + return [{ + theme: themeAliases[rawTheme] ?? rawTheme, + status: minuteVariations.length > 0 ? "sensitive" as const : "stable" as const, + stableLayers: stringArray(row.stable_layers).slice(0, 24), + sensitiveLayers: minuteVariations.map((entry) => entry.layer), + minuteVariations, + }]; + }); + return { + window: { startTime, endTime, representativeTime, candidateCount }, + themes, + claimBoundary: claimBoundary.slice(0, 500), + }; +} + function readCurrentDasha(workflow: JsonRecord): ReportCurrentDashaFact | null { const snapshot = readEvidenceSnapshot(workflow); const modules = readChartModules(workflow); @@ -1828,6 +1875,10 @@ export function buildReportEvidenceBundleV2( } return null; })(); + const birthTimeSensitivity = collectFirst((workflow) => { + const fact = readBirthTimeSensitivity(workflow); + return fact ? [fact] : []; + })[0]; const interpretiveFacts: ReportInterpretiveFacts = { yogas: yogaRef ? collectFirst((workflow) => readYogaFacts(workflow, yogaRef)) : [], functionalRoles: functionalRef @@ -1840,6 +1891,7 @@ export function buildReportEvidenceBundleV2( convergenceDomains: collectFirst(readConvergenceDomains), planetaryFriendship, pratyantarTimeline, + ...(birthTimeSensitivity ? { birthTimeSensitivity } : {}), }; // --- theme narrative seeds ---------------------------------------------- @@ -2559,6 +2611,21 @@ function evidenceAppendixFromBundle( blockedTechniques: uniqueInOrder(bundle.blockedSections.flatMap((section) => ( section.missingTechniqueRefs.map((ref) => ledgerById.get(ref)?.technique ?? ref) ))).slice(0, 100), + ...(bundle.interpretiveFacts.birthTimeSensitivity + ? { birthTimeSensitivity: { + ...bundle.interpretiveFacts.birthTimeSensitivity, + window: { ...bundle.interpretiveFacts.birthTimeSensitivity.window }, + themes: bundle.interpretiveFacts.birthTimeSensitivity.themes.map((theme) => ({ + ...theme, + stableLayers: [...theme.stableLayers], + sensitiveLayers: [...theme.sensitiveLayers], + minuteVariations: theme.minuteVariations.map((variation) => ({ + ...variation, + values: variation.values.map((value) => ({ ...value })), + })), + })), + } } + : {}), }; } @@ -2611,7 +2678,11 @@ export function assembleReportDocumentV2( id: section.id, theme: section.theme, title: section.title, - narrative: section.narrative, + narrative: bundle.interpretiveFacts.birthTimeSensitivity?.themes.some( + (theme) => theme.theme === section.theme && theme.status === "sensitive", + ) + ? `${section.narrative}\n\n本主题在当前出生时间可信区间内存在层级变化,以下内容只能作条件性解读,不能用于认定唯一出生分钟。` + : section.narrative, actions: [...section.actions], caveats: [...section.caveats], claimStatus: section.claimStatus, @@ -3215,6 +3286,12 @@ export function filterReportEvidenceBundleForSection( convergenceDomains: source.interpretiveFacts.convergenceDomains, planetaryFriendship: source.interpretiveFacts.planetaryFriendship, pratyantarTimeline: section.theme === "timing" ? source.interpretiveFacts.pratyantarTimeline : null, + ...(source.interpretiveFacts.birthTimeSensitivity ? { + birthTimeSensitivity: { + ...source.interpretiveFacts.birthTimeSensitivity, + themes: source.interpretiveFacts.birthTimeSensitivity.themes.filter((theme) => theme.theme === section.theme), + }, + } : {}), }; const charts = source.charts.filter((chart) => (chart.id === "D1" || ledger.some((receipt) => receipt.technique.toUpperCase() === chart.id))); diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 93bfde08..5d3d5306 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -191,6 +191,7 @@ export type ReportCreateCoreDeps = Readonly<{ userId: string | null; rawBody: unknown; profile: unknown | null; + loadCandidateRange?: () => Promise | null>; checkSessionOwned: (sessionId: string) => Promise; checkChartProfileOwned: (chartProfileId: string) => Promise; featureEnabled: boolean; @@ -211,6 +212,43 @@ export type ReportCreateCoreDeps = Readonly<{ now?: () => Date; }>; +const candidateClockPattern = /^((?:[01]\d|2[0-3]):[0-5]\d)(?::00(?:\.0+)?)?$/; + +function candidateWindow( + range: Readonly<{ startTime: string; endTime: string }> | null, +): { startTime: string; endTime: string } | null { + if (!range) return null; + const startTime = text(range.startTime)?.match(candidateClockPattern)?.[1]; + const endTime = text(range.endTime)?.match(candidateClockPattern)?.[1]; + if (!startTime || !endTime || startTime > endTime) throw new Error("report_candidate_range_invalid"); + return { startTime, endTime }; +} + +export function resolveReportBirthTimeSensitivityInput( + profileValue: unknown, + birthTimeStatus: ReportSubjectBirthTimeStatus, + representativeTime: string, + candidateRange: Readonly<{ startTime: string; endTime: string }> | null, +): Partial { + void profileValue; + if (birthTimeStatus === "confirmed") { + return { birth_time_accuracy: "confirmed", representative_time: representativeTime }; + } + + const trustedRange = candidateWindow(candidateRange); + if (trustedRange && trustedRange.startTime === trustedRange.endTime) { + return { birth_time_accuracy: "confirmed", representative_time: representativeTime }; + } + + const base = { + birth_time_accuracy: birthTimeStatus === "reported" ? "approximate" : "provisional", + representative_time: representativeTime, + } as const; + return trustedRange + ? { ...base, candidate_range: { start_time: trustedRange.startTime, end_time: trustedRange.endTime } } + : base; +} + export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise { // Same-origin gate first (CSRF), then auth. const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders); @@ -406,6 +444,24 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise => { // Real workflow evidence (main chain), never mock/example/random data. + let sensitivityInput: Partial; + try { + sensitivityInput = resolveReportBirthTimeSensitivityInput( + profile, + birthTimeStatus, + `${String(birthClock.hour).padStart(2, "0")}:${String(birthClock.minute).padStart(2, "0")}`, + birthTimeStatus === "confirmed" || !deps.loadCandidateRange + ? null + : await deps.loadCandidateRange(), + ); + } catch { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable); + await releaseBilling(REPORT_STABLE_CODES.calculationUnavailable); + return { + status: 502, + body: { error: "出生时间可信区间不可用", code: REPORT_STABLE_CODES.calculationUnavailable }, + }; + } const workflowInputs: ConsultationInput[] = payload.themes.map((theme) => ({ year: birthDate.year, month: birthDate.month, @@ -420,6 +476,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise | null = null, +) { const profile = record(context.profile); if (!profile) throw new PersonalReportWorkerError("profile_incomplete", false); @@ -104,6 +115,12 @@ async function generateProductionReport(context: PersonalReportWorkerGenerationC } const birthClock = usableBirth.clock; const birthTimeStatus = usableBirth.status; + const sensitivityInput = resolveReportBirthTimeSensitivityInput( + profile, + birthTimeStatus, + `${String(birthClock.hour).padStart(2, "0")}:${String(birthClock.minute).padStart(2, "0")}`, + candidateRange, + ); const catalog = await loadLanguageModelCatalog(); const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null; @@ -125,6 +142,7 @@ async function generateProductionReport(context: PersonalReportWorkerGenerationC question: `请为个人报告计算 ${rawTheme} 主题证据`, theme: rawTheme as ConsultationInput["theme"], entryMode: "direct_chart", + ...sensitivityInput, }; try { workflows.push({ @@ -233,7 +251,45 @@ function createProductionWorker(workerId: string) { if (error) throw new Error("personal report profile load failed"); return data ?? null; }, - generate: generateProductionReport, + generate: async (context) => { + const profile = record(context.profile) ?? {}; + if (resolveReportBirthClock(profile)?.status === "confirmed") { + return generateProductionReport(context); + } + const caseId = text(profile.rectification_case_id); + if (caseId) { + const { data, error } = await admin + .from("birth_time_rectification_cases") + .select("candidate_start,candidate_end") + .eq("id", caseId) + .eq("user_id", context.report.userId) + .in("status", ["confirmed", "completed"]) + .maybeSingle(); + if (error) throw new PersonalReportWorkerError("calculation_unavailable", true); + const row = record(data); + if (typeof row?.candidate_start === "string" && typeof row.candidate_end === "string") { + return generateProductionReport(context, { + startTime: row.candidate_start, + endTime: row.candidate_end, + }); + } + } + const { data, error } = await admin + .from("agentic_rectification_cases") + .select("candidate_range,updated_at") + .eq("user_id", context.report.userId) + .eq("status", "candidate_accepted") + .order("updated_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw new PersonalReportWorkerError("calculation_unavailable", true); + const range = record(record(data)?.candidate_range); + return generateProductionReport(context, range + && typeof range.start_time === "string" + && typeof range.end_time === "string" + ? { startTime: range.start_time, endTime: range.end_time } + : null); + }, }); } diff --git a/frontend/src/lib/report-evidence-bundle-v2.ts b/frontend/src/lib/report-evidence-bundle-v2.ts index fc5069cf..8e59c646 100644 --- a/frontend/src/lib/report-evidence-bundle-v2.ts +++ b/frontend/src/lib/report-evidence-bundle-v2.ts @@ -172,6 +172,25 @@ export type ReportPratyantarTimeline = Readonly<{ current: ReportDashaPeriod | null; next: ReportDashaPeriod | null; }>; +export type ReportBirthTimeSensitivityFact = Readonly<{ + window: Readonly<{ + startTime: string; + endTime: string; + representativeTime: string; + candidateCount: number; + }>; + themes: readonly Readonly<{ + theme: string; + status: "stable" | "sensitive"; + stableLayers: readonly string[]; + sensitiveLayers: readonly string[]; + minuteVariations: readonly Readonly<{ + layer: string; + values: readonly Readonly<{ minute: string; value: string }>[]; + }>[]; + }>[]; + claimBoundary: string; +}>; /** * Server-computed interpretive facts. Every entry is a closed vocabulary value @@ -188,6 +207,7 @@ export type ReportInterpretiveFacts = Readonly<{ convergenceDomains: readonly string[]; planetaryFriendship: readonly ReportPlanetaryFriendshipFact[]; pratyantarTimeline: ReportPratyantarTimeline | null; + birthTimeSensitivity?: ReportBirthTimeSensitivityFact; }>; /** @@ -376,6 +396,28 @@ const interpretiveFactsSchema = z.object({ current: dashaPeriodSchema.nullable(), next: dashaPeriodSchema.nullable(), }).strict().nullable(), + birthTimeSensitivity: z.object({ + window: z.object({ + startTime: z.string().regex(/^\d{2}:\d{2}$/), + endTime: z.string().regex(/^\d{2}:\d{2}$/), + representativeTime: z.string().regex(/^\d{2}:\d{2}$/), + candidateCount: z.number().int().min(2).max(15), + }).strict(), + themes: z.array(z.object({ + theme: idSchema, + status: z.enum(["stable", "sensitive"]), + stableLayers: z.array(seedTextSchema(100)).max(24), + sensitiveLayers: z.array(seedTextSchema(100)).max(24), + minuteVariations: z.array(z.object({ + layer: seedTextSchema(100), + values: z.array(z.object({ + minute: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), + value: seedTextSchema(300), + }).strict()).min(2).max(15), + }).strict()).max(24), + }).strict()).max(12), + claimBoundary: seedTextSchema(500), + }).strict().optional(), }).strict(); const themeNarrativeSeedSchema = z.object({ @@ -567,6 +609,24 @@ function sortedBundleContent(bundle: Omit) })) .sort((a, b) => a.planet.localeCompare(b.planet)), pratyantarTimeline: bundle.interpretiveFacts.pratyantarTimeline, + ...(bundle.interpretiveFacts.birthTimeSensitivity ? { + birthTimeSensitivity: { + ...bundle.interpretiveFacts.birthTimeSensitivity, + themes: [...bundle.interpretiveFacts.birthTimeSensitivity.themes] + .map((theme) => ({ + ...theme, + stableLayers: sortedUnique(theme.stableLayers), + sensitiveLayers: sortedUnique(theme.sensitiveLayers), + minuteVariations: [...theme.minuteVariations] + .map((variation) => ({ + ...variation, + values: [...variation.values].sort((a, b) => a.minute.localeCompare(b.minute)), + })) + .sort((a, b) => a.layer.localeCompare(b.layer)), + })) + .sort((a, b) => a.theme.localeCompare(b.theme)), + }, + } : {}), }, themeNarrativeSeeds: [...bundle.themeNarrativeSeeds] .map((seed) => ({ ...seed, evidenceRefs: sortedUnique(seed.evidenceRefs) })) diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 643c6515..af81b82c 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -19,6 +19,13 @@ export const consultationInputSchema = z.object({ entryMode: z.enum(["direct_chart", "rectification"]).default("direct_chart"), declared_accuracy: z.enum(["rectified", "minute", "15min", "1hour", "unknown"]).optional(), time_source: z.string().trim().min(1).max(40).optional(), + birth_time_accuracy: z.enum(["confirmed", "provisional", "approximate"]).optional(), + candidate_range: z.object({ start_time: z.string(), end_time: z.string() }).passthrough().optional(), + representative_time: z.string().optional(), + declared_window_start: z.string().optional(), + declared_window_end: z.string().optional(), + uncertainty_before_minutes: z.number().int().min(0).max(720).optional(), + uncertainty_after_minutes: z.number().int().min(0).max(720).optional(), }); export type ConsultationInput = z.infer; type JsonRecord = Record; @@ -41,6 +48,10 @@ export const consultationWorkflowResponseSchema = z.object({ chart: z.record(z.unknown()), routing: z.record(z.unknown()), consumer_context: workflowConsumerContextSchema, + birth_time_sensitivity: z.object({ + schema: z.literal("jyotish.report_birth_time_sensitivity.v1"), + status: z.enum(["not_applicable", "candidate_window_only"]), + }).passthrough().optional(), }).passthrough(); function record(value: unknown): JsonRecord { diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts index 8a2427e3..cb0c6ad0 100644 --- a/frontend/tests/personal-report-api.test.ts +++ b/frontend/tests/personal-report-api.test.ts @@ -469,6 +469,72 @@ test("core create: 201 for a reported minute uses that clock and directional pol assert.equal(bundle.answerPolicy.birthTimePolicy, "reported_directional_only"); }); +test("core create: confirmed reports do not query rectification ranges", async () => { + const response = await resolveReportCreate(baseDeps({ + loadCandidateRange: async () => { throw new Error("must not query"); }, + })); + assert.equal(response.status, 201); +}); + +test("core create: trusted rectification range wins and accepted multi-minute input stays provisional", async () => { + const inputs: Record[] = []; + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ + birth_time_status: "accepted", + declared_window_start: "09:00", + declared_window_end: "11:00", + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + }), + loadCandidateRange: async () => ({ startTime: "10:00:00", endTime: "10:04:00" }), + runWorkflow: async (input) => { + inputs.push(input as unknown as Record); + return chartPayload(); + }, + })); + assert.equal(response.status, 201); + assert.equal(inputs[0].birth_time_accuracy, "provisional"); + assert.deepEqual(inputs[0].candidate_range, { start_time: "10:00", end_time: "10:04" }); + assert.equal("declared_window_start" in inputs[0], false); + assert.equal("uncertainty_before_minutes" in inputs[0], false); +}); + +test("core create: accepted single-minute range becomes confirmed without a candidate window", async () => { + const inputs: Record[] = []; + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ birth_time_status: "accepted" }), + loadCandidateRange: async () => ({ startTime: "10:00", endTime: "10:00" }), + runWorkflow: async (input) => { + inputs.push(input as unknown as Record); + return chartPayload(); + }, + })); + assert.equal(response.status, 201); + assert.equal(inputs[0].birth_time_accuracy, "confirmed"); + assert.equal("candidate_range" in inputs[0], false); +}); + +test("core create: no adopted range uses the fixed accuracy fallback", async () => { + const inputs: Record[] = []; + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ + birth_time_status: "accepted", + declared_window_start: "10:00", + declared_window_end: "10:00", + uncertainty_before_minutes: 0, + uncertainty_after_minutes: 0, + }), + runWorkflow: async (input) => { + inputs.push(input as unknown as Record); + return chartPayload(); + }, + })); + assert.equal(response.status, 201); + assert.equal(inputs[0].birth_time_accuracy, "provisional"); + assert.equal("declared_window_start" in inputs[0], false); + assert.equal("uncertainty_before_minutes" in inputs[0], false); +}); + test("core create: 422 birth_time_not_usable for incomplete profile fields", async () => { const response = await resolveReportCreate(baseDeps({ profile: profileFixture({ latitude: null, longitude: null }), @@ -1139,6 +1205,13 @@ test("POST route uses dual clients: authenticated reads + admin persistence", () assert.doesNotMatch(createRoute, /not wired yet|尚未就绪/); }); +test("POST route only trusts terminal legacy rectification ranges", () => { + assert.match( + createRoute, + /from\("birth_time_rectification_cases"\)[\s\S]*?\.in\("status", \["confirmed", "completed"\]\)/, + ); +}); + test("GET/DELETE use the authenticated client (least privilege) and the core handlers", () => { assert.match(itemRoute, /createServerSupabaseClient\(\)/); assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/); diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index 9e6278ed..7e5379b2 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -175,6 +175,36 @@ test("expandedByDefault=true expands the appendix even in default mode", () => { assert.match(markup, /
    /, "expandedByDefault forces the expanded state"); }); +test("birth-time sensitivity alone keeps the appendix visible and shows minute changes", () => { + const document = structuredClone(canonicalV2Fixture); + document.evidenceAppendix = { + expandedByDefault: false, + techniqueAudit: [], + conflicts: [], + calculationEvidence: [], + blockedTechniques: [], + birthTimeSensitivity: { + window: { startTime: "10:00", endTime: "10:01", representativeTime: "10:00", candidateCount: 2 }, + themes: [{ + theme: "career", + status: "sensitive", + stableLayers: [], + sensitiveLayers: ["D10.ascendant"], + minuteVariations: [{ + layer: "D10.ascendant", + values: [{ minute: "10:00", value: "Leo" }, { minute: "10:01", value: "Virgo" }], + }], + }], + claimBoundary: "Candidate-window comparison only.", + }, + }; + const markup = render(document); + assert.match(markup, /personal-report-appendix-details/); + assert.match(markup, /D10\.ascendant/); + assert.match(markup, /10:00=Leo/); + assert.match(markup, /10:01=Virgo/); +}); + test("D1 SVG renders when real houses exist; D9/D10 are never fabricated", () => { const svgCount = (markup: string) => (markup.match(/; function workflowFixture(options: FixtureOptions = {}): JsonRecord { @@ -289,6 +290,30 @@ function workflowFixture(options: FixtureOptions = {}): JsonRecord { answer_policy: { can_answer_direction: true, can_answer_precise_timing: false }, }, machine_evidence_packet: { sections: machineSections(), conflicts: [] }, + ...(options.birthTimeSensitivity ? { + birth_time_sensitivity: { + schema: "jyotish.report_birth_time_sensitivity.v1", + status: "candidate_window_only", + window: { + start_time: "10:00", + end_time: "10:01", + representative_time: "10:00", + candidate_count: 2, + }, + theme_sensitivity: { + career: { status: "sensitive", stable_layers: ["arudha.A10"], sensitive_layers: ["D10.ascendant"] }, + marriage: { status: "sensitive", stable_layers: ["arudha.UL"], sensitive_layers: ["D9.ascendant"] }, + }, + sensitive_evidence: { + evidence_keys: ["D10.ascendant", "D9.ascendant"], + layers: { + "D10.ascendant": { "10:01": "Virgo", "10:00": "Leo" }, + "D9.ascendant": { "10:01": "Taurus", "10:00": "Aries" }, + }, + }, + claim_boundary: "Candidate-window comparison only; no minute is selected or confirmed.", + }, + } : {}), }; } @@ -420,6 +445,60 @@ test("bundle hash is stable under interpretive field ordering", () => { assert.equal(shuffled.bundleHash, bundle.bundleHash); }); +test("birth-time sensitivity carries actual minute changes and hashes independently of ordering", () => { + const workflow = workflowFixture({ birthTimeSensitivity: true }); + const build = (value: JsonRecord) => buildReportEvidenceBundleV2({ + workflows: ["career", "marriage"].map((theme) => ({ theme, workflow: value })), + subject: { displayName: "冒烟用户", birthTimeStatus: "accepted", birthPlaceLabel: "冒烟市" }, + requestedThemes: ["career", "marriage"], + reportType: "personal_full", + presentationMode: "default", + skillSnapshot: { name: "jyotish-personal-report", version: "1.0.0", sha256: "a".repeat(64), sourceCommit: null }, + }); + const bundle = build(workflow); + const sensitivity = bundle.interpretiveFacts.birthTimeSensitivity; + assert.ok(sensitivity); + assert.deepEqual(sensitivity.themes.find((theme) => theme.theme === "career")?.minuteVariations, [{ + layer: "D10.ascendant", + values: [{ minute: "10:00", value: "Leo" }, { minute: "10:01", value: "Virgo" }], + }]); + + const shuffledWorkflow = structuredClone(workflow); + const packet = shuffledWorkflow.birth_time_sensitivity as JsonRecord; + packet.theme_sensitivity = Object.fromEntries(Object.entries(packet.theme_sensitivity as JsonRecord).reverse()); + const layers = (packet.sensitive_evidence as JsonRecord).layers as JsonRecord; + layers["D10.ascendant"] = Object.fromEntries(Object.entries(layers["D10.ascendant"] as JsonRecord).reverse()); + assert.equal(build(shuffledWorkflow).bundleHash, bundle.bundleHash); + + const plan = buildPersonalReportSectionPlan(bundle, "standard"); + const careerSection = plan.sections.find((section) => section.theme === "career")!; + const careerBundle = filterReportEvidenceBundleForSection(bundle, careerSection); + assert.deepEqual(careerBundle.interpretiveFacts.birthTimeSensitivity?.themes.map((theme) => theme.theme), ["career"]); + assert.deepEqual( + careerBundle.interpretiveFacts.birthTimeSensitivity?.themes.flatMap((theme) => theme.minuteVariations.map((row) => row.layer)), + ["D10.ascendant"], + ); +}); + +test("confirmed not-applicable sensitivity leaves the legacy bundle hash unchanged", () => { + const baseline = buildBundle(); + const workflow = workflowFixture(); + workflow.birth_time_sensitivity = { + schema: "jyotish.report_birth_time_sensitivity.v1", + status: "not_applicable", + accuracy: "confirmed", + }; + const confirmed = buildReportEvidenceBundleV2({ + workflows: THEMES.map((theme) => ({ theme, workflow })), + subject: { displayName: "冒烟用户", birthTimeStatus: "reported", birthPlaceLabel: "冒烟市" }, + requestedThemes: [...THEMES], + reportType: "personal_full", + presentationMode: "default", + skillSnapshot: { name: "jyotish-personal-report", version: "1.0.0", sha256: "a".repeat(64), sourceCommit: null }, + }); + assert.equal(confirmed.bundleHash, baseline.bundleHash); +}); + // --------------------------------------------------------------------------- // Task 1: fail-closed validation of the new fields // --------------------------------------------------------------------------- diff --git a/scripts/flexible_birth_time_full_report_projection.py b/scripts/flexible_birth_time_full_report_projection.py new file mode 100644 index 00000000..32836fec --- /dev/null +++ b/scripts/flexible_birth_time_full_report_projection.py @@ -0,0 +1,53 @@ +"""Build the report-facing projection of birth-time sensitivity evidence.""" + +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any, Mapping + +try: + from flexible_birth_time_profile import _candidate_window_authority_violation +except ImportError: # pragma: no cover - package import + from scripts.flexible_birth_time_profile import _candidate_window_authority_violation + +SCHEMA_VERSION = "jyotish.flexible_birth_time_full_report_projection.v1" +SUPPORT_SCHEMA_VERSION = "jyotish.flexible_birth_time_report_support.v1" + + +class FlexibleBirthTimeFullReportProjectionError(ValueError): + """Raised when report support cannot be projected safely.""" + + +def build_flexible_birth_time_full_report_projection(support: Mapping[str, Any]) -> dict[str, Any]: + packet = _validate_support(support) + window = deepcopy(dict(packet.get("birth_time_window") or {})) + return { + "schema_version": SCHEMA_VERSION, + "projection_id": _digest_id(str(packet.get("support_id")), json.dumps(window, sort_keys=True, separators=(",", ":"))), + "report_section_type": "birth_time_sensitivity", + "window": window, + "stable_structure_section": deepcopy(dict(packet.get("stable_report_evidence") or {})), + "minute_sensitive_section": deepcopy(dict(packet.get("sensitive_report_evidence") or {})), + "theme_sensitivity": deepcopy(dict(packet.get("theme_sensitivity") or {})), + "trace": deepcopy(list(packet.get("trace") or [])), + "status": "candidate_window_only", + "claim_boundary": str(packet.get("claim_boundary") or "Sensitivity remains conditional."), + } + + +def _validate_support(value: Mapping[str, Any]) -> dict[str, Any]: + violation = _candidate_window_authority_violation(value) + if violation: + raise FlexibleBirthTimeFullReportProjectionError(f"candidate_window_authority_forbidden:{violation}") + if not isinstance(value, Mapping) or value.get("schema_version") != SUPPORT_SCHEMA_VERSION: + raise FlexibleBirthTimeFullReportProjectionError("flexible_birth_time_report_support_schema_invalid") + if value.get("status") != "candidate_window_only": + raise FlexibleBirthTimeFullReportProjectionError("support_must_remain_candidate_window_only") + return dict(value) + + +def _digest_id(*parts: str) -> str: + payload = json.dumps(parts, ensure_ascii=True, separators=(",", ":")) + return f"flex-report-projection://{sha256(payload.encode('utf-8')).hexdigest()[:24]}" diff --git a/scripts/flexible_birth_time_profile.py b/scripts/flexible_birth_time_profile.py new file mode 100644 index 00000000..3ef12bc5 --- /dev/null +++ b/scripts/flexible_birth_time_profile.py @@ -0,0 +1,323 @@ +"""Build a read-only sensitivity profile for an unresolved birth-time window.""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime +from hashlib import sha256 +import json +import re +from typing import Any, Mapping, Sequence + +SCHEMA_VERSION = "jyotish.flexible_birth_time_profile.v1" +MAX_CANDIDATE_MINUTES = 15 +_CLOCK_RE = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d") +_PROHIBITED_AUTHORITY_FIELDS = frozenset({ + "approved_birth_time", + "final_birth_time", + "winner", + "approval", + "approval_authority", +}) +_PROHIBITED_SOURCE_STATUSES = frozenset({"approved", "confirmed"}) + + +class FlexibleBirthTimeProfileError(ValueError): + """Raised when a candidate window cannot be represented safely.""" + + +def build_flexible_birth_time_profile( + candidates: Sequence[Mapping[str, Any]], + *, + source_reference: Mapping[str, Any], +) -> dict[str, Any]: + _reject_candidate_window_authority(candidates) + rows = _normalize_candidates(candidates) + source = _normalize_source_reference(source_reference) + stable: dict[str, Any] = {} + sensitive: dict[str, dict[str, Any]] = {} + for key in sorted({key for row in rows for key in row["evidence"]}): + values = {row["candidate_time"]: row["evidence"].get(key) for row in rows} + if len({_canonical(value) for value in values.values()}) == 1: + stable[key] = deepcopy(next(iter(values.values()))) + else: + sensitive[key] = deepcopy(values) + times = [row["candidate_time"] for row in rows] + profile_id = _profile_id(times, source["review_id"]) + return { + "schema_version": SCHEMA_VERSION, + "flexible_profile_id": profile_id, + "birth_time_window": { + "start_time": times[0], + "end_time": times[-1], + "candidate_count": len(times), + "candidate_times": times, + }, + "candidate_references": [ + {"candidate_id": row["candidate_id"], "candidate_time": row["candidate_time"]} + for row in rows + ], + "stable_evidence": stable, + "sensitive_evidence": sensitive, + "source_reference": source, + "trace": [ + {"kind": "flexible_birth_time_profile", "reference": profile_id}, + {"kind": "candidate_window", "reference": source["review_id"]}, + *( + {"kind": "candidate", "reference": f"candidate://{row['candidate_id']}"} + for row in rows + ), + ], + "status": "candidate_window_only", + "claim_boundary": ( + "Read-only candidate-window comparison. It cannot select or confirm a birth minute, " + "replace chart identity, or grant authority to a candidate chart." + ), + } + + +def build_flexible_birth_time_profile_from_window( + *, + birth_date: str, + start_time: str, + end_time: str, + candidate_times: Sequence[str], + lat: float, + lon: float, + tz: float, + ayanamsa: str = "raman", + node_mode: str = "mean", + source_reference: Mapping[str, Any], +) -> dict[str, Any]: + start, end = _parse_window(birth_date, start_time, end_time) + normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times) + candidates = [ + _candidate_from_recast( + candidate_at=value, + recast=_recast_candidate_layers( + value, lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode, + ), + ayanamsa=ayanamsa, + node_mode=node_mode, + ) + for value in normalized_times + ] + profile = build_flexible_birth_time_profile(candidates, source_reference=source_reference) + profile["calculation_profile"] = { + "ayanamsa": ayanamsa, + "node_mode": node_mode, + "coordinate_mode": "explicit_lat_lon_tz", + "candidate_recast": "native_domain_calculation_service", + } + return profile + + +def _recast_candidate_layers( + candidate: datetime, + *, + lat: float, + lon: float, + tz: float, + ayanamsa: str, + node_mode: str, +) -> dict[str, Any]: + try: + import domain_calculation_service + import jaimini + import kp_system + import varga + except ModuleNotFoundError: # pragma: no cover - package import + from scripts import domain_calculation_service, jaimini, kp_system, varga + + chart = domain_calculation_service.compute_chart({ + "year": candidate.year, + "month": candidate.month, + "day": candidate.day, + "hour": candidate.hour, + "minute": candidate.minute, + "second": candidate.second, + "lat": lat, + "lon": lon, + "tz": tz, + "ayanamsa": ayanamsa, + "node_mode": node_mode, + }) + planets = { + name: row["lon"] + for name, row in (chart.get("planets") or {}).items() + if name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"} + } + ascendant = chart.get("ascendant") or {} + asc_lon = float(ascendant["lon"]) + divisions = [2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 16, 24, 30, 40, 45, 60] + vargas = varga.calc_all_vargas(planets, asc_lon, divisions=divisions) + arudha = jaimini.calc_arudha_padas(int(asc_lon // 30), planets) + padas = arudha.get("padas") or {} + kp_cusps: dict[str, Any] = {} + for house_key in ("house_1", "house_4", "house_7", "house_10"): + degree = ((chart.get("houses") or {}).get(house_key) or {}).get("cusp_degree") + if degree is None: + continue + lords = kp_system.get_kp_lords(float(degree)) + kp_cusps[house_key] = { + "sign": lords.get("sign"), + "nakshatra_lord": lords.get("nakshatra_lord"), + "sub_lord": lords.get("sub_lord"), + } + return { + "ascendant": ascendant, + "varga_lagna": {key: value.get("Ascendant") or {} for key, value in vargas.items()}, + "arudha": {"A7": padas.get("A7") or {}, "A10": padas.get("A10") or {}, "UL": arudha.get("upapada") or {}}, + "kp_cusps": kp_cusps, + } + + +def _candidate_from_recast( + *, + candidate_at: datetime, + recast: Mapping[str, Any], + ayanamsa: str, + node_mode: str, +) -> dict[str, Any]: + evidence: dict[str, Any] = {"D1.ascendant": (recast.get("ascendant") or {}).get("sign")} + for key, value in (recast.get("varga_lagna") or {}).items(): + if isinstance(key, str) and key.startswith("D") and isinstance(value, Mapping): + evidence[f"{key.split('_', 1)[0]}.ascendant"] = value.get("sign") + for key in ("A7", "A10", "UL"): + value = (recast.get("arudha") or {}).get(key) + if isinstance(value, Mapping): + evidence[f"arudha.{key}"] = value.get("sign") + evidence["KP.cusp_observation"] = recast.get("kp_cusps") or {} + candidate_time = candidate_at.strftime("%H:%M") + candidate_id = f"candidate-{candidate_time.replace(':', '')}" + return { + "candidate_id": candidate_id, + "candidate_time": candidate_time, + "evidence": evidence, + "trace": [ + {"kind": "candidate_chart_recast", "reference": f"candidate-chart://{candidate_id}"}, + {"kind": "calculation_profile", "reference": f"ayanamsa://{ayanamsa}/node/{node_mode}"}, + ], + } + + +def _parse_window(birth_date: str, start_time: str, end_time: str) -> tuple[datetime, datetime]: + if not _is_hh_mm(start_time) or not _is_hh_mm(end_time): + raise FlexibleBirthTimeProfileError("birth_date_or_candidate_time_invalid") + try: + start = datetime.strptime(f"{birth_date} {start_time}", "%Y-%m-%d %H:%M") + end = datetime.strptime(f"{birth_date} {end_time}", "%Y-%m-%d %H:%M") + except (TypeError, ValueError) as exc: + raise FlexibleBirthTimeProfileError("birth_date_or_candidate_time_invalid") from exc + if end < start: + raise FlexibleBirthTimeProfileError("candidate_window_must_not_cross_midnight") + return start, end + + +def _normalize_candidate_times( + birth_date: str, + start: datetime, + end: datetime, + candidate_times: Sequence[str], +) -> list[datetime]: + if isinstance(candidate_times, (str, bytes)) or not isinstance(candidate_times, Sequence): + raise FlexibleBirthTimeProfileError("candidate_times_required") + if len(candidate_times) < 2 or len(candidate_times) > MAX_CANDIDATE_MINUTES: + raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_fifteen") + values: list[datetime] = [] + for raw in candidate_times: + if not _is_hh_mm(raw): + raise FlexibleBirthTimeProfileError("candidate_time_invalid") + try: + value = datetime.strptime(f"{birth_date} {raw}", "%Y-%m-%d %H:%M") + except (TypeError, ValueError) as exc: + raise FlexibleBirthTimeProfileError("candidate_time_invalid") from exc + if value < start or value > end: + raise FlexibleBirthTimeProfileError("candidate_time_outside_window") + values.append(value) + if len(set(values)) != len(values): + raise FlexibleBirthTimeProfileError("candidate_times_must_be_unique") + return sorted(values) + + +def _normalize_candidates(candidates: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + if isinstance(candidates, (str, bytes)) or not isinstance(candidates, Sequence): + raise FlexibleBirthTimeProfileError("candidates_required") + if len(candidates) < 2 or len(candidates) > MAX_CANDIDATE_MINUTES: + raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_fifteen") + rows: list[dict[str, Any]] = [] + for candidate in candidates: + if not isinstance(candidate, Mapping): + raise FlexibleBirthTimeProfileError("candidate_must_be_mapping") + candidate_id = candidate.get("candidate_id") + candidate_time = candidate.get("candidate_time") + evidence = candidate.get("evidence") + trace = candidate.get("trace") + if not isinstance(candidate_id, str) or not candidate_id: + raise FlexibleBirthTimeProfileError("candidate_id_required") + if not _is_hh_mm(candidate_time): + raise FlexibleBirthTimeProfileError("candidate_time_required") + if not isinstance(evidence, Mapping) or not evidence: + raise FlexibleBirthTimeProfileError("candidate_evidence_required") + if not isinstance(trace, list) or not trace: + raise FlexibleBirthTimeProfileError("candidate_trace_required") + rows.append({"candidate_id": candidate_id, "candidate_time": candidate_time, "evidence": dict(evidence), "trace": trace}) + if len({row["candidate_id"] for row in rows}) != len(rows): + raise FlexibleBirthTimeProfileError("candidate_ids_must_be_unique") + if len({row["candidate_time"] for row in rows}) != len(rows): + raise FlexibleBirthTimeProfileError("candidate_times_must_be_unique") + return sorted(rows, key=lambda row: row["candidate_time"]) + + +def _normalize_source_reference(value: Mapping[str, Any]) -> dict[str, str]: + if not isinstance(value, Mapping): + raise FlexibleBirthTimeProfileError("source_reference_must_be_mapping") + _reject_candidate_window_authority(value) + status = value.get("status") + if isinstance(status, str) and status.strip().lower() in _PROHIBITED_SOURCE_STATUSES: + raise FlexibleBirthTimeProfileError("approved_or_confirmed_source_reference_forbidden") + review_id = value.get("review_id") + if not isinstance(review_id, str) or not review_id: + raise FlexibleBirthTimeProfileError("source_review_id_required") + return {"review_id": review_id, "status": "review_required"} + + +def _profile_id(candidate_times: Sequence[str], review_id: str) -> str: + digest = sha256(repr((tuple(candidate_times), review_id)).encode("utf-8")).hexdigest()[:24] + return f"flexible-birth-time://{digest}" + + +def _canonical(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str) + + +def _is_hh_mm(value: Any) -> bool: + return isinstance(value, str) and _CLOCK_RE.fullmatch(value) is not None + + +def _candidate_window_authority_violation(value: Any, path: str = "$") -> str | None: + if isinstance(value, Mapping): + for raw_key, item in value.items(): + key = str(raw_key).strip().lower() + child_path = f"{path}.{raw_key}" + if key in _PROHIBITED_AUTHORITY_FIELDS: + return child_path + if key == "source_reference" and isinstance(item, Mapping): + status = item.get("status") + if isinstance(status, str) and status.strip().lower() in _PROHIBITED_SOURCE_STATUSES: + return f"{child_path}.status" + violation = _candidate_window_authority_violation(item, child_path) + if violation: + return violation + elif isinstance(value, (list, tuple)): + for index, item in enumerate(value): + violation = _candidate_window_authority_violation(item, f"{path}[{index}]") + if violation: + return violation + return None + + +def _reject_candidate_window_authority(value: Any) -> None: + violation = _candidate_window_authority_violation(value) + if violation: + raise FlexibleBirthTimeProfileError(f"candidate_window_authority_forbidden:{violation}") diff --git a/scripts/flexible_birth_time_report_section.py b/scripts/flexible_birth_time_report_section.py new file mode 100644 index 00000000..a8350f66 --- /dev/null +++ b/scripts/flexible_birth_time_report_section.py @@ -0,0 +1,63 @@ +"""Render a bounded birth-time sensitivity appendix section.""" + +from __future__ import annotations + +from typing import Any, Mapping + +try: + from flexible_birth_time_profile import _candidate_window_authority_violation +except ImportError: # pragma: no cover - package import + from scripts.flexible_birth_time_profile import _candidate_window_authority_violation + +SCHEMA_VERSION = "jyotish.flexible_birth_time_full_report_projection.v1" + + +class FlexibleBirthTimeReportSectionError(ValueError): + """Raised when a sensitivity projection cannot be rendered safely.""" + + +def render_flexible_birth_time_report_section(projection: Mapping[str, Any]) -> str: + packet = _validate_projection(projection) + window = packet.get("window") or {} + stable = packet.get("stable_structure_section") or {} + sensitive = packet.get("minute_sensitive_section") or {} + lines = [ + "### 出生时间敏感度", + "", + f"- 可信区间:{window.get('start_time')}–{window.get('end_time')}", + f"- 代表分钟:{window.get('representative_time')}", + f"- 候选分钟数:{window.get('candidate_count')}", + "", + "#### 窗口内稳定层", + ] + for key in stable.get("evidence_keys") or []: + lines.append(f"- `{key}`:{_render_value((stable.get('layers') or {}).get(key))}") + lines.extend(["", "#### 窗口内敏感层"]) + for key in sensitive.get("evidence_keys") or []: + lines.append(f"- `{key}`:{_render_value((sensitive.get('layers') or {}).get(key))}") + lines.extend([ + "", + "#### 使用边界", + "", + "- 被标记为敏感的主题只能作条件性解读,不能据此认定唯一出生分钟。", + ]) + return "\n".join(lines).rstrip() + "\n" + + +def _validate_projection(value: Mapping[str, Any]) -> dict[str, Any]: + violation = _candidate_window_authority_violation(value) + if violation: + raise FlexibleBirthTimeReportSectionError(f"candidate_window_authority_forbidden:{violation}") + if not isinstance(value, Mapping) or value.get("schema_version") != SCHEMA_VERSION: + raise FlexibleBirthTimeReportSectionError("flexible_birth_time_full_report_projection_schema_invalid") + if value.get("status") != "candidate_window_only": + raise FlexibleBirthTimeReportSectionError("projection_must_remain_candidate_window_only") + return dict(value) + + +def _render_value(value: Any) -> str: + if isinstance(value, Mapping): + return " / ".join(f"{key}={_render_value(item)}" for key, item in value.items()) + if isinstance(value, list): + return " / ".join(_render_value(item) for item in value) + return str(value) diff --git a/scripts/flexible_birth_time_report_support.py b/scripts/flexible_birth_time_report_support.py new file mode 100644 index 00000000..a4848c93 --- /dev/null +++ b/scripts/flexible_birth_time_report_support.py @@ -0,0 +1,88 @@ +"""Project candidate-window evidence into bounded report support.""" + +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any, Mapping + +try: + from flexible_birth_time_profile import _candidate_window_authority_violation +except ImportError: # pragma: no cover - package import + from scripts.flexible_birth_time_profile import _candidate_window_authority_violation + +SCHEMA_VERSION = "jyotish.flexible_birth_time_report_support.v1" +PROFILE_SCHEMA_VERSION = "jyotish.flexible_birth_time_profile.v1" + +THEME_LAYERS: dict[str, tuple[str, ...]] = { + "career": ("D10.ascendant", "arudha.A10"), + "marriage": ("D7.ascendant", "D9.ascendant", "arudha.A7", "arudha.UL"), + "wealth": ("D2.ascendant", "D11.ascendant"), + "health": ("D6.ascendant", "D8.ascendant", "D30.ascendant"), + "timing": ("D1.ascendant", "D60.ascendant", "KP.cusp_observation"), + "general": (), +} + + +class FlexibleBirthTimeReportSupportError(ValueError): + """Raised when an unresolved profile cannot form report support.""" + + +def build_flexible_birth_time_report_support(profile: Mapping[str, Any]) -> dict[str, Any]: + packet = _validate_profile(profile) + stable = deepcopy(dict(packet.get("stable_evidence") or {})) + sensitive = deepcopy(dict(packet.get("sensitive_evidence") or {})) + themes = { + theme: _theme_status(theme, stable, sensitive) + for theme in THEME_LAYERS + } + support_id = _digest_id( + str(packet["flexible_profile_id"]), + str((packet.get("birth_time_window") or {}).get("start_time")), + str((packet.get("birth_time_window") or {}).get("end_time")), + ) + return { + "schema_version": SCHEMA_VERSION, + "support_id": support_id, + "birth_time_window": deepcopy(dict(packet["birth_time_window"])), + "stable_report_evidence": {"evidence_keys": sorted(stable), "layers": stable}, + "sensitive_report_evidence": {"evidence_keys": sorted(sensitive), "layers": sensitive}, + "theme_sensitivity": themes, + "trace": deepcopy(list(packet.get("trace") or [])), + "status": "candidate_window_only", + "claim_boundary": ( + "Sensitivity support only. Sensitive themes must remain conditional and this packet " + "cannot identify, rank, or confirm a birth minute." + ), + } + + +def _theme_status(theme: str, stable: Mapping[str, Any], sensitive: Mapping[str, Any]) -> dict[str, Any]: + configured = THEME_LAYERS[theme] + keys = tuple(sorted(set(stable) | set(sensitive))) if theme == "general" else configured + sensitive_layers = [key for key in keys if key in sensitive] + stable_layers = [key for key in keys if key in stable] + return { + "status": "sensitive" if sensitive_layers else "stable", + "sensitive_layers": sensitive_layers, + "stable_layers": stable_layers, + } + + +def _validate_profile(value: Mapping[str, Any]) -> dict[str, Any]: + violation = _candidate_window_authority_violation(value) + if violation: + raise FlexibleBirthTimeReportSupportError(f"candidate_window_authority_forbidden:{violation}") + if not isinstance(value, Mapping) or value.get("schema_version") != PROFILE_SCHEMA_VERSION: + raise FlexibleBirthTimeReportSupportError("flexible_birth_time_profile_schema_invalid") + if value.get("status") != "candidate_window_only": + raise FlexibleBirthTimeReportSupportError("profile_must_remain_candidate_window_only") + if not isinstance(value.get("birth_time_window"), Mapping): + raise FlexibleBirthTimeReportSupportError("birth_time_window_required") + return dict(value) + + +def _digest_id(*parts: str) -> str: + payload = json.dumps(parts, ensure_ascii=True, separators=(",", ":")) + return f"flex-report-support://{sha256(payload.encode('utf-8')).hexdigest()[:24]}" diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index bd5d7a33..4733f753 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -2155,6 +2155,11 @@ def execute_consultation_workflow( from scripts.reference_transparency_contract import build_reference_transparency_contract birth_payload = handler._high_rigor_birth_payload(body) + sensitivity_args = type('BirthTimeSensitivityArgs', (), birth_payload)() + try: + birth_time_sensitivity = _load_local_module('jyotish_engine')._build_birth_time_sensitivity(sensitivity_args) + except ValueError as exc: + raise BadRequest(str(exc)) from exc themes = handler._high_rigor_requested_themes(body) events = handler._high_rigor_events(body) question = body.get('question') or '' @@ -2225,6 +2230,7 @@ def execute_consultation_workflow( ] if body.get('dry_run') or body.get('plan_only'): result = handler._high_rigor_workflow_plan_only(birth_payload, themes, events) + result['birth_time_sensitivity'] = birth_time_sensitivity result['endpoint'] = 'consultation_workflow' result['entry_mode'] = entry_mode result['routing'] = route_packet @@ -2506,6 +2512,7 @@ def execute_consultation_workflow( 'consumer_context': consumer_context, 'western_evidence_packet': western_evidence_packet or {}, 'real_case_calibration': real_case_calibration, + 'birth_time_sensitivity': birth_time_sensitivity, 'runtime_evidence_log': runtime_evidence_log, 'next_questions': handler._high_rigor_next_questions(rectification, historical_backtest), 'boundary': ( @@ -4745,6 +4752,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), 'today': body.get('today') or body.get('current_date'), 'transit_date': body.get('transit_date') or body.get('reference_date'), + 'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'), + 'candidate_range': body.get('candidate_range'), + 'representative_time': body.get('representative_time'), + 'declared_window_start': body.get('declared_window_start'), + 'declared_window_end': body.get('declared_window_end'), + 'uncertainty_before_minutes': body.get('uncertainty_before_minutes'), + 'uncertainty_after_minutes': body.get('uncertainty_after_minutes'), } def _high_rigor_requested_themes(self, body): @@ -6556,8 +6570,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'today': body.get('today') or body.get('current_date'), 'transit_date': body.get('transit_date'), 'target_year': body.get('target_year'), + 'birth_time_accuracy': body.get('birth_time_accuracy', 'confirmed'), + 'candidate_range': body.get('candidate_range'), + 'representative_time': body.get('representative_time'), + 'declared_window_start': body.get('declared_window_start'), + 'declared_window_end': body.get('declared_window_end'), + 'uncertainty_before_minutes': body.get('uncertainty_before_minutes'), + 'uncertainty_after_minutes': body.get('uncertainty_after_minutes'), })() - result = engine.cmd_full_reading(args) + try: + result = engine.cmd_full_reading(args) + except ValueError as exc: + raise BadRequest(str(exc)) from exc if not isinstance(result, dict) or not isinstance(result.get('modules'), dict): raise BadRequest('full-reading did not return modules') return result diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index bce5fb61..b2caaff9 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -2041,7 +2041,6 @@ def _event_replay_contract(args) -> dict: } path = getattr(args, 'event_replay_file', None) if not path: - candidate_segment_table = _load_1993_candidate_segment_table(args) return { 'schema': 'jyotish.event_replay_contract.v1', 'status': 'blocked', @@ -2052,12 +2051,6 @@ def _event_replay_contract(args) -> dict: 'Parent/family events may be entered with domain=family and compared against D12 only as ' 'candidate discrimination evidence; D12 cannot select a minute by itself.' ), - 'candidate_segment_table': candidate_segment_table, - 'candidate_segment_reference': ( - 'candidate_segment_table_not_imported' - if candidate_segment_table - else None - ), 'blind_holdout_policy': blind_holdout_policy, 'boundary': 'No user-known events were supplied. This report does not claim retrospective calibration.', } @@ -2066,19 +2059,11 @@ def _event_replay_contract(args) -> dict: if not isinstance(events, list): raise ValueError('expected JSON array') except Exception as exc: - candidate_segment_table = _load_1993_candidate_segment_table(args) return { 'schema': 'jyotish.event_replay_contract.v1', 'status': 'blocked', 'reason': f'event_replay_file_invalid:{exc}', 'events': [], - 'candidate_segment_table': candidate_segment_table, - 'candidate_segment_reference': ( - 'candidate_segment_table_not_imported' - if candidate_segment_table - else None - ), 'blind_holdout_policy': blind_holdout_policy, } - candidate_segment_table = _load_1993_candidate_segment_table(args) return { 'schema': 'jyotish.event_replay_contract.v1', 'status': 'intake_ready', @@ -2088,21 +2073,10 @@ def _event_replay_contract(args) -> dict: 'Parent/family events may be entered with domain=family and compared against D12 only as ' 'candidate discrimination evidence; D12 cannot select a minute by itself.' ), - 'candidate_segment_table': candidate_segment_table, - 'candidate_segment_reference': ( - 'candidate_segment_table_not_imported' - if candidate_segment_table - else None - ), 'blind_holdout_policy': blind_holdout_policy, 'boundary': 'Events are ingested for later blind replay comparison; intake alone is not a calibration pass.', } - -def _load_1993_candidate_segment_table(args) -> dict | None: - # Author-private candidate table is not imported (hard red line 3 / privacy). - return None - def _load_rectification_evidence_contract() -> dict: """Load the versioned event-to-varga registry without scoring personal events.""" path = Path(__file__).resolve().parents[1] / 'references/rectification_evidence_contract_v1.json' @@ -2120,99 +2094,126 @@ def _load_rectification_evidence_contract() -> dict: } +DEFAULT_BIRTH_TIME_WINDOW_RADIUS_MINUTES = {"provisional": 15, "approximate": 60} + + +def _birth_time_accuracy(args) -> str: + missing = object() + value = getattr(args, "birth_time_accuracy", missing) + if value is missing: + return "confirmed" + if not isinstance(value, str) or value not in {"confirmed", "provisional", "approximate"}: + raise ValueError("birth_time_accuracy_must_be_confirmed_provisional_or_approximate") + return value + + +def _clock_on_birth_date(center: datetime, value) -> datetime | None: + if value is None: + return None + if not isinstance(value, str) or re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", value) is None: + raise ValueError("birth_time_clock_must_be_hh_mm") + parsed = datetime.strptime(value, "%H:%M") + return center.replace(hour=parsed.hour, minute=parsed.minute, second=0, microsecond=0) + + +def _birth_time_candidate_window(args, center: datetime, accuracy: str) -> tuple[datetime, datetime, datetime]: + candidate_range = getattr(args, "candidate_range", None) + if candidate_range is not None and not isinstance(candidate_range, dict): + raise ValueError("candidate_range_must_be_mapping") + candidate_range = candidate_range or {} + start_raw = candidate_range["start_time"] if "start_time" in candidate_range else getattr(args, "declared_window_start", None) + end_raw = candidate_range["end_time"] if "end_time" in candidate_range else getattr(args, "declared_window_end", None) + has_start = "start_time" in candidate_range or getattr(args, "declared_window_start", None) is not None + has_end = "end_time" in candidate_range or getattr(args, "declared_window_end", None) is not None + representative_raw = ( + candidate_range["representative_time"] + if "representative_time" in candidate_range + else getattr(args, "representative_time", None) + ) + representative = _clock_on_birth_date(center, representative_raw) or center.replace(second=0, microsecond=0) + if has_start or has_end: + if not has_start or not has_end: + raise ValueError("birth_time_window_requires_start_and_end") + start = _clock_on_birth_date(center, start_raw) + end = _clock_on_birth_date(center, end_raw) + if end < start: + raise ValueError("birth_time_window_must_not_cross_midnight") + else: + before = getattr(args, "uncertainty_before_minutes", None) + after = getattr(args, "uncertainty_after_minutes", None) + radius = DEFAULT_BIRTH_TIME_WINDOW_RADIUS_MINUTES[accuracy] + before = int(before) if isinstance(before, (int, float)) and before >= 0 else radius + after = int(after) if isinstance(after, (int, float)) and after >= 0 else radius + start = max(center.replace(hour=0, minute=0, second=0, microsecond=0), representative - timedelta(minutes=before)) + end = min(center.replace(hour=23, minute=59, second=0, microsecond=0), representative + timedelta(minutes=after)) + representative = min(max(representative, start), end) + return start, representative, end + + +def _candidate_minutes(start: datetime, representative: datetime, end: datetime) -> list[str]: + minute_count = int((end - start).total_seconds() // 60) + 1 + if minute_count <= 15: + return [(start + timedelta(minutes=index)).strftime("%H:%M") for index in range(minute_count)] + return list(dict.fromkeys(value.strftime("%H:%M") for value in (start, representative, end))) + + def _build_birth_time_sensitivity(args) -> dict: - """Preserve a narrow, unapproved three-minute recast matrix for reporting.""" + """Compare only the trusted unresolved window; never select a birth minute.""" + accuracy = _birth_time_accuracy(args) + if accuracy == "confirmed": + return { + "schema": "jyotish.report_birth_time_sensitivity.v1", + "status": "not_applicable", + "accuracy": "confirmed", + } + center = _birth_datetime_from_args(args) - start = center - timedelta(minutes=1) - end = center + timedelta(minutes=1) - start_time = start.strftime('%H:%M') - end_time = end.strftime('%H:%M') - source_reference = { - 'review_id': f"rectification-review://report-window/{center.strftime('%Y%m%d-%H%M')}", - 'status': 'review_required', - } - approval_gate = _default_birth_time_approval_gate(source_reference) - candidate_segment_table = _load_1993_candidate_segment_table(args) + start, representative, end = _birth_time_candidate_window(args, center, accuracy) + if start == end: + return { + "schema": "jyotish.report_birth_time_sensitivity.v1", + "status": "not_applicable", + "accuracy": "confirmed", + } + candidate_times = _candidate_minutes(start, representative, end) try: from flexible_birth_time_profile import build_flexible_birth_time_profile_from_window from flexible_birth_time_report_support import build_flexible_birth_time_report_support from flexible_birth_time_full_report_projection import build_flexible_birth_time_full_report_projection + except ImportError: # pragma: no cover - package import + from scripts.flexible_birth_time_profile import build_flexible_birth_time_profile_from_window + from scripts.flexible_birth_time_report_support import build_flexible_birth_time_report_support + from scripts.flexible_birth_time_full_report_projection import build_flexible_birth_time_full_report_projection - profile = build_flexible_birth_time_profile_from_window( - birth_date=center.strftime('%Y-%m-%d'), - start_time=start_time, - end_time=end_time, - lat=float(args.lat), - lon=float(args.lon), - tz=float(args.tz), - ayanamsa=_current_ayanamsa_name(args), - node_mode=getattr(args, 'node_mode', 'mean') or 'mean', - source_reference=source_reference, - ) - support = build_flexible_birth_time_report_support( - profile, - approval_gate=approval_gate, - ) - projection = build_flexible_birth_time_full_report_projection(support) - return { - 'schema': 'jyotish.report_birth_time_sensitivity.v1', - 'status': 'candidate_window_only', - 'window': {'start': start_time, 'center': center.strftime('%H:%M'), 'end': end_time, 'candidate_count': 3}, - 'profile': profile, - 'approval_gate': approval_gate, - 'candidate_segment_table': candidate_segment_table, - 'candidate_micro_compare': { - 'schema_version': 'jyotish.rectification_candidate_micro_compare.v1', - 'status': 'blocked', - 'review_required': True, - 'candidate_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], - 'leader': None, - 'minute_results': [], - 'claim_boundary': ( - 'Minute comparison is packaged for the report but remains blocked until a user event replay is supplied.' - ), - }, - 'micro_compare_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], - 'report_projection': projection, - 'required_discrimination_layers': [ - 'D1', 'D3', 'D4', 'D6', 'D7', 'D9', 'D10', 'D11', 'D12', 'D16', 'D24', 'D30', - 'D40', 'D45', 'D60', 'UL', 'A7', 'A10', 'KP cusp', - ], - 'd12_parent_family_policy': ( - 'D12 is displayed as stable or minute-sensitive across all three candidates. ' - 'Only dated parent/family events can use a D12 difference for candidate discrimination; ' - 'neither D12 nor a single family event can approve a birth minute.' - ), - 'claim_boundary': ( - 'This is a local candidate comparison, not a rectification result. It cannot replace the ' - 'current chart, select a winning minute, or alter Chart Identity.' - ), - } - except Exception as exc: - return { - 'schema': 'jyotish.report_birth_time_sensitivity.v1', - 'status': 'blocked', - 'window': {'start': start_time, 'center': center.strftime('%H:%M'), 'end': end_time, 'candidate_count': 3}, - 'approval_gate': approval_gate, - 'candidate_segment_table': candidate_segment_table, - 'candidate_micro_compare': { - 'schema_version': 'jyotish.rectification_candidate_micro_compare.v1', - 'status': 'blocked', - 'review_required': True, - 'candidate_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], - 'leader': None, - 'minute_results': [], - 'claim_boundary': ( - 'Minute comparison is packaged for the report but remains blocked until a user event replay is supplied.' - ), - }, - 'micro_compare_minutes': candidate_segment_table.get('recommended_next_pass', {}).get('candidate_minutes', []) if isinstance(candidate_segment_table, dict) else [], - 'reason': f'birth_time_sensitivity_producer_failed:{exc}', - 'required_discrimination_layers': [ - 'D1', 'D3', 'D4', 'D6', 'D7', 'D9', 'D10', 'D11', 'D12', 'D16', 'D24', 'D30', - 'D40', 'D45', 'D60', 'UL', 'A7', 'A10', 'KP cusp', - ], - } + profile = build_flexible_birth_time_profile_from_window( + birth_date=center.strftime("%Y-%m-%d"), + start_time=start.strftime("%H:%M"), + end_time=end.strftime("%H:%M"), + candidate_times=candidate_times, + lat=float(args.lat), + lon=float(args.lon), + tz=float(args.tz), + ayanamsa=_current_ayanamsa_name(args), + node_mode=getattr(args, "node_mode", "mean") or "mean", + source_reference={ + "review_id": f"birth-time-window://{center.strftime('%Y%m%d')}/{start.strftime('%H%M')}-{end.strftime('%H%M')}", + }, + ) + profile["birth_time_window"]["representative_time"] = representative.strftime("%H:%M") + support = build_flexible_birth_time_report_support(profile) + projection = build_flexible_birth_time_full_report_projection(support) + return { + "schema": "jyotish.report_birth_time_sensitivity.v1", + "status": "candidate_window_only", + "accuracy": accuracy, + "window": projection["window"], + "theme_sensitivity": projection["theme_sensitivity"], + "stable_evidence": projection["stable_structure_section"], + "sensitive_evidence": projection["minute_sensitive_section"], + "report_projection": projection, + "trace": projection["trace"], + "claim_boundary": projection["claim_boundary"], + } def _default_birth_time_approval_gate(source_reference: dict) -> dict: @@ -2379,7 +2380,11 @@ def _attach_report_governance_contracts(packet: dict, args) -> dict: packet['event_replay'] = _event_replay_contract(args) packet['rectification_evidence_contract'] = _load_rectification_evidence_contract() packet['raw_module_usage_map'] = _raw_module_usage_map(packet) - packet['birth_time_sensitivity'] = _build_birth_time_sensitivity(args) + birth_time_sensitivity = _build_birth_time_sensitivity(args) + if birth_time_sensitivity.get('status') == 'candidate_window_only': + packet['birth_time_sensitivity'] = birth_time_sensitivity + else: + packet.pop('birth_time_sensitivity', None) packet['timing_boundary_attribution'] = _build_timing_boundary_attribution(packet) packet['module_execution_audit'] = _build_module_execution_audit(packet) ai_pack = ((packet.get('raw_full_reading') or {}).get('ai_prompt_pack') or {}) @@ -9302,91 +9307,12 @@ def render_pl9_markdown(packet: dict) -> str: lines.append(f"- 边界:{_md_cell(blind_policy.get('boundary'))}") if event_replay.get('family_d12_binding'): lines.append(f"- D12/父母家庭绑定:{_md_cell(event_replay.get('family_d12_binding'))}") - candidate_segment_table = event_replay.get('candidate_segment_table') if isinstance(event_replay.get('candidate_segment_table'), dict) else {} - if candidate_segment_table: - summary = candidate_segment_table.get('summary') if isinstance(candidate_segment_table.get('summary'), dict) else {} - primary = summary.get('primary_candidate_window') if isinstance(summary.get('primary_candidate_window'), dict) else {} - if primary: - lines.append(f"- 当前代表时间:{_md_cell(primary.get('representative_time'))}") - lines.append(f"- 当前候选带:{_md_cell(primary.get('start_time'))} - {_md_cell(primary.get('end_time'))}") - lines.append(f"- 校时批准状态:not_approved") - lines.append("- 详细分钟微比较见后文“校时附录”。") - - lines.extend(['', '## 生时敏感性矩阵(-)', '']) - sensitivity_window = birth_time_sensitivity.get('window') if isinstance(birth_time_sensitivity.get('window'), dict) else {} - lines.append(f"- 状态:{_md_cell(birth_time_sensitivity.get('status', 'blocked'))}") - lines.append(f"- 候选窗口:{_md_cell(sensitivity_window.get('start'))} / {_md_cell(sensitivity_window.get('center'))} / {_md_cell(sensitivity_window.get('end'))}") - approval_gate = birth_time_sensitivity.get('approval_gate') if isinstance(birth_time_sensitivity.get('approval_gate'), dict) else {} - if approval_gate: - lines.append(f"- Review 到 Approval 门槛:{_md_cell(approval_gate.get('status', 'not_provided'))}") - lines.append(f"- 可进入 pending approval:{_md_cell(approval_gate.get('can_enter_pending_approval'))}") - if approval_gate.get('blocked_reasons'): - lines.append(f"- 阻断原因:{_md_cell(';'.join(str(item) for item in approval_gate.get('blocked_reasons', [])))}") - lines.append(f"- 下一步:{_md_cell(approval_gate.get('required_next_step', 'not_recorded'))}") - lines.append(f"- D12 使用边界:{_md_cell(birth_time_sensitivity.get('d12_parent_family_policy', 'not_recorded'))}") - sensitivity_segment_table = birth_time_sensitivity.get('candidate_segment_table') if isinstance(birth_time_sensitivity.get('candidate_segment_table'), dict) else {} - if sensitivity_segment_table: - lines.append(f"- 候选段表:{_md_cell(sensitivity_segment_table.get('scope', 'candidate_window_only'))}") - sensitivity_micro_compare = birth_time_sensitivity.get('candidate_micro_compare') if isinstance(birth_time_sensitivity.get('candidate_micro_compare'), dict) else {} - if sensitivity_micro_compare: - lines.append(f"- 比较包:{_md_cell(sensitivity_micro_compare.get('status', 'blocked'))}") - sensitivity_profile = birth_time_sensitivity.get('profile') if isinstance(birth_time_sensitivity.get('profile'), dict) else {} - stable_layers = sensitivity_profile.get('stable_evidence') if isinstance(sensitivity_profile.get('stable_evidence'), dict) else {} - sensitive_layers = sensitivity_profile.get('sensitive_evidence') if isinstance(sensitivity_profile.get('sensitive_evidence'), dict) else {} - lines.extend(['', '| 校时层 | 三分钟结果 | 类型 | 使用边界 |', '|--------|------------|------|----------|']) - sensitivity_keys = ('D1.ascendant', 'D3.ascendant', 'D4.ascendant', 'D6.ascendant', 'D7.ascendant', 'D9.ascendant', 'D10.ascendant', 'D11.ascendant', 'D12.ascendant', 'D16.ascendant', 'D24.ascendant', 'D30.ascendant', 'D40.ascendant', 'D45.ascendant', 'D60.ascendant', 'arudha.UL', 'arudha.A7', 'arudha.A10', 'KP.cusp_observation') - for key in sensitivity_keys: - if key in stable_layers: - value, kind = stable_layers.get(key), 'stable_across_window' - elif key in sensitive_layers: - value, kind = sensitive_layers.get(key), 'minute_sensitive' - else: - value, kind = 'not_returned', 'blocked' - boundary = '可作为候选比较的原始结构,不可单独批准出生分钟。' - if key == 'D12.ascendant': - boundary = '仅在有日期化父母/家庭事件时参与候选比较;不得单独选择分钟。' - elif key == 'KP.cusp_observation': - boundary = '只作局部计算观察;exact-cusp parity gate 未闭环。' - lines.append(f"| `{key}` | {_md_cell(_json_safe_report_snapshot(value))} | {kind} | {boundary} |") - lines.append(f"- 总边界:{_md_cell(birth_time_sensitivity.get('claim_boundary', 'not_recorded'))}") - if candidate_segment_table: - lines.extend(['', '### 校时附录:候选分钟计划', '']) - lines.append(f"- 状态:{_md_cell(candidate_segment_table.get('scope', 'candidate_window_only'))}") - lines.append(f"- 结论边界:{_md_cell(candidate_segment_table.get('claim_boundary', 'not_recorded'))}") - summary = candidate_segment_table.get('summary') if isinstance(candidate_segment_table.get('summary'), dict) else {} - primary = summary.get('primary_candidate_window') if isinstance(summary.get('primary_candidate_window'), dict) else {} - if primary: - lines.append( - f"- 主候选:{_md_cell(primary.get('start_time'))} / {_md_cell(primary.get('representative_time'))} / {_md_cell(primary.get('end_time'))}" - ) - recommended = candidate_segment_table.get('recommended_next_pass') if isinstance(candidate_segment_table.get('recommended_next_pass'), dict) else {} - if recommended.get('candidate_minutes'): - lines.append(f"- 微调分钟:{_md_cell(' / '.join(str(item) for item in recommended.get('candidate_minutes', [])))}") - if sensitivity_micro_compare: - lines.extend(['', '### 校时附录:分钟排行表', '']) - lines.append(f"- 状态:{_md_cell(sensitivity_micro_compare.get('status', 'blocked'))}") - lines.append(f"- 结论边界:{_md_cell(sensitivity_micro_compare.get('claim_boundary', 'not_recorded'))}") - if sensitivity_micro_compare.get('candidate_minutes'): - lines.append(f"- 候选分钟:{_md_cell(' / '.join(str(item) for item in sensitivity_micro_compare.get('candidate_minutes', [])))}") - if sensitivity_micro_compare.get('leader'): - leader = sensitivity_micro_compare.get('leader') if isinstance(sensitivity_micro_compare.get('leader'), dict) else {} - lines.append(f"- 当前领先:{_md_cell(leader.get('candidate_time'))} / {_md_cell(leader.get('top_score'))}") - ranking_rows = sensitivity_micro_compare.get('minute_results') if isinstance(sensitivity_micro_compare.get('minute_results'), list) else [] - if not ranking_rows and sensitivity_micro_compare.get('candidate_minutes'): - ranking_rows = [ - {'candidate_time': minute, 'confidence': 'pending_replay', 'top_score': None, 'margin_percent': None, 'note': 'Waiting for event replay'} - for minute in sensitivity_micro_compare.get('candidate_minutes', []) - ] - if ranking_rows: - lines.extend(['', '| 排名 | 分钟 | 状态 | 分数 | 边际 | 备注 |', '|------|------|------|------|------|------|']) - for index, row in enumerate(ranking_rows, start=1): - if not isinstance(row, dict): - continue - lines.append( - f"| {index} | {_md_cell(row.get('candidate_time'))} | {_md_cell(row.get('confidence', 'blocked'))} | " - f"{_md_cell(row.get('top_score', 'pending'))} | {_md_cell(row.get('margin_percent', 'pending'))} | " - f"{_md_cell((row.get('candidate_summary') or {}).get('claim_status') or row.get('note') or 'pending')} |" - ) + if birth_time_sensitivity.get('status') == 'candidate_window_only': + try: + from flexible_birth_time_report_section import render_flexible_birth_time_report_section + except ImportError: # pragma: no cover - package import + from scripts.flexible_birth_time_report_section import render_flexible_birth_time_report_section + lines.extend(['', render_flexible_birth_time_report_section(birth_time_sensitivity['report_projection']).rstrip()]) lines.extend(['', '## 校时证据领域合同', '']) lines.append(_md_cell(rectification_evidence_contract.get('claim_boundary', 'rectification_evidence_contract_missing'))) @@ -17375,6 +17301,12 @@ def main(): p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)') p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)') p.add_argument('--target-year', type=int, default=None, help='太阳返照盘目标年份(默认不计算 Varshaphala)') + p.add_argument('--birth-time-accuracy', choices=['confirmed', 'provisional', 'approximate'], default='confirmed') + p.add_argument('--declared-window-start', default=None, help='出生时间可信区间开始 HH:MM') + p.add_argument('--declared-window-end', default=None, help='出生时间可信区间结束 HH:MM') + p.add_argument('--representative-time', default=None, help='出生时间可信区间代表分钟 HH:MM') + p.add_argument('--uncertainty-before-minutes', type=int, default=None) + p.add_argument('--uncertainty-after-minutes', type=int, default=None) p.add_argument('--crosscheck-planets', default=None, help='D4/D9/D10交叉检查行星,逗号分隔') p.add_argument('--profile-stages', action='store_true', help='输出 full-reading 粗粒度阶段耗时,并在 summary 中附带 stage timings') diff --git a/tests/test_flexible_birth_time_engine.py b/tests/test_flexible_birth_time_engine.py new file mode 100644 index 00000000..2cc0634c --- /dev/null +++ b/tests/test_flexible_birth_time_engine.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Focused engine/API behavior for report birth-time sensitivity windows.""" + +from __future__ import annotations + +from datetime import datetime +from hashlib import sha256 +import json +import os +import sys +from types import SimpleNamespace + +import pytest + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +import flexible_birth_time_profile as profile_module # noqa: E402 +import jyotish_api_server as api # noqa: E402 +import jyotish_engine as engine # noqa: E402 + + +def _args(**overrides) -> SimpleNamespace: + values = { + "year": 2004, + "month": 5, + "day": 6, + "hour": 10, + "minute": 7, + "second": 0, + "lat": 12.34, + "lon": 56.78, + "tz": 5.5, + "ayanamsa": "raman", + "node_mode": "mean", + "birth_time_accuracy": "provisional", + "candidate_range": None, + "declared_window_start": None, + "declared_window_end": None, + "representative_time": None, + "uncertainty_before_minutes": None, + "uncertainty_after_minutes": None, + "event_replay_file": None, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _fictional_recast(candidate: datetime, **_kwargs) -> dict: + sensitive_sign = "Capricorn" if candidate.minute < 7 else "Aquarius" + return { + "ascendant": {"sign": "Aries"}, + "varga_lagna": { + "D9": {"sign": "Taurus"}, + "D10": {"sign": sensitive_sign}, + }, + "arudha": {"A7": {"sign": "Gemini"}, "A10": {"sign": sensitive_sign}, "UL": {"sign": "Cancer"}}, + "kp_cusps": {"house_10": {"sub_lord": "Saturn" if candidate.minute < 7 else "Mercury"}}, + } + + +def _digest(value: dict) -> str: + payload = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + return sha256(payload.encode("utf-8")).hexdigest() + + +def test_clock_parser_requires_exact_hh_mm() -> None: + center = datetime(2004, 5, 6, 10, 7) + assert engine._clock_on_birth_date(center, "09:05") == datetime(2004, 5, 6, 9, 5) + assert engine._clock_on_birth_date(center, None) is None + for invalid in ("9:05", "09:05:00", "09:05 trailing", "24:00", ""): + with pytest.raises(ValueError, match="birth_time_clock_must_be_hh_mm"): + engine._clock_on_birth_date(center, invalid) + + +def test_explicit_reverse_or_incomplete_window_is_rejected_instead_of_falling_back() -> None: + center = datetime(2004, 5, 6, 10, 7) + with pytest.raises(ValueError, match="must_not_cross_midnight"): + engine._birth_time_candidate_window( + _args(declared_window_start="23:58", declared_window_end="00:02"), + center, + "provisional", + ) + with pytest.raises(ValueError, match="requires_start_and_end"): + engine._birth_time_candidate_window( + _args(declared_window_start="10:00"), + center, + "provisional", + ) + with pytest.raises(ValueError, match="birth_time_clock_must_be_hh_mm"): + engine._birth_time_candidate_window( + _args(declared_window_start="10:00 extra", declared_window_end="10:10"), + center, + "provisional", + ) + + fallback = engine._birth_time_candidate_window( + _args(uncertainty_before_minutes=1, uncertainty_after_minutes=1), + center, + "provisional", + ) + assert tuple(value.strftime("%H:%M") for value in fallback) == ("10:06", "10:07", "10:08") + + +@pytest.mark.parametrize( + "args", + [ + _args(birth_time_accuracy="confirmed"), + _args(declared_window_start="10:07", declared_window_end="10:07", representative_time="10:07"), + _args(birth_time_accuracy="approximate", uncertainty_before_minutes=0, uncertainty_after_minutes=0), + ], +) +def test_confirmed_or_actual_single_minute_skips_candidate_recast(monkeypatch, args: SimpleNamespace) -> None: + monkeypatch.setattr( + profile_module, + "_recast_candidate_layers", + lambda *_args, **_kwargs: pytest.fail("single-minute input must not recast candidate charts"), + ) + + assert engine._build_birth_time_sensitivity(args) == { + "schema": "jyotish.report_birth_time_sensitivity.v1", + "status": "not_applicable", + "accuracy": "confirmed", + } + + +@pytest.mark.parametrize( + ("range_status", "end_time", "candidate_count"), + [("accepted", "10:01", 2), ("candidate", "10:14", 15)], +) +def test_multi_minute_accepted_or_candidate_windows_remain_provisional( + monkeypatch, range_status: str, end_time: str, candidate_count: int, +) -> None: + monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast) + args = _args( + candidate_range={ + "status": range_status, + "start_time": "10:00", + "end_time": end_time, + "representative_time": "10:07", + }, + ) + + first = engine._build_birth_time_sensitivity(args) + second = engine._build_birth_time_sensitivity(args) + + assert first["status"] == "candidate_window_only" + assert first["accuracy"] == "provisional" + assert first["window"]["candidate_count"] == candidate_count + assert _digest(first) == _digest(second) + for minute_values in first["sensitive_evidence"]["layers"].values(): + assert len({json.dumps(value, sort_keys=True) for value in minute_values.values()}) > 1 + for theme in first["theme_sensitivity"].values(): + for layer in theme["sensitive_layers"]: + values = first["sensitive_evidence"]["layers"][layer] + assert len({json.dumps(value, sort_keys=True) for value in values.values()}) > 1 + + +def test_confirmed_report_packet_does_not_gain_sensitivity_key() -> None: + packet = {"worksheets": {}, "raw_full_reading": {"modules": {}}} + + result = engine._attach_report_governance_contracts(packet, _args(birth_time_accuracy="confirmed")) + + assert "birth_time_sensitivity" not in result + + +@pytest.mark.parametrize("accuracy", ["confirmed", "provisional", "approximate"]) +def test_birth_time_accuracy_accepts_only_exact_supported_values(accuracy: str) -> None: + assert engine._birth_time_accuracy(_args(birth_time_accuracy=accuracy)) == accuracy + + +def test_birth_time_accuracy_defaults_to_confirmed_when_field_is_absent() -> None: + assert engine._birth_time_accuracy(SimpleNamespace()) == "confirmed" + + +@pytest.mark.parametrize("invalid", [None, "", "bogus", " PROVISIONAL ", "PROVISIONAL", 1]) +def test_birth_time_accuracy_rejects_invalid_or_formatted_values(invalid: object) -> None: + with pytest.raises( + ValueError, + match="birth_time_accuracy_must_be_confirmed_provisional_or_approximate", + ): + engine._birth_time_accuracy(_args(birth_time_accuracy=invalid)) + + +def test_api_turns_invalid_birth_time_accuracy_into_bad_request(monkeypatch) -> None: + class FakeEngine: + @staticmethod + def cmd_full_reading(args): + engine._birth_time_accuracy(args) + return {"modules": {}} + + monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine) + handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler) + + with pytest.raises( + api.BadRequest, + match="birth_time_accuracy_must_be_confirmed_provisional_or_approximate", + ): + handler._compute_full_reading_for_thematic({ + "year": 2004, + "month": 5, + "day": 6, + "hour": 10, + "minute": 7, + "lat": 12.34, + "lon": 56.78, + "tz": 5.5, + "birth_time_accuracy": " PROVISIONAL ", + }) + + +def test_api_turns_invalid_report_window_into_bad_request(monkeypatch) -> None: + class FakeEngine: + @staticmethod + def cmd_full_reading(_args): + raise ValueError("birth_time_window_must_not_cross_midnight") + + monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine) + handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler) + + with pytest.raises(api.BadRequest, match="birth_time_window_must_not_cross_midnight"): + handler._compute_full_reading_for_thematic({ + "year": 2004, + "month": 5, + "day": 6, + "hour": 10, + "minute": 7, + "lat": 12.34, + "lon": 56.78, + "tz": 5.5, + "birth_time_accuracy": "provisional", + "declared_window_start": "23:58", + "declared_window_end": "00:02", + }) diff --git a/tests/test_flexible_birth_time_profile.py b/tests/test_flexible_birth_time_profile.py new file mode 100644 index 00000000..2e5dff25 --- /dev/null +++ b/tests/test_flexible_birth_time_profile.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Focused contracts for unresolved birth-time candidate profiles.""" + +from __future__ import annotations + +import json +import os +import sys + +import pytest + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from flexible_birth_time_profile import ( # noqa: E402 + FlexibleBirthTimeProfileError, + build_flexible_birth_time_profile, +) + + +def _candidate(index: int, count: int) -> dict: + candidate_time = f"08:{index:02d}" + return { + "candidate_id": f"fictional-{index}", + "candidate_time": candidate_time, + "evidence": { + "D1.ascendant": "Aries", + "D10.ascendant": "Capricorn" if index < count - 1 else "Aquarius", + }, + "trace": [{"kind": "fictional_candidate", "reference": f"fixture://{candidate_time}"}], + } + + +@pytest.mark.parametrize("count", [2, 15]) +def test_profile_supports_two_to_fifteen_candidates_with_real_minute_variation(count: int) -> None: + candidates = [_candidate(index, count) for index in range(count)] + + profile = build_flexible_birth_time_profile( + candidates, + source_reference={"review_id": "fictional-review"}, + ) + reversed_profile = build_flexible_birth_time_profile( + list(reversed(candidates)), + source_reference={"review_id": "fictional-review"}, + ) + + assert profile == reversed_profile + assert profile["birth_time_window"]["candidate_count"] == count + assert profile["stable_evidence"] == {"D1.ascendant": "Aries"} + assert set(profile["sensitive_evidence"]) == {"D10.ascendant"} + for minute_values in profile["sensitive_evidence"].values(): + assert len({json.dumps(value, sort_keys=True) for value in minute_values.values()}) > 1 + + +@pytest.mark.parametrize("count", [1, 16]) +def test_profile_rejects_candidate_counts_outside_two_to_fifteen(count: int) -> None: + with pytest.raises(FlexibleBirthTimeProfileError, match="candidate_count_must_be_two_to_fifteen"): + build_flexible_birth_time_profile( + [_candidate(index, max(count, 2)) for index in range(count)], + source_reference={"review_id": "fictional-review"}, + ) + + +@pytest.mark.parametrize("status", ["approved", "confirmed"]) +def test_profile_rejects_approved_or_confirmed_source_reference(status: str) -> None: + with pytest.raises(FlexibleBirthTimeProfileError, match="approved_or_confirmed_source_reference_forbidden"): + build_flexible_birth_time_profile( + [_candidate(0, 2), _candidate(1, 2)], + source_reference={"review_id": "fictional-review", "status": status}, + ) + + +@pytest.mark.parametrize( + "field", + ["approved_birth_time", "final_birth_time", "winner", "approval", "approval_authority"], +) +def test_profile_rejects_birth_minute_authority_fields(field: str) -> None: + candidates = [_candidate(0, 2), _candidate(1, 2)] + candidates[0]["evidence"][field] = "08:00" + + with pytest.raises(FlexibleBirthTimeProfileError, match="candidate_window_authority_forbidden"): + build_flexible_birth_time_profile( + candidates, + source_reference={"review_id": "fictional-review"}, + ) diff --git a/tests/test_flexible_birth_time_report_contract.py b/tests/test_flexible_birth_time_report_contract.py new file mode 100644 index 00000000..a8938167 --- /dev/null +++ b/tests/test_flexible_birth_time_report_contract.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Negative-authority contracts for flexible birth-time report projections.""" + +from __future__ import annotations + +from copy import deepcopy +import os +import sys + +import pytest + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from flexible_birth_time_full_report_projection import ( # noqa: E402 + FlexibleBirthTimeFullReportProjectionError, + build_flexible_birth_time_full_report_projection, +) +from flexible_birth_time_profile import build_flexible_birth_time_profile # noqa: E402 +from flexible_birth_time_report_section import ( # noqa: E402 + FlexibleBirthTimeReportSectionError, + render_flexible_birth_time_report_section, +) +from flexible_birth_time_report_support import ( # noqa: E402 + FlexibleBirthTimeReportSupportError, + build_flexible_birth_time_report_support, +) + + +def _profile() -> dict: + rows = [ + { + "candidate_id": "fictional-a", + "candidate_time": "08:00", + "evidence": {"D1.ascendant": "Aries", "D10.ascendant": "Capricorn"}, + "trace": [{"kind": "fictional_candidate", "reference": "fixture://08:00"}], + }, + { + "candidate_id": "fictional-b", + "candidate_time": "08:01", + "evidence": {"D1.ascendant": "Aries", "D10.ascendant": "Aquarius"}, + "trace": [{"kind": "fictional_candidate", "reference": "fixture://08:01"}], + }, + ] + profile = build_flexible_birth_time_profile(rows, source_reference={"review_id": "fictional-review"}) + profile["birth_time_window"]["representative_time"] = "08:00" + return profile + + +def _packets() -> tuple[dict, dict, dict]: + profile = _profile() + support = build_flexible_birth_time_report_support(profile) + projection = build_flexible_birth_time_full_report_projection(support) + return profile, support, projection + + +def test_report_projection_is_hash_stable_and_renders_only_candidate_window_evidence() -> None: + _, support, projection = _packets() + reordered = deepcopy(support) + reordered["birth_time_window"] = dict(reversed(list(reordered["birth_time_window"].items()))) + + assert build_flexible_birth_time_full_report_projection(reordered)["projection_id"] == projection["projection_id"] + assert projection["status"] == "candidate_window_only" + assert "出生时间敏感度" in render_flexible_birth_time_report_section(projection) + + +@pytest.mark.parametrize("field", ["approved_birth_time", "final_birth_time", "winner", "approval"]) +def test_every_report_layer_rejects_birth_minute_authority_fields(field: str) -> None: + profile, support, projection = _packets() + cases = [ + (profile, build_flexible_birth_time_report_support, FlexibleBirthTimeReportSupportError), + (support, build_flexible_birth_time_full_report_projection, FlexibleBirthTimeFullReportProjectionError), + (projection, render_flexible_birth_time_report_section, FlexibleBirthTimeReportSectionError), + ] + for packet, consumer, error_type in cases: + tampered = deepcopy(packet) + tampered["authority_probe"] = {field: "08:00"} + with pytest.raises(error_type, match="candidate_window_authority_forbidden"): + consumer(tampered) + + +@pytest.mark.parametrize("status", ["approved", "confirmed"]) +def test_every_report_layer_rejects_approved_or_confirmed_source_reference(status: str) -> None: + profile, support, projection = _packets() + cases = [ + (profile, build_flexible_birth_time_report_support, FlexibleBirthTimeReportSupportError), + (support, build_flexible_birth_time_full_report_projection, FlexibleBirthTimeFullReportProjectionError), + (projection, render_flexible_birth_time_report_section, FlexibleBirthTimeReportSectionError), + ] + for packet, consumer, error_type in cases: + tampered = deepcopy(packet) + tampered["source_reference"] = {"review_id": "fictional-review", "status": status} + with pytest.raises(error_type, match="candidate_window_authority_forbidden"): + consumer(tampered)