feat: harden rectification candidate validation

This commit is contained in:
Jesse_Chen
2026-07-30 10:55:39 +08:00
parent e9d20382d9
commit 2384faf367
32 changed files with 1093 additions and 44 deletions
+16
View File
@@ -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 / deferredper-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,
@@ -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 } : {}),
})),
}),
});
+41 -4
View File
@@ -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);
+45 -8
View File
@@ -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)
+10 -4
View File
@@ -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]:
+10 -1
View File
@@ -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:
+11 -2
View File
@@ -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()
+47
View File
@@ -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()
+57 -3
View File
@@ -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))