feat(rectification): drive questions from candidate contrast

This commit is contained in:
Jesse_Chen
2026-07-31 13:17:00 +08:00
parent fdf791953e
commit a7a11a1a7a
21 changed files with 703 additions and 42 deletions
+17
View File
@@ -1849,3 +1849,20 @@
- 防复发:拒答保护必须有 Orchestrator/Director 级回归;Pending resolution 必须验证缺口已被对应 Revision 补齐;评分政策变化必须同时处理历史 Snapshot;公开技术层过滤按完整技术命名空间测试。
- 相关记录:BUG-099、BUG-102、BUG-103
- 修复版本:`birth-time-rectification-v6` / `rectification-director-v2`
## BUG-105 | 候选差异未驱动下一问、Event Kind 未进入评分且不可回答 Case 被错误复用
- 状态:resolved
- 首次发现:2026-07-31
- 最近更新:2026-07-31
- 影响面:V6 Agent 问题排序、V5 Python 贡献矩阵、V4 Case 创建/复用、Staging 新建校正后的首次回答
- 用户现象:诊断已经显示候选簇在特定技术层存在差异时,访谈仍可能继续做通用领域轮询;关系确立与关系变化在评分中缺少语义差异;新建校正后首次提交回答可能返回“当前没有待回答的问题,请刷新后重试。”;已知主体的非评分事件还可能被错误追问“发生在谁身上”。
- 触发条件:候选分歧只携带技术层而没有可行动的缺失证据;评分仅按 Domain 汇总;创建 Case 时复用 `paused`、没有 Active Job 的 `processing`、或没有 `currentQuestion``awaiting_answer` Case`pending_review` 被等同于主体不明确。
- 根因:Director Dossier 缺少 Candidate Contrast Packet,问题排序对同领域历史提问施加通用重复惩罚;共享评分入口没有按 Event Kind 和真实命中 Rule ID 调整贡献;Service、Memory Store 与 Supabase RPC 的可恢复 Case 条件不一致且没有算法版本隔离;主体澄清条件错误地依赖整个 Scoreability 状态。
- 修复:共享诊断层按主候选与次候选的静态特征差异计算区分层、按事件贡献差值计算相关事件,再生成不暴露候选分钟的 Candidate Contrast Packet,用 Cluster Rank、区分层、相关事件和缺失 Event Kind 驱动问题机会;候选驱动的不同 Event Kind 不受通用同领域重复惩罚,并优先于无诊断依据的领域轮询,非评分事件不作为 Candidate Split Target;共享 Python 贡献矩阵按真实 Rule ID 对 `relationship_start``relationship_change` 应用不同 Profile,零 Activation 不凭空加分,`relationship_end` 继续 Fail Closed;算法版本升级到 `rectification-v5-matrix-scoring-2`;三层 Case 复用统一为仅恢复可回答 Case 或拥有 Active Job 的 Processing Case,且必须匹配算法版本;主体澄清仅针对 `subject=other`
- 数据库:新增向前迁移,更新新 Case 默认算法版本、保留 range-scoring v1 与 matrix-scoring v1 历史 Candidate Snapshot 解析兼容、废弃未完成的旧算法 Case、标记其活跃 Job 为 Stale,并重建带可恢复状态和算法版本检查的 Case 创建 RPC;不写入 Profile 出生时间。
- 验证:真实用户回放覆盖复读、大学入学并离家、开始工作、分手和负债,断言关系结束保持 `pending_review`、不会把大学入学换词重问为迁居、D9 内部差异转为关系确立/状态变化的自然存在性问题、公开问题不泄露技术层或候选分钟,并允许“没有、不知道、不想回答、换方向”;Service 回归覆盖 Paused、孤儿 Processing、空问题 Awaiting Answer、新 Case 首次回答与旧算法 Case 替换;Python 回归覆盖关系 Event Kind 反转候选排序及零 Activation。
- 安全边界:Candidate Contrast 只在服务器内部使用,不向用户公开技术层、分数、代表分钟或第二候选簇;Agent 不能把 `relationship_end` 变为可评分事件,不能确认精确出生分钟,也不能自动写入 Profile。
- 防复发:完整回放测试必须同时覆盖事件账本、问题排序、退出方式和公开文本;评分版本变化必须同步 Case 复用、数据库默认值和历史快照兼容;Case 创建测试必须随后真实调用一次 Answer。
- 相关记录:BUG-101、BUG-102、BUG-104
- 修复版本:local follow-up / `rectification-v5-matrix-scoring-2`
@@ -90,6 +90,19 @@ export const rectificationTurnPlanSchema = z.object({
}).strict();
export type RectificationTurnPlan = z.infer<typeof rectificationTurnPlanSchema>;
export const candidateContrastPacketSchema = z.object({
primaryClusterRank: z.number().int().positive().nullable(),
secondaryClusterRank: z.number().int().positive().nullable(),
discriminatingLayers: z.array(nonblank(80)).max(40),
relevantEventIds: z.array(uuid).max(100),
missingEvidence: z.array(z.object({
domain: evidenceDomainSchema,
eventKind: eventKindSchema,
reason: z.literal("highest_candidate_separation"),
}).strict()).max(8),
}).strict();
export type CandidateContrastPacket = z.infer<typeof candidateContrastPacketSchema>;
export const rectificationCaseDossierSchema = z.object({
case: z.object({
candidateWindow: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(),
@@ -140,6 +153,7 @@ export const rectificationCaseDossierSchema = z.object({
rangeChanged: z.boolean(),
topClusters: z.array(z.object({ rank: z.number().int(), widthMinutes: z.number().int(), stability: z.enum(["stable", "unstable"]) }).strict()).max(4),
contrasts: z.array(z.object({ techniqueLayers: z.array(z.string()), relevantEventIds: z.array(uuid) }).strict()).max(8),
contrastIntelligence: candidateContrastPacketSchema.nullable(),
eventDiagnostics: z.array(z.object({ eventId: uuid, winnerRetentionRate: z.number(), scoreVariance: z.number() }).strict()).max(100),
gateReasons: z.array(z.string()).max(20),
currentSnapshotId: uuid.nullable(),
@@ -5,6 +5,7 @@ import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts";
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
import { hasPolicyInvalidScoreableEvents } from "../rectification-v4/evidence-ledger.ts";
import { buildCandidateContrastPacket } from "./opportunity-builder.ts";
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace } from "./contracts.ts";
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
@@ -55,7 +56,7 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti
conversation: { recentRawTurns: recent.map(({ question, answer }) => ({ question, answer })), earlierConversationSummary: summarizeEarlierTurns(input.turns) },
eventLedger: input.events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" : "superseded" })),
interviewState: { currentTargetEventId: input.currentTargetEventId, declinedDomains: [...new Set(input.turns.flatMap((turn) => turn.questionDomain && declinedPattern.test(turn.answer) ? [turn.questionDomain] : []))], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], pendingEvidence: (input.pendingEvidence ?? []).filter((item) => !item.resolvedAt).map(({ rawText, reasonCode, targetEventId, createdAt }) => ({ rawText, reasonCode, targetEventId, createdAt })), askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition },
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), contrastIntelligence: buildCandidateContrastPacket({ events: input.events, snapshot: input.snapshot, diagnostics: input.diagnostics }), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxDiagnosticsPerRun: 2, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
});
}
@@ -1,8 +1,9 @@
import { createHash } from "node:crypto";
import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts";
import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts";
import { chronologicalEvents, latestEventRevisions } from "../rectification-v4/evidence-ledger.ts";
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts";
import type { CandidateContrastPacket, DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts";
const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [
"switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event",
@@ -86,21 +87,21 @@ const routingValue: Record<QuestionOpportunity["kind"], number> = {
type OpportunityInput = Omit<SemanticQuestionOpportunity, "contractVersion" | "opportunityId" | "utility" | "active" | "forbiddenMoves">;
function utility(value: OpportunityInput): number {
function utility(value: OpportunityInput, contrastPriority = 0): number {
return Number((
.35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance
+ .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty
+ routingValue[value.kind] - value.repetitionPenalty - value.privacyCost
+ routingValue[value.kind] + contrastPriority - value.repetitionPenalty - value.privacyCost
).toFixed(6));
}
function opportunity(caseId: string, input: OpportunityInput): QuestionOpportunity {
function opportunity(caseId: string, input: OpportunityInput, contrastPriority = 0): QuestionOpportunity {
return {
contractVersion: "semantic-question-v2",
...input,
forbiddenMoves,
opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.goal}:${input.fallbackPrompt}`),
utility: utility(input),
utility: utility(input, contrastPriority),
active: true,
};
}
@@ -121,6 +122,47 @@ function declinedSensitiveDomains(turns: readonly RectificationV4Turn[]): Readon
return result;
}
const genericTechniqueLayers = new Set(["vimshottari", "narayana"]);
export function buildCandidateContrastPacket(input: Readonly<{
events: readonly LifeEventRevision[];
snapshot: CandidateSnapshot | null;
diagnostics: DiagnosticsSummary | null;
}>): CandidateContrastPacket | null {
const split = input.diagnostics?.candidateSplits[0];
if (!split) return null;
const discriminatingLayers = split.techniqueLayers.filter((layer) => !genericTechniqueLayers.has(layer.toLowerCase()));
const existingKinds = new Set(latestEventRevisions(input.events)
.filter((event) => event.scoreability === "scoreable")
.map((event) => event.eventKind));
const missingEvidence = (Object.entries(domainScorerRegistry) as [EvidenceDomain, (typeof domainScorerRegistry)[EvidenceDomain]][])
.flatMap(([domain, policy]) => {
if (!policy.techniqueLayers.some((layer) => discriminatingLayers.includes(layer))) return [];
const eventKind = policy.supportedKinds.find((kind) => !existingKinds.has(kind));
return eventKind ? [{ domain, eventKind, reason: "highest_candidate_separation" as const }] : [];
});
return {
primaryClusterRank: input.snapshot?.clusters[0]?.rank ?? null,
secondaryClusterRank: input.snapshot?.clusters[1]?.rank ?? null,
discriminatingLayers,
relevantEventIds: [...split.eventIds],
missingEvidence,
};
}
function contrastQuestion(eventKind: EventKind, anchor: string | null): Readonly<{ goal: string; fallbackPrompt: string }> | null {
const prefix = anchor ? `在“${anchor}”之外,` : "";
if (eventKind === "relationship_start") return {
goal: `${prefix}在用户愿意的前提下,询问是否有一段关系正式确立或开始共同生活的经历及其大致年月,不预设一定发生。`,
fallbackPrompt: `${prefix}如果你愿意,有没有一段关系正式确立或开始共同生活的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`,
};
if (eventKind === "relationship_change") return {
goal: `${prefix}在用户愿意的前提下,询问是否有一段关系状态明显改变的经历及其大致年月,不预设一定发生。`,
fallbackPrompt: `${prefix}如果你愿意,有没有一段关系状态明显改变的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`,
};
return null;
}
export function buildQuestionOpportunities(input: Readonly<{
caseId: string;
events: readonly LifeEventRevision[];
@@ -140,6 +182,7 @@ export function buildQuestionOpportunities(input: Readonly<{
const latestEvent = chronologicalEvents(input.events).at(-1);
const latestContext = input.turns.at(-1)?.answer ?? latestEvent?.rawText ?? "";
const opportunities: QuestionOpportunity[] = [];
const contrastPacket = buildCandidateContrastPacket(input);
if (input.targetDisposition === "answered_other_event") {
for (const eventId of retryTargets) {
@@ -165,7 +208,7 @@ export function buildQuestionOpportunities(input: Readonly<{
if (targetClosed && retryTargets.has(event.eventId)) continue;
const attemptCount = targetAttempts.get(event.eventId) ?? 0;
const anchor = anchorFor(event);
if ((event.scoreability === "pending_review" || event.subject === "other") && attemptCount === 0) {
if (event.subject === "other" && attemptCount === 0) {
opportunities.push(opportunity(input.caseId, {
kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId,
goal: `确认“${anchor}”发生在本人、家人还是伴侣。`, requestedFields: ["event_subject"],
@@ -205,16 +248,17 @@ export function buildQuestionOpportunities(input: Readonly<{
const split = input.diagnostics?.candidateSplits[0];
if (split) {
const target = input.events.find((event) => split.eventIds.includes(event.eventId)
const target = input.events.find((event) => event.scoreability === "scoreable"
&& split.eventIds.includes(event.eventId)
&& (targetAttempts.get(event.eventId) ?? 0) === 0
&& !(targetClosed && retryTargets.has(event.eventId)));
const anchor = target ? anchorFor(target) : null;
opportunities.push(opportunity(input.caseId, {
if (target) opportunities.push(opportunity(input.caseId, {
kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null,
goal: target ? `确认“${anchor}”更接近开始、高峰还是正式结束。` : "确认一件现有事件的发生阶段。",
requestedFields: ["event_stage"], anchors: anchor ? [anchor] : [],
goal: `确认“${anchor}”更接近开始、高峰还是正式结束。`,
requestedFields: ["event_stage"], anchors: [anchor!],
contextFacts: [`候选分歧涉及 ${split.techniqueLayers.length} 个已计算技术层。`],
fallbackPrompt: target ? `${anchor}”当时更接近事情开始、达到高峰,还是正式结束?` : "那件经历更接近开始、达到高峰,还是正式结束?",
fallbackPrompt: `${anchor}”当时更接近事情开始、达到高峰,还是正式结束?`,
reason: "候选簇在现有诊断中出现可检验分歧。",
expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0,
recallEase: .65, novelty: .9, repetitionPenalty: 0, privacyCost: .1,
@@ -231,8 +275,10 @@ export function buildQuestionOpportunities(input: Readonly<{
const pendingThemeBonus = !latestEvent && policy.signals.test(latestContext) ? .12 : 0;
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
const contrastEvidence = contrastPacket?.missingEvidence.find((item) => item.domain === domain) ?? null;
const targetedQuestion = contrastEvidence ? contrastQuestion(contrastEvidence.eventKind, latestAnchor) : null;
opportunities.push(opportunity(input.caseId, {
kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal(latestAnchor),
kind: "ask_new_event", domain, targetEventId: null, goal: targetedQuestion?.goal ?? policy.goal(latestAnchor),
requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [],
contextFacts: [
`已有 ${scoreableCount} 件可评分事件。`,
@@ -242,16 +288,17 @@ export function buildQuestionOpportunities(input: Readonly<{
"只询问一件带大致年月的新事件,不要求用户逐项回答例子。",
"允许用户回答没有、记不清、不想回答或换方向。",
"不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。",
...(contrastEvidence ? ["该类证据对当前候选区分力最高,应优先确认是否存在。"] : []),
...(semanticOverlap ? ["该领域与最新事件语义重叠,必须降低优先级,避免把同一经历换词重问。"] : []),
],
fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
expectedInformationGain: covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus,
fallbackPrompt: targetedQuestion?.fallbackPrompt ?? policy.fallbackPrompt(latestAnchor), reason: contrastEvidence ? "补足当前候选分离所需的关键证据。" : covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
expectedInformationGain: contrastEvidence ? .95 : covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus,
dateSensitivity: input.snapshot ? .5 : .35,
candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42,
domainCoverageGain: covered ? 0 : scoreableDomains.size < 2 ? 1 : .15,
recallEase: policy.recallEase, novelty: semanticOverlap ? .45 : alreadyAsked ? .35 : .9,
repetitionPenalty: (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost,
}));
candidateSplitRelevance: contrastEvidence ? .98 : input.diagnostics?.candidateSplits.length ? .58 : .42,
domainCoverageGain: contrastEvidence ? 1 : covered ? 0 : scoreableDomains.size < 2 ? 1 : .15,
recallEase: policy.recallEase, novelty: contrastEvidence ? .95 : semanticOverlap ? .45 : alreadyAsked ? .35 : .9,
repetitionPenalty: contrastEvidence ? 0 : (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost,
}, contrastEvidence ? .08 : 0));
}
return opportunities
@@ -27,7 +27,7 @@ const newEventDomainTerms: Readonly<Partial<Record<QuestionOpportunity["domain"]
};
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
const openingMessageSchema = z.object({ message: z.string().trim().min(1).max(1_000) }).strict();
const domainChecklistTerms = /(?:学业|教育|搬家|迁居|感情|婚姻|工作|职业|财务|健康)/g;
const fixedChoiceStructure = /(?:从|在)[^。!??\n]{1,80}(?:、||,|或|或者)[^。!??\n]{1,80}(?:(?:中|里|方面)(?:选|选择|挑|说|讲|开始)|(?:选|选择|挑)(?:一|1)?(?:个|件|段))|按[^。!??\n]{1,80}(?:依次|逐一|分别)(?:回答|说|讲)/;
export type OpeningQuestionGenerator = (prompt: string, phase: "generate" | "repair") => Promise<Readonly<{ object: unknown }>>;
@@ -60,7 +60,7 @@ function validateOpeningMessage(value: unknown, range: Readonly<{ start: string;
if (internalTerms.test(message)) issues.push("private_detail_exposed");
const positiveClaims = message.split(/[。;;!??!]/).filter((sentence) => !/(?:不是|并非|尚未|还未|不能)/.test(sentence)).join(" ");
if (exactMinuteClaim.test(positiveClaims)) issues.push("exact_minute_claimed");
if ((message.match(domainChecklistTerms) ?? []).length >= 3) issues.push("fixed_domain_checklist");
if (fixedChoiceStructure.test(message)) issues.push("fixed_domain_checklist");
return { message: issues.length ? null : message, issues };
}
@@ -13,7 +13,7 @@ import { generateOpeningQuestion, regenerateQuestionRealization } from "../recti
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
import { hasPolicyInvalidScoreableEvents } from "./evidence-ledger.ts";
import { openingQuestion } from "./opening-question.ts";
import type { RectificationV4Store } from "./store.ts";
import { canResumeRectificationCase, type RectificationV4Store } from "./store.ts";
const regenerationInFlight = new Map<string, Promise<RectificationV4Case | null>>();
@@ -58,7 +58,10 @@ export function createRectificationV4CaseService(
const specHash = calculationSpecHash(input.calculationSpec);
const active = await store.findActiveCase(input.userId);
if (active?.calculationSpecHash === specHash) {
const activeJob = active?.status === "processing" ? await store.loadActiveJob(input.userId, active.id) : null;
if (active?.calculationSpecHash === specHash
&& active.algorithmVersion === rectificationV4AlgorithmVersion
&& canResumeRectificationCase(active, Boolean(activeJob))) {
return response(input.userId, await store.createCase({ case: active, actionId: input.actionId }));
}
@@ -4,7 +4,11 @@ export const rectificationV4Protocol = "rectification-evidence-v4" as const;
export const rectificationAgentV5Protocol = "rectification-evidence-v5" as const;
export const rectificationDeploymentModeSchema = z.enum(["v4_legacy", "v5_shadow", "v5_agent"]);
export type RectificationDeploymentMode = z.infer<typeof rectificationDeploymentModeSchema>;
export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-1" as const;
export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-2" as const;
const rectificationLegacyAlgorithmVersions = [
"rectification-v4-range-scoring-1",
"rectification-v5-matrix-scoring-1",
] as const;
export const rectificationV4CaseStatusSchema = z.enum([
"awaiting_answer",
@@ -182,7 +186,7 @@ const candidateSnapshotBaseSchema = z.object({
caseVersion: z.number().int().nonnegative(),
evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
algorithmVersion: z.literal(rectificationV4AlgorithmVersion),
algorithmVersion: z.enum([...rectificationLegacyAlgorithmVersions, rectificationV4AlgorithmVersion]),
candidates: z.array(candidateMinuteSchema).min(1).max(1_440),
clusters: z.array(candidateClusterSchema).max(20),
robustness: robustnessSchema,
@@ -11,7 +11,7 @@ import type {
RectificationV4Store,
RectificationV4Turn,
} from "./store.ts";
import { RectificationV4StoreError } from "./store.ts";
import { canResumeRectificationCase, RectificationV4StoreError } from "./store.ts";
import { evidenceSetHash } from "./fingerprints.ts";
export function createRectificationV4MemoryStore(): RectificationV4Store & {
@@ -93,7 +93,10 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
if (replay) return owned(input.case.userId, replay.caseId);
const active = [...cases.values()].find((value) => value.userId === input.case.userId
&& value.status !== "abandoned" && value.acceptedRange === null);
if (active?.calculationSpecHash === input.case.calculationSpecHash) {
const hasActiveJob = active ? [...jobs.values()].some((job) => job.caseId === active.id && ["pending", "processing"].includes(job.status)) : false;
if (active?.calculationSpecHash === input.case.calculationSpecHash
&& active.algorithmVersion === input.case.algorithmVersion
&& canResumeRectificationCase(active, hasActiveJob)) {
actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: active.id, jobId: null });
return active;
}
@@ -12,6 +12,11 @@ import type {
} from "./contracts.ts";
export type { RectificationV4Turn } from "./contracts.ts";
export function canResumeRectificationCase(caseValue: RectificationV4Case, hasActiveJob: boolean): boolean {
return (caseValue.status === "awaiting_answer" && caseValue.currentQuestion !== null)
|| (caseValue.status === "processing" && hasActiveJob);
}
export type ClaimedRectificationV4Job = Readonly<{
job: RectificationV4Job;
case: RectificationV4Case;
@@ -97,7 +97,7 @@ function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): Rectific
narrationModelId: row.narration_model_id ? String(row.narration_model_id) : null,
skillVersion: row.skill_version ? String(row.skill_version) : "birth-time-rectification-v5",
promptVersion: row.prompt_version ? String(row.prompt_version) : "rectification-agent-v5-1",
algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-1",
algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-2",
deploymentMode: row.deployment_mode === "v5_agent" || row.deployment_mode === "v5_shadow" ? row.deployment_mode : "v4_legacy",
agentMode: row.agent_mode === "agent" ? "agent" : "deterministic_fallback",
featureSnapshotId: row.feature_snapshot_id ? String(row.feature_snapshot_id) : null,
@@ -0,0 +1,151 @@
-- A Case is resumable only when the client can answer it or a worker still owns active work.
-- Scoring v2 changes Event Kind semantics, so unfinished v1 Cases must not be resumed under v2.
alter table public.birth_time_rectification_v4_cases
alter column algorithm_version set default 'rectification-v5-matrix-scoring-2';
alter table public.birth_time_rectification_v4_candidate_snapshots
alter column algorithm_version set default 'rectification-v5-matrix-scoring-2';
do $$
declare value record;
begin
for value in
select constraint_value.conname
from pg_catalog.pg_constraint constraint_value
where constraint_value.conrelid = 'public.birth_time_rectification_v4_candidate_snapshots'::regclass
and constraint_value.contype = 'c'
and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%algorithm_version%'
loop
execute pg_catalog.format(
'alter table public.birth_time_rectification_v4_candidate_snapshots drop constraint %I',
value.conname
);
end loop;
end $$;
alter table public.birth_time_rectification_v4_candidate_snapshots
add constraint birth_time_rectification_scoring_v2_candidate_snapshots_algorithm_check
check (algorithm_version in (
'rectification-v4-range-scoring-1',
'rectification-v5-matrix-scoring-1',
'rectification-v5-matrix-scoring-2'
));
update public.birth_time_rectification_v4_jobs job
set status = 'stale', lease_expires_at = null, updated_at = pg_catalog.now()
from public.birth_time_rectification_v4_cases case_value
where job.case_id = case_value.id
and job.status in ('pending', 'processing')
and case_value.algorithm_version <> 'rectification-v5-matrix-scoring-2'
and case_value.accepted_range_start is null
and case_value.status <> 'abandoned';
update public.birth_time_rectification_v4_cases
set status = 'abandoned', phase = 'complete', current_question = null, updated_at = pg_catalog.now()
where algorithm_version <> 'rectification-v5-matrix-scoring-2'
and accepted_range_start is null
and status <> 'abandoned';
create or replace function public.create_birth_time_rectification_v5_case(
p_user_id uuid,
p_case_id uuid,
p_action_id uuid,
p_status text,
p_phase text,
p_calculation_spec jsonb,
p_calculation_spec_hash text,
p_evidence_set_hash text,
p_current_question jsonb,
p_orchestration_model_id text,
p_narration_model_id text,
p_skill_version text,
p_prompt_version text,
p_algorithm_version text,
p_deployment_mode text,
p_now timestamptz
) returns uuid
language plpgsql security definer set search_path = '' as $$
declare
v_case public.birth_time_rectification_v4_cases%rowtype;
v_case_id uuid;
v_protocol text;
begin
if p_deployment_mode not in ('v4_legacy', 'v5_shadow', 'v5_agent') then
raise exception 'invalid_rectification_v5_deployment_mode';
end if;
if p_algorithm_version <> 'rectification-v5-matrix-scoring-2' then
raise exception 'invalid_rectification_v5_algorithm_version';
end if;
v_protocol := case when p_deployment_mode = 'v4_legacy'
then 'rectification-evidence-v4' else 'rectification-evidence-v5' end;
select action.case_id into v_case_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_case_id is not null then return v_case_id; end if;
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(p_user_id::text || ':rectification-v5-case', 0)
);
select value.* into v_case
from public.birth_time_rectification_v4_cases value
where value.user_id = p_user_id
and value.status <> 'abandoned'
and value.accepted_range_start is null
order by value.created_at desc
limit 1
for update;
if found
and v_case.calculation_spec_hash = p_calculation_spec_hash
and v_case.algorithm_version = p_algorithm_version
and (
(v_case.status = 'awaiting_answer' and v_case.current_question is not null)
or (
v_case.status = 'processing'
and exists (
select 1
from public.birth_time_rectification_v4_jobs job
where job.case_id = v_case.id
and job.status in ('pending', 'processing')
)
)
) then
insert into public.birth_time_rectification_v4_actions(
user_id, action_id, case_id, created_at
) values (
p_user_id, p_action_id, v_case.id, p_now
);
return v_case.id;
end if;
if found then
update public.birth_time_rectification_v4_cases
set status = 'abandoned', phase = 'complete', current_question = null, updated_at = p_now
where id = v_case.id;
update public.birth_time_rectification_v4_jobs
set status = 'stale', lease_expires_at = null, updated_at = p_now
where case_id = v_case.id and status in ('pending', 'processing');
end if;
insert into public.birth_time_rectification_v4_cases (
id, user_id, protocol, status, phase, calculation_spec, calculation_spec_hash,
evidence_set_hash, current_question, orchestration_model_id, narration_model_id,
skill_version, prompt_version, algorithm_version, deployment_mode, agent_mode,
created_at, updated_at
) values (
p_case_id, p_user_id, v_protocol, p_status, p_phase, p_calculation_spec,
p_calculation_spec_hash, p_evidence_set_hash, p_current_question,
nullif(btrim(p_orchestration_model_id), ''), nullif(btrim(p_narration_model_id), ''),
p_skill_version, p_prompt_version, p_algorithm_version, p_deployment_mode,
'deterministic_fallback', p_now, p_now
);
insert into public.birth_time_rectification_v4_actions(
user_id, action_id, case_id, created_at
) values (
p_user_id, p_action_id, p_case_id, p_now
);
return p_case_id;
end;
$$;
@@ -50,8 +50,8 @@ test("opening Agent generates and validates the first rectification message", as
generate: async (_prompt, phase) => {
phases.push(phase);
return phase === "generate"
? { object: { message: "目前核对的是 00:00–23:59 候选范围,尚未确认出生分钟。请按学业、搬家、感情、工作依次回答。" } }
: { object: { message: "目前核对的是 00:00–23:59 候选范围,并不是已确认的出生分钟。你愿意先从一段自己记得清楚的经历开始说吗?" } };
? { object: { message: "目前核对的是 00:00–23:59 候选范围,尚未确认出生分钟。请从学习、工作或感情中选一段经历来说。" } }
: { object: { message: "目前核对的是 00:00–23:59 候选范围,并不是已确认的出生分钟。请讲一段你最清楚的经历,也可以连续讲几件相关的事;记不清或想换方向都可以。" } };
},
});
assert.deepEqual(phases, ["generate", "repair"]);
@@ -475,7 +475,7 @@ test("V6 agent conversation follows dated events, respects direction change, and
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");
assert.equal(fourth.case.algorithmVersion, "rectification-v5-matrix-scoring-2");
const finalMessage = [...store.publicMessages.values()].at(-1);
assert.doesNotMatch(`${finalMessage?.candidateUpdate ?? ""}${finalMessage?.limitation ?? ""}`, /唯一分钟|准确分钟|代表分钟|05:13/);
});
+134 -1
View File
@@ -9,7 +9,7 @@ import {
type QuestionOpportunity,
type ValidatedDecision,
} from "../src/lib/rectification-agent/contracts.ts";
import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts";
import { buildCandidateContrastPacket, buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts";
import { buildReasonerState } from "../src/lib/rectification-agent/reasoner-agent.ts";
import { candidateUpdateFor, realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts";
import { extractLifeEventEvidence, validatedModelAssistedEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts";
@@ -464,6 +464,139 @@ test("新事件机会提供具体回忆线索和退出方式,不把离家上
assert.equal(validateQuestionRealization(career.fallbackPrompt, career).valid, true);
});
test("D9 候选差异优先请求缺失的关系事件语义而不是继续领域轮询", () => {
const events = [
event({ summary: "2015年复读", rawText: "2015年复读" }),
event({ eventId: randomUUID(), domain: "career", eventKind: "career_change", summary: "2020年开始工作", rawText: "2020年开始工作" }),
event({ eventId: randomUUID(), domain: "finance", eventKind: "finance_change", summary: "2026年开始负债", rawText: "2026年开始负债" }),
];
const splitDiagnostics = diagnostics({
candidateSplits: [{
leftCluster: { start: "05:10", end: "05:14" },
rightCluster: { start: "05:16", end: "05:20" },
techniqueLayers: ["D9", "vimshottari"],
eventIds: [],
}],
});
const currentSnapshot = snapshot(["05:10", "05:14"], {
clusters: [
{ rank: 1, startTime: "05:10", endTime: "05:14", representativeTime: "05:12", widthMinutes: 5, peakScore: 10, scoreMass: .55 },
{ rank: 2, startTime: "05:16", endTime: "05:20", representativeTime: "05:18", widthMinutes: 5, peakScore: 9.8, scoreMass: .45 },
],
});
const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics });
assert.deepEqual(packet?.missingEvidence[0], {
domain: "relationship",
eventKind: "relationship_start",
reason: "highest_candidate_separation",
});
assert.deepEqual(packet?.discriminatingLayers, ["D9"]);
const opportunities = buildQuestionOpportunities({ caseId, events, turns: [], snapshot: currentSnapshot, diagnostics: splitDiagnostics });
assert.equal(opportunities.some((item) => item.kind === "disambiguate_candidate_split" && item.targetEventId === null), false);
assert.equal(opportunities[0]?.kind, "ask_new_event");
assert.equal(opportunities[0]?.domain, "relationship");
assert.ok(opportunities[0]?.contextFacts.some((fact) => /候选区分力最高/.test(fact)));
assert.doesNotMatch(opportunities[0]?.contextFacts.join(" ") ?? "", /D9|05:1/);
const withStart = buildCandidateContrastPacket({
events: [...events, event({ eventId: randomUUID(), domain: "relationship", eventKind: "relationship_start", summary: "2022年确定关系", rawText: "2022年确定关系" })],
snapshot: currentSnapshot,
diagnostics: splitDiagnostics,
});
assert.equal(withStart?.missingEvidence[0]?.eventKind, "relationship_change");
});
test("真实用户回放按候选差异追问关系证据且不泄露内部候选", () => {
const replay = [
{ rawText: "2015年复读", domain: "education", eventKind: "education_milestone" },
{ rawText: "2016年离家去外地上大学", domain: "education", eventKind: "education_milestone" },
{ rawText: "2020年开始工作", domain: "career", eventKind: "career_change" },
{ rawText: "2024年分手", domain: "relationship", eventKind: "relationship_end" },
{ rawText: "2026年开始负债", domain: "finance", eventKind: "finance_change" },
] as const;
let events: LifeEventRevision[] = [];
const turns: RectificationV4Turn[] = [];
replay.forEach((item, index) => {
const sourceTurnId = randomUUID();
const dateText = item.rawText.slice(0, 5);
const assisted = validatedModelAssistedEvidence({
rawText: item.rawText,
sourceTurnId,
asOfDate: "2026-07-31",
extraction: {
sourceSpan: item.rawText,
summary: item.rawText.slice(5),
domain: item.domain,
eventKind: item.eventKind,
subject: "self",
relatedPerson: null,
dateText,
},
});
assert.ok(assisted);
const reconciled = reconcileV4Evidence({
caseId,
answer: item.rawText,
sourceTurnId,
asOfDate: "2026-07-31",
existing: events,
assistedEvidence: [assisted],
now: new Date(`2026-07-31T0${index}:00:00.000Z`),
});
assert.equal(reconciled.pending.length, 0);
events = [...events, ...reconciled.revisions];
turns.push(turn({
id: sourceTurnId,
caseVersion: index + 1,
questionDomain: item.domain,
answer: item.rawText,
createdAt: `2026-07-31T0${index}:00:00.000Z`,
}));
});
const breakup = events.find((item) => item.eventKind === "relationship_end");
assert.ok(breakup);
assert.equal(breakup.scoreability, "pending_review");
assert.equal(events.some((item) => item.eventKind === "relationship_end" && item.scoreability === "scoreable"), false);
assert.equal(events.some((item) => item.domain === "relocation"), false);
const currentSnapshot = snapshot(["05:10", "05:14"], {
canAcceptRange: false,
gateReasons: ["insufficient_candidate_separation"],
clusters: [
{ rank: 1, startTime: "05:10", endTime: "05:14", representativeTime: "05:13", widthMinutes: 5, peakScore: 10, scoreMass: .51 },
{ rank: 2, startTime: "05:16", endTime: "05:20", representativeTime: "05:17", widthMinutes: 5, peakScore: 9.9, scoreMass: .49 },
],
});
const splitDiagnostics = diagnostics({
candidateSplits: [{
leftCluster: { start: "05:10", end: "05:14" },
rightCluster: { start: "05:16", end: "05:20" },
techniqueLayers: ["D9", "vimshottari"],
eventIds: [breakup.eventId],
}],
});
const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics });
assert.equal(packet?.missingEvidence[0]?.eventKind, "relationship_start");
const opportunities = buildQuestionOpportunities({ caseId, events, turns, snapshot: currentSnapshot, diagnostics: splitDiagnostics });
const next = opportunities[0];
assert.ok(next);
assert.equal(next.kind, "ask_new_event");
assert.equal(next.domain, "relationship");
assert.match(next.fallbackPrompt, /关系正式确立|开始共同生活/);
assert.match(next.fallbackPrompt, /没有/);
assert.match(next.fallbackPrompt, /不知道/);
assert.match(next.fallbackPrompt, /不想回答/);
assert.match(next.fallbackPrompt, /换方向/);
assert.doesNotMatch(next.fallbackPrompt, /搬家|迁居|离乡|外地/);
assert.doesNotMatch([next.goal, next.fallbackPrompt, ...next.contextFacts].join(" "), /D9|vimshottari|05:1[037]/i);
assert.equal(validateQuestionRealization(next.fallbackPrompt, next).valid, true);
});
test("研究院实习后的迁居机会以存在性问题主动引导,不要求用户自己发明事件", () => {
const internship = event({
domain: "career",
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const migration = readFileSync(new URL(
"../supabase/migrations/20260731010000_rectification_case_resume_and_scoring_v2.sql",
import.meta.url,
), "utf8");
test("scoring v2 migration only resumes an answerable or actively processing Case", () => {
assert.match(migration, /v_case\.algorithm_version = p_algorithm_version/);
assert.match(migration, /v_case\.status = 'awaiting_answer'[\s\S]*v_case\.current_question is not null/);
assert.match(migration, /v_case\.status = 'processing'[\s\S]*birth_time_rectification_v4_jobs[\s\S]*status in \('pending', 'processing'\)/);
assert.doesNotMatch(migration, /v_case\.status = 'paused'/);
});
test("scoring v2 migration retires incompatible unfinished work without touching profile birth time", () => {
assert.match(migration, /algorithm_version <> 'rectification-v5-matrix-scoring-2'[\s\S]*accepted_range_start is null/);
assert.match(migration, /set status = 'stale'/);
assert.match(migration, /set status = 'abandoned', phase = 'complete', current_question = null/);
assert.doesNotMatch(migration, /profiles\.active_birth_time|update\s+public\.profiles/i);
});
test("scoring v2 migration permits historical snapshots but defaults new artifacts to v2", () => {
assert.match(migration, /alter column algorithm_version set default 'rectification-v5-matrix-scoring-2'/);
assert.match(migration, /rectification-v4-range-scoring-1[\s\S]*rectification-v5-matrix-scoring-1[\s\S]*rectification-v5-matrix-scoring-2/);
});
@@ -88,6 +88,14 @@ function snapshotInput(overrides: Record<string, unknown> = {}) {
};
}
test("pre-matrix historical snapshots remain readable", () => {
const parsed = candidateSnapshotSchema.parse(snapshotInput({
algorithmVersion: "rectification-v4-range-scoring-1",
robustness,
}));
assert.equal(parsed.algorithmVersion, "rectification-v4-range-scoring-1");
});
test("legacy snapshots without domain retention still parse without retroactive rejection", () => {
const parsed = candidateSnapshotSchema.parse(snapshotInput({
robustness: {
@@ -83,6 +83,48 @@ test("same calculation spec resumes while a changed spec abandons the old case a
assert.equal(store.jobs.get(queued.job.id)?.status, "stale");
}));
test("new case replaces paused or orphaned processing state and can be answered immediately", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
await service.transition({ userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, kind: "pause" });
const afterPause = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.notEqual(afterPause.case.id, first.case.id);
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
assert.ok(afterPause.case.currentQuestion);
store.cases.set(afterPause.case.id, { ...afterPause.case, status: "processing", phase: "reasoning", currentQuestion: null });
const replacement = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.notEqual(replacement.case.id, afterPause.case.id);
assert.equal(store.cases.get(afterPause.case.id)?.status, "abandoned");
assert.ok(replacement.case.currentQuestion);
const queued = await service.answer({
userId,
caseId: replacement.case.id,
actionId: randomUUID(),
expectedCaseVersion: replacement.case.version,
answer: "2016 年离家去外地上大学",
});
assert.ok(queued?.job);
}));
test("new scoring version replaces a resumable Case created by an older algorithm", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
store.cases.set(first.case.id, { ...first.case, algorithmVersion: "rectification-v5-matrix-scoring-1" });
const second = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.notEqual(second.case.id, first.case.id);
assert.equal(second.case.algorithmVersion, "rectification-v5-matrix-scoring-2");
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
}));
test("answer is durably queued and a processing case reload restores its active job", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
+1 -1
View File
@@ -11,7 +11,7 @@ DatePrecision = Literal["day", "month", "quarter", "year", "range"]
SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
"education": frozenset({"education_milestone"}),
"relocation": frozenset({"relocation"}),
"relationship": frozenset({"relationship_start", "relationship_end", "relationship_change"}),
"relationship": frozenset({"relationship_start", "relationship_change"}),
"career": frozenset({"career_change"}),
"finance": frozenset({"finance_change"}),
"health_pressure": frozenset({"self_health_event"}),
+45 -2
View File
@@ -40,6 +40,48 @@ def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[
return [{**row, "score": round(row["score"] - sum(item["points"] for item in row["evidence"] if item["event_id"] in removed_ids), 4)} for row in rows]
def _candidate_feature_contrast(built: dict[str, Any], primary_time: str, secondary_time: str) -> list[str]:
features = {
value["time"]: value
for context in built.get("static_contexts") or []
if isinstance((value := context.get("feature")), dict) and isinstance(value.get("time"), str)
}
primary = features.get(primary_time)
secondary = features.get(secondary_time)
if not primary or not secondary:
return []
layers = []
for section in ("varga_ascendants", "arudha_signs"):
primary_values = primary.get(section) or {}
secondary_values = secondary.get(section) or {}
layers.extend(
key for key in set(primary_values) | set(secondary_values)
if primary_values.get(key) != secondary_values.get(key)
)
fingerprints = (("ashtakavarga", "Ashtakavarga"), ("shadbala", "Shadbala"))
primary_fingerprints = primary.get("fingerprints") or {}
secondary_fingerprints = secondary.get("fingerprints") or {}
layers.extend(
layer for key, layer in fingerprints
if primary_fingerprints.get(key) != secondary_fingerprints.get(key)
)
return sorted(set(layers))[:8]
def _candidate_contrast(built: dict[str, Any], primary_time: str, secondary_time: str) -> tuple[list[str], list[str]]:
event_deltas: list[tuple[float, str]] = []
for event_id, candidates in built["matrix"].items():
primary = candidates.get(primary_time)
secondary = candidates.get(secondary_time)
if not primary or not secondary:
continue
delta = abs(float(primary["points"]) - float(secondary["points"]))
if delta > 1e-9:
event_deltas.append((delta, event_id))
events = [event_id for _, event_id in sorted(event_deltas, key=lambda item: (-item[0], item[1]))]
return _candidate_feature_contrast(built, primary_time, secondary_time), events
def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow], built: dict[str, Any]) -> dict[str, Any]:
primary = set(_primary_cluster(rows))
event_runs = []
@@ -74,11 +116,12 @@ def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow]
clusters = [_primary_cluster(rows)]
candidate_splits = []
if secondary and clusters[0]:
contrast_layers, contrast_event_ids = _candidate_contrast(built, top[0]["time"], secondary["time"])
candidate_splits.append({
"left_cluster": {"start": clusters[0][0], "end": clusters[0][-1]},
"right_cluster": {"start": secondary["time"], "end": secondary["time"]},
"technique_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:8]],
"event_ids": [item["event_id"] for item in secondary["evidence"] if item["points"] != 0],
"technique_layers": contrast_layers,
"event_ids": contrast_event_ids,
})
return {
"primary_cluster_retention_rate": 1.0 if primary else 0.0,
+51 -3
View File
@@ -11,7 +11,7 @@ from scripts.active_rectification_event_engine import compute_candidate_static_c
from scripts.active_rectification_events import CandidateScoreRow
from scripts.rectification.contracts import LifeEvent, RectificationRequest
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-1"
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-2"
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
@@ -82,6 +82,42 @@ def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]:
return tuple(compute_event_candidate_rows(json.loads(serialized)))
_RELATIONSHIP_SUPPORT_RULES = (
"functional_benefic_auxiliary",
"arudha_auxiliary",
"ashtakavarga_target_house_support_auxiliary",
"shadbala_sthana_drik_naisargika_support_auxiliary",
"controlled_transit_jupiter_domain_house",
)
_RELATIONSHIP_CHANGE_RULES = (
"functional_malefic_auxiliary",
"ashtakavarga_target_house_pressure_auxiliary",
"shadbala_sthana_drik_naisargika_pressure_auxiliary",
"controlled_transit_saturn_domain_house",
)
def _relationship_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float:
if event_kind not in {"relationship_start", "relationship_change"}:
return 1.0
support = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_SUPPORT_RULES) for rule in rule_ids)
change = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_CHANGE_RULES) for rule in rule_ids)
direction = support - change if event_kind == "relationship_start" else change - support
return max(0.8, min(1.2, 1 + 0.08 * direction))
def _kind_adjusted_evidence(event: LifeEvent, evidence: dict[str, Any]) -> dict[str, Any]:
if event["domain"] != "relationship":
return evidence
event_kind = event["event_kind"]
rules = list(evidence["rule_ids"])
return {
**evidence,
"rule_ids": [*rules, f"event_kind_profile:{event_kind}"],
"points": round(float(evidence["points"]) * _relationship_kind_factor(event_kind, rules), 4),
}
def build_event_contribution_matrix(
request: RectificationRequest,
row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None,
@@ -94,7 +130,14 @@ def build_event_contribution_matrix(
candidate_grid: list[str] | None = None
for event in request["events"]:
samples = sample_event_dates(event)
sample_rows = [list(provider(_legacy_request(request, event, sampled))) for sampled in samples]
sample_rows = []
for sampled in samples:
rows = list(provider(_legacy_request(request, event, sampled)))
sample_rows.append([
{**row, "score": adjusted["points"], "evidence": [adjusted]}
for row in rows
for adjusted in [_kind_adjusted_evidence(event, row["evidence"][0])]
])
grids = [[row["time"] for row in rows] for rows in sample_rows]
if any(grid != grids[0] for grid in grids[1:]) or (candidate_grid is not None and grids[0] != candidate_grid):
raise ValueError("candidate_grid_mismatch")
@@ -109,7 +152,12 @@ def build_event_contribution_matrix(
matrix[event["id"]][candidate_time] = {
"points": round(sum(points) / len(points), 4),
"rule_ids": sorted({rule for item in evidences for rule in item["rule_ids"]}),
"technique_layers": sorted({rule.split(":", 1)[0] for item in evidences for rule in item["rule_ids"]}),
"technique_layers": sorted({
rule.split(":", 1)[0]
for item in evidences
for rule in item["rule_ids"]
if not rule.startswith(("event_kind:", "event_kind_profile:"))
}),
}
winner = max(set(winners), key=winners.count)
mean = sum(matrix[event["id"]][time]["points"] for time in candidate_grid) / len(candidate_grid)
@@ -48,6 +48,56 @@ class RectificationDiagnosticsClustersTest(unittest.TestCase):
"end": "05:16",
})
def test_candidate_split_reports_actual_candidate_deltas_not_global_activation(self):
stable_event = "00000000-0000-4000-8000-000000000001"
separating_event = "00000000-0000-4000-8000-000000000002"
rows = [
{
"time": "05:13", "score": 100, "missing_layers": [],
"evidence": [
{"event_id": stable_event, "domain": "relationship", "candidate_time": "05:13", "rule_ids": ["D9:a"], "points": 50},
{"event_id": separating_event, "domain": "education", "candidate_time": "05:13", "rule_ids": ["D24:a"], "points": 50},
],
},
{
"time": "05:14", "score": 90, "missing_layers": [],
"evidence": [
{"event_id": stable_event, "domain": "relationship", "candidate_time": "05:14", "rule_ids": ["D9:b"], "points": 50},
{"event_id": separating_event, "domain": "education", "candidate_time": "05:14", "rule_ids": ["D24:b"], "points": 40},
],
},
]
built = {
"date_sensitivity": [],
"static_contexts": [
{"feature": {
"time": "05:13",
"varga_ascendants": {"D9": 1, "D24": 2},
"arudha_signs": {},
"fingerprints": {"ashtakavarga": "same", "shadbala": "same"},
}},
{"feature": {
"time": "05:14",
"varga_ascendants": {"D9": 1, "D24": 3},
"arudha_signs": {},
"fingerprints": {"ashtakavarga": "same", "shadbala": "same"},
}},
],
"matrix": {
stable_event: {
"05:13": {"points": 50, "technique_layers": ["D9", "D24"]},
"05:14": {"points": 50, "technique_layers": ["D9", "D24"]},
},
separating_event: {
"05:13": {"points": 50, "technique_layers": ["D9", "D24"]},
"05:14": {"points": 40, "technique_layers": ["D9", "D24"]},
},
},
}
result = run_diagnostics({"events": [{"id": stable_event, "domain": "relationship"}, {"id": separating_event, "domain": "education"}]}, rows, built)
self.assertEqual(result["candidate_splits"][0]["technique_layers"], ["D24"])
self.assertEqual(result["candidate_splits"][0]["event_ids"], [separating_event])
if __name__ == "__main__":
unittest.main()
+66 -1
View File
@@ -113,6 +113,71 @@ class RectificationV5ServicesTest(unittest.TestCase):
normalized = normalize_rectification_request(request(domain="health_pressure", event_kind="self_health_event"), today=date(2026, 7, 28))
self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event")
def test_relationship_end_is_not_scoreable(self):
with self.assertRaisesRegex(ValueError, "event_kind does not match domain"):
normalize_rectification_request(
request(domain="relationship", event_kind="relationship_end"),
today=date(2026, 7, 28),
)
def test_relationship_start_and_change_use_distinct_rule_conditioned_profiles(self):
def relationship_rows(_value):
return [
{
"time": "05:13", "score": 10,
"evidence": [{
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13",
"rule_ids": ["vim_md_domain_house", "vim_ad_functional_benefic_auxiliary"],
"points": 10,
}],
"missing_layers": [],
},
{
"time": "05:14", "score": 9,
"evidence": [{
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:14",
"rule_ids": ["controlled_transit_saturn_domain_house", "vim_ad_functional_malefic_auxiliary"],
"points": 9,
}],
"missing_layers": [],
},
]
start = normalize_rectification_request(
request(domain="relationship", event_kind="relationship_start"), today=date(2026, 7, 28)
)
change = normalize_rectification_request(
request(domain="relationship", event_kind="relationship_change"), today=date(2026, 7, 28)
)
start_matrix = build_event_contribution_matrix(start, row_provider=relationship_rows)
change_matrix = build_event_contribution_matrix(change, row_provider=relationship_rows)
self.assertGreater(start_matrix["matrix"][EVENT_ID]["05:13"]["points"], start_matrix["matrix"][EVENT_ID]["05:14"]["points"] )
self.assertGreater(change_matrix["matrix"][EVENT_ID]["05:14"]["points"], change_matrix["matrix"][EVENT_ID]["05:13"]["points"] )
self.assertEqual(change_matrix["date_sensitivity"][0]["sample_winners"], ["05:14", "05:14", "05:14"])
self.assertNotIn("event_kind_profile", change_matrix["matrix"][EVENT_ID]["05:14"]["technique_layers"])
def test_relationship_kind_profile_does_not_create_points_without_activation(self):
normalized = normalize_rectification_request(
request(domain="relationship", event_kind="relationship_change", precision="day"),
today=date(2026, 7, 28),
)
def rows(_value):
return [{
"time": "05:13", "score": 0,
"evidence": [{
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13",
"rule_ids": ["no_domain_activation", "event_kind:relationship_change"],
"points": 0,
}],
"missing_layers": [],
}]
built = build_event_contribution_matrix(normalized, row_provider=rows)
self.assertEqual(built["matrix"][EVENT_ID]["05:13"]["points"], 0)
def test_date_sampling_preserves_declared_range_and_uses_bounded_samples(self):
base = request()["events"][0]
self.assertEqual(sample_event_dates({**base, "precision": "month"}), ["2016-09-01", "2016-09-15", "2016-09-30"])
@@ -167,7 +232,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
}
feature = {
"calculation_spec_hash": "0" * 64,
"algorithm_version": "rectification-v5-matrix-scoring-1",
"algorithm_version": "rectification-v5-matrix-scoring-2",
"candidate_count": 2,
"feature_hash": "1" * 64,
"features": [{"time": "05:13"}, {"time": "05:14"}],