From 2384faf367806c117b55328eea41467e10df63e7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 30 Jul 2026 10:55:39 +0800 Subject: [PATCH] feat: harden rectification candidate validation --- docs/BUG_HISTORY.md | 16 + .../src/app/api/rectification/v4/_server.ts | 28 +- .../events/[eventId]/revisions/route.ts | 3 +- .../lib/rectification-agent/orchestrator.ts | 6 +- .../rectification-v4/candidate-clusters.ts | 3 + .../lib/rectification-v4/candidate-engine.ts | 8 + .../src/lib/rectification-v4/contracts.ts | 45 +- .../src/lib/rectification-v4/decision-gate.ts | 17 +- .../lib/rectification-v4/evidence-ledger.ts | 14 + .../src/lib/rectification-v4/extraction.ts | 4 +- .../src/lib/rectification-v4/fingerprints.ts | 3 +- .../lib/rectification-v4/supabase-store.ts | 11 +- .../technique-layer-policy.ts | 43 ++ ...0260730010000_rectification_provenance.sql | 390 ++++++++++++++++++ frontend/tests/rectification-agent-v5.test.ts | 3 +- frontend/tests/rectification-agent-v6.test.ts | 2 +- ...cation-candidate-clusters-midnight.test.ts | 53 +++ .../tests/rectification-provenance.test.ts | 89 ++++ ...rectification-public-range-gate-v6.test.ts | 112 +++++ .../rectification-renderer-safety-v6.test.ts | 2 +- .../tests/rectification-v4-domain.test.ts | 4 +- scripts/rectification/contracts.py | 53 ++- scripts/rectification/diagnostics_service.py | 14 +- scripts/rectification/scoring_service.py | 11 +- skills/birth-time-rectification/SKILL.md | 13 +- .../references/event-schema.md | 2 + .../references/failure-policy.md | 6 + .../references/output-contract.md | 6 +- .../references/technique-policy.md | 16 +- ...test_rectification_diagnostics_clusters.py | 53 +++ tests/test_rectification_provenance.py | 47 +++ tests/test_rectification_v5_services.py | 60 ++- 32 files changed, 1093 insertions(+), 44 deletions(-) create mode 100644 frontend/src/lib/rectification-v4/technique-layer-policy.ts create mode 100644 frontend/supabase/migrations/20260730010000_rectification_provenance.sql create mode 100644 frontend/tests/rectification-candidate-clusters-midnight.test.ts create mode 100644 frontend/tests/rectification-provenance.test.ts create mode 100644 frontend/tests/rectification-public-range-gate-v6.test.ts create mode 100644 tests/test_rectification_diagnostics_clusters.py create mode 100644 tests/test_rectification_provenance.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 60ec2433..487cb860 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 延期 diff --git a/frontend/src/app/api/rectification/v4/_server.ts b/frontend/src/app/api/rectification/v4/_server.ts index 32445068..6cd759d3 100644 --- a/frontend/src/app/api/rectification/v4/_server.ts +++ b/frontend/src/app/api/rectification/v4/_server.ts @@ -43,13 +43,33 @@ export async function calculationSpecForUser( userId: string, ): Promise { 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, diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts index 31955439..dd26fe02 100644 --- a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts @@ -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({ diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts index efb95a6e..aafbdea7 100644 --- a/frontend/src/lib/rectification-agent/orchestrator.ts +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -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({ diff --git a/frontend/src/lib/rectification-v4/candidate-clusters.ts b/frontend/src/lib/rectification-v4/candidate-clusters.ts index 1b32637d..d3138a5f 100644 --- a/frontend/src/lib/rectification-v4/candidate-clusters.ts +++ b/frontend/src/lib/rectification-v4/candidate-clusters.ts @@ -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)!; diff --git a/frontend/src/lib/rectification-v4/candidate-engine.ts b/frontend/src/lib/rectification-v4/candidate-engine.ts index 499cfed3..a4c3dd39 100644 --- a/frontend/src/lib/rectification-v4/candidate-engine.ts +++ b/frontend/src/lib/rectification-v4/candidate-engine.ts @@ -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 } : {}), })), }), }); diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts index 91d75009..e9d91f0d 100644 --- a/frontend/src/lib/rectification-v4/contracts.ts +++ b/frontend/src/lib/rectification-v4/contracts.ts @@ -82,6 +82,13 @@ export type RelatedPerson = z.infer; export const scoreabilitySchema = z.enum(["scoreable", "context_only", "pending_review", "unsupported"]); export type Scoreability = z.infer; +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; -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; +export type Robustness = z.infer; +export const robustnessSchema: z.ZodType = 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; -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; + +export type CandidateSnapshot = z.infer; +export const candidateSnapshotSchema: z.ZodType = 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(); diff --git a/frontend/src/lib/rectification-v4/decision-gate.ts b/frontend/src/lib/rectification-v4/decision-gate.ts index 5c0a6519..e4cd896e 100644 --- a/frontend/src/lib/rectification-v4/decision-gate.ts +++ b/frontend/src/lib/rectification-v4/decision-gate.ts @@ -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 }; } diff --git a/frontend/src/lib/rectification-v4/evidence-ledger.ts b/frontend/src/lib/rectification-v4/evidence-ledger.ts index 3236751e..3870bba8 100644 --- a/frontend/src/lib/rectification-v4/evidence-ledger.ts +++ b/frontend/src/lib/rectification-v4/evidence-ledger.ts @@ -6,6 +6,20 @@ export type NewEventRevision = Omit; + +export function eventDateProvenance(value: Partial): Partial { + 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(); for (const revision of revisions) { diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index c1231b72..998df44d 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -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([ "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); diff --git a/frontend/src/lib/rectification-v4/fingerprints.ts b/frontend/src/lib/rectification-v4/fingerprints.ts index 2912313c..dbca91e3 100644 --- a/frontend/src/lib/rectification-v4/fingerprints.ts +++ b/frontend/src/lib/rectification-v4/fingerprints.ts @@ -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, }))); } diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 7b7348fa..4928bea5 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -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 { diff --git a/frontend/src/lib/rectification-v4/technique-layer-policy.ts b/frontend/src/lib/rectification-v4/technique-layer-policy.ts new file mode 100644 index 00000000..eff4c57d --- /dev/null +++ b/frontend/src/lib/rectification-v4/technique-layer-policy.ts @@ -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> = { + 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; +} diff --git a/frontend/supabase/migrations/20260730010000_rectification_provenance.sql b/frontend/supabase/migrations/20260730010000_rectification_provenance.sql new file mode 100644 index 00000000..d6dba0bb --- /dev/null +++ b/frontend/supabase/migrations/20260730010000_rectification_provenance.sql @@ -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; diff --git a/frontend/tests/rectification-agent-v5.test.ts b/frontend/tests/rectification-agent-v5.test.ts index c67d4202..c72cc79d 100644 --- a/frontend/tests/rectification-agent-v5.test.ts +++ b/frontend/tests/rectification-agent-v5.test.ts @@ -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); diff --git a/frontend/tests/rectification-agent-v6.test.ts b/frontend/tests/rectification-agent-v6.test.ts index c4c9e900..37a37672 100644 --- a/frontend/tests/rectification-agent-v6.test.ts +++ b/frontend/tests/rectification-agent-v6.test.ts @@ -53,7 +53,7 @@ function snapshot(range: readonly [string, string], overrides: Partial ({ + 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 }, + ], + ); +}); diff --git a/frontend/tests/rectification-provenance.test.ts b/frontend/tests/rectification-provenance.test.ts new file mode 100644 index 00000000..6ce643f7 --- /dev/null +++ b/frontend/tests/rectification-provenance.test.ts @@ -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 | null = null; + const engine = createRectificationV4CandidateEngine({ + apiBase: "http://example.test", + fetchImpl: async (_input, init) => { + body = JSON.parse(String(init?.body)) as Record; + 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; + 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[])[0]; + assert.equal(event.date_source, "user_reported"); + assert.equal(event.date_reliability, null); + assert.equal(Object.hasOwn(event, "date_corroboration"), false); +}); diff --git a/frontend/tests/rectification-public-range-gate-v6.test.ts b/frontend/tests/rectification-public-range-gate-v6.test.ts new file mode 100644 index 00000000..cd4c8074 --- /dev/null +++ b/frontend/tests/rectification-public-range-gate-v6.test.ts @@ -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[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 = {}) { + 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")); +}); diff --git a/frontend/tests/rectification-renderer-safety-v6.test.ts b/frontend/tests/rectification-renderer-safety-v6.test.ts index c31fae51..24bd11d1 100644 --- a/frontend/tests/rectification-renderer-safety-v6.test.ts +++ b/frontend/tests/rectification-renderer-safety-v6.test.ts @@ -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: [], diff --git a/frontend/tests/rectification-v4-domain.test.ts b/frontend/tests/rectification-v4-domain.test.ts index 9e3b6770..ab6526d0 100644 --- a/frontend/tests/rectification-v4-domain.test.ts +++ b/frontend/tests/rectification-v4-domain.test.ts @@ -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); diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index a2dd90ce..9311ea9f 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -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) diff --git a/scripts/rectification/diagnostics_service.py b/scripts/rectification/diagnostics_service.py index f80aed88..846a7524 100644 --- a/scripts/rectification/diagnostics_service.py +++ b/scripts/rectification/diagnostics_service.py @@ -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]: diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py index 84a2816f..578d0e15 100644 --- a/scripts/rectification/scoring_service.py +++ b/scripts/rectification/scoring_service.py @@ -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: diff --git a/skills/birth-time-rectification/SKILL.md b/skills/birth-time-rectification/SKILL.md index fac7ab30..c7c59e3b 100644 --- a/skills/birth-time-rectification/SKILL.md +++ b/skills/birth-time-rectification/SKILL.md @@ -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. diff --git a/skills/birth-time-rectification/references/event-schema.md b/skills/birth-time-rectification/references/event-schema.md index 7cf1f139..9bdbea0c 100644 --- a/skills/birth-time-rectification/references/event-schema.md +++ b/skills/birth-time-rectification/references/event-schema.md @@ -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`. diff --git a/skills/birth-time-rectification/references/failure-policy.md b/skills/birth-time-rectification/references/failure-policy.md index 21155376..4bb64a71 100644 --- a/skills/birth-time-rectification/references/failure-policy.md +++ b/skills/birth-time-rectification/references/failure-policy.md @@ -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. diff --git a/skills/birth-time-rectification/references/output-contract.md b/skills/birth-time-rectification/references/output-contract.md index fdd87b9d..651f1b1e 100644 --- a/skills/birth-time-rectification/references/output-contract.md +++ b/skills/birth-time-rectification/references/output-contract.md @@ -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. diff --git a/skills/birth-time-rectification/references/technique-policy.md b/skills/birth-time-rectification/references/technique-policy.md index 0fdb5910..0a6c9914 100644 --- a/skills/birth-time-rectification/references/technique-policy.md +++ b/skills/birth-time-rectification/references/technique-policy.md @@ -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. diff --git a/tests/test_rectification_diagnostics_clusters.py b/tests/test_rectification_diagnostics_clusters.py new file mode 100644 index 00000000..223fb87d --- /dev/null +++ b/tests/test_rectification_diagnostics_clusters.py @@ -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() diff --git a/tests/test_rectification_provenance.py b/tests/test_rectification_provenance.py new file mode 100644 index 00000000..387da6c9 --- /dev/null +++ b/tests/test_rectification_provenance.py @@ -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() diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index 60364f0e..2ed2bd5f 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -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))