Merge pull request #66 from jesse-ux/staging
feat: add VedAstro rectification post-validation
This commit is contained in:
@@ -1740,3 +1740,19 @@
|
||||
- 相关记录:BUG-090、BUG-093、BUG-094、BUG-095
|
||||
- 复发自:BUG-093、BUG-095
|
||||
- 修复版本:待本次 staging 修复提交与部署验收
|
||||
|
||||
## BUG-098 | V6 公开候选门禁未明确 LODO、技法层级与独立 holdout 边界
|
||||
|
||||
- 状态:resolved/partial
|
||||
- 首次发现:2026-07-30
|
||||
- 最近更新:2026-07-30
|
||||
- 影响面:V6 Python 候选扫描、Candidate Snapshot 公开范围门禁、生时纠正 Skill 与参考 Skill 能力声明
|
||||
- 用户现象:现有说明容易把“支持某技法”、同一 Case 内的留一诊断和参考 Skill 方法论误读为已完成独立验证;缺失 `KP_cusps` 或 D60 也可能被错误当成公开范围的统一硬阻塞。
|
||||
- 根因:文档没有把真实实现链、LODO public gate、active-domain required/optional/reference-only 技法策略和参考 Skill 的未实现能力分开;LOEO/LODO 使用同一事件贡献矩阵做事后减项,不能替代 prospective independent holdout。
|
||||
- 修复:记录 V6 的真实链路为跨午夜兼容的逐分钟 Python 扫描、事件贡献矩阵、Snapshot、LOEO/LODO、date sensitivity、neighbor stability 与 candidate split;公开范围新增 LODO 稳定性门禁,并按活跃可评分领域判定 required layer,`KP_cusps` 为 optional、D60 为 reference-only、未知层失败关闭;事件 provenance 仅用于审计且不参与加权。
|
||||
- 验证:本工作树的聚焦合同覆盖跨午夜分钟枚举、LODO 低于 `0.8` 拒绝公开范围、required layer 缺失阻塞、`KP_cusps`/D60 缺失不阻塞,以及旧 Snapshot 兼容;本记录不把这些回归误写成独立 holdout 验证。
|
||||
- Partial / deferred:per-Case independent holdout 因缺少 prospective sticky partition 与 calibration contract 延期。当前 LOEO/LODO 只证明同一 Case 内的敏感性,不得伪称 prospective、independent 或 calibrated validation 已完成。
|
||||
- 安全边界:继续禁止手工 `supports/conflicts` 伪评分、任意外部仓动态加载、唯一分钟结论和自动写入 `profiles.active_birth_time`;参考 Skill 只作方法与审计参照,不成为第二评分真源。
|
||||
- 防复发:公开候选必须同时通过事件/领域覆盖、范围宽度、邻近分钟、LOEO、LODO、日期敏感性、计算规格和 required-technique 门禁;任何 holdout 完成声明必须先有稳定分区持久化与校准验收证据。
|
||||
- 相关记录:BUG-082、BUG-090、BUG-093、BUG-095
|
||||
- 修复版本:`birth-time-rectification-v6` / `rectification-agent-v6-1`,独立 holdout 延期
|
||||
|
||||
@@ -43,13 +43,33 @@ export async function calculationSpecForUser(
|
||||
userId: string,
|
||||
): Promise<CalculationSpec> {
|
||||
const { data, error } = await auth.from("profiles")
|
||||
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset")
|
||||
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_source,timezone_offset")
|
||||
.eq("id", userId).maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) throw new RectificationV4HttpError(409, "请先补全出生日期、时间线索和出生地点。");
|
||||
const profile = await resolveMissingBirthTimezoneOffset(data);
|
||||
let resolvedLocalTimeStatus: CalculationSpec["localTimeStatus"] | undefined;
|
||||
const profile = await resolveMissingBirthTimezoneOffset(data, {
|
||||
fetchImpl: async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
const payload = await response.clone().json().catch(() => null) as { localTimeStatus?: unknown } | null;
|
||||
const status = payload?.localTimeStatus;
|
||||
if (status === "resolved" || status === "not_provided" || status === "ambiguous" || status === "nonexistent") {
|
||||
resolvedLocalTimeStatus = status;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const assessment = parseBirthTimeProfile(profile);
|
||||
const range = assessBirthTime(assessment, { kind: "unavailable" }).reportedRange;
|
||||
const birthTimeSource = typeof data.birth_time_source === "string" && data.birth_time_source.trim()
|
||||
? data.birth_time_source.trim() as CalculationSpec["birthTimeSource"]
|
||||
: undefined;
|
||||
const timezoneId = typeof data.timezone_id === "string" && data.timezone_id.trim()
|
||||
? data.timezone_id.trim()
|
||||
: undefined;
|
||||
const timezoneSource = typeof data.timezone_source === "string" && data.timezone_source.trim()
|
||||
? data.timezone_source.trim()
|
||||
: undefined;
|
||||
return {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: assessment.date,
|
||||
@@ -60,6 +80,10 @@ export async function calculationSpecForUser(
|
||||
latitude: assessment.location.lat,
|
||||
longitude: assessment.location.lon,
|
||||
timezoneOffsetHours: assessment.location.tz,
|
||||
...(birthTimeSource ? { birthTimeSource } : {}),
|
||||
...(timezoneId ? { timezoneId } : {}),
|
||||
...(timezoneSource ? { timezoneSource } : {}),
|
||||
...(resolvedLocalTimeStatus ? { localTimeStatus: resolvedLocalTimeStatus } : {}),
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { reviseEventRequestSchema } from "@/lib/rectification-v4/contracts";
|
||||
import { appendEventRevision } from "@/lib/rectification-v4/evidence-ledger";
|
||||
import { appendEventRevision, eventDateProvenance } from "@/lib/rectification-v4/evidence-ledger";
|
||||
import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../../../_server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -23,6 +23,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ cas
|
||||
summary: body.summary,
|
||||
rawText: body.rawText,
|
||||
dateRange: body.dateRange,
|
||||
...eventDateProvenance(body),
|
||||
scoreability: body.scoreability,
|
||||
});
|
||||
return NextResponse.json(await context.service.reviseEvent({
|
||||
|
||||
@@ -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<typeof vedAstroPostValidationSchema>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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: "扫描候选分钟",
|
||||
@@ -163,6 +196,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
const robustness = {
|
||||
neighborSupportMinutes: scored.robustness.neighborSupportMinutes,
|
||||
leaveOneOutRetentionRate: scored.robustness.leaveOneOutRetentionRate,
|
||||
leaveOneDomainOutRetentionRate: scored.robustness.leaveOneDomainOutRetentionRate,
|
||||
dateSensitivityRetentionRate: scored.robustness.dateSensitivityRetentionRate,
|
||||
calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash,
|
||||
};
|
||||
@@ -170,7 +204,8 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
clusters,
|
||||
robustness,
|
||||
scoreableEventCount: scoreable.length,
|
||||
scoreableDomainCount: domains.size,
|
||||
scoreableDomains: [...domains],
|
||||
missingTechniqueLayers: scored.missingLayers,
|
||||
});
|
||||
snapshot = {
|
||||
id: scored.resultId,
|
||||
@@ -184,7 +219,7 @@ export async function processRectificationAgentTurn(input: Readonly<{
|
||||
robustness,
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: gate.canAcceptRange,
|
||||
gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)],
|
||||
gateReasons: [...gate.reasons],
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
diagnostics = diagnosticsSummarySchema.parse({
|
||||
@@ -238,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,
|
||||
|
||||
@@ -24,6 +24,9 @@ export function buildCandidateClusters(
|
||||
if (group && nextMinute(group.at(-1)!.time, candidate.time)) group.push(candidate);
|
||||
else groups.push([candidate]);
|
||||
}
|
||||
if (groups.length > 1 && nextMinute(groups.at(-1)!.at(-1)!.time, groups[0]![0]!.time)) {
|
||||
groups[0] = [...groups.pop()!, ...groups[0]!];
|
||||
}
|
||||
return groups.map((group) => {
|
||||
const peakScore = Math.max(...group.map((candidate) => candidate.score));
|
||||
const peakCandidate = group.find((candidate) => candidate.score === peakScore)!;
|
||||
|
||||
@@ -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<typeof vedAstroResponseSchema>): 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<CandidateEngineResult>;
|
||||
validateWithVedAstro?(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[]; readonly candidateTimes: readonly [string, string] }): Promise<VedAstroPostValidation>;
|
||||
}
|
||||
|
||||
export function createRectificationV4CandidateEngine(options: { readonly apiBase: string; readonly fetchImpl?: typeof fetch }): RectificationV4CandidateEngine {
|
||||
@@ -88,14 +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,
|
||||
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,
|
||||
})),
|
||||
}),
|
||||
body: JSON.stringify(rectificationRequestBody(calculationSpec, events)),
|
||||
});
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) throw new Error(`rectification_v5_engine_${response.status}`);
|
||||
@@ -115,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));
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -82,6 +82,13 @@ export type RelatedPerson = z.infer<typeof relatedPersonSchema>;
|
||||
export const scoreabilitySchema = z.enum(["scoreable", "context_only", "pending_review", "unsupported"]);
|
||||
export type Scoreability = z.infer<typeof scoreabilitySchema>;
|
||||
|
||||
const eventDateProvenanceFields = {
|
||||
dateSource: z.string().trim().min(1).max(120).nullable().optional(),
|
||||
dateReliability: z.string().trim().min(1).max(120).nullable().optional(),
|
||||
dateCorroboration: z.string().trim().min(1).max(1_000).nullable().optional(),
|
||||
dateConflictStatus: z.string().trim().min(1).max(120).nullable().optional(),
|
||||
} as const;
|
||||
|
||||
export const lifeEventRevisionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
eventId: z.string().uuid(),
|
||||
@@ -93,6 +100,7 @@ export const lifeEventRevisionSchema = z.object({
|
||||
summary: z.string().trim().min(1).max(1_000),
|
||||
rawText: z.string().trim().min(1).max(4_000),
|
||||
dateRange: eventDateRangeSchema,
|
||||
...eventDateProvenanceFields,
|
||||
scoreability: scoreabilitySchema,
|
||||
supersedesRevisionId: z.string().uuid().nullable(),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
@@ -119,6 +127,17 @@ export const calculationSpecSchema = z.object({
|
||||
latitude: z.number().finite().min(-90).max(90),
|
||||
longitude: z.number().finite().min(-180).max(180),
|
||||
timezoneOffsetHours: z.number().finite().min(-14).max(14),
|
||||
birthTimeSource: z.enum([
|
||||
"hospital_record",
|
||||
"family_exact",
|
||||
"approximate",
|
||||
"period_only",
|
||||
"unknown",
|
||||
"legacy_import",
|
||||
]).nullable().optional(),
|
||||
timezoneId: z.string().trim().min(1).max(120).nullable().optional(),
|
||||
timezoneSource: z.string().trim().min(1).max(80).nullable().optional(),
|
||||
localTimeStatus: z.enum(["resolved", "not_provided", "ambiguous", "nonexistent"]).nullable().optional(),
|
||||
ayanamsa: z.literal("lahiri"),
|
||||
nodeMode: z.literal("mean"),
|
||||
minuteStep: z.literal(1),
|
||||
@@ -144,15 +163,20 @@ export const candidateClusterSchema = z.object({
|
||||
}).strict();
|
||||
export type CandidateCluster = z.infer<typeof candidateClusterSchema>;
|
||||
|
||||
export const robustnessSchema = z.object({
|
||||
const robustnessValueSchema = z.object({
|
||||
neighborSupportMinutes: z.number().int().nonnegative(),
|
||||
leaveOneOutRetentionRate: z.number().finite().min(0).max(1),
|
||||
leaveOneDomainOutRetentionRate: z.number().finite().min(0).max(1),
|
||||
dateSensitivityRetentionRate: z.number().finite().min(0).max(1),
|
||||
calculationSpecHashMatched: z.boolean(),
|
||||
}).strict();
|
||||
export type Robustness = z.infer<typeof robustnessSchema>;
|
||||
export type Robustness = z.infer<typeof robustnessValueSchema>;
|
||||
export const robustnessSchema: z.ZodType<Robustness> = z.preprocess((value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || "leaveOneDomainOutRetentionRate" in value) return value;
|
||||
return { ...value, leaveOneDomainOutRetentionRate: 0.8 };
|
||||
}, robustnessValueSchema) as z.ZodType<Robustness>;
|
||||
|
||||
export const candidateSnapshotSchema = z.object({
|
||||
const candidateSnapshotBaseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
caseId: z.string().uuid(),
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
@@ -167,7 +191,19 @@ export const candidateSnapshotSchema = z.object({
|
||||
gateReasons: z.array(z.string().trim().min(1).max(120)).max(20),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type CandidateSnapshot = z.infer<typeof candidateSnapshotSchema>;
|
||||
|
||||
export type CandidateSnapshot = z.infer<typeof candidateSnapshotBaseSchema>;
|
||||
export const candidateSnapshotSchema: z.ZodType<CandidateSnapshot> = candidateSnapshotBaseSchema.transform((snapshot) => {
|
||||
if (snapshot.robustness.leaveOneDomainOutRetentionRate >= 0.8) return snapshot;
|
||||
const reason = "leave_one_domain_out_not_stable";
|
||||
return {
|
||||
...snapshot,
|
||||
canAcceptRange: false,
|
||||
gateReasons: snapshot.gateReasons.includes(reason)
|
||||
? snapshot.gateReasons
|
||||
: [...snapshot.gateReasons, reason].slice(0, 20),
|
||||
};
|
||||
});
|
||||
|
||||
export const rectificationV4QuestionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
@@ -243,6 +279,7 @@ export const reviseEventRequestSchema = z.object({
|
||||
summary: z.string().trim().min(1).max(1_000),
|
||||
rawText: z.string().trim().min(1).max(4_000),
|
||||
dateRange: eventDateRangeSchema,
|
||||
...eventDateProvenanceFields,
|
||||
scoreability: scoreabilitySchema.optional(),
|
||||
}).strict();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CandidateCluster, Robustness } from "./contracts.ts";
|
||||
import type { CandidateCluster, EvidenceDomain, Robustness } from "./contracts.ts";
|
||||
import { classifyMissingTechniqueLayers } from "./technique-layer-policy.ts";
|
||||
|
||||
export type DecisionGateResult = Readonly<{
|
||||
canConfirmExactMinute: false;
|
||||
@@ -10,18 +11,28 @@ export function evaluateDecisionGate(input: {
|
||||
readonly clusters: readonly CandidateCluster[];
|
||||
readonly robustness: Robustness;
|
||||
readonly scoreableEventCount: number;
|
||||
readonly scoreableDomainCount: number;
|
||||
readonly scoreableDomains: readonly EvidenceDomain[];
|
||||
readonly missingTechniqueLayers?: readonly string[];
|
||||
}): DecisionGateResult {
|
||||
const reasons: string[] = [];
|
||||
const primary = input.clusters[0];
|
||||
if (!primary) reasons.push("no_primary_candidate_cluster");
|
||||
if (input.scoreableEventCount < 5) reasons.push("insufficient_scoreable_events");
|
||||
if (input.scoreableDomainCount < 3) reasons.push("insufficient_scoreable_domains");
|
||||
if (new Set(input.scoreableDomains).size < 3) reasons.push("insufficient_scoreable_domains");
|
||||
if ((primary?.widthMinutes ?? 0) < 2) reasons.push("single_minute_cluster_not_acceptable");
|
||||
if ((primary?.widthMinutes ?? Number.POSITIVE_INFINITY) > 15) reasons.push("primary_cluster_too_wide");
|
||||
if (input.robustness.neighborSupportMinutes < 2) reasons.push("neighbor_support_not_passed");
|
||||
if (input.robustness.leaveOneOutRetentionRate < 0.8) reasons.push("leave_one_out_not_stable");
|
||||
if (input.robustness.leaveOneDomainOutRetentionRate < 0.8) reasons.push("leave_one_domain_out_not_stable");
|
||||
if (input.robustness.dateSensitivityRetentionRate < 0.8) reasons.push("date_range_sensitivity_not_stable");
|
||||
if (!input.robustness.calculationSpecHashMatched) reasons.push("calculation_spec_changed");
|
||||
|
||||
const missing = classifyMissingTechniqueLayers(
|
||||
input.missingTechniqueLayers ?? [],
|
||||
input.scoreableDomains,
|
||||
);
|
||||
reasons.push(...missing.required.map((layer) => `missing_required_layer:${layer}`));
|
||||
reasons.push(...missing.unclassified.map((layer) => `missing_unclassified_layer:${layer}`));
|
||||
|
||||
return { canConfirmExactMinute: false, canAcceptRange: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,20 @@ export type NewEventRevision = Omit<LifeEventRevision, "id" | "revision" | "supe
|
||||
readonly scoreability?: LifeEventRevision["scoreability"];
|
||||
};
|
||||
|
||||
export type EventDateProvenance = Pick<
|
||||
LifeEventRevision,
|
||||
"dateSource" | "dateReliability" | "dateCorroboration" | "dateConflictStatus"
|
||||
>;
|
||||
|
||||
export function eventDateProvenance(value: Partial<EventDateProvenance>): Partial<EventDateProvenance> {
|
||||
return {
|
||||
...(Object.prototype.hasOwnProperty.call(value, "dateSource") ? { dateSource: value.dateSource } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(value, "dateReliability") ? { dateReliability: value.dateReliability } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(value, "dateCorroboration") ? { dateCorroboration: value.dateCorroboration } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(value, "dateConflictStatus") ? { dateConflictStatus: value.dateConflictStatus } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function latestEventRevisions(revisions: readonly LifeEventRevision[]): readonly LifeEventRevision[] {
|
||||
const latest = new Map<string, LifeEventRevision>();
|
||||
for (const revision of revisions) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
Scoreability,
|
||||
} from "./contracts.ts";
|
||||
import { dateRangeFromDeclared } from "./date-range.ts";
|
||||
import { appendEventRevision, latestEventRevisions } from "./evidence-ledger.ts";
|
||||
import { appendEventRevision, eventDateProvenance, latestEventRevisions } from "./evidence-ledger.ts";
|
||||
|
||||
const allowedKinds = new Set<EventKind>([
|
||||
"education_milestone", "relocation", "relationship_start", "relationship_end", "relationship_change",
|
||||
@@ -111,6 +111,7 @@ function subjectRevision(answer: string, target: LifeEventRevision, existing: re
|
||||
summary: target.summary,
|
||||
rawText: answer,
|
||||
dateRange: target.dateRange,
|
||||
...eventDateProvenance(target),
|
||||
scoreability,
|
||||
}, { now });
|
||||
}
|
||||
@@ -161,6 +162,7 @@ export function reconcileV4Evidence(input: {
|
||||
summary: target.summary,
|
||||
rawText: input.answer,
|
||||
dateRange,
|
||||
...eventDateProvenance(target),
|
||||
scoreability: target.scoreability,
|
||||
}, { id: targetAnswer.id, now: input.now }));
|
||||
consumed.add(targetAnswer.id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CalculationSpec, LifeEventRevision } from "./contracts.ts";
|
||||
import { latestEventRevisions } from "./evidence-ledger.ts";
|
||||
import { eventDateProvenance, latestEventRevisions } from "./evidence-ledger.ts";
|
||||
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
@@ -28,6 +28,7 @@ export function evidenceSetHash(revisions: readonly LifeEventRevision[]): string
|
||||
subject: event.subject,
|
||||
relatedPerson: event.relatedPerson,
|
||||
dateRange: event.dateRange,
|
||||
...eventDateProvenance(event),
|
||||
scoreability: event.scoreability,
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -108,7 +108,10 @@ function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): Rectific
|
||||
});
|
||||
}
|
||||
|
||||
function eventRevision(row: Row): LifeEventRevision {
|
||||
export function rectificationEventRevisionFromRow(row: Row): LifeEventRevision {
|
||||
const provenance = row.date_provenance && typeof row.date_provenance === "object" && !Array.isArray(row.date_provenance)
|
||||
? row.date_provenance as Row
|
||||
: null;
|
||||
return lifeEventRevisionSchema.parse({
|
||||
id: row.id,
|
||||
eventId: row.event_id,
|
||||
@@ -125,6 +128,10 @@ function eventRevision(row: Row): LifeEventRevision {
|
||||
precision: row.date_precision,
|
||||
label: row.date_label,
|
||||
},
|
||||
...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateSource") ? { dateSource: provenance.dateSource } : {}),
|
||||
...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateReliability") ? { dateReliability: provenance.dateReliability } : {}),
|
||||
...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateCorroboration") ? { dateCorroboration: provenance.dateCorroboration } : {}),
|
||||
...(provenance && Object.prototype.hasOwnProperty.call(provenance, "dateConflictStatus") ? { dateConflictStatus: provenance.dateConflictStatus } : {}),
|
||||
scoreability: row.scoreability,
|
||||
supersedesRevisionId: row.supersedes_revision_id,
|
||||
createdAt: timestamp(row.created_at),
|
||||
@@ -191,7 +198,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
.select("*").eq("case_id", caseId).eq("user_id", userId)
|
||||
.order("created_at", { ascending: true });
|
||||
if (error) throw storeError(error);
|
||||
return ((data ?? []) as Row[]).map(eventRevision);
|
||||
return ((data ?? []) as Row[]).map(rectificationEventRevisionFromRow);
|
||||
}
|
||||
|
||||
async function loadTurnsByCase(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]> {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { EvidenceDomain } from "./contracts.ts";
|
||||
import { domainScorerRegistry } from "./domain-scorers.ts";
|
||||
|
||||
export type MissingTechniqueLayerClassification = Readonly<{
|
||||
required: readonly string[];
|
||||
optional: readonly string[];
|
||||
referenceOnly: readonly string[];
|
||||
unclassified: readonly string[];
|
||||
}>;
|
||||
|
||||
const aliases: Readonly<Record<string, string>> = {
|
||||
Vimshottari_MD_AD_PD: "vimshottari",
|
||||
Narayana_MD_AD: "narayana",
|
||||
};
|
||||
const optionalLayers = new Set(["KP_cusps", "A7", "Ashtakavarga", "Shadbala"]);
|
||||
const referenceOnlyLayers = new Set(["D60"]);
|
||||
const knownDomainLayers = new Set(
|
||||
Object.values(domainScorerRegistry).flatMap((policy) => policy.techniqueLayers),
|
||||
);
|
||||
|
||||
export function classifyMissingTechniqueLayers(
|
||||
missingLayers: readonly string[],
|
||||
activeScoreableDomains: readonly EvidenceDomain[],
|
||||
): MissingTechniqueLayerClassification {
|
||||
const requiredLayers = new Set(
|
||||
activeScoreableDomains.flatMap((domain) => domainScorerRegistry[domain].techniqueLayers),
|
||||
);
|
||||
const classified = {
|
||||
required: [] as string[],
|
||||
optional: [] as string[],
|
||||
referenceOnly: [] as string[],
|
||||
unclassified: [] as string[],
|
||||
};
|
||||
|
||||
for (const layer of [...new Set(missingLayers)]) {
|
||||
const canonical = aliases[layer] ?? layer;
|
||||
if (referenceOnlyLayers.has(canonical)) classified.referenceOnly.push(layer);
|
||||
else if (requiredLayers.has(canonical)) classified.required.push(layer);
|
||||
else if (optionalLayers.has(canonical) || knownDomainLayers.has(canonical)) classified.optional.push(layer);
|
||||
else classified.unclassified.push(layer);
|
||||
}
|
||||
return classified;
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
begin;
|
||||
|
||||
alter table public.birth_time_rectification_v4_event_revisions
|
||||
add column if not exists date_provenance jsonb;
|
||||
|
||||
alter table public.birth_time_rectification_v4_event_revisions
|
||||
drop constraint if exists birth_time_rectification_v4_event_revisions_date_provenance_check;
|
||||
alter table public.birth_time_rectification_v4_event_revisions
|
||||
add constraint birth_time_rectification_v4_event_revisions_date_provenance_check
|
||||
check (date_provenance is null or pg_catalog.jsonb_typeof(date_provenance) = 'object');
|
||||
|
||||
create or replace function public.revise_birth_time_rectification_v4_event(
|
||||
p_user_id uuid, p_case_id uuid, p_action_id uuid, p_expected_version bigint,
|
||||
p_revision jsonb, p_output_evidence_set_hash text, p_turn_id uuid, p_job_id uuid, p_now timestamptz
|
||||
) returns uuid
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare v_case public.birth_time_rectification_v4_cases%rowtype; v_job_id uuid; v_event_id uuid;
|
||||
begin
|
||||
select action.job_id into v_job_id from public.birth_time_rectification_v4_actions action
|
||||
where action.user_id = p_user_id and action.action_id = p_action_id;
|
||||
if v_job_id is not null then return v_job_id; end if;
|
||||
select value.* into v_case from public.birth_time_rectification_v4_cases value
|
||||
where value.id = p_case_id and value.user_id = p_user_id for update;
|
||||
if not found then raise exception 'rectification_v4_case_not_found'; end if;
|
||||
if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if;
|
||||
if v_case.status in ('processing', 'abandoned', 'paused') then raise exception 'rectification_v4_case_invalid_state'; end if;
|
||||
if jsonb_typeof(p_revision) <> 'object' then raise exception 'invalid_rectification_v4_event_revision'; end if;
|
||||
v_event_id = (p_revision->>'eventId')::uuid;
|
||||
insert into public.birth_time_rectification_v4_events(id, case_id, user_id, created_at)
|
||||
values (v_event_id, p_case_id, p_user_id, p_now) on conflict (id) do nothing;
|
||||
insert into public.birth_time_rectification_v4_event_revisions(
|
||||
id, event_id, case_id, user_id, revision, domain, event_kind, summary, raw_text,
|
||||
date_start, date_end, date_precision, date_label, date_provenance, scoreability, supersedes_revision_id, created_at
|
||||
) values (
|
||||
(p_revision->>'id')::uuid, v_event_id, p_case_id, p_user_id,
|
||||
(p_revision->>'revision')::integer, p_revision->>'domain', p_revision->>'eventKind',
|
||||
p_revision->>'summary', p_revision->>'rawText',
|
||||
(p_revision#>>'{dateRange,start}')::date, (p_revision#>>'{dateRange,end}')::date,
|
||||
p_revision#>>'{dateRange,precision}', p_revision#>>'{dateRange,label}',
|
||||
(select pg_catalog.jsonb_object_agg(entry.key, entry.value)
|
||||
from pg_catalog.jsonb_each(p_revision) entry
|
||||
where entry.key in ('dateSource', 'dateReliability', 'dateCorroboration', 'dateConflictStatus')),
|
||||
p_revision->>'scoreability', nullif(p_revision->>'supersedesRevisionId', '')::uuid,
|
||||
(p_revision->>'createdAt')::timestamptz
|
||||
);
|
||||
insert into public.birth_time_rectification_v4_turns(
|
||||
id, case_id, user_id, case_version, question, answer, action_id, created_at
|
||||
) values (p_turn_id, p_case_id, p_user_id, p_expected_version + 1, '修订事件', '', p_action_id, p_now);
|
||||
update public.birth_time_rectification_v4_cases set
|
||||
version = p_expected_version + 1, status = 'processing', phase = 'scoring_candidates',
|
||||
evidence_set_hash = p_output_evidence_set_hash, current_question = null, updated_at = p_now
|
||||
where id = p_case_id;
|
||||
insert into public.birth_time_rectification_v4_jobs(
|
||||
id, case_id, user_id, turn_id, status, phase, expected_case_version,
|
||||
evidence_set_hash, calculation_spec_hash, created_at, updated_at
|
||||
) values (
|
||||
p_job_id, p_case_id, p_user_id, p_turn_id, 'pending', 'scoring_candidates', p_expected_version + 1,
|
||||
p_output_evidence_set_hash, v_case.calculation_spec_hash, p_now, p_now
|
||||
);
|
||||
insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, job_id, created_at)
|
||||
values (p_user_id, p_action_id, p_case_id, p_job_id, p_now);
|
||||
return p_job_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.complete_birth_time_rectification_v5_job(
|
||||
p_worker_id uuid,
|
||||
p_job_id uuid,
|
||||
p_expected_case_version bigint,
|
||||
p_input_evidence_set_hash text,
|
||||
p_output_evidence_set_hash text,
|
||||
p_calculation_spec_hash text,
|
||||
p_completion_payload_hash text,
|
||||
p_event_revisions jsonb,
|
||||
p_pending_evidence jsonb,
|
||||
p_snapshot jsonb,
|
||||
p_diagnostics jsonb,
|
||||
p_feature_snapshot jsonb,
|
||||
p_validated_decision jsonb,
|
||||
p_public_message jsonb,
|
||||
p_agent_run jsonb,
|
||||
p_next_question jsonb,
|
||||
p_status text,
|
||||
p_phase text,
|
||||
p_now timestamptz
|
||||
) returns uuid
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.birth_time_rectification_v4_jobs%rowtype;
|
||||
v_case public.birth_time_rectification_v4_cases%rowtype;
|
||||
v_existing_run public.birth_time_rectification_agent_runs%rowtype;
|
||||
v_existing_message public.birth_time_rectification_public_messages%rowtype;
|
||||
item jsonb;
|
||||
v_snapshot_id uuid;
|
||||
v_feature_id uuid;
|
||||
v_diagnostics_id uuid;
|
||||
v_event_id uuid;
|
||||
v_supersedes_id uuid;
|
||||
v_pending_count integer;
|
||||
begin
|
||||
if jsonb_typeof(p_event_revisions) is distinct from 'array'
|
||||
or jsonb_typeof(p_pending_evidence) is distinct from 'array'
|
||||
or jsonb_typeof(p_validated_decision) is distinct from 'object'
|
||||
or jsonb_typeof(p_public_message) is distinct from 'object'
|
||||
or jsonb_typeof(p_agent_run) is distinct from 'object'
|
||||
or p_output_evidence_set_hash !~ '^[a-f0-9]{64}$'
|
||||
or p_calculation_spec_hash !~ '^[a-f0-9]{64}$'
|
||||
or p_completion_payload_hash !~ '^[a-f0-9]{64}$' then
|
||||
raise exception 'invalid_rectification_v5_completion_payload';
|
||||
end if;
|
||||
|
||||
select value.* into v_job
|
||||
from public.birth_time_rectification_v4_jobs value
|
||||
where value.id = p_job_id
|
||||
for update;
|
||||
if not found then raise exception 'rectification_v4_job_lease_lost'; end if;
|
||||
|
||||
select value.* into v_case
|
||||
from public.birth_time_rectification_v4_cases value
|
||||
where value.id = v_job.case_id
|
||||
for update;
|
||||
if not found then raise exception 'rectification_v4_case_not_found'; end if;
|
||||
|
||||
-- A network retry after commit is an idempotent read, never a second artifact write.
|
||||
if v_job.status = 'completed' then
|
||||
select value.* into v_existing_run
|
||||
from public.birth_time_rectification_agent_runs value
|
||||
where value.job_id = p_job_id;
|
||||
select value.* into v_existing_message
|
||||
from public.birth_time_rectification_public_messages value
|
||||
where value.job_id = p_job_id;
|
||||
select count(*) into v_pending_count
|
||||
from public.birth_time_rectification_pending_evidence value
|
||||
where value.turn_id = v_job.turn_id;
|
||||
if v_existing_run.id is null
|
||||
or v_existing_run.id is distinct from (p_agent_run->>'id')::uuid
|
||||
or v_existing_run.case_id is distinct from v_case.id
|
||||
or v_existing_run.case_version is distinct from p_expected_case_version
|
||||
or v_existing_run.validated_decision_json is distinct from p_validated_decision
|
||||
or v_existing_message.job_id is null
|
||||
or v_existing_message.message is distinct from p_public_message
|
||||
or v_job.completion_payload_hash is distinct from p_completion_payload_hash
|
||||
or v_pending_count is distinct from pg_catalog.jsonb_array_length(p_pending_evidence) then
|
||||
raise exception 'rectification_v5_replay_payload_mismatch';
|
||||
end if;
|
||||
for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop
|
||||
if not exists (
|
||||
select 1 from public.birth_time_rectification_pending_evidence value
|
||||
where value.id = (item->>'id')::uuid
|
||||
and value.case_id = v_case.id
|
||||
and value.user_id = v_case.user_id
|
||||
and value.turn_id = v_job.turn_id
|
||||
and value.target_event_id is not distinct from nullif(item->>'targetEventId', '')::uuid
|
||||
and value.raw_text = item->>'rawText'
|
||||
and value.reason_code = item->>'reasonCode'
|
||||
and value.resolved_event_id is not distinct from nullif(item->>'resolvedEventId', '')::uuid
|
||||
and value.created_at = (item->>'createdAt')::timestamptz
|
||||
and value.resolved_at is not distinct from nullif(item->>'resolvedAt', '')::timestamptz
|
||||
) then
|
||||
raise exception 'rectification_v5_replay_payload_mismatch';
|
||||
end if;
|
||||
end loop;
|
||||
return v_case.id;
|
||||
end if;
|
||||
|
||||
if v_job.worker_id is distinct from p_worker_id
|
||||
or v_job.status <> 'processing'
|
||||
or v_job.lease_expires_at <= p_now then
|
||||
raise exception 'rectification_v4_job_lease_lost';
|
||||
end if;
|
||||
if v_case.version is distinct from p_expected_case_version
|
||||
or v_case.evidence_set_hash is distinct from p_input_evidence_set_hash
|
||||
or v_case.calculation_spec_hash is distinct from p_calculation_spec_hash
|
||||
or v_job.expected_case_version is distinct from p_expected_case_version
|
||||
or v_job.evidence_set_hash is distinct from p_input_evidence_set_hash
|
||||
or v_job.calculation_spec_hash is distinct from p_calculation_spec_hash then
|
||||
raise exception 'stale_rectification_v4_job';
|
||||
end if;
|
||||
|
||||
if (p_agent_run->>'caseId')::uuid is distinct from v_case.id
|
||||
or (p_agent_run->>'jobId')::uuid is distinct from p_job_id
|
||||
or (p_agent_run->>'caseVersion')::bigint is distinct from p_expected_case_version
|
||||
or p_agent_run->>'deploymentMode' is distinct from v_case.deployment_mode
|
||||
or p_agent_run->'validatedDecision' is distinct from p_validated_decision
|
||||
or jsonb_typeof(p_agent_run->'toolCalls') is distinct from 'array'
|
||||
or pg_catalog.jsonb_array_length(p_agent_run->'toolCalls') > 8
|
||||
or p_validated_decision->>'mode' not in ('agent', 'deterministic_fallback') then
|
||||
raise exception 'invalid_rectification_v5_agent_run';
|
||||
end if;
|
||||
|
||||
for item in select value from pg_catalog.jsonb_array_elements(p_event_revisions) loop
|
||||
v_event_id := (item->>'eventId')::uuid;
|
||||
v_supersedes_id := nullif(item->>'supersedesRevisionId', '')::uuid;
|
||||
if (item->>'caseId') is not null and (item->>'caseId')::uuid is distinct from v_case.id then
|
||||
raise exception 'rectification_v5_event_case_mismatch';
|
||||
end if;
|
||||
insert into public.birth_time_rectification_v4_events(
|
||||
id, case_id, user_id, created_at
|
||||
) values (
|
||||
v_event_id, v_case.id, v_case.user_id, (item->>'createdAt')::timestamptz
|
||||
) on conflict (id) do nothing;
|
||||
if not exists (
|
||||
select 1 from public.birth_time_rectification_v4_events value
|
||||
where value.id = v_event_id and value.case_id = v_case.id and value.user_id = v_case.user_id
|
||||
) then
|
||||
raise exception 'rectification_v5_event_case_mismatch';
|
||||
end if;
|
||||
if v_supersedes_id is not null and not exists (
|
||||
select 1 from public.birth_time_rectification_v4_event_revisions value
|
||||
where value.id = v_supersedes_id and value.event_id = v_event_id and value.case_id = v_case.id
|
||||
) then
|
||||
raise exception 'rectification_v5_superseded_revision_mismatch';
|
||||
end if;
|
||||
insert into public.birth_time_rectification_v4_event_revisions(
|
||||
id, event_id, case_id, user_id, revision, domain, event_kind, subject,
|
||||
related_person, summary, raw_text, date_start, date_end, date_precision,
|
||||
date_label, date_provenance, scoreability, supersedes_revision_id, created_at
|
||||
) values (
|
||||
(item->>'id')::uuid, v_event_id, v_case.id, v_case.user_id,
|
||||
(item->>'revision')::integer, item->>'domain', item->>'eventKind', item->>'subject',
|
||||
nullif(item->>'relatedPerson', ''), item->>'summary', item->>'rawText',
|
||||
(item#>>'{dateRange,start}')::date, (item#>>'{dateRange,end}')::date,
|
||||
item#>>'{dateRange,precision}', item#>>'{dateRange,label}',
|
||||
(select pg_catalog.jsonb_object_agg(entry.key, entry.value)
|
||||
from pg_catalog.jsonb_each(item) entry
|
||||
where entry.key in ('dateSource', 'dateReliability', 'dateCorroboration', 'dateConflictStatus')),
|
||||
item->>'scoreability', v_supersedes_id, (item->>'createdAt')::timestamptz
|
||||
);
|
||||
end loop;
|
||||
|
||||
for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop
|
||||
if (item->>'caseId')::uuid is distinct from v_case.id
|
||||
or (item->>'turnId')::uuid is distinct from v_job.turn_id
|
||||
or item->>'reasonCode' not in ('date_unresolved', 'event_unparsed')
|
||||
or nullif(btrim(item->>'rawText'), '') is null
|
||||
or (nullif(item->>'resolvedEventId', '') is null) is distinct from (nullif(item->>'resolvedAt', '') is null) then
|
||||
raise exception 'invalid_rectification_v5_pending_evidence';
|
||||
end if;
|
||||
if nullif(item->>'targetEventId', '') is not null and not exists (
|
||||
select 1 from public.birth_time_rectification_v4_events value
|
||||
where value.id = (item->>'targetEventId')::uuid and value.case_id = v_case.id
|
||||
) then
|
||||
raise exception 'rectification_v5_pending_target_event_mismatch';
|
||||
end if;
|
||||
if nullif(item->>'resolvedEventId', '') is not null and not exists (
|
||||
select 1 from public.birth_time_rectification_v4_events value
|
||||
where value.id = (item->>'resolvedEventId')::uuid and value.case_id = v_case.id
|
||||
) then
|
||||
raise exception 'rectification_v5_pending_resolved_event_mismatch';
|
||||
end if;
|
||||
insert into public.birth_time_rectification_pending_evidence(
|
||||
id, case_id, user_id, turn_id, target_event_id, raw_text, reason_code,
|
||||
resolved_event_id, created_at, resolved_at
|
||||
) values (
|
||||
(item->>'id')::uuid, v_case.id, v_case.user_id, (item->>'turnId')::uuid,
|
||||
nullif(item->>'targetEventId', '')::uuid, item->>'rawText', item->>'reasonCode',
|
||||
nullif(item->>'resolvedEventId', '')::uuid, (item->>'createdAt')::timestamptz,
|
||||
nullif(item->>'resolvedAt', '')::timestamptz
|
||||
);
|
||||
end loop;
|
||||
|
||||
if p_snapshot is not null then
|
||||
if jsonb_typeof(p_snapshot) is distinct from 'object'
|
||||
or coalesce((p_snapshot->>'canConfirmExactMinute')::boolean, false) then
|
||||
raise exception 'exact_minute_confirmation_forbidden';
|
||||
end if;
|
||||
v_snapshot_id := (p_snapshot->>'id')::uuid;
|
||||
if (p_snapshot->>'caseId')::uuid is distinct from v_case.id
|
||||
or (p_snapshot->>'caseVersion')::bigint is distinct from p_expected_case_version
|
||||
or p_snapshot->>'evidenceSetHash' is distinct from p_output_evidence_set_hash
|
||||
or p_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash
|
||||
or p_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then
|
||||
raise exception 'rectification_v5_snapshot_mismatch';
|
||||
end if;
|
||||
insert into public.birth_time_rectification_v4_candidate_snapshots(
|
||||
id, case_id, user_id, case_version, evidence_set_hash, calculation_spec_hash,
|
||||
algorithm_version, candidates, clusters, robustness, can_confirm_exact_minute,
|
||||
can_accept_range, gate_reasons, created_at
|
||||
) values (
|
||||
v_snapshot_id, v_case.id, v_case.user_id, (p_snapshot->>'caseVersion')::bigint,
|
||||
p_snapshot->>'evidenceSetHash', p_snapshot->>'calculationSpecHash',
|
||||
p_snapshot->>'algorithmVersion', p_snapshot->'candidates', p_snapshot->'clusters',
|
||||
p_snapshot->'robustness', false, (p_snapshot->>'canAcceptRange')::boolean,
|
||||
p_snapshot->'gateReasons', (p_snapshot->>'createdAt')::timestamptz
|
||||
);
|
||||
end if;
|
||||
|
||||
if p_feature_snapshot is not null then
|
||||
if jsonb_typeof(p_feature_snapshot) is distinct from 'object' then
|
||||
raise exception 'invalid_rectification_v5_feature_snapshot';
|
||||
end if;
|
||||
v_feature_id := (p_feature_snapshot->>'id')::uuid;
|
||||
if (p_feature_snapshot->>'caseId')::uuid is distinct from v_case.id
|
||||
or p_feature_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash
|
||||
or p_feature_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then
|
||||
raise exception 'rectification_v5_feature_snapshot_mismatch';
|
||||
end if;
|
||||
insert into public.birth_time_rectification_candidate_feature_snapshots(
|
||||
id, case_id, user_id, calculation_spec_hash, algorithm_version,
|
||||
candidate_count, feature_hash, features, created_at
|
||||
) values (
|
||||
v_feature_id, v_case.id, v_case.user_id,
|
||||
p_feature_snapshot->>'calculationSpecHash', p_feature_snapshot->>'algorithmVersion',
|
||||
(p_feature_snapshot->>'candidateCount')::integer, p_feature_snapshot->>'featureHash',
|
||||
p_feature_snapshot->'features', (p_feature_snapshot->>'createdAt')::timestamptz
|
||||
);
|
||||
end if;
|
||||
|
||||
if p_diagnostics is not null then
|
||||
if jsonb_typeof(p_diagnostics) is distinct from 'object' or v_snapshot_id is null then
|
||||
raise exception 'invalid_rectification_v5_diagnostics';
|
||||
end if;
|
||||
v_diagnostics_id := (p_diagnostics->>'id')::uuid;
|
||||
if (p_diagnostics->>'caseId')::uuid is distinct from v_case.id
|
||||
or (p_diagnostics->>'snapshotId')::uuid is distinct from v_snapshot_id then
|
||||
raise exception 'rectification_v5_diagnostics_mismatch';
|
||||
end if;
|
||||
insert into public.birth_time_rectification_diagnostics(
|
||||
id, case_id, user_id, snapshot_id, summary, calculation_hash, created_at
|
||||
) values (
|
||||
v_diagnostics_id, v_case.id, v_case.user_id, v_snapshot_id,
|
||||
p_diagnostics, p_diagnostics->>'calculationHash',
|
||||
(p_diagnostics->>'createdAt')::timestamptz
|
||||
);
|
||||
end if;
|
||||
|
||||
if (p_diagnostics is null) is distinct from (p_snapshot is null)
|
||||
or (p_feature_snapshot is null) is distinct from (p_snapshot is null) then
|
||||
raise exception 'rectification_v5_artifact_set_incomplete';
|
||||
end if;
|
||||
|
||||
insert into public.birth_time_rectification_agent_runs(
|
||||
id, case_id, job_id, user_id, case_version, model_id, skill_version,
|
||||
prompt_version, deployment_sha, deployment_mode, decision_json,
|
||||
validated_decision_json, tool_calls_json, tool_call_count, fallback_reason,
|
||||
input_token_count, output_token_count, latency_ms, created_at
|
||||
) values (
|
||||
(p_agent_run->>'id')::uuid, v_case.id, p_job_id, v_case.user_id,
|
||||
(p_agent_run->>'caseVersion')::bigint, nullif(p_agent_run->>'modelId', ''),
|
||||
p_agent_run->>'skillVersion', p_agent_run->>'promptVersion',
|
||||
nullif(p_agent_run->>'deploymentSha', ''), p_agent_run->>'deploymentMode',
|
||||
p_agent_run->'decision', p_validated_decision, p_agent_run->'toolCalls',
|
||||
pg_catalog.jsonb_array_length(p_agent_run->'toolCalls'),
|
||||
nullif(p_agent_run->>'fallbackReason', ''),
|
||||
nullif(p_agent_run->>'inputTokenCount', '')::integer,
|
||||
nullif(p_agent_run->>'outputTokenCount', '')::integer,
|
||||
(p_agent_run->>'latencyMs')::integer,
|
||||
(p_agent_run->>'createdAt')::timestamptz
|
||||
);
|
||||
insert into public.birth_time_rectification_public_messages(
|
||||
job_id, case_id, user_id, message, created_at
|
||||
) values (
|
||||
p_job_id, v_case.id, v_case.user_id, p_public_message, p_now
|
||||
);
|
||||
|
||||
update public.birth_time_rectification_v4_cases
|
||||
set version = p_expected_case_version + 1,
|
||||
evidence_set_hash = p_output_evidence_set_hash,
|
||||
latest_snapshot_id = coalesce(v_snapshot_id, latest_snapshot_id),
|
||||
feature_snapshot_id = coalesce(v_feature_id, feature_snapshot_id),
|
||||
latest_diagnostics_id = coalesce(v_diagnostics_id, latest_diagnostics_id),
|
||||
agent_mode = p_validated_decision->>'mode',
|
||||
current_question = p_next_question,
|
||||
status = p_status,
|
||||
phase = p_phase,
|
||||
updated_at = p_now
|
||||
where id = v_case.id;
|
||||
update public.birth_time_rectification_v4_jobs
|
||||
set status = 'completed', phase = p_phase, result_snapshot_id = v_snapshot_id,
|
||||
completion_payload_hash = p_completion_payload_hash,
|
||||
lease_expires_at = null, updated_at = p_now
|
||||
where id = p_job_id;
|
||||
return v_case.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.revise_birth_time_rectification_v4_event(uuid, uuid, uuid, bigint, jsonb, text, uuid, uuid, timestamptz) from public, anon, authenticated;
|
||||
grant execute on function public.revise_birth_time_rectification_v4_event(uuid, uuid, uuid, bigint, jsonb, text, uuid, uuid, timestamptz) to service_role;
|
||||
revoke all on function public.complete_birth_time_rectification_v5_job(
|
||||
uuid, uuid, bigint, text, text, text, text,
|
||||
jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb,
|
||||
text, text, timestamptz
|
||||
) from public, anon, authenticated;
|
||||
grant execute on function public.complete_birth_time_rectification_v5_job(
|
||||
uuid, uuid, bigint, text, text, text, text,
|
||||
jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb,
|
||||
text, text, timestamptz
|
||||
) to service_role;
|
||||
|
||||
commit;
|
||||
@@ -57,7 +57,7 @@ const snapshot: CandidateSnapshot = {
|
||||
algorithmVersion: "rectification-v5-matrix-scoring-1",
|
||||
candidates: [{ time: "05:13", score: 10, supportingEventIds: [], conflictingEventIds: [] }],
|
||||
clusters: [{ rank: 1, startTime: "05:13", endTime: "05:15", representativeTime: "05:13", widthMinutes: 3, peakScore: 10, scoreMass: 1 }],
|
||||
robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 1, calculationSpecHashMatched: true },
|
||||
robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, leaveOneDomainOutRetentionRate: 1, dateSensitivityRetentionRate: 1, calculationSpecHashMatched: true },
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: false,
|
||||
gateReasons: ["insufficient_scoreable_events"],
|
||||
@@ -444,6 +444,7 @@ test("V6 agent conversation follows dated events, respects direction change, and
|
||||
assert.ok(fourth.events.some((event) => event.domain === "career" && event.summary.includes("商业巡演经纪公司")));
|
||||
assert.equal(scoreCalls, 1);
|
||||
assert.ok(store.diagnostics.size > 0);
|
||||
assert.equal(fourth.case.latestSnapshot?.robustness.leaveOneDomainOutRetentionRate, 1);
|
||||
assert.equal(fourth.case.latestSnapshot?.canConfirmExactMinute, false);
|
||||
assert.equal(fourth.case.algorithmVersion, "rectification-v5-matrix-scoring-1");
|
||||
const finalMessage = [...store.publicMessages.values()].at(-1);
|
||||
|
||||
@@ -53,7 +53,7 @@ function snapshot(range: readonly [string, string], overrides: Partial<Candidate
|
||||
algorithmVersion: "rectification-v5-matrix-scoring-1",
|
||||
candidates: [{ time: startTime, score: 10, supportingEventIds: [], conflictingEventIds: [] }],
|
||||
clusters: [{ rank: 1, startTime, endTime, representativeTime: startTime, widthMinutes: 7, peakScore: 10, scoreMass: 1 }],
|
||||
robustness: { neighborSupportMinutes: 8, leaveOneOutRetentionRate: .8, dateSensitivityRetentionRate: .8, calculationSpecHashMatched: true },
|
||||
robustness: { neighborSupportMinutes: 8, leaveOneOutRetentionRate: .8, leaveOneDomainOutRetentionRate: .8, dateSensitivityRetentionRate: .8, calculationSpecHashMatched: true },
|
||||
canConfirmExactMinute: false, canAcceptRange: true, gateReasons: [], createdAt: now, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildCandidateClusters } from "../src/lib/rectification-v4/candidate-clusters.ts";
|
||||
|
||||
const candidate = (time: string, score: number) => ({
|
||||
time,
|
||||
score,
|
||||
supportingEventIds: [],
|
||||
conflictingEventIds: [],
|
||||
});
|
||||
|
||||
test("candidate clusters join midnight neighbors without treating representativeTime as a confirmed minute", () => {
|
||||
const clusters = buildCandidateClusters([
|
||||
candidate("23:59", 100),
|
||||
candidate("12:00", 98),
|
||||
candidate("00:00", 99),
|
||||
]);
|
||||
|
||||
assert.deepEqual(clusters, [
|
||||
{
|
||||
rank: 1,
|
||||
startTime: "23:59",
|
||||
endTime: "00:00",
|
||||
representativeTime: "23:59",
|
||||
widthMinutes: 2,
|
||||
peakScore: 100,
|
||||
scoreMass: 199,
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
startTime: "12:00",
|
||||
endTime: "12:00",
|
||||
representativeTime: "12:00",
|
||||
widthMinutes: 1,
|
||||
peakScore: 98,
|
||||
scoreMass: 98,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("candidate clusters keep ordinary daytime gaps separate", () => {
|
||||
assert.deepEqual(
|
||||
buildCandidateClusters([
|
||||
candidate("05:13", 100),
|
||||
candidate("05:14", 99),
|
||||
candidate("05:16", 98),
|
||||
]).map(({ startTime, endTime, widthMinutes }) => ({ startTime, endTime, widthMinutes })),
|
||||
[
|
||||
{ startTime: "05:13", endTime: "05:14", widthMinutes: 2 },
|
||||
{ startTime: "05:16", endTime: "05:16", widthMinutes: 1 },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { calculationSpecSchema, lifeEventRevisionSchema, reviseEventRequestSchema, type CalculationSpec, type LifeEventRevision } from "../src/lib/rectification-v4/contracts.ts";
|
||||
import { createRectificationV4CandidateEngine } from "../src/lib/rectification-v4/candidate-engine.ts";
|
||||
import { calculationSpecHash, evidenceSetHash } from "../src/lib/rectification-v4/fingerprints.ts";
|
||||
import { rectificationEventRevisionFromRow } from "../src/lib/rectification-v4/supabase-store.ts";
|
||||
|
||||
const legacySpec: CalculationSpec = {
|
||||
version: "rectification-calculation-spec-v4", birthDate: "1990-01-02",
|
||||
candidateRange: { start: "05:00", end: "06:00" }, latitude: 25.033, longitude: 121.5654,
|
||||
timezoneOffsetHours: 8, ayanamsa: "lahiri", nodeMode: "mean", minuteStep: 1,
|
||||
};
|
||||
const baseEvent: LifeEventRevision = {
|
||||
id: "00000000-0000-4000-8000-000000000001", eventId: "00000000-0000-4000-8000-000000000002", revision: 1,
|
||||
domain: "career", eventKind: "career_change", subject: "self", relatedPerson: null,
|
||||
summary: "工作变动", rawText: "2020年工作变动", dateRange: { start: "2020-01-01", end: "2020-12-31", precision: "year", label: "2020" },
|
||||
scoreability: "scoreable", supersedesRevisionId: null, createdAt: "2026-07-30T00:00:00.000Z",
|
||||
};
|
||||
|
||||
test("legacy provenance stays absent and legacy hashes stay unchanged", () => {
|
||||
const spec = calculationSpecSchema.parse(legacySpec);
|
||||
const event = lifeEventRevisionSchema.parse(baseEvent);
|
||||
assert.equal(Object.hasOwn(spec, "birthTimeSource"), false);
|
||||
assert.equal(Object.hasOwn(event, "dateSource"), false);
|
||||
assert.equal(calculationSpecHash(spec), "b50754817fd516b4bb089ee85f9ebb4c8fa2a148e9cdfa5c6bf694e979b216fe");
|
||||
assert.equal(evidenceSetHash([event]), "9086797178bfcaf2f5d23f309eacf49ddab4e75e2aefa120b111dbc31a7382f5");
|
||||
});
|
||||
|
||||
test("enriched calculation spec and evidence provenance hash deterministically", () => {
|
||||
const enriched = calculationSpecSchema.parse({ ...legacySpec, birthTimeSource: "family_exact", timezoneId: "Asia/Taipei", timezoneSource: "iana_historical", localTimeStatus: "resolved" });
|
||||
assert.equal(calculationSpecHash(enriched), "fa4afe79228bedebd809c7b3c9d9d32a428f7066e3e1fd1813e44b317bd38e66");
|
||||
assert.notEqual(evidenceSetHash([{ ...baseEvent, dateSource: null }]), evidenceSetHash([baseEvent]));
|
||||
assert.equal(evidenceSetHash([{ ...baseEvent, dateSource: "user_reported", dateReliability: "medium" }]), evidenceSetHash([{ ...baseEvent, dateReliability: "medium", dateSource: "user_reported" }]));
|
||||
});
|
||||
|
||||
test("revision requests and Supabase rows preserve missing versus explicit null", () => {
|
||||
const request = reviseEventRequestSchema.parse({
|
||||
actionId: "00000000-0000-4000-8000-000000000003", expectedCaseVersion: 1,
|
||||
domain: "career", eventKind: "career_change", subject: "self", relatedPerson: null,
|
||||
summary: "工作变动", rawText: "2020年工作变动", dateRange: baseEvent.dateRange, dateSource: null,
|
||||
});
|
||||
assert.equal(Object.hasOwn(request, "dateSource"), true);
|
||||
const row = {
|
||||
id: baseEvent.id, event_id: baseEvent.eventId, revision: 1, domain: "career", event_kind: "career_change",
|
||||
subject: "self", related_person: null, summary: baseEvent.summary, raw_text: baseEvent.rawText,
|
||||
date_start: "2020-01-01", date_end: "2020-12-31", date_precision: "year", date_label: "2020",
|
||||
scoreability: "scoreable", supersedes_revision_id: null, created_at: baseEvent.createdAt,
|
||||
};
|
||||
assert.equal(Object.hasOwn(rectificationEventRevisionFromRow(row), "dateSource"), false);
|
||||
const enriched = rectificationEventRevisionFromRow({ ...row, date_provenance: { dateSource: null, dateReliability: "medium" } });
|
||||
assert.equal(Object.hasOwn(enriched, "dateSource"), true);
|
||||
assert.equal(enriched.dateSource, null);
|
||||
assert.equal(enriched.dateReliability, "medium");
|
||||
});
|
||||
|
||||
test("forward-only migration persists provenance in both RPCs and retains completed replay", () => {
|
||||
const sql = readFileSync(new URL("../supabase/migrations/20260730010000_rectification_provenance.sql", import.meta.url), "utf8");
|
||||
assert.match(sql, /add column if not exists date_provenance jsonb/);
|
||||
assert.equal((sql.match(/where entry\.key in \('dateSource'/g) ?? []).length, 2);
|
||||
assert.match(sql, /create or replace function public\.revise_birth_time_rectification_v4_event/);
|
||||
assert.match(sql, /create or replace function public\.complete_birth_time_rectification_v5_job/);
|
||||
assert.match(sql, /if v_job\.status = 'completed'[\s\S]*rectification_v5_replay_payload_mismatch[\s\S]*return v_case\.id/);
|
||||
assert.doesNotMatch(sql, /update public\.birth_time_rectification_v4_event_revisions/i);
|
||||
});
|
||||
|
||||
test("candidate engine forwards present provenance without inventing missing fields", async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
const engine = createRectificationV4CandidateEngine({
|
||||
apiBase: "http://example.test",
|
||||
fetchImpl: async (_input, init) => {
|
||||
body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
throw new Error("captured");
|
||||
},
|
||||
});
|
||||
await assert.rejects(engine.score({
|
||||
calculationSpec: { ...legacySpec, birthTimeSource: "family_exact", timezoneId: "Asia/Taipei", localTimeStatus: null },
|
||||
events: [{ ...baseEvent, dateSource: "user_reported", dateReliability: null }],
|
||||
}), /captured/);
|
||||
const captured = body as unknown as Record<string, unknown>;
|
||||
assert.equal(captured.birth_time_source, "family_exact");
|
||||
assert.equal(captured.timezone_id, "Asia/Taipei");
|
||||
assert.equal(Object.hasOwn(captured, "timezone_source"), false);
|
||||
assert.equal(captured.local_time_status, null);
|
||||
const event = (captured.events as Record<string, unknown>[])[0];
|
||||
assert.equal(event.date_source, "user_reported");
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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/);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { candidateSnapshotSchema, type EvidenceDomain, type Robustness } from "../src/lib/rectification-v4/contracts.ts";
|
||||
import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts";
|
||||
import { classifyMissingTechniqueLayers } from "../src/lib/rectification-v4/technique-layer-policy.ts";
|
||||
|
||||
const cluster = {
|
||||
rank: 1,
|
||||
startTime: "05:13",
|
||||
endTime: "05:17",
|
||||
representativeTime: "05:15",
|
||||
widthMinutes: 5,
|
||||
peakScore: 10,
|
||||
scoreMass: 40,
|
||||
} as const;
|
||||
const robustness: Robustness = {
|
||||
neighborSupportMinutes: 4,
|
||||
leaveOneOutRetentionRate: 1,
|
||||
leaveOneDomainOutRetentionRate: 1,
|
||||
dateSensitivityRetentionRate: 1,
|
||||
calculationSpecHashMatched: true,
|
||||
};
|
||||
const domains = ["education", "relocation", "career"] as const satisfies readonly EvidenceDomain[];
|
||||
|
||||
function gate(overrides: Partial<Parameters<typeof evaluateDecisionGate>[0]> = {}) {
|
||||
return evaluateDecisionGate({
|
||||
clusters: [cluster],
|
||||
robustness,
|
||||
scoreableEventCount: 6,
|
||||
scoreableDomains: domains,
|
||||
missingTechniqueLayers: [],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("single-domain dependence blocks the public candidate range", () => {
|
||||
const result = gate({ robustness: { ...robustness, leaveOneDomainOutRetentionRate: 0.79 } });
|
||||
assert.equal(result.canAcceptRange, false);
|
||||
assert.ok(result.reasons.includes("leave_one_domain_out_not_stable"));
|
||||
assert.equal(result.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
test("KP cusps alone do not block the public candidate range", () => {
|
||||
const classified = classifyMissingTechniqueLayers(["KP_cusps"], domains);
|
||||
assert.deepEqual(classified.optional, ["KP_cusps"]);
|
||||
assert.equal(gate({ missingTechniqueLayers: ["KP_cusps"] }).canAcceptRange, true);
|
||||
});
|
||||
|
||||
test("D60 remains reference-only and does not block the public candidate range", () => {
|
||||
const classified = classifyMissingTechniqueLayers(["D60"], domains);
|
||||
assert.deepEqual(classified.referenceOnly, ["D60"]);
|
||||
assert.equal(gate({ missingTechniqueLayers: ["D60"] }).canAcceptRange, true);
|
||||
});
|
||||
|
||||
test("missing D10 blocks when career evidence is active", () => {
|
||||
const result = gate({ missingTechniqueLayers: ["D10"] });
|
||||
assert.equal(result.canAcceptRange, false);
|
||||
assert.ok(result.reasons.includes("missing_required_layer:D10"));
|
||||
});
|
||||
|
||||
test("missing D10 does not block without career evidence", () => {
|
||||
const scoreableDomains = ["education", "relocation", "relationship"] as const;
|
||||
assert.equal(gate({ scoreableDomains, missingTechniqueLayers: ["D10"] }).canAcceptRange, true);
|
||||
});
|
||||
|
||||
test("unclassified missing layers fail closed", () => {
|
||||
const result = gate({ missingTechniqueLayers: ["future_unknown_layer"] });
|
||||
assert.equal(result.canAcceptRange, false);
|
||||
assert.ok(result.reasons.includes("missing_unclassified_layer:future_unknown_layer"));
|
||||
});
|
||||
|
||||
function snapshotInput(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
caseId: "00000000-0000-4000-8000-000000000002",
|
||||
caseVersion: 1,
|
||||
evidenceSetHash: "e".repeat(64),
|
||||
calculationSpecHash: "c".repeat(64),
|
||||
algorithmVersion: "rectification-v5-matrix-scoring-1",
|
||||
candidates: [{ time: "05:15", score: 10, supportingEventIds: [], conflictingEventIds: [] }],
|
||||
clusters: [cluster],
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: true,
|
||||
gateReasons: [],
|
||||
createdAt: "2026-07-29T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("legacy snapshots without domain retention still parse without retroactive rejection", () => {
|
||||
const parsed = candidateSnapshotSchema.parse(snapshotInput({
|
||||
robustness: {
|
||||
neighborSupportMinutes: 4,
|
||||
leaveOneOutRetentionRate: 1,
|
||||
dateSensitivityRetentionRate: 1,
|
||||
calculationSpecHashMatched: true,
|
||||
},
|
||||
}));
|
||||
assert.equal(parsed.robustness.leaveOneDomainOutRetentionRate, 0.8);
|
||||
assert.equal(parsed.canAcceptRange, true);
|
||||
assert.ok(!parsed.gateReasons.includes("leave_one_domain_out_not_stable"));
|
||||
});
|
||||
|
||||
test("snapshots with explicit low domain retention are normalized to rejected", () => {
|
||||
const parsed = candidateSnapshotSchema.parse(snapshotInput({
|
||||
robustness: { ...robustness, leaveOneDomainOutRetentionRate: 0.79 },
|
||||
}));
|
||||
assert.equal(parsed.robustness.leaveOneDomainOutRetentionRate, 0.79);
|
||||
assert.equal(parsed.canAcceptRange, false);
|
||||
assert.ok(parsed.gateReasons.includes("leave_one_domain_out_not_stable"));
|
||||
});
|
||||
@@ -73,7 +73,7 @@ function snapshot(range: readonly [string, string]): CandidateSnapshot {
|
||||
algorithmVersion: "rectification-v5-matrix-scoring-1",
|
||||
candidates: [{ time: startTime, score: 10, supportingEventIds: [], conflictingEventIds: [] }],
|
||||
clusters: [{ rank: 1, startTime, endTime, representativeTime: startTime, widthMinutes: 7, peakScore: 10, scoreMass: 1 }],
|
||||
robustness: { neighborSupportMinutes: 8, leaveOneOutRetentionRate: 0.8, dateSensitivityRetentionRate: 0.8, calculationSpecHashMatched: true },
|
||||
robustness: { neighborSupportMinutes: 8, leaveOneOutRetentionRate: 0.8, leaveOneDomainOutRetentionRate: 0.8, dateSensitivityRetentionRate: 0.8, calculationSpecHashMatched: true },
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: true,
|
||||
gateReasons: [],
|
||||
|
||||
@@ -70,9 +70,9 @@ test("candidate minutes merge into ranked contiguous clusters and never confirm
|
||||
assert.deepEqual(clusters.map((cluster) => [cluster.rank, cluster.startTime, cluster.endTime]), [[1, "05:13", "05:15"], [2, "05:17", "05:18"]]);
|
||||
const gate = evaluateDecisionGate({
|
||||
clusters: [clusters[0]!],
|
||||
robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 0.9, calculationSpecHashMatched: true },
|
||||
robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, leaveOneDomainOutRetentionRate: 1, dateSensitivityRetentionRate: 0.9, calculationSpecHashMatched: true },
|
||||
scoreableEventCount: 10,
|
||||
scoreableDomainCount: 5,
|
||||
scoreableDomains: ["education", "relocation", "relationship", "career", "finance"],
|
||||
});
|
||||
assert.equal(gate.canAcceptRange, true);
|
||||
assert.equal(gate.canConfirmExactMinute, false);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> = {},
|
||||
): 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<T>(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise<T>): Promise<T> {
|
||||
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]]));
|
||||
|
||||
@@ -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']},
|
||||
|
||||
@@ -17,8 +17,12 @@ SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
|
||||
"health_pressure": frozenset({"self_health_event"}),
|
||||
}
|
||||
DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"})
|
||||
_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"})
|
||||
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"})
|
||||
_BIRTH_TIME_SOURCES = frozenset({"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"})
|
||||
_LOCAL_TIME_STATUSES = frozenset({"resolved", "not_provided", "ambiguous", "nonexistent"})
|
||||
_REQUEST_PROVENANCE_FIELDS = frozenset({"birth_time_source", "timezone_id", "timezone_source", "local_time_status"})
|
||||
_EVENT_PROVENANCE_FIELDS = frozenset({"date_source", "date_reliability", "date_corroboration", "date_conflict_status"})
|
||||
_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"}) | _REQUEST_PROVENANCE_FIELDS
|
||||
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
|
||||
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
|
||||
|
||||
|
||||
@@ -30,6 +34,10 @@ class LifeEvent(TypedDict):
|
||||
date_end: str
|
||||
precision: DatePrecision
|
||||
summary: NotRequired[str]
|
||||
date_source: NotRequired[str | None]
|
||||
date_reliability: NotRequired[str | None]
|
||||
date_corroboration: NotRequired[str | None]
|
||||
date_conflict_status: NotRequired[str | None]
|
||||
|
||||
|
||||
class RectificationRequest(TypedDict):
|
||||
@@ -40,6 +48,10 @@ class RectificationRequest(TypedDict):
|
||||
lon: float
|
||||
tz: float
|
||||
events: list[LifeEvent]
|
||||
birth_time_source: NotRequired[str | None]
|
||||
timezone_id: NotRequired[str | None]
|
||||
timezone_source: NotRequired[str | None]
|
||||
local_time_status: NotRequired[str | None]
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
@@ -64,6 +76,24 @@ def _calendar_date(value: Any, label: str) -> date:
|
||||
raise ValueError(f"{label} must be a valid YYYY-MM-DD value") from exc
|
||||
|
||||
|
||||
def _copy_nullable_text(
|
||||
source: dict[str, Any], target: dict[str, Any], name: str, label: str, maximum: int,
|
||||
allowed: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
if name not in source:
|
||||
return
|
||||
value = source[name]
|
||||
if value is None:
|
||||
target[name] = None
|
||||
return
|
||||
if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
|
||||
raise ValueError(f"{label} must be null or a non-empty string up to {maximum} characters")
|
||||
cleaned = value.strip()
|
||||
if allowed is not None and cleaned not in allowed:
|
||||
raise ValueError(f"{label} is invalid")
|
||||
target[name] = cleaned
|
||||
|
||||
|
||||
def normalize_rectification_request(body: Any, *, today: date | None = None) -> RectificationRequest:
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("request body must be an object")
|
||||
@@ -77,9 +107,6 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
raise ValueError("start_time must be HH:MM")
|
||||
if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time):
|
||||
raise ValueError("end_time must be HH:MM")
|
||||
if start_time > end_time:
|
||||
raise ValueError("start_time must not exceed end_time")
|
||||
|
||||
events = body.get("events")
|
||||
if not isinstance(events, list) or not 1 <= len(events) <= 100:
|
||||
raise ValueError("events must contain between 1 and 100 items")
|
||||
@@ -112,7 +139,7 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
summary = raw_event.get("summary", "")
|
||||
if not isinstance(summary, str) or len(summary) > 1_000:
|
||||
raise ValueError(f"events[{index}].summary must be a string up to 1000 characters")
|
||||
cleaned_events.append({
|
||||
cleaned_event: dict[str, Any] = {
|
||||
"id": event_id,
|
||||
"domain": cast(str, domain),
|
||||
"event_kind": cast(str, event_kind),
|
||||
@@ -120,9 +147,14 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
"date_end": end_day.isoformat(),
|
||||
"precision": cast(DatePrecision, precision),
|
||||
"summary": summary.strip(),
|
||||
})
|
||||
}
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_source", f"events[{index}].date_source", 120)
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_reliability", f"events[{index}].date_reliability", 120)
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_corroboration", f"events[{index}].date_corroboration", 1_000)
|
||||
_copy_nullable_text(raw_event, cleaned_event, "date_conflict_status", f"events[{index}].date_conflict_status", 120)
|
||||
cleaned_events.append(cast(LifeEvent, cleaned_event))
|
||||
|
||||
return {
|
||||
cleaned_request: dict[str, Any] = {
|
||||
"birth_date": birth_day.isoformat(),
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
@@ -131,3 +163,8 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
"tz": _bounded_number(body, "tz", -14, 14),
|
||||
"events": cleaned_events,
|
||||
}
|
||||
_copy_nullable_text(body, cleaned_request, "birth_time_source", "birth_time_source", 120, _BIRTH_TIME_SOURCES)
|
||||
_copy_nullable_text(body, cleaned_request, "timezone_id", "timezone_id", 120)
|
||||
_copy_nullable_text(body, cleaned_request, "timezone_source", "timezone_source", 80)
|
||||
_copy_nullable_text(body, cleaned_request, "local_time_status", "local_time_status", 120, _LOCAL_TIME_STATUSES)
|
||||
return cast(RectificationRequest, cleaned_request)
|
||||
|
||||
@@ -12,22 +12,28 @@ def _winner(rows: Sequence[CandidateScoreRow]) -> str | None:
|
||||
return max(rows, key=lambda row: row["score"])["time"] if rows else None
|
||||
|
||||
|
||||
def _minute_value(value: str) -> int:
|
||||
return int(value[:2]) * 60 + int(value[3:])
|
||||
|
||||
|
||||
def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = .97) -> list[str]:
|
||||
if not rows:
|
||||
return []
|
||||
peak = max(row["score"] for row in rows)
|
||||
floor = peak * relative_floor if peak >= 0 else peak / relative_floor
|
||||
selected = [row["time"] for row in rows if row["score"] >= floor]
|
||||
selected = sorted((row["time"] for row in rows if row["score"] >= floor), key=_minute_value)
|
||||
if not selected:
|
||||
return []
|
||||
groups: list[list[str]] = []
|
||||
for current in selected:
|
||||
minute = lambda value: int(value[:2]) * 60 + int(value[3:])
|
||||
if groups and minute(current) - minute(groups[-1][-1]) == 1:
|
||||
if groups and (_minute_value(current) - _minute_value(groups[-1][-1])) % 1_440 == 1:
|
||||
groups[-1].append(current)
|
||||
else:
|
||||
groups.append([current])
|
||||
return max(groups, key=lambda group: (max(next(row["score"] for row in rows if row["time"] == time) for time in group), len(group)))
|
||||
if len(groups) > 1 and (_minute_value(groups[0][0]) - _minute_value(groups[-1][-1])) % 1_440 == 1:
|
||||
groups[0] = [*groups.pop(), *groups[0]]
|
||||
scores = {row["time"]: row["score"] for row in rows}
|
||||
return max(groups, key=lambda group: (max(scores[time] for time in group), sum(max(scores[time], 0) for time in group)))
|
||||
|
||||
|
||||
def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[CandidateScoreRow]:
|
||||
|
||||
@@ -153,7 +153,7 @@ def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
|
||||
def json_number(value: float) -> int | float:
|
||||
return int(value) if value.is_integer() else value
|
||||
|
||||
return {
|
||||
spec = {
|
||||
"version": INPUT_CONTRACT_VERSION,
|
||||
"birthDate": request["birth_date"],
|
||||
"candidateRange": {"start": request["start_time"], "end": request["end_time"]},
|
||||
@@ -162,6 +162,15 @@ def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
|
||||
"timezoneOffsetHours": json_number(request["tz"]),
|
||||
"ayanamsa": "lahiri", "nodeMode": "mean", "minuteStep": 1,
|
||||
}
|
||||
for source, target in (
|
||||
("birth_time_source", "birthTimeSource"),
|
||||
("timezone_id", "timezoneId"),
|
||||
("timezone_source", "timezoneSource"),
|
||||
("local_time_status", "localTimeStatus"),
|
||||
):
|
||||
if source in request:
|
||||
spec[target] = request[source] # type: ignore[literal-required]
|
||||
return spec
|
||||
|
||||
|
||||
def sha256(value: Any) -> str:
|
||||
|
||||
@@ -14,11 +14,22 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re
|
||||
- Current skill version: `birth-time-rectification-v6`.
|
||||
- Current prompt version: `rectification-agent-v6-1`.
|
||||
- The scoring algorithm remains `rectification-v5-matrix-scoring-1`; the V6 label describes the conversation contract, not a replacement scoring engine.
|
||||
- The server owns event reconciliation, candidate-minute scanning, event contributions, snapshots, diagnostics, stability gates, jobs, replay, persistence, and final decision validation.
|
||||
- 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
|
||||
|
||||
- The current decision path is server-owned: minute scan -> event contribution matrix -> Snapshot -> LOEO/LODO/date sensitivity/neighbor stability/candidate split -> deterministic public gate. The reference skill remains methodology and audit input; it is not a second scoring authority.
|
||||
- The public range gate includes LODO and an active-domain technique policy. Missing required or unclassified layers block publication; `KP_cusps` is optional, while D60 is reference-only and cannot score, gate, or support a conclusion.
|
||||
- Event source, raw wording, Turn, and revision lineage are provenance for audit only. Provenance never adds or removes points and is not a confidence multiplier.
|
||||
- LOEO and LODO are same-Case sensitivity checks, not an independent holdout. Per-Case independent holdout is deferred until a prospective sticky partition and calibration contract exist; never describe it as implemented or validated.
|
||||
- Continue to reject manual `supports/conflicts` pseudo-scoring, arbitrary external repository loading, a unique-minute answer, and automatic profile writes.
|
||||
|
||||
## Seventeen conversation boundaries
|
||||
|
||||
@@ -33,7 +44,7 @@ Before choosing an action, read the contracts in `references/`. Treat `assets/re
|
||||
9. Do not use empty stock phrases such as “这个信息很有用” or repetitive “已记录” openings.
|
||||
10. Do not assign life meaning to an ordinary experience or claim an unconfirmed turning point.
|
||||
11. The agent selects a server-generated semantic opportunity; it does not create candidate results or an unrestricted question route.
|
||||
12. Show a candidate range only after the deterministic stability gate passes.
|
||||
12. Show a candidate range only after the deterministic stability gate, including LODO and required-technique availability, passes.
|
||||
13. Never confirm, imply, or display a unique or representative birth minute as the answer.
|
||||
14. Stop with an honest low-confidence result when evidence is sparse, conflicting, or unstable; do not prolong the interview indefinitely.
|
||||
15. Family events are background/context evidence by default, not the user's own scoreable event.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Preserve the event's subject, related person, domain, event kind, original user wording, declared date text, normalized date range, precision, extraction status, correction lineage, source Turn, and scoreability.
|
||||
|
||||
These source fields are provenance for audit and replay only. Raw wording, source Turn, extraction path, and revision lineage must never add points, change technique weights, or act as a confidence multiplier. Scoring uses only the validated scoreable event contract and the server-owned contribution rules.
|
||||
|
||||
## Subject and scoreability
|
||||
|
||||
- A user's own supported event may be `scoreable`.
|
||||
|
||||
@@ -14,11 +14,18 @@ Stop with low confidence when evidence is too sparse, conflicting, tied, unstabl
|
||||
|
||||
A month-dated event is not a failure. Refine it only when date-sensitivity diagnostics show that finer precision could change candidate ranking.
|
||||
|
||||
- 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.
|
||||
|
||||
Never log raw sensitive answers to ordinary telemetry. Persist user text only in the approved Turn/evidence stores required by the product contract.
|
||||
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 expose or persist raw VedAstro provider payloads in public output or the analysis receipt.
|
||||
|
||||
|
||||
## Analysis receipt failures
|
||||
|
||||
@@ -37,7 +37,9 @@ 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, an internal unstable Snapshot, or a repeated equivalent calculation. 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.
|
||||
|
||||
## Deterministic fallback
|
||||
|
||||
@@ -57,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. 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.
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
# Technique policy
|
||||
|
||||
The conversation refactor does not change the scoring algorithm. Keep `rectification-v5-matrix-scoring-1`, Python candidate-minute scanning, the event contribution matrix, Candidate Snapshots, leave-one-event-out, leave-one-domain-out, date sensitivity, neighbor stability, candidate split, Decision Validator, and deterministic fallback.
|
||||
The conversation refactor does not change the scoring algorithm. Keep `rectification-v5-matrix-scoring-1`, the real Python scan of every minute in the inclusive candidate window, the event contribution matrix, Candidate Snapshots, leave-one-event-out (LOEO), leave-one-domain-out (LODO), date sensitivity, neighbor stability, candidate split, Decision Validator, and deterministic fallback. A cross-midnight window continues into the next calendar day; equal endpoints mean one minute and the maximum window is 1,440 minutes.
|
||||
|
||||
Only server-reported available layers may be described as used. Missing, blocked, reference-only, and research-only layers are not evidence of a result. Do not import or reproduce the portable ZIP's candidate segmentation, manual `supports/conflicts` scoring, fixed unknown-mode blocks, dynamic repository loading, or `main_repository_enhanced` mode.
|
||||
Only server-reported available layers may be described as used. Missing, blocked, reference-only, and research-only layers are not evidence of a result. Do not import or reproduce the portable ZIP's candidate segmentation, manual `supports/conflicts` scoring, fixed unknown-mode blocks, arbitrary/dynamic external repository loading, or `main_repository_enhanced` mode.
|
||||
|
||||
## Public technique availability gate
|
||||
|
||||
- **Required:** only the layers registered for active scoreable domains. Education requires `D24 + vimshottari + narayana`; relocation `D4 + vimshottari + narayana`; relationship `D9 + UL + vimshottari + narayana`; career `D10 + A10 + vimshottari + narayana`; finance `D2 + D11 + vimshottari + narayana`; self health pressure `D30 + vimshottari + narayana`. A missing required layer blocks the public range.
|
||||
- **Optional:** `KP_cusps`, `A7`, `Ashtakavarga`, and `Shadbala`, plus known domain layers that are not required by the active domains. Their absence does not block the public range. In particular, `KP_cusps` is optional.
|
||||
- **Reference-only:** D60. It must not contribute points, satisfy a gate, appear as executed in the public receipt, or drive a conclusion.
|
||||
- **Unclassified:** fail closed. An unknown missing layer blocks publication until classified server-side.
|
||||
|
||||
## Diagnostic use
|
||||
|
||||
- The Reasoner may request at most one allowed read-only diagnostic in a turn.
|
||||
- Send only compact conclusions needed for opportunity selection, not the full contribution matrix.
|
||||
- Date sensitivity determines whether finer date precision is worth asking for.
|
||||
- Leave-one-event/domain-out, neighbor stability, and candidate split diagnose fragility; they do not independently authorize public certainty.
|
||||
- LOEO, LODO, neighbor stability, and candidate split diagnose fragility; they do not independently authorize public certainty. The public gate requires LOEO and LODO retention of at least `0.8`.
|
||||
- Sparse, conflicting, or unstable diagnostics require a lower-confidence stop or another genuinely discriminating question.
|
||||
|
||||
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.
|
||||
- D9 and D10 may support relationship and career analysis when available.
|
||||
- Topic-specific layers such as D4, D24, D2/D11, D7, and D30 remain bounded by server capability.
|
||||
- D60 is reference-only and must never drive candidate selection or the public conclusion.
|
||||
- Event provenance is audit lineage only. Source Turn, raw wording, extraction path, and revision lineage must not change contribution points, layer weights, or confidence.
|
||||
- Never expose private scores, weights, contribution values, internal technique traces, or tool/model names in the user-facing message.
|
||||
- No technique result can override `canConfirmExactMinute === false` or authorize an automatic profile birth-time write.
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from scripts.rectification.diagnostics_service import run_diagnostics
|
||||
|
||||
|
||||
def row(time: str, score: float) -> dict:
|
||||
return {"time": time, "score": score, "evidence": [], "missing_layers": []}
|
||||
|
||||
|
||||
def diagnostics(rows: list[dict]) -> dict:
|
||||
return run_diagnostics({"events": []}, rows, {"date_sensitivity": [], "matrix": {}})
|
||||
|
||||
|
||||
class RectificationDiagnosticsClustersTest(unittest.TestCase):
|
||||
def test_primary_cluster_joins_adjacent_minutes_across_midnight(self):
|
||||
result = diagnostics([
|
||||
row("23:59", 100),
|
||||
row("12:00", 98),
|
||||
row("00:00", 99),
|
||||
])
|
||||
|
||||
self.assertEqual(result["neighbor_support_minutes"], 2)
|
||||
self.assertEqual(result["candidate_splits"][0]["left_cluster"], {
|
||||
"start": "23:59",
|
||||
"end": "00:00",
|
||||
})
|
||||
self.assertEqual(result["candidate_splits"][0]["right_cluster"], {
|
||||
"start": "12:00",
|
||||
"end": "12:00",
|
||||
})
|
||||
|
||||
def test_primary_cluster_keeps_ordinary_daytime_gaps_separate(self):
|
||||
result = diagnostics([
|
||||
row("05:13", 100),
|
||||
row("05:14", 99),
|
||||
row("05:16", 98),
|
||||
])
|
||||
|
||||
self.assertEqual(result["neighbor_support_minutes"], 2)
|
||||
self.assertEqual(result["candidate_splits"][0]["left_cluster"], {
|
||||
"start": "05:13",
|
||||
"end": "05:14",
|
||||
})
|
||||
self.assertEqual(result["candidate_splits"][0]["right_cluster"], {
|
||||
"start": "05:16",
|
||||
"end": "05:16",
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
from datetime import date
|
||||
import unittest
|
||||
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
from scripts.rectification.scoring_service import build_event_contribution_matrix, calculation_spec, sha256
|
||||
|
||||
EVENT_ID = "00000000-0000-4000-8000-000000000002"
|
||||
|
||||
def request():
|
||||
return {
|
||||
"birth_date": "1990-01-02", "start_time": "05:00", "end_time": "06:00",
|
||||
"lat": 25.033, "lon": 121.5654, "tz": 8,
|
||||
"events": [{"id": EVENT_ID, "domain": "career", "event_kind": "career_change", "date_start": "2020-01-01", "date_end": "2020-12-31", "precision": "year", "summary": "工作变动"}],
|
||||
}
|
||||
|
||||
class RectificationProvenanceTest(unittest.TestCase):
|
||||
def test_missing_and_explicit_null_remain_distinct(self):
|
||||
legacy = normalize_rectification_request(request(), today=date(2026, 7, 30))
|
||||
self.assertNotIn("birth_time_source", legacy)
|
||||
enriched_input = request()
|
||||
enriched_input["birth_time_source"] = None
|
||||
enriched_input["events"][0]["date_source"] = None
|
||||
enriched = normalize_rectification_request(enriched_input, today=date(2026, 7, 30))
|
||||
self.assertIn("birth_time_source", enriched)
|
||||
self.assertIsNone(enriched["birth_time_source"])
|
||||
self.assertIn("date_source", enriched["events"][0])
|
||||
|
||||
def test_enriched_spec_hash_matches_typescript_fixture(self):
|
||||
value = request()
|
||||
value.update({"birth_time_source": "family_exact", "timezone_id": "Asia/Taipei", "timezone_source": "iana_historical", "local_time_status": "resolved"})
|
||||
normalized = normalize_rectification_request(value, today=date(2026, 7, 30))
|
||||
self.assertEqual(sha256(calculation_spec(normalized)), "fa4afe79228bedebd809c7b3c9d9d32a428f7066e3e1fd1813e44b317bd38e66")
|
||||
|
||||
def test_event_provenance_is_not_forwarded_to_scoring_weights(self):
|
||||
value = request()
|
||||
value["events"][0].update({"date_source": "user_reported", "date_reliability": "medium"})
|
||||
normalized = normalize_rectification_request(value, today=date(2026, 7, 30))
|
||||
seen = []
|
||||
def provider(payload):
|
||||
seen.append(payload)
|
||||
return [{"time": "05:00", "score": 1, "evidence": [{"event_id": EVENT_ID, "domain": "career", "candidate_time": "05:00", "rule_ids": ["D10:test"], "points": 1}], "missing_layers": []}]
|
||||
build_event_contribution_matrix(normalized, provider)
|
||||
self.assertTrue(seen)
|
||||
self.assertTrue(all("date_source" not in payload["events"][0] for payload in seen))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts.active_rectification_event_engine import _candidate_datetimes
|
||||
from scripts.rectification.api_service import diagnostics, score_candidates
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
from scripts.rectification.scoring_service import (
|
||||
@@ -23,11 +24,18 @@ from scripts.jyotish_api_server import (
|
||||
EVENT_ID = "00000000-0000-4000-8000-000000000001"
|
||||
|
||||
|
||||
def request(*, precision: str = "month", event_kind: str = "education_milestone", domain: str = "education"):
|
||||
def request(
|
||||
*,
|
||||
precision: str = "month",
|
||||
event_kind: str = "education_milestone",
|
||||
domain: str = "education",
|
||||
start_time: str = "05:13",
|
||||
end_time: str = "05:15",
|
||||
):
|
||||
return {
|
||||
"birth_date": "1997-08-08",
|
||||
"start_time": "05:13",
|
||||
"end_time": "05:15",
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"lat": 36.419,
|
||||
"lon": 114.213,
|
||||
"tz": 8,
|
||||
@@ -51,6 +59,52 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
"f05fe0f56ef9ba2b18ec3c6c54f1649f06f1ae5a926491a5c5f676d718d92865",
|
||||
)
|
||||
|
||||
def test_cross_midnight_range_preserves_next_day_datetimes_and_typescript_hash(self):
|
||||
normalized = normalize_rectification_request(
|
||||
request(start_time="23:00", end_time="03:59"),
|
||||
today=date(2026, 7, 28),
|
||||
)
|
||||
|
||||
candidates = _candidate_datetimes(normalized)
|
||||
|
||||
self.assertEqual(len(candidates), 300)
|
||||
self.assertEqual(candidates[0].isoformat(), "1997-08-08T23:00:00")
|
||||
self.assertEqual(candidates[60].isoformat(), "1997-08-09T00:00:00")
|
||||
self.assertEqual(candidates[-1].isoformat(), "1997-08-09T03:59:00")
|
||||
self.assertEqual(
|
||||
sha256(calculation_spec(normalized)),
|
||||
"b0d5c5ec7f56edbfa2b2e1041b4aa3b648c6cb0681f3f502c7b7910c2894b205",
|
||||
)
|
||||
|
||||
def test_candidate_range_boundaries_stay_bounded_and_equal_is_one_minute(self):
|
||||
full_day = normalize_rectification_request(
|
||||
request(start_time="00:00", end_time="23:59"),
|
||||
today=date(2026, 7, 28),
|
||||
)
|
||||
equal = normalize_rectification_request(
|
||||
request(start_time="05:13", end_time="05:13"),
|
||||
today=date(2026, 7, 28),
|
||||
)
|
||||
|
||||
self.assertEqual(len(_candidate_datetimes(full_day)), 1_440)
|
||||
self.assertEqual(len(_candidate_datetimes(equal)), 1)
|
||||
with self.assertRaisesRegex(ValueError, "end_time must be HH:MM"):
|
||||
normalize_rectification_request(
|
||||
request(start_time="00:00", end_time="24:00"),
|
||||
today=date(2026, 7, 28),
|
||||
)
|
||||
|
||||
def test_daytime_candidate_range_remains_on_birth_date(self):
|
||||
normalized = normalize_rectification_request(request(), today=date(2026, 7, 28))
|
||||
|
||||
candidates = _candidate_datetimes(normalized)
|
||||
|
||||
self.assertEqual([value.isoformat() for value in candidates], [
|
||||
"1997-08-08T05:13:00",
|
||||
"1997-08-08T05:14:00",
|
||||
"1997-08-08T05:15:00",
|
||||
])
|
||||
|
||||
def test_shared_validator_rejects_family_and_non_self_health_scoring(self):
|
||||
with self.assertRaisesRegex(ValueError, "domain is not scoreable"):
|
||||
normalize_rectification_request(request(domain="family", event_kind="family_bereavement"), today=date(2026, 7, 28))
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user