feat: harden rectification candidate 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({
|
||||
|
||||
@@ -163,6 +163,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 +171,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 +186,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({
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -91,9 +91,17 @@ export function createRectificationV4CandidateEngine(options: { readonly apiBase
|
||||
body: JSON.stringify({
|
||||
birth_date: calculationSpec.birthDate, start_time: calculationSpec.candidateRange.start, end_time: calculationSpec.candidateRange.end,
|
||||
lat: calculationSpec.latitude, lon: calculationSpec.longitude, tz: calculationSpec.timezoneOffsetHours,
|
||||
...(Object.hasOwn(calculationSpec, "birthTimeSource") ? { birth_time_source: calculationSpec.birthTimeSource } : {}),
|
||||
...(Object.hasOwn(calculationSpec, "timezoneId") ? { timezone_id: calculationSpec.timezoneId } : {}),
|
||||
...(Object.hasOwn(calculationSpec, "timezoneSource") ? { timezone_source: calculationSpec.timezoneSource } : {}),
|
||||
...(Object.hasOwn(calculationSpec, "localTimeStatus") ? { local_time_status: calculationSpec.localTimeStatus } : {}),
|
||||
events: events.map((event) => ({
|
||||
id: event.eventId, domain: event.domain, event_kind: event.eventKind,
|
||||
date_start: event.dateRange.start, date_end: event.dateRange.end, precision: event.dateRange.precision, summary: event.summary,
|
||||
...(Object.hasOwn(event, "dateSource") ? { date_source: event.dateSource } : {}),
|
||||
...(Object.hasOwn(event, "dateReliability") ? { date_reliability: event.dateReliability } : {}),
|
||||
...(Object.hasOwn(event, "dateCorroboration") ? { date_corroboration: event.dateCorroboration } : {}),
|
||||
...(Object.hasOwn(event, "dateConflictStatus") ? { date_conflict_status: event.dateConflictStatus } : {}),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,89 @@
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,12 +14,21 @@ 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.
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
1. Conduct a natural conversation, never a fixed questionnaire.
|
||||
@@ -33,7 +42,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,10 +14,16 @@ 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
@@ -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. 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, and sensitive user wording. Provenance may support audit linkage only and must never be rendered as added evidence strength. D60 is never displayed. Historical messages without a receipt remain valid, and `v4_legacy`/`v5_shadow` keep their established visible reply semantics.
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
# 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.
|
||||
|
||||
## 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))
|
||||
|
||||
Reference in New Issue
Block a user