fix(rectification): make delivery report use inference-window facts (BUG-593)
Independent Staging Quality Gate / validate (push) Successful in 15m5s
Independent Staging Quality Gate / publish (push) Has been cancelled

The verification template was still quoting the engine's pre-inference span and eliminated dasha tops, and D9/D10 signs were left for the model to guess from transition clocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 15:33:48 +08:00
parent f17af254c5
commit 06e4410431
35 changed files with 1049 additions and 65 deletions
@@ -17,6 +17,13 @@ export function timeToMinutes(value: string): number | null {
* Width of the indistinguishable top cluster, in minutes.
* Uses the engine's tied_minute_count and the inclusive span of public times.
*/
export function inclusiveClockWidthMinutes(start: string, end: string): number | null {
const from = timeToMinutes(start);
const to = timeToMinutes(end);
if (from === null || to === null) return null;
return Math.max(to - from + 1, 1);
}
export function indistinguishableWidthMinutes(
candidates: readonly Readonly<{
time: string;
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.15";
export const RECTIFICATION_SKILL_VERSION = "10.0.16";
@@ -122,7 +122,7 @@ function signFromProbe(
return null;
}
function signFromTransitions(
export function signFromTransitions(
transitions: readonly WindowScanTransition[],
layer: "d9" | "d10",
time: string,
@@ -384,6 +384,63 @@ export function parseDashaAgreement(value: unknown): DashaAgreement | null {
};
}
function dashaAgreementCopy(
status: DashaAgreement["status"],
vim: string | null,
nar: string | null,
): DashaAgreement {
if (status === "conflict" && vim && nar) {
return {
status,
vimshottari_top: vim,
narayana_top: nar,
user_meaning: `主限更偏向 ${vim},分盘大运更偏向 ${nar}。冲突时不能按更高把握收口。`,
};
}
if (status === "agree") {
return {
status,
vimshottari_top: vim,
narayana_top: nar,
user_meaning: "主限和分盘大运都更支持同一段代表性时间。这仍不是唯一分钟确认。",
};
}
if (status === "partial") {
return {
status,
vimshottari_top: vim,
narayana_top: nar,
user_meaning: "主限和分盘大运还不能做成完整对照,只作观察。",
};
}
return {
status: "unavailable",
vimshottari_top: null,
narayana_top: null,
user_meaning: "还没有足够的大运对照。",
};
}
/** Recompute Vimshottari / Narayana tops inside the still-active inference minutes. */
export function dashaAgreementAmongActive(
engine: DashaAgreement | null,
activeTimes: readonly string[],
): DashaAgreement | null {
if (!engine) return null;
const active = new Set(activeTimes);
if (active.size === 0) return dashaAgreementCopy("unavailable", null, null);
const vim = engine.vimshottari_top && active.has(engine.vimshottari_top)
? engine.vimshottari_top
: null;
const nar = engine.narayana_top && active.has(engine.narayana_top)
? engine.narayana_top
: null;
if (vim && nar && vim === nar) return dashaAgreementCopy("agree", vim, nar);
if (vim && nar) return dashaAgreementCopy("conflict", vim, nar);
if (!vim && !nar) return dashaAgreementCopy("agree", null, null);
return dashaAgreementCopy("partial", vim, nar);
}
function parseLords(value: unknown): LagnaContrastInterval["lords"] | null {
const row = asRecord(value);
if (!row) return null;
@@ -6,9 +6,10 @@
* fate promises.
*/
import type { EventDashaLedgerRow } from "./refinement-packet";
import type { DashaAgreement, EventDashaLedgerRow, WindowScanTransition } from "./refinement-packet";
import type { WindowScan } from "./varga-observations";
import type { CandidateSeparation } from "../core/candidate-separation";
import { signFromTransitions } from "./divergence-panel";
import { D9_TYPE_TABLE, D10_TYPE_TABLE, signKey } from "./varga-type-tables";
function typeRow(sign: string, table: "d9" | "d10"): string {
@@ -23,6 +24,18 @@ function typeRow(sign: string, table: "d9" | "d10"): string {
return `| ${key} | ${row.trait} | ${row.occupation} | ${row.style} |`;
}
export type CandidateVargaSigns = Readonly<{
d9: string | null;
d10: string | null;
}>;
export type SkillVerificationReport = Readonly<{
width_minutes: number | null;
dasha_agreement: DashaAgreement | null;
sign_by_candidate: Readonly<Record<string, CandidateVargaSigns>>;
markdown: string;
}>;
export type SkillVerificationReportInput = Readonly<{
representativeTime: string | null;
widthMinutes: number | null;
@@ -38,7 +51,8 @@ export type SkillVerificationReportInput = Readonly<{
user_meaning: string;
unique_minute_claim: false;
}> | null;
dashaAgreement?: Readonly<{ status: string; user_meaning: string }> | null;
dashaAgreement?: DashaAgreement | null;
signByCandidate?: Readonly<Record<string, CandidateVargaSigns>>;
techniqueAuditTable?: readonly Readonly<{
technique?: string;
status?: string;
@@ -49,26 +63,71 @@ export type SkillVerificationReportInput = Readonly<{
engineResultId?: string | null;
}>;
export function signByCandidateFromTransitions(
times: readonly string[],
transitions: readonly WindowScanTransition[],
): Readonly<Record<string, CandidateVargaSigns>> {
const out: Record<string, CandidateVargaSigns> = {};
for (const time of times) {
out[time] = {
d9: signFromTransitions(transitions, "d9", time),
d10: signFromTransitions(transitions, "d10", time),
};
}
return out;
}
function uniqueSigns(signs: readonly (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const sign of signs) {
const keyed = sign ? signKey(sign) : "";
if (!keyed || seen.has(keyed)) continue;
seen.add(keyed);
out.push(keyed);
}
return out;
}
export function buildSkillVerificationReport(input: SkillVerificationReportInput): string {
return buildSkillVerificationPacket(input).markdown;
}
export function buildSkillVerificationPacket(input: SkillVerificationReportInput): SkillVerificationReport {
const time = input.representativeTime ?? "尚未选定";
const width = input.widthMinutes == null ? "未知" : `${input.widthMinutes} 分钟`;
const lagna = input.houseLagna?.trim() || "未交付";
const fit = input.eventFitRate;
const ledger = input.eventDashaLedger ?? [];
const d9Signs = input.windowScan?.d9_sign_names ?? [];
const d10Signs = input.windowScan?.d10_sign_names ?? [];
const signByCandidate = input.signByCandidate
?? signByCandidateFromTransitions(
input.candidates.map((row) => row.time),
input.windowScan?.transitions ?? [],
);
const d9Signs = uniqueSigns(Object.values(signByCandidate).map((row) => row.d9));
const d10Signs = uniqueSigns(Object.values(signByCandidate).map((row) => row.d10));
const d9TableSigns = d9Signs.length > 0 ? d9Signs : uniqueSigns(input.windowScan?.d9_sign_names ?? []);
const d10TableSigns = d10Signs.length > 0 ? d10Signs : uniqueSigns(input.windowScan?.d10_sign_names ?? []);
const d9Differs = d9Signs.length >= 2
|| (d9Signs.length === 0 && input.windowScan?.d9_candidates_differ === true);
const d10Differs = d10Signs.length >= 2
|| (d10Signs.length === 0 && input.windowScan?.d10_candidates_differ === true);
const tied = input.separation?.status === "not_separated";
const candidateLines = input.candidates.slice(0, 5).map((row) => (
`| ${row.rank} | ${row.time} | ${row.relativeSupport} |`
));
const signRows = input.candidates.map((row) => {
const signs = signByCandidate[row.time];
return `| ${row.time} | ${signs?.d9 ?? "—"} | ${signs?.d10 ?? "—"} |`;
});
const dashaRows = ledger.length > 0
? ledger.map((row) => `| ${row.summary} | ${row.match_label} | ${row.user_meaning} |`)
: ["| (本轮还没有已确认事件行) | — | 先收带日期经历再填这张表 |"];
const d9Table = d9Signs.length > 0
? ["| D9上升 | 感情特质 | 配偶类型 | 婚姻特点 |", "|---|---|---|---|", ...d9Signs.map((sign) => typeRow(sign, "d9"))]
const d9Table = d9TableSigns.length > 0
? ["| D9上升 | 感情特质 | 配偶类型 | 婚姻特点 |", "|---|---|---|---|", ...d9TableSigns.map((sign) => typeRow(sign, "d9"))]
: ["当前窗还没有可对照的 D9 上升名,类型表待候选换升后填写。"];
const d10Table = d10Signs.length > 0
? ["| D10上升 | 事业特质 | 职业类型 | 工作风格 |", "|---|---|---|---|", ...d10Signs.map((sign) => typeRow(sign, "d10"))]
const d10Table = d10TableSigns.length > 0
? ["| D10上升 | 事业特质 | 职业类型 | 工作风格 |", "|---|---|---|---|", ...d10TableSigns.map((sign) => typeRow(sign, "d10"))]
: ["当前窗还没有可对照的 D10 上升名,类型表待候选换升后填写。"];
const audit = (input.techniqueAuditTable ?? []).slice(0, 16);
const auditRows = audit.length > 0
@@ -89,9 +148,9 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
"| 占问 | observation_only | AI 暂不支持独立占问;有问起时间则观察 |",
];
return [
const markdown = [
"## 生时纠正验证报告(skill 八法)",
"这是当前窗的相对拟合,标签是 `candidate_range_not_birth_time_truth`。采用只会把代表性时间写入当前排盘,不等于确认唯一出生分钟。",
"这是当前窗的相对拟合,标签是 `candidate_range_not_birth_time_truth`。采用只会把代表性时间写入当前排盘,不是已确认唯一出生分钟。",
"",
"### 筛选",
tied
@@ -99,10 +158,10 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
: `- 当前代表分钟:${time}`,
`- 不可分宽度:${width}`,
`- 本命上升(该分钟):${lagna}`,
input.windowScan?.d9_candidates_differ
d9Differs
? "- D9 仍会换升。这是候选结构差异,应生成区分探针,不能直接宣布不可分。"
: "- D9 上升在当前窗较稳。",
input.windowScan?.d10_candidates_differ
d10Differs
? "- D10 仍会换升。这是候选结构差异,应生成区分探针,不能直接宣布不可分。"
: "- D10 上升在当前窗较稳。",
"",
@@ -111,6 +170,11 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
"|---|---|---|",
...(candidateLines.length > 0 ? candidateLines : ["| — | — | 还没有可出示的候选 |"]),
"",
"### 各候选分盘上升(只抄本表)",
"| 时间 | D9 | D10 |",
"|---|---|---|",
...(signRows.length > 0 ? signRows : ["| — | — | 还没有可出示的候选 |"]),
"",
"### 方法1Dasha + Gochara",
fit
? `${fit.user_meaning} ${fit.label}。这是事件拟合程度,不是候选区分程度。`
@@ -122,7 +186,7 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
input.dashaAgreement ? `双轨:${input.dashaAgreement.user_meaning}` : "Narayana 对照未交付时只保留 Vimshottari。",
"",
"### 方法23D9 / D10 类型表",
"写的是「该分钟下分盘升 X,与用户所述特质的对应/冲突」。这是校时方法,不是咨询命运承诺。",
"写的是「该分钟下分盘升 X,与用户所述特质的对应/冲突」。这是校时方法,不是咨询命运承诺。分盘上升只抄本报告 `sign_by_candidate`,不得自行按换升时刻推算。",
...d9Table,
"",
...d10Table,
@@ -147,4 +211,11 @@ export function buildSkillVerificationReport(input: SkillVerificationReportInput
"",
"不得把 VedAstro/holdout 或高吻合写成已校到唯一分钟。",
].join("\n");
return {
width_minutes: input.widthMinutes,
dasha_agreement: input.dashaAgreement ?? null,
sign_by_candidate: signByCandidate,
markdown,
};
}
+38 -10
View File
@@ -57,7 +57,9 @@ import {
isHoldoutVerificationQuote,
isPersistedFocusId,
} from "@/lib/rectification-agentic/v9/choice-card";
import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau";
import { inclusiveClockWidthMinutes, indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau";
import { rankActive } from "@/lib/rectification-agentic/core/convergence-evaluator";
import { unionStillValidRange } from "@/lib/rectification-agentic/core/credible-range";
import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan";
import { scoreAndPersistCurrentEvidence as persistCurrentEvidenceScore } from "@/lib/rectification-agentic/v9/score-persist";
import {
@@ -73,7 +75,7 @@ import {
spokenCollectFallbackFollowup,
collectQuestionDomain,
} from "@/lib/rectification-agentic/v9/method-followup";
import { refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet";
import { dashaAgreementAmongActive, refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet";
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification-label";
import {
@@ -128,7 +130,10 @@ import {
storedSnapshotIsCurrent,
SNAPSHOT_STALE_COPY,
} from "@/lib/rectification-agentic/core/snapshot-source";
import { buildSkillVerificationReport } from "@/lib/rectification-agentic/v9/skill-verification-report";
import {
buildSkillVerificationPacket,
signByCandidateFromTransitions,
} from "@/lib/rectification-agentic/v9/skill-verification-report";
import {
internalObservationsFromWindowScan,
windowScanFromDecisionReceipt,
@@ -458,15 +463,35 @@ export function latestResultToolProjection(
latest as unknown as Record<string, unknown>,
latest.decisionReceipt ?? null,
);
const reportCandidates = candidateProjection.fromInference
? candidateProjection.candidates
: latest.candidates;
const activeTimes = inference
? rankActive(inference.candidates).map((item) => item.time)
: reportCandidates.map((item) => item.time);
const reportRange = inference
? inference.credible_range ?? unionStillValidRange(inference.candidates)
: null;
const reportWidth = reportRange
? inclusiveClockWidthMinutes(reportRange[0], reportRange[1]) ?? width
: width;
const engineDasha = refinement.dasha_agreement;
const reportDasha = inference
? dashaAgreementAmongActive(engineDasha, activeTimes)
: engineDasha;
const signByCandidate = signByCandidateFromTransitions(
activeTimes.length > 0 ? activeTimes : reportCandidates.map((item) => item.time),
windowScan?.transitions ?? [],
);
const base = {
result_id: latest.resultId,
candidates: candidateProjection.fromInference ? candidateProjection.candidates : latest.candidates,
candidates: reportCandidates,
confirmation_allowed: confirmationGate.confirmation_allowed,
representative_time: representativeTime,
selected_time: latest.selectedTime,
selection_kind: latest.selectionKind,
algorithm_version: latest.algorithmVersion,
indistinguishable_width_minutes: width,
engine_indistinguishable_width_minutes: width,
window_scan: windowScan,
confirmation_gate: confirmationGate,
candidate_separation: decision.separation,
@@ -477,7 +502,8 @@ export function latestResultToolProjection(
candidate_contrast_packet: contrastPacket,
event_dasha_ledger: refinement.event_dasha_ledger,
event_fit_rate: refinement.event_fit_rate,
dasha_agreement: refinement.dasha_agreement,
dasha_agreement: reportDasha,
...(inference ? { dasha_agreement_pre_inference: engineDasha } : {}),
lagna_contrast: refinement.lagna_contrast,
nakshatra_boundary: refinement.nakshatra_boundary,
precision_stage: refinement.precision_stage,
@@ -488,15 +514,16 @@ export function latestResultToolProjection(
candidate_contrast_opportunities: refinement.candidate_contrast_opportunities,
unique_minute_claim: false,
candidate_range_not_birth_time_truth: true,
skill_verification_report: buildSkillVerificationReport({
skill_verification_report: buildSkillVerificationPacket({
representativeTime,
widthMinutes: width,
candidates: candidateProjection.fromInference ? candidateProjection.candidates : latest.candidates,
widthMinutes: reportWidth,
candidates: reportCandidates,
houseLagna: chart.houseTable?.lagna ?? null,
windowScan,
eventDashaLedger: refinement.event_dasha_ledger,
eventFitRate: refinement.event_fit_rate,
dashaAgreement: refinement.dasha_agreement,
dashaAgreement: reportDasha,
signByCandidate,
techniqueAuditTable: Array.isArray(latest.decisionReceipt?.technique_audit_table)
? latest.decisionReceipt.technique_audit_table as Array<{
technique?: string;
@@ -551,6 +578,7 @@ function agentVisibleLatestProjection(
evidence_collection_probes: _collect,
candidate_contrast_opportunities: _opportunities,
inference_state: inference,
dasha_agreement_pre_inference: _preDasha,
...rest
} = projection;
const currentQuestion = extras.openQuestion