diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts index 26f13300..569a390c 100644 --- a/frontend/src/lib/rectification-agent/contracts.ts +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -188,6 +188,38 @@ export const candidateSplitSchema = z.object({ eventIds: z.array(uuid).max(100), }).strict(); +export const vedAstroCandidateMetricSchema = z.object({ + role: z.enum(["primary", "runner_up"]), + requestedEventCount: z.number().int().nonnegative().max(20), + successfulEventCount: z.number().int().nonnegative().max(20), + matchedEventCount: z.number().int().nonnegative().max(20), + eventHitCount: z.number().int().nonnegative(), + signalLift: z.number().finite(), +}).strict(); + +export const vedAstroPostValidationSchema = z.object({ + contractVersion: z.literal("vedastro-post-validation-v1"), + provider: z.literal("vedastro_official"), + status: z.enum(["pass", "blocked", "not_validated"]), + providerStatus: nonblank(80), + blockers: z.array(nonblank(120)).max(20), + primaryCandidateTime: clockTimeSchema.nullable(), + runnerUpCandidateTime: clockTimeSchema.nullable(), + eligibleEventCount: z.number().int().nonnegative().max(100), + selectedEventCount: z.number().int().nonnegative().max(20), + unsupportedEventCount: z.number().int().nonnegative().max(100), + candidateMetrics: z.array(vedAstroCandidateMetricSchema).max(2), + minuteSensitiveValidation: z.object({ + comparisonReady: z.boolean(), + discriminated: z.boolean(), + discriminatedLayers: z.array(nonblank(80)).max(10), + }).strict(), + validationHash: hash, + validatedAt: z.string().datetime({ offset: true }), + canConfirmExactMinute: z.literal(false), +}).strict(); +export type VedAstroPostValidation = z.infer; + export const diagnosticsSummarySchema = z.object({ id: uuid, caseId: uuid, @@ -203,6 +235,7 @@ export const diagnosticsSummarySchema = z.object({ mostDiscriminatingLayers: z.array(nonblank(80)).max(40), eventDateSensitivity: z.array(eventDateSensitivitySchema).max(100), candidateSplits: z.array(candidateSplitSchema).max(20), + externalValidation: vedAstroPostValidationSchema.optional(), calculationHash: hash, createdAt: z.string().datetime({ offset: true }), }).strict(); diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index aafbdea7..13073858 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -17,18 +17,51 @@ import { recordRectificationAgentTelemetry } from "./telemetry.ts"; import { candidateFeatureSnapshotSchema, diagnosticsSummarySchema, + vedAstroPostValidationSchema, validateRectificationDecision, type AgentRun, type CandidateFeatureSnapshot, type DiagnosticsSummary, type StoredPublicMessage, type ValidatedDecision, + type VedAstroPostValidation, } from "./contracts.ts"; function hash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } +function vedAstroCandidateTimes(snapshot: CandidateSnapshot): readonly [string, string] | null { + const primary = snapshot.clusters[0]?.representativeTime; + if (!primary) return null; + const runnerUp = snapshot.clusters[1]?.representativeTime + ?? [...snapshot.candidates].sort((left, right) => right.score - left.score).find((candidate) => candidate.time !== primary)?.time; + return runnerUp && runnerUp !== primary ? [primary, runnerUp] : null; +} + +function blockedVedAstroValidation(now: Date, blocker: string, candidateTimes: readonly [string, string] | null): VedAstroPostValidation { + const safe = { + contractVersion: "vedastro-post-validation-v1" as const, + provider: "vedastro_official" as const, + status: "blocked" as const, + providerStatus: "unavailable", + blockers: [blocker], + primaryCandidateTime: candidateTimes?.[0] ?? null, + runnerUpCandidateTime: candidateTimes?.[1] ?? null, + eligibleEventCount: 0, + selectedEventCount: 0, + unsupportedEventCount: 0, + candidateMetrics: [], + minuteSensitiveValidation: { comparisonReady: false, discriminated: false, discriminatedLayers: [] }, + canConfirmExactMinute: false as const, + }; + return vedAstroPostValidationSchema.parse({ + ...safe, + validationHash: hash(safe), + validatedAt: now.toISOString(), + }); +} + const analysisPhaseLabels = { extracting_evidence: "整理用户经历", scoring_candidates: "扫描候选分钟", @@ -240,6 +273,50 @@ export async function processRectificationAgentTurn(input: Readonly<{ }); } + if (snapshot?.canAcceptRange && diagnostics && claimed.case.deploymentMode === "v5_agent") { + const candidateTimes = vedAstroCandidateTimes(snapshot); + const validationStarted = Date.now(); + let externalValidation: VedAstroPostValidation; + let outcome: "succeeded" | "failed" | "rejected"; + if (!candidateTimes) { + externalValidation = blockedVedAstroValidation(now, "vedastro_runner_up_candidate_missing", null); + outcome = "rejected"; + } else if (!input.engine.validateWithVedAstro) { + externalValidation = blockedVedAstroValidation(now, "vedastro_validator_unavailable", candidateTimes); + outcome = "failed"; + } else { + try { + externalValidation = await input.engine.validateWithVedAstro({ + calculationSpec: claimed.case.calculationSpec, + events: scoreable, + candidateTimes, + }); + outcome = externalValidation.status === "pass" ? "succeeded" : "rejected"; + } catch { + externalValidation = blockedVedAstroValidation(now, "vedastro_validation_failed", candidateTimes); + outcome = "failed"; + } + } + diagnostics = diagnosticsSummarySchema.parse({ ...diagnostics, externalValidation }); + analysisToolCalls.push({ + category: "diagnostic", + label: "VedAstro 事后校验", + outcome, + durationMs: Date.now() - validationStarted, + }); + if (externalValidation.status !== "pass") { + snapshot = { + ...snapshot, + canAcceptRange: false, + gateReasons: [...new Set([ + ...snapshot.gateReasons, + "vedastro_validation_not_passed", + ...externalValidation.blockers, + ])].slice(0, 20), + }; + } + } + const safeDiagnostics = diagnostics ?? diagnosticsSummarySchema.parse({ id: randomUUID(), caseId: claimed.case.id, diff --git a/frontend/src/lib/rectification-v4/candidate-engine.ts b/frontend/src/lib/rectification-v4/candidate-engine.ts index a4c3dd39..2f04deaf 100644 --- a/frontend/src/lib/rectification-v4/candidate-engine.ts +++ b/frontend/src/lib/rectification-v4/candidate-engine.ts @@ -1,6 +1,8 @@ +import { createHash } from "node:crypto"; import { z } from "zod"; import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "./contracts.ts"; import { rectificationV4AlgorithmVersion } from "./contracts.ts"; +import { vedAstroPostValidationSchema, type VedAstroPostValidation } from "../rectification-agent/contracts.ts"; const uuid = z.string().uuid(); const hash = z.string().regex(/^[a-f0-9]{64}$/); @@ -45,6 +47,34 @@ const featureSchema = z.object({ fingerprints: z.record(z.string(), z.string()), }).passthrough()), }).passthrough(); +const vedAstroResponseSchema = z.object({ + status: z.enum(["pass", "fail"]), + passed: z.boolean(), + can_confirm_exact_minute: z.literal(false), + candidate_times: z.object({ primary: z.string(), runner_up: z.string() }).strict(), + blockers: z.array(z.string()), + minute_sensitive_validation: z.object({ + comparison_ready: z.boolean(), + discriminated: z.boolean(), + discriminated_layers: z.array(z.string()), + }).passthrough(), + event_validation: z.object({ + eligible_event_count: z.number().int().nonnegative(), + supported_event_count: z.number().int().nonnegative(), + unsupported_events: z.array(z.unknown()), + candidates: z.array(z.object({ + role: z.enum(["primary", "runner_up"]), + metric: z.object({ + requested_event_count: z.number().int().nonnegative(), + successful_event_count: z.number().int().nonnegative(), + matched_event_count: z.number().int().nonnegative(), + event_hit_count: z.number().int().nonnegative(), + signal_lift: z.number().finite(), + }).strict(), + }).passthrough()).max(2), + }).passthrough(), +}).passthrough(); + const responseSchema = z.object({ result_id: uuid, algorithm_version: z.literal(rectificationV4AlgorithmVersion), @@ -79,8 +109,71 @@ export type CandidateEngineResult = Readonly<{ missingLayers: readonly string[]; }>; +function rectificationRequestBody(calculationSpec: CalculationSpec, events: readonly LifeEventRevision[]) { + return { + birth_date: calculationSpec.birthDate, + start_time: calculationSpec.candidateRange.start, + end_time: calculationSpec.candidateRange.end, + lat: calculationSpec.latitude, + lon: calculationSpec.longitude, + tz: calculationSpec.timezoneOffsetHours, + ...(Object.hasOwn(calculationSpec, "birthTimeSource") ? { birth_time_source: calculationSpec.birthTimeSource } : {}), + ...(Object.hasOwn(calculationSpec, "timezoneId") ? { timezone_id: calculationSpec.timezoneId } : {}), + ...(Object.hasOwn(calculationSpec, "timezoneSource") ? { timezone_source: calculationSpec.timezoneSource } : {}), + ...(Object.hasOwn(calculationSpec, "localTimeStatus") ? { local_time_status: calculationSpec.localTimeStatus } : {}), + events: events.map((event) => ({ + id: event.eventId, + domain: event.domain, + event_kind: event.eventKind, + date_start: event.dateRange.start, + date_end: event.dateRange.end, + precision: event.dateRange.precision, + summary: event.summary, + ...(Object.hasOwn(event, "dateSource") ? { date_source: event.dateSource } : {}), + ...(Object.hasOwn(event, "dateReliability") ? { date_reliability: event.dateReliability } : {}), + ...(Object.hasOwn(event, "dateCorroboration") ? { date_corroboration: event.dateCorroboration } : {}), + ...(Object.hasOwn(event, "dateConflictStatus") ? { date_conflict_status: event.dateConflictStatus } : {}), + })), + }; +} + +function projectVedAstroValidation(payload: z.infer): VedAstroPostValidation { + const safe = { + contractVersion: "vedastro-post-validation-v1" as const, + provider: "vedastro_official" as const, + status: payload.passed && payload.status === "pass" ? "pass" as const : "blocked" as const, + providerStatus: payload.status, + blockers: payload.blockers, + primaryCandidateTime: payload.candidate_times.primary, + runnerUpCandidateTime: payload.candidate_times.runner_up, + eligibleEventCount: payload.event_validation.eligible_event_count, + selectedEventCount: payload.event_validation.supported_event_count, + unsupportedEventCount: payload.event_validation.unsupported_events.length, + candidateMetrics: payload.event_validation.candidates.map((candidate) => ({ + role: candidate.role, + requestedEventCount: candidate.metric.requested_event_count, + successfulEventCount: candidate.metric.successful_event_count, + matchedEventCount: candidate.metric.matched_event_count, + eventHitCount: candidate.metric.event_hit_count, + signalLift: candidate.metric.signal_lift, + })), + minuteSensitiveValidation: { + comparisonReady: payload.minute_sensitive_validation.comparison_ready, + discriminated: payload.minute_sensitive_validation.discriminated, + discriminatedLayers: payload.minute_sensitive_validation.discriminated_layers, + }, + canConfirmExactMinute: false as const, + }; + return vedAstroPostValidationSchema.parse({ + ...safe, + validationHash: createHash("sha256").update(JSON.stringify(safe)).digest("hex"), + validatedAt: new Date().toISOString(), + }); +} + export interface RectificationV4CandidateEngine { score(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[] }): Promise; + validateWithVedAstro?(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[]; readonly candidateTimes: readonly [string, string] }): Promise; } export function createRectificationV4CandidateEngine(options: { readonly apiBase: string; readonly fetchImpl?: typeof fetch }): RectificationV4CandidateEngine { @@ -88,22 +181,7 @@ export function createRectificationV4CandidateEngine(options: { readonly apiBase return { async score({ calculationSpec, events }) { const response = await fetchImpl(`${options.apiBase}/api/rectification/v5/score`, { method: "POST", headers: { "content-type": "application/json" }, signal: AbortSignal.timeout(5 * 60_000), - body: JSON.stringify({ - birth_date: calculationSpec.birthDate, start_time: calculationSpec.candidateRange.start, end_time: calculationSpec.candidateRange.end, - lat: calculationSpec.latitude, lon: calculationSpec.longitude, tz: calculationSpec.timezoneOffsetHours, - ...(Object.hasOwn(calculationSpec, "birthTimeSource") ? { birth_time_source: calculationSpec.birthTimeSource } : {}), - ...(Object.hasOwn(calculationSpec, "timezoneId") ? { timezone_id: calculationSpec.timezoneId } : {}), - ...(Object.hasOwn(calculationSpec, "timezoneSource") ? { timezone_source: calculationSpec.timezoneSource } : {}), - ...(Object.hasOwn(calculationSpec, "localTimeStatus") ? { local_time_status: calculationSpec.localTimeStatus } : {}), - events: events.map((event) => ({ - id: event.eventId, domain: event.domain, event_kind: event.eventKind, - date_start: event.dateRange.start, date_end: event.dateRange.end, precision: event.dateRange.precision, summary: event.summary, - ...(Object.hasOwn(event, "dateSource") ? { date_source: event.dateSource } : {}), - ...(Object.hasOwn(event, "dateReliability") ? { date_reliability: event.dateReliability } : {}), - ...(Object.hasOwn(event, "dateCorroboration") ? { date_corroboration: event.dateCorroboration } : {}), - ...(Object.hasOwn(event, "dateConflictStatus") ? { date_conflict_status: event.dateConflictStatus } : {}), - })), - }), + body: JSON.stringify(rectificationRequestBody(calculationSpec, events)), }); const payload: unknown = await response.json(); if (!response.ok) throw new Error(`rectification_v5_engine_${response.status}`); @@ -123,5 +201,15 @@ export function createRectificationV4CandidateEngine(options: { readonly apiBase contributionMatrix: parsed.event_contribution_matrix, missingLayers: parsed.missing_layers, }; + }, async validateWithVedAstro({ calculationSpec, events, candidateTimes }) { + const response = await fetchImpl(`${options.apiBase}/api/rectification/v5/vedastro-validate`, { + method: "POST", + headers: { "content-type": "application/json" }, + signal: AbortSignal.timeout(90_000), + body: JSON.stringify({ ...rectificationRequestBody(calculationSpec, events), candidate_times: candidateTimes }), + }); + const payload: unknown = await response.json(); + if (!response.ok) throw new Error(`rectification_v5_vedastro_${response.status}`); + return projectVedAstroValidation(vedAstroResponseSchema.parse(payload)); }}; } diff --git a/frontend/tests/rectification-analysis-trace.test.ts b/frontend/tests/rectification-analysis-trace.test.ts index 3fe839a5..7cf53024 100644 --- a/frontend/tests/rectification-analysis-trace.test.ts +++ b/frontend/tests/rectification-analysis-trace.test.ts @@ -14,6 +14,7 @@ import { createRectificationV4CaseService } from "../src/lib/rectification-v4/ca import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts"; import type { CalculationSpec, + CandidateMinute, LifeEventRevision, RectificationAnalysisTrace, RectificationV4Case, @@ -24,9 +25,19 @@ import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/me import { projectAnalysisMessages } from "../src/lib/rectification-v4/supabase-store.ts"; import type { ClaimedRectificationV4Job } from "../src/lib/rectification-v4/store.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; -import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; +import { passingVedAstroValidation, v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; const now = "2026-07-29T00:00:00.000Z"; +const rangeReadyCandidates: CandidateMinute[] = [ + { time: "05:13", score: 100, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:14", score: 99, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:15", score: 98, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:17", score: 97.8, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:18", score: 97.7, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:19", score: 97.6, supportingEventIds: [], conflictingEventIds: [] }, +]; + const spec: CalculationSpec = { version: "rectification-calculation-spec-v4", birthDate: "1997-08-08", @@ -258,7 +269,7 @@ test("scoring trace records one real engine call and only matrix-confirmed techn engine: { score: async ({ calculationSpec, events }) => { scoreCalls += 1; - const base = v5EngineResult(calculationSpec, events); + const base = v5EngineResult(calculationSpec, events, rangeReadyCandidates); const contributionMatrix: CandidateEngineResult["contributionMatrix"] = Object.fromEntries( Object.entries(base.contributionMatrix).map(([eventId, candidates]) => [ eventId, @@ -289,6 +300,87 @@ test("scoring trace records one real engine call and only matrix-confirmed techn assert.deepEqual(analysisToolLabels(trace, "agent_diagnostic"), []); }); +test("VedAstro post-validation runs only after the local range gate and blocks publication on safe failure", async () => { + const claimed = makeClaimed([ + event("education", "education_milestone", "离家去外地上大学", "2016-09"), + event("relocation", "relocation", "搬到北京长期居住", "2018-08"), + event("career", "career_change", "开始负责商业巡演公司", "2023-09"), + event("relationship", "relationship_start", "开始一段长期关系", "2021-05"), + event("finance", "finance_change", "收入结构发生明显变化", "2024-02"), + ]); + let validationCalls = 0; + const blocked = await processRectificationAgentTurn({ + claimed, + engine: { + score: async ({ calculationSpec, events }) => v5EngineResult(calculationSpec, events, rangeReadyCandidates), + validateWithVedAstro: async ({ candidateTimes }) => { + validationCalls += 1; + assert.deepEqual(candidateTimes, ["05:13", "05:14"]); + return passingVedAstroValidation(candidateTimes, { + status: "blocked", + providerStatus: "timeout", + blockers: ["vedastro_timeout"], + minuteSensitiveValidation: { comparisonReady: false, discriminated: false, discriminatedLayers: [] }, + }); + }, + }, + now: new Date(now), + }); + assert.equal(validationCalls, 1); + assert.equal(blocked.snapshot?.canAcceptRange, false); + assert.equal(blocked.snapshot?.canConfirmExactMinute, false); + assert.ok(blocked.snapshot?.gateReasons.includes("vedastro_validation_not_passed")); + assert.equal(blocked.diagnostics?.externalValidation?.status, "blocked"); + assert.deepEqual(analysisToolLabels(blocked.publicMessage.analysisTrace!, "diagnostic"), ["VedAstro 事后校验"]); + + validationCalls = 0; + const localGateBlocked = await processRectificationAgentTurn({ + claimed, + engine: { + score: async ({ calculationSpec, events }) => { + const result = v5EngineResult(calculationSpec, events, rangeReadyCandidates); + return { ...result, robustness: { ...result.robustness, leaveOneDomainOutRetentionRate: 0 } }; + }, + validateWithVedAstro: async ({ candidateTimes }) => { + validationCalls += 1; + return passingVedAstroValidation(candidateTimes); + }, + }, + now: new Date(now), + }); + assert.equal(validationCalls, 0); + assert.equal(localGateBlocked.snapshot?.canAcceptRange, false); + assert.equal(localGateBlocked.diagnostics?.externalValidation, undefined); +}); + +test("legacy and shadow modes never call VedAstro post-validation", async () => { + const events = [ + event("education", "education_milestone", "离家去外地上大学", "2016-09"), + event("relocation", "relocation", "搬到北京长期居住", "2018-08"), + event("career", "career_change", "开始负责商业巡演公司", "2023-09"), + event("relationship", "relationship_start", "开始一段长期关系", "2021-05"), + event("finance", "finance_change", "收入结构发生明显变化", "2024-02"), + ]; + for (const deploymentMode of ["v4_legacy", "v5_shadow"] as const) { + const base = makeClaimed(events); + let validationCalls = 0; + const result = await processRectificationAgentTurn({ + claimed: { ...base, case: { ...base.case, deploymentMode } }, + engine: { + score: async ({ calculationSpec, events: scoreEvents }) => v5EngineResult(calculationSpec, scoreEvents), + validateWithVedAstro: async ({ candidateTimes }) => { + validationCalls += 1; + return passingVedAstroValidation(candidateTimes); + }, + }, + now: new Date(now), + }); + assert.equal(validationCalls, 0, deploymentMode); + assert.equal(result.diagnostics?.externalValidation, undefined, deploymentMode); + assert.equal(result.snapshot?.canConfirmExactMinute, false, deploymentMode); + } +}); + test("read-only Agent diagnostics are traced only when the reasoner actually requests one", async () => { const caseValue = makeClaimed([]).case; const direct = await runBoundedReasoner({ diff --git a/frontend/tests/rectification-provenance.test.ts b/frontend/tests/rectification-provenance.test.ts index 6ce643f7..3bb8580d 100644 --- a/frontend/tests/rectification-provenance.test.ts +++ b/frontend/tests/rectification-provenance.test.ts @@ -87,3 +87,50 @@ test("candidate engine forwards present provenance without inventing missing fie assert.equal(event.date_reliability, null); assert.equal(Object.hasOwn(event, "date_corroboration"), false); }); + +test("candidate engine sends only the selected pair and persists a safe VedAstro projection", async () => { + let body: Record | null = null; + const engine = createRectificationV4CandidateEngine({ + apiBase: "http://example.test", + fetchImpl: async (input, init) => { + assert.equal(String(input), "http://example.test/api/rectification/v5/vedastro-validate"); + body = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ + status: "pass", + passed: true, + can_confirm_exact_minute: false, + candidate_times: { primary: "05:13", runner_up: "05:14" }, + blockers: [], + minute_sensitive_validation: { + comparison_ready: true, + discriminated: true, + discriminated_layers: ["D9"], + raw_response: { secret: true }, + }, + event_validation: { + eligible_event_count: 1, + supported_event_count: 1, + unsupported_events: [{ event_id: "private", summary: "must not persist" }], + candidates: [ + { role: "primary", metric: { requested_event_count: 1, successful_event_count: 1, matched_event_count: 1, event_hit_count: 2, signal_lift: 3 }, events: [{ raw_response: "secret" }] }, + { role: "runner_up", metric: { requested_event_count: 1, successful_event_count: 1, matched_event_count: 1, event_hit_count: 1, signal_lift: 1 } }, + ], + }, + raw_request: { api_key: "secret" }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + assert.ok(engine.validateWithVedAstro); + const validation = await engine.validateWithVedAstro({ + calculationSpec: legacySpec, + events: [baseEvent], + candidateTimes: ["05:13", "05:14"], + }); + const captured = body as unknown as Record; + assert.deepEqual(captured.candidate_times, ["05:13", "05:14"]); + assert.equal(validation.status, "pass"); + assert.equal(validation.canConfirmExactMinute, false); + assert.deepEqual(validation.minuteSensitiveValidation.discriminatedLayers, ["D9"]); + const serialized = JSON.stringify(validation); + assert.doesNotMatch(serialized, /raw_request|raw_response|api_key|must not persist|secret/); +}); diff --git a/frontend/tests/rectification-v4-replay.test.ts b/frontend/tests/rectification-v4-replay.test.ts index f7fdcebe..9ee496be 100644 --- a/frontend/tests/rectification-v4-replay.test.ts +++ b/frontend/tests/rectification-v4-replay.test.ts @@ -6,7 +6,7 @@ import { createRectificationV4CaseService } from "../src/lib/rectification-v4/ca import type { CalculationSpec, CandidateMinute } from "../src/lib/rectification-v4/contracts.ts"; import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; -import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; +import { passingVedAstroValidation, v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; const now = () => new Date("2026-07-26T08:00:00.000Z"); const spec: CalculationSpec = { @@ -62,6 +62,9 @@ test("V5 golden replay persists the full artifact chain, returns ranges only, an conflictingEventIds: candidate.score < 97 ? ids : [], }))); }, + async validateWithVedAstro({ candidateTimes }) { + return passingVedAstroValidation(candidateTimes); + }, }, }); const userId = randomUUID(); diff --git a/frontend/tests/rectification-v5-test-support.ts b/frontend/tests/rectification-v5-test-support.ts index 841548a3..411d67cf 100644 --- a/frontend/tests/rectification-v5-test-support.ts +++ b/frontend/tests/rectification-v5-test-support.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts"; +import { vedAstroPostValidationSchema, type VedAstroPostValidation } from "../src/lib/rectification-agent/contracts.ts"; import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "../src/lib/rectification-v4/contracts.ts"; import { rectificationV4AlgorithmVersion } from "../src/lib/rectification-v4/contracts.ts"; import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts"; @@ -72,6 +73,33 @@ export function v5EngineResult( }; } +export function passingVedAstroValidation( + candidateTimes: readonly [string, string], + overrides: Partial = {}, +): VedAstroPostValidation { + return vedAstroPostValidationSchema.parse({ + contractVersion: "vedastro-post-validation-v1", + provider: "vedastro_official", + status: "pass", + providerStatus: "pass", + blockers: [], + primaryCandidateTime: candidateTimes[0], + runnerUpCandidateTime: candidateTimes[1], + eligibleEventCount: 5, + selectedEventCount: 3, + unsupportedEventCount: 0, + candidateMetrics: [ + { role: "primary", requestedEventCount: 3, successfulEventCount: 3, matchedEventCount: 3, eventHitCount: 5, signalLift: 4 }, + { role: "runner_up", requestedEventCount: 3, successfulEventCount: 3, matchedEventCount: 2, eventHitCount: 3, signalLift: 2 }, + ], + minuteSensitiveValidation: { comparisonReady: true, discriminated: true, discriminatedLayers: ["D9"] }, + validationHash: "a".repeat(64), + validatedAt: "2026-07-30T00:00:00.000Z", + canConfirmExactMinute: false, + ...overrides, + }); +} + export async function withV5Mode(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise): Promise { const keys = ["RECTIFICATION_AGENT_V5_ENABLED", "RECTIFICATION_AGENT_V5_SHADOW", "RECTIFICATION_AGENT_V5_CANARY_PERCENT"] as const; const before = Object.fromEntries(keys.map((key) => [key, process.env[key]])); diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 68d1c2d7..8b85e45c 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -148,6 +148,8 @@ _VEDASTRO_RECTIFICATION_DOMAIN_MAP = { def _rectification_event_date_range(event): + if event.get('date_start') and event.get('date_end'): + return str(event['date_start']), str(event['date_end']) value = str(event.get('date') or '') precision = str(event.get('precision') or '') if precision == 'day': @@ -173,7 +175,7 @@ def _rectification_event_representative_date(start_date, end_date): def _select_vedastro_rectification_events(events): - precision_rank = {'year': 1, 'month': 2, 'day': 3} + precision_rank = {'range': 1, 'year': 2, 'quarter': 3, 'month': 4, 'day': 5} selected_by_domain = {} eligible_event_count = 0 unsupported_events = [] @@ -294,6 +296,23 @@ def _safe_vedastro_minute_snapshot_summary(candidate_time, report): } +def _vedastro_minute_snapshot_is_complete(report, summary): + if ( + not isinstance(report, dict) + or report.get('source') != 'vedastro_official' + or summary.get('status') != 'ok' + or not summary.get('available') + ): + return False + layers = summary.get('layers') if isinstance(summary.get('layers'), dict) else {} + return all( + isinstance(layers.get(name), dict) + and layers[name].get('status') == 'ok' + and bool(layers[name].get('fingerprint')) + for name in _VEDASTRO_MINUTE_SENSITIVE_LAYERS + ) + + def _compare_vedastro_minute_snapshots(candidate_snapshots): comparison_ready = len(candidate_snapshots) == 2 and all( item.get('available') for item in candidate_snapshots @@ -324,6 +343,18 @@ def _compare_vedastro_minute_snapshots(candidate_snapshots): } +def _safe_vedastro_adapter_call(call, *args, **kwargs): + try: + result = call(*args, **kwargs) + except TimeoutError: + return {'available': False, 'status': 'timeout', '_failure_kind': 'timeout'} + except Exception: + return {'available': False, 'status': 'exception', '_failure_kind': 'exception'} + if not isinstance(result, dict): + return {'available': False, 'status': 'invalid_response', '_failure_kind': 'exception'} + return result + + def _rectification_candidate_ready_for_external_validation(result): """Allow external validation once local scoring has a narrow, auditable lead.""" segment = result.get('winning_segment') @@ -1633,6 +1664,7 @@ API_COMMAND_MAP = { 'rectification-v5-candidate-features': '/api/rectification/v5/candidate-features', 'rectification-v5-score': '/api/rectification/v5/score', 'rectification-v5-diagnostics': '/api/rectification/v5/diagnostics', + 'rectification-v5-vedastro-validate': '/api/rectification/v5/vedastro-validate', 'case-validation': '/api/case_validation', 'divisional-yoga': '/api/divisional_yoga', 'deep-varga-avastha': '/api/deep_varga_avastha', @@ -1671,6 +1703,7 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = { '/api/rectification/v5/candidate-features', '/api/rectification/v5/score', '/api/rectification/v5/diagnostics', + '/api/rectification/v5/vedastro-validate', '/api/relationship', '/api/remedies', '/api/sade_sati', @@ -2126,6 +2159,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._json(self._compute_rectification_v5_score(body)) elif path == '/api/rectification/v5/diagnostics': self._json(self._compute_rectification_v5_diagnostics(body)) + elif path == '/api/rectification/v5/vedastro-validate': + self._json(self._compute_rectification_v5_vedastro_validate(body)) elif path == '/api/dynamic_rectification_opportunities': result = self._compute_dynamic_rectification_opportunities(body) self._json(result) @@ -7570,6 +7605,154 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): **diagnostics(self._rectification_v5_request(body)), } + def _compute_rectification_v5_vedastro_validate(self, body): + if not isinstance(body, dict): + raise BadRequest('request body must be an object') + candidate_times = body.get('candidate_times') + if ( + not isinstance(candidate_times, list) + or len(candidate_times) != 2 + or any(not isinstance(value, str) or not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', value) for value in candidate_times) + or candidate_times[0] == candidate_times[1] + ): + raise BadRequest('candidate_times must contain exactly two distinct HH:MM values: primary then runner-up') + + request_body = dict(body) + request_body.pop('candidate_times', None) + request = self._rectification_v5_request(request_body) + from scripts.active_rectification_event_engine import _candidate_datetimes + allowed_candidate_times = {value.strftime('%H:%M') for value in _candidate_datetimes(request)} + if any(value not in allowed_candidate_times for value in candidate_times): + raise BadRequest('candidate_times must fall within the V5 candidate range') + + birth_day = datetime.strptime(request['birth_date'], '%Y-%m-%d') + selected_events, eligible_event_count, unsupported_events = _select_vedastro_rectification_events( + request['events'] + ) + adapter = _load_local_module('vedastro_service_adapter') + minute_cache = {} + range_scan_cache = {} + minute_reports = [] + minute_raw_reports = [] + candidate_validations = [] + raw_reports = [] + + for role, candidate_time in zip(('primary', 'runner_up'), candidate_times): + hour, minute = candidate_time.split(':', 1) + candidate_case = { + 'year': birth_day.year, + 'month': birth_day.month, + 'day': birth_day.day, + 'hour': int(hour), + 'minute': int(minute), + 'second': 0, + 'lat': request['lat'], + 'lon': request['lon'], + 'tz': request['tz'], + } + if candidate_time not in minute_cache: + minute_cache[candidate_time] = _safe_vedastro_adapter_call( + adapter.run_rectification_minute_snapshot_for_case, + candidate_case, + case_id=f'rectification_v5_{role}_{candidate_time.replace(":", "")}', + ) + minute_report = minute_cache[candidate_time] + minute_raw_reports.append(minute_report) + raw_reports.append(minute_report) + minute_reports.append(_safe_vedastro_minute_snapshot_summary(candidate_time, minute_report)) + + event_scans = [] + for event, adapter_domain, mapping_mode, start_date, end_date in selected_events: + cache_key = (candidate_time, adapter_domain, start_date, end_date) + if cache_key not in range_scan_cache: + range_scan_cache[cache_key] = _safe_vedastro_adapter_call( + adapter.run_range_scan_for_case, + candidate_case, + adapter_domain, + start_date, + end_date, + case_id=f'rectification_v5_{role}_{candidate_time.replace(":", "")}', + ) + report = range_scan_cache[cache_key] + raw_reports.append(report) + event_scans.append(_safe_vedastro_range_scan_summary( + event, + adapter_domain, + mapping_mode, + start_date, + end_date, + report, + )) + candidate_validations.append({ + 'role': role, + 'candidate_time': candidate_time, + 'metric': _vedastro_candidate_metric(event_scans), + 'events': event_scans, + }) + + minute_comparison = _compare_vedastro_minute_snapshots(minute_reports) + minute_snapshots_verified = bool( + minute_comparison['comparison_ready'] + and all( + _vedastro_minute_snapshot_is_complete(report, summary) + for report, summary in zip(minute_raw_reports, minute_reports) + ) + ) + scans_succeeded = bool(selected_events) and all( + item['metric']['successful_event_count'] == item['metric']['requested_event_count'] + for item in candidate_validations + ) + search_events_primary_supports_local_winner = bool( + scans_succeeded + and _vedastro_metric_key(candidate_validations[0]['metric']) + > _vedastro_metric_key(candidate_validations[1]['metric']) + ) + + blockers = [] + failure_kinds = {report.get('_failure_kind') for report in raw_reports} + if 'timeout' in failure_kinds or any(report.get('status') == 'timeout' for report in raw_reports): + blockers.append('vedastro_timeout') + if 'exception' in failure_kinds: + blockers.append('vedastro_exception') + if not minute_snapshots_verified or not scans_succeeded: + blockers.append('vedastro_official_response_missing') + if minute_snapshots_verified and not minute_comparison['discriminated']: + blockers.append('vedastro_minute_sensitive_layers_not_discriminated') + if not selected_events: + blockers.append('vedastro_supported_events_missing') + blockers = list(dict.fromkeys(blockers)) + passed = not blockers + + return { + 'success': True, + 'endpoint': 'rectification_v5_vedastro_validate', + 'status': 'pass' if passed else 'fail', + 'passed': passed, + 'can_confirm_exact_minute': False, + 'candidate_times': { + 'primary': candidate_times[0], + 'runner_up': candidate_times[1], + }, + 'blockers': blockers, + 'minute_sensitive_validation': { + 'status': 'pass' if minute_snapshots_verified and minute_comparison['discriminated'] else 'fail', + 'candidates': minute_reports, + **minute_comparison, + }, + 'event_validation': { + 'status': 'pass' if scans_succeeded else 'fail', + 'eligible_event_count': eligible_event_count, + 'supported_event_count': len(selected_events), + 'selection_policy': ( + 'one_strongest_event_per_native_adapter_domain; ' + 'direct_mapping_before_proxy; higher_precision_before_lower_precision; newest_on_equal_precision' + ), + 'unsupported_events': unsupported_events, + 'search_events_primary_supports_local_winner': search_events_primary_supports_local_winner, + 'candidates': candidate_validations, + }, + } + def _compute_active_rectification_events_v4(self, body): """Compatibility projection; validation and calculations are owned by V5 services.""" from scripts.rectification.api_service import score_candidates @@ -8445,6 +8628,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/rectification/v5/candidate-features': self._compute_rectification_v5_candidate_features, '/api/rectification/v5/score': self._compute_rectification_v5_score, '/api/rectification/v5/diagnostics': self._compute_rectification_v5_diagnostics, + '/api/rectification/v5/vedastro-validate': self._compute_rectification_v5_vedastro_validate, '/api/relationship': self._compute_relationship, '/api/remedies': self._compute_remedies, '/api/sade_sati': self._compute_sade_sati, @@ -8574,6 +8758,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/rectification/v5/candidate-features': 'Scan immutable candidate static features once per calculation specification', '/api/rectification/v5/score': 'Build the V5 event-by-candidate contribution matrix and score candidate ranges', '/api/rectification/v5/diagnostics': 'Run V5 stability diagnostics over the server-owned contribution matrix', + '/api/rectification/v5/vedastro-validate': 'Validate one V5 primary/runner-up pair with safe official VedAstro summaries', '/api/relationship': 'Compute relationship and spouse-status evidence', '/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha', '/api/sade_sati': 'Compute Sade Sati status and phase', @@ -8651,6 +8836,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'lat': 36.419, 'lon': 114.213, 'tz': 8, 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], }, + '/api/rectification/v5/vedastro-validate': { + 'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03', + 'lat': 36.419, 'lon': 114.213, 'tz': 8, + 'candidate_times': ['05:01', '05:02'], + 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], + }, '/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}}, '/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'}, '/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']}, diff --git a/skills/birth-time-rectification/SKILL.md b/skills/birth-time-rectification/SKILL.md index c7c59e3b..cc04463d 100644 --- a/skills/birth-time-rectification/SKILL.md +++ b/skills/birth-time-rectification/SKILL.md @@ -17,9 +17,11 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re - The server owns event reconciliation, the real Python scan of every minute in the candidate window, the event contribution matrix, Candidate Snapshots, LOEO/LODO, date sensitivity, neighbor stability, candidate split, jobs, replay, persistence, and final decision validation. - The agent may select one active server opportunity, call at most one allowed read-only diagnostic, offer an already-gated candidate range, or stop for low confidence. - The agent never creates events, dates, scores, candidate minutes, or profile updates. +- VedAstro is a read-only post-validation gate for `v5_agent` only. It runs only after the local stability and range-eligibility gates pass, compares the server-provided primary and runner-up, and never replaces V5 local scoring or lets SearchEvents choose the final candidate. - Candidate windows are inclusive. When `start_time > end_time`, the Python scan continues across midnight into the next calendar day; equal endpoints mean one candidate minute, and a window may not exceed 1,440 minutes. - The persisted “分析过程” is a server-owned execution receipt, not hidden chain-of-thought. It may list only stages, tools, and techniques that actually ran, plus a provider-explicit reasoning summary after server-side safety filtering. - `canConfirmExactMinute` is always `false`. Never write `profiles.active_birth_time` automatically. +- A missing, timed-out, failed, or non-discriminating VedAstro result blocks public range disclosure but must not discard the Job or its durable local artifacts. Never expose raw provider payloads or internal provider/technique traces. ## Implementation truth and reference gap diff --git a/skills/birth-time-rectification/references/failure-policy.md b/skills/birth-time-rectification/references/failure-policy.md index 4bb64a71..d6e621f2 100644 --- a/skills/birth-time-rectification/references/failure-policy.md +++ b/skills/birth-time-rectification/references/failure-policy.md @@ -17,14 +17,15 @@ A month-dated event is not a failure. Refine it only when date-sensitivity diagn - LODO retention below `0.8` blocks the public range, as does LOEO below `0.8`. - A missing active-domain required layer or an unclassified missing layer blocks publication. Missing optional layers such as `KP_cusps`, or reference-only D60, do not make the calculation fail and must not be presented as completed evidence. - LOEO/LODO are same-Case sensitivity checks. Until prospective sticky partitioning and calibration exist, the absence of per-Case independent holdout is a deferred safety boundary, not a passed validation. +- For `v5_agent`, a missing, timed-out, exceptional, incomplete, tied, or non-discriminating minute-sensitive VedAstro snapshot blocks public range disclosure. SearchEvents failure blocks validation completeness; SearchEvents disagreement is diagnostic only and must not veto, choose, or reverse the local V5 candidate. ## System failures -Preserve the existing Job and persistence guarantees: claim/lease, idempotency, completed-job replay, and atomic completion. A renderer or extraction failure must not cause partial artifact writes, duplicate completion, profile mutation, or a different replay result. +Preserve the existing Job and persistence guarantees: claim/lease, idempotency, completed-job replay, and atomic completion. A renderer, extraction, or VedAstro post-validation failure must not cause partial artifact writes, duplicate completion, profile mutation, a different replay result, or loss of completed local scoring artifacts. Do not recover a failed gate by loading an arbitrary external repository, adding provenance-based weight, inventing manual `supports/conflicts` scores, choosing a unique minute, or writing a profile birth time. -Never log raw sensitive answers to ordinary telemetry. Persist user text only in the approved Turn/evidence stores required by the product contract. +Never log raw sensitive answers to ordinary telemetry. Persist user text only in the approved Turn/evidence stores required by the product contract. Never expose or persist raw VedAstro provider payloads in public output or the analysis receipt. ## Analysis receipt failures diff --git a/skills/birth-time-rectification/references/output-contract.md b/skills/birth-time-rectification/references/output-contract.md index 651f1b1e..752055f7 100644 --- a/skills/birth-time-rectification/references/output-contract.md +++ b/skills/birth-time-rectification/references/output-contract.md @@ -37,7 +37,7 @@ Reject multi-question transitions such as “另外”, “还有”, “同时 1. it differs materially from the previous Snapshot's primary range; or 2. it is the first Snapshot to pass the public stability gate. -The no-repeat rule takes precedence: it must be `null` for an unchanged range, insufficient event/domain coverage, LOEO or LODO retention below `0.8`, failed date/neighbor stability, a missing active-domain required or unclassified technique layer, an internal unstable Snapshot, or a repeated equivalent calculation. Missing optional `KP_cusps` and reference-only D60 do not block by themselves. Never state or imply a unique or representative birth minute. +The no-repeat rule takes precedence: it must be `null` for an unchanged range, insufficient event/domain coverage, LOEO or LODO retention below `0.8`, failed date/neighbor stability, a missing active-domain required or unclassified technique layer, an internal unstable Snapshot, or a repeated equivalent calculation. In `v5_agent`, it must also be `null` when the required VedAstro primary/runner-up post-validation is missing, timed out, failed, tied, incomplete, or unable to distinguish the pair. Missing optional `KP_cusps` and reference-only D60 do not block by themselves. Never state or imply a unique or representative birth minute. Do not describe LOEO/LODO as an independent holdout, prospective validation, or calibrated accuracy result. Per-Case independent holdout remains deferred until sticky partitioning and calibration exist. @@ -59,4 +59,4 @@ The public projection may contain only: Do not infer missing phases from the final Job phase, and do not label a capability as executed merely because the deployment supports it. If no safe provider summary exists, omit it; never create a substitute or expose hidden chain-of-thought. -The receipt must exclude scores, weights, contribution matrices, internal IDs and field names, candidate minutes, tool arguments or raw results, prompts, model/provider internals, and sensitive user wording. Provenance may support audit linkage only and must never be rendered as added evidence strength. D60 is never displayed. Historical messages without a receipt remain valid, and `v4_legacy`/`v5_shadow` keep their established visible reply semantics. +The receipt must exclude scores, weights, contribution matrices, internal IDs and field names, candidate minutes, tool arguments or raw results, prompts, model/provider internals, raw VedAstro requests/responses, internal provider or technique traces, and sensitive user wording. It may state only that an allowlisted read-only post-validation ran and whether the public gate passed. Provenance may support audit linkage only and must never be rendered as added evidence strength. D60 is never displayed. Historical messages without a receipt remain valid, and `v4_legacy`/`v5_shadow` keep their established visible reply semantics. diff --git a/skills/birth-time-rectification/references/technique-policy.md b/skills/birth-time-rectification/references/technique-policy.md index 0a6c9914..22b33b27 100644 --- a/skills/birth-time-rectification/references/technique-policy.md +++ b/skills/birth-time-rectification/references/technique-policy.md @@ -21,6 +21,13 @@ Only server-reported available layers may be described as used. Missing, blocked LOEO/LODO reuse the same Case matrix after subtracting one event or domain. They are not prospective or independent holdout validation. Per-Case independent holdout remains deferred because no sticky train/holdout partition or calibrated acceptance threshold exists; do not claim it is complete. +## VedAstro post-validation + +- This read-only check is available only in `v5_agent`, after the local stability and range-eligibility gates pass, and only for the server-selected primary and runner-up. It does not rescore candidates or replace `rectification-v5-matrix-scoring-1`. +- Minute-sensitive snapshots may test whether the pair is distinguishable. SearchEvents is bounded supporting evidence only and must never select or reverse the final candidate. +- Missing, timed-out, exceptional, incomplete, tied, or non-discriminating provider results fail closed for public range disclosure. They do not invalidate or delete the local Snapshot, diagnostics, or Job artifacts. +- VedAstro can never authorize a unique-minute claim or profile write. Public output and receipts must exclude raw provider requests/responses and internal provider or technique traces. + ## Technique boundaries - Dasha and dated evidence can frame comparison only when present in server results. diff --git a/tests/test_rectification_v5_vedastro_validation.py b/tests/test_rectification_v5_vedastro_validation.py new file mode 100644 index 00000000..e9d3e4eb --- /dev/null +++ b/tests/test_rectification_v5_vedastro_validation.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import pytest + +from scripts.jyotish_api_server import ( + API_COMMAND_MAP, + TECHNIQUE_EXAMPLE_ENDPOINTS, + BadRequest, + JyotishAPIHandler, +) + + +def request(candidate_times=None): + events = [ + { + "id": "00000000-0000-4000-8000-000000000001", + "domain": "education", + "event_kind": "education_milestone", + "date_start": "2016-09-01", + "date_end": "2016-09-30", + "precision": "month", + "summary": "private education summary that must not be returned", + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "domain": "career", + "event_kind": "career_change", + "date_start": "2020-05-01", + "date_end": "2020-05-01", + "precision": "day", + "summary": "private career summary that must not be returned", + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "domain": "career", + "event_kind": "career_change", + "date_start": "2021-06-01", + "date_end": "2021-06-01", + "precision": "day", + "summary": "newer career event wins the bounded selection", + }, + { + "id": "00000000-0000-4000-8000-000000000004", + "domain": "relationship", + "event_kind": "relationship_change", + "date_start": "2022-01-01", + "date_end": "2022-12-31", + "precision": "year", + "summary": "private relationship summary that must not be returned", + }, + { + "id": "00000000-0000-4000-8000-000000000005", + "domain": "finance", + "event_kind": "finance_change", + "date_start": "2023-03-01", + "date_end": "2023-03-31", + "precision": "month", + "summary": "private finance summary that must not be returned", + }, + { + "id": "00000000-0000-4000-8000-000000000006", + "domain": "health_pressure", + "event_kind": "self_health_event", + "date_start": "2024-04-01", + "date_end": "2024-04-30", + "precision": "month", + "summary": "private health summary that must not be returned", + }, + ] + return { + "birth_date": "1997-08-08", + "start_time": "05:00", + "end_time": "05:30", + "lat": 36.419, + "lon": 114.213, + "tz": 8, + "events": events, + "candidate_times": candidate_times if candidate_times is not None else ["05:13", "05:14"], + } + + +def minute_snapshot(case, *, same=False): + minute = case["minute"] + suffix = "same" if same else str(minute) + return { + "available": True, + "status": "ok", + "source": "vedastro_official", + "layers": { + "ascendant_house_boundaries": { + "status": "ok", + "fingerprint": f"asc-{suffix}", + "ascendant": {"sign": "Leo", "degree_in_sign": minute / 10}, + "houses": {"House1": {}}, + }, + "D9": {"status": "ok", "fingerprint": f"d9-{suffix}", "houses": {"House1": {}}, "planets": {}}, + "D10": {"status": "ok", "fingerprint": f"d10-{suffix}", "houses": {"House1": {}}, "planets": {}}, + "dasha_boundaries": {"status": "ok", "fingerprint": f"dasha-{suffix}", "boundary_count": 3}, + "kp_cusp_sub_lord": {"status": "unsupported", "reason": "not available"}, + }, + "raw_request": {"api_key": "secret"}, + "raw_response": {"private": True}, + } + + +def test_registry_exposes_independent_vedastro_validation_endpoint(): + endpoint = "/api/rectification/v5/vedastro-validate" + assert API_COMMAND_MAP["rectification-v5-vedastro-validate"] == endpoint + assert endpoint in TECHNIQUE_EXAMPLE_ENDPOINTS + + +def test_requires_exactly_two_distinct_candidate_times_before_running_vedastro(): + handler = object.__new__(JyotishAPIHandler) + for candidate_times in ([], ["05:13"], ["05:13", "05:14", "05:15"], ["05:13", "05:13"]): + with pytest.raises(BadRequest, match="candidate_times"): + handler._compute_rectification_v5_vedastro_validate(request(candidate_times)) + + +def test_passes_only_when_official_layers_discriminate_and_primary_is_strictly_better(monkeypatch): + range_calls = [] + + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + return minute_snapshot(case) + + @staticmethod + def run_range_scan_for_case(case, domain, start, end, case_id="user_chart"): + range_calls.append((case["minute"], domain, start, end)) + lift = 3 if case["minute"] == 13 else 1 + return { + "available": True, + "status": "ok", + "event_count": lift, + "top_event": {"event_id": f"event-{domain}"}, + "evidence_ledger": [{"signal_lift": lift}], + "raw_response": {"must_not": "leak"}, + } + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + monkeypatch.setenv("VEDASTRO_API_KEY", "must-not-leak") + + result = object.__new__(JyotishAPIHandler)._compute_rectification_v5_vedastro_validate(request()) + + assert result["status"] == "pass" + assert result["passed"] is True + assert result["can_confirm_exact_minute"] is False + assert result["candidate_times"] == {"primary": "05:13", "runner_up": "05:14"} + assert result["minute_sensitive_validation"]["discriminated"] is True + assert result["event_validation"]["search_events_primary_supports_local_winner"] is True + assert result["event_validation"]["supported_event_count"] == 3 + assert len(range_calls) == 6 + assert {call[1] for call in range_calls} == {"career", "marriage", "wealth"} + serialized = str(result) + assert "private" not in serialized + assert "raw_request" not in serialized + assert "raw_response" not in serialized + assert "must-not-leak" not in serialized + + +@pytest.mark.parametrize("mutation", ["missing_source", "missing_layer"]) +def test_missing_official_minute_response_cannot_pass(monkeypatch, mutation): + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + report = minute_snapshot(case) + if mutation == "missing_source": + report.pop("source") + else: + report["layers"].pop("D10") + return report + + @staticmethod + def run_range_scan_for_case(case, domain, start, end, case_id="user_chart"): + lift = 3 if case["minute"] == 13 else 1 + return { + "available": True, + "status": "ok", + "event_count": lift, + "top_event": {"event_id": f"event-{domain}"}, + "evidence_ledger": [{"signal_lift": lift}], + } + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = object.__new__(JyotishAPIHandler)._compute_rectification_v5_vedastro_validate(request()) + + assert result["status"] == "fail" + assert result["passed"] is False + assert result["can_confirm_exact_minute"] is False + assert "vedastro_official_response_missing" in result["blockers"] + + +def test_identical_minute_layers_cannot_pass(monkeypatch): + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + return minute_snapshot(case, same=True) + + @staticmethod + def run_range_scan_for_case(case, domain, start, end, case_id="user_chart"): + return { + "available": True, + "status": "ok", + "event_count": 1, + "top_event": {"event_id": f"event-{domain}"}, + "evidence_ledger": [{"signal_lift": 1}], + } + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = object.__new__(JyotishAPIHandler)._compute_rectification_v5_vedastro_validate(request()) + + assert result["status"] == "fail" + assert result["passed"] is False + assert result["can_confirm_exact_minute"] is False + assert "vedastro_minute_sensitive_layers_not_discriminated" in result["blockers"] + + +def test_search_events_disagreement_is_diagnostic_and_cannot_reverse_local_winner(monkeypatch): + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + return minute_snapshot(case) + + @staticmethod + def run_range_scan_for_case(case, domain, start, end, case_id="user_chart"): + lift = 1 if case["minute"] == 13 else 3 + return { + "available": True, + "status": "ok", + "event_count": lift, + "top_event": {"event_id": f"event-{domain}"}, + "evidence_ledger": [{"signal_lift": lift}], + } + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = object.__new__(JyotishAPIHandler)._compute_rectification_v5_vedastro_validate(request()) + + assert result["status"] == "pass" + assert result["passed"] is True + assert result["event_validation"]["search_events_primary_supports_local_winner"] is False + assert result["blockers"] == [] + assert result["can_confirm_exact_minute"] is False + + +def test_timeout_is_returned_as_safe_failure(monkeypatch): + class Adapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + raise TimeoutError("secret upstream URL and key") + + @staticmethod + def run_range_scan_for_case(case, domain, start, end, case_id="user_chart"): + raise RuntimeError("secret raw response") + + monkeypatch.setattr("scripts.jyotish_api_server._load_local_module", lambda name: Adapter) + result = object.__new__(JyotishAPIHandler)._compute_rectification_v5_vedastro_validate(request()) + + assert result["status"] == "fail" + assert result["passed"] is False + assert result["can_confirm_exact_minute"] is False + assert "vedastro_timeout" in result["blockers"] + assert "vedastro_exception" in result["blockers"] + assert "secret" not in str(result)