Files
Jyotisha/frontend/src/lib/conversational-rectification/technical-packet.ts
T
2026-07-23 13:12:09 +08:00

494 lines
19 KiB
TypeScript

import type { CandidateDifferenceBuild } from "../birth-time-dynamic-choice-internal.ts";
import type { CandidateResult } from "../birth-time-evidence.ts";
import type { RectificationQuestionnaire } from "../birth-time-journey-service.ts";
export type RectificationEvidenceDomain =
| "career"
| "education"
| "finance"
| "health_pressure"
| "relocation"
| "relationship"
| "family"
| "other";
export type ServerComputedRectificationConsultation = {
readonly source: "server_consultation_workflow";
readonly calculationVersion: string;
readonly availableLayers: readonly string[];
readonly layerReferences: Readonly<Record<string, readonly string[]>>;
readonly timeLinkedScanSamples: readonly {
readonly sampleIndex: number;
readonly time: string;
}[];
readonly boundaryDistanceMinutes: number | null;
readonly futureWindows: readonly {
readonly label: string;
readonly startDate: string;
readonly endDate: string;
}[];
};
export type RectificationLayerEvidence = {
readonly layer: string;
readonly values: readonly string[];
readonly referenceIds: readonly string[];
};
export type SuggestedEvidenceDomain = {
readonly domain: RectificationEvidenceDomain;
readonly layer: string;
readonly reason: string;
};
export type RectificationTechniqueAuditRow = {
readonly technique: string;
readonly status: "used" | "partial" | "blocked" | "not_evaluated";
readonly evidence: readonly string[];
readonly boundary: string;
};
export type RectificationExpertWorkflow = {
readonly boundary: "not_auto_rectified";
readonly candidateWindows: readonly {
readonly startTime: string;
readonly endTime: string;
readonly status: "pending_validation" | "ready_for_confirmation";
}[];
readonly techniqueAuditTable: readonly RectificationTechniqueAuditRow[];
readonly confirmationAllowed: boolean;
readonly hardBlockers: readonly string[];
readonly gates: Readonly<Record<string, {
readonly status: "pass" | "fail" | "blocked" | "not_evaluated";
readonly reason: string;
}>>;
};
export type RectificationTechnicalPacket = {
readonly calculationVersion: string;
readonly candidate: {
readonly status: "pending_validation" | "ready_for_confirmation";
readonly representativeTime: string;
readonly range: { readonly startTime: string; readonly endTime: string };
};
readonly useBoundary: string;
readonly candidateModelRefs: readonly string[];
readonly candidateDifferenceRefs: readonly string[];
readonly candidateWeights: Readonly<Record<string, number>>;
readonly partitionIds: readonly string[];
readonly d1Stability: "stable" | "sensitive" | "unavailable";
readonly boundaryDistanceMinutes: number | null;
readonly sensitivityScope: {
readonly source: "time_linked_candidate_scan_samples";
readonly rangeStart: string;
readonly rangeEnd: string;
readonly sampleTimes: readonly string[];
};
readonly stableLayers: readonly RectificationLayerEvidence[];
readonly sensitiveLayers: readonly RectificationLayerEvidence[];
readonly supportedSensitiveLayers: readonly string[];
readonly scoredHistoricalEvidence: readonly {
readonly evidenceId: string;
readonly domain: RectificationEvidenceDomain;
readonly candidateTime: string | null;
readonly score: number;
readonly ruleRefs: readonly string[];
}[];
readonly suggestedDomains: readonly SuggestedEvidenceDomain[];
readonly referenceIds: readonly string[];
readonly futureWindows: readonly {
readonly label: string;
readonly startDate: string;
readonly endDate: string;
readonly scoreable: false;
}[];
readonly expertWorkflow?: RectificationExpertWorkflow;
};
type PacketInput = {
readonly scan: RectificationQuestionnaire;
readonly candidateDifferences: CandidateDifferenceBuild;
readonly eventScore: CandidateResult | null;
readonly consultation: ServerComputedRectificationConsultation;
};
type TimeLinkedVargaSample = {
readonly time: string;
readonly sample: RectificationQuestionnaire["samples"][number];
};
export class RectificationTechnicalPacketRangeError extends TypeError {
constructor(readonly reason: "insufficient_samples" | "insufficient_domains") {
super(`rectification candidate range has ${reason.replace("_", " ")}`);
this.name = "RectificationTechnicalPacketRangeError";
}
}
const layerFields = [
["D1", "ascendantSign"],
["D2", "d2Sign"],
["D4", "d4Sign"],
["D9", "d9Sign"],
["D10", "d10Sign"],
["D11", "d11Sign"],
["D24", "d24Sign"],
["D30", "d30Sign"],
["A7", "a7Sign"],
["UL", "ulSign"],
["A10", "a10Sign"],
] as const;
const domainByLayer = {
D9: "relationship",
D2: "finance",
D10: "career",
D11: "finance",
D24: "education",
D4: "relocation",
D30: "health_pressure",
A7: "relationship",
UL: "relationship",
A10: "career",
} as const satisfies Readonly<Record<string, RectificationEvidenceDomain>>;
const domainLabels = {
career: "事业",
education: "教育",
finance: "财富",
health_pressure: "健康与重大压力",
relocation: "迁居",
relationship: "关系",
family: "家庭",
other: "其他",
} as const satisfies Readonly<Record<RectificationEvidenceDomain, string>>;
function unique(values: readonly string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}
function buildExpertWorkflow(
eventScore: CandidateResult | null,
range: RectificationTechnicalPacket["candidate"]["range"],
status: RectificationTechnicalPacket["candidate"]["status"],
): RectificationExpertWorkflow {
const receipt = eventScore?.techniqueReceipt;
const row = (
technique: string,
used: boolean,
evidence: readonly string[],
boundary: string,
unavailableStatus: RectificationTechniqueAuditRow["status"] = "not_evaluated",
): RectificationTechniqueAuditRow => ({
technique,
status: used ? "used" : unavailableStatus,
evidence,
boundary,
});
const missingLayers = receipt?.missingLayers ?? [];
const shadbalaPartial = missingLayers.some((layer) => layer.toLowerCase().includes("shadbala"));
const strengthLayers = receipt?.auxiliaryLayers.filter((layer) => /shadbala|ashtakavarga/i.test(layer)) ?? [];
return {
boundary: "not_auto_rectified",
candidateWindows: [{ ...range, status }],
techniqueAuditTable: [
row(
"Vimshottari Dasha",
receipt?.dashaTracks.some((track) => track.includes("vimshottari")) === true,
receipt?.dashaTracks.filter((track) => track.includes("vimshottari")) ?? [],
"用于已发生事件的时间匹配,不单独确认出生分钟。",
),
row(
"Narayana Dasha",
receipt?.dashaTracks.some((track) => track.includes("narayana")) === true,
receipt?.dashaTracks.filter((track) => track.includes("narayana")) ?? [],
"作为第二条时序轨交叉检查,不替代分钟稳定性门禁。",
),
row(
"UL / A7 / A10",
(receipt?.usedArudha.length ?? 0) > 0,
receipt?.usedArudha ?? [],
"只在对应关系或事业事件中参与候选比较。",
),
row(
"Divisional charts",
(receipt?.usedDivisionalCharts.length ?? 0) > 0,
receipt?.usedDivisionalCharts ?? [],
"分盘只用于区分候选范围,不能把范围中点当作准确分钟。",
),
{
technique: "Shadbala / Ashtakavarga",
status: shadbalaPartial ? "partial" : strengthLayers.length > 0 ? "used" : "not_evaluated",
evidence: unique([...strengthLayers, ...missingLayers.filter((layer) => /shadbala|ashtakavarga/i.test(layer))]),
boundary: shadbalaPartial
? "仅使用已验证组件;缺失组件不得补算或包装成完整闭环。"
: "仅作为辅助权重,不单独确认出生分钟。",
},
row(
"Functional Benefic / Malefic",
receipt?.auxiliaryLayers.includes("functional_benefic_malefic") === true,
receipt?.auxiliaryLayers.filter((layer) => layer === "functional_benefic_malefic") ?? [],
"仅用于解释事件与候选的相容性。",
),
{
technique: "KP cusp / sub-lord",
status: "blocked",
evidence: [],
boundary: "当前服务端生时校正评分合同未提供可审计的 KP cusp 结果,禁止声称已使用。",
},
{
technique: "Minute confirmation",
status: receipt?.confirmationAllowed === true ? "used" : "blocked",
evidence: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
boundary: receipt?.confirmationAllowed === true
? "仍须用户明确确认后才能替换当前排盘时间。"
: "公开 AA 分钟 holdout 与盲测门禁未通过前,不得自动确认分钟。",
},
],
confirmationAllowed: receipt?.confirmationAllowed === true,
hardBlockers: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
gates: receipt?.gates ?? {},
};
}
function timeToMinute(value: string): number {
const [hour = 0, minute = 0] = value.split(":").map(Number);
return hour * 60 + minute;
}
function minuteToTime(value: number): string {
const normalized = ((value % 1_440) + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
function sampleClockTime(value: unknown): string | null {
if (typeof value !== "string") return null;
const match = value.trim().match(
/(?:^|[T\s])(([01]\d|2[0-3]):[0-5]\d)(?::[0-5]\d)?(?:Z|[+-]\d{2}:?\d{2})?$/,
);
return match?.[1] ?? null;
}
function timeIsInsideRange(time: string, startTime: string, endTime: string): boolean {
const minute = timeToMinute(time);
const start = timeToMinute(startTime);
const end = timeToMinute(endTime);
return end >= start
? minute >= start && minute <= end
: minute >= start || minute <= end;
}
function isNextMinute(previous: string, current: string): boolean {
return timeToMinute(current) === (timeToMinute(previous) + 1) % 1_440;
}
function timeLinkedSamples(
scan: RectificationQuestionnaire,
links: ServerComputedRectificationConsultation["timeLinkedScanSamples"],
): readonly TimeLinkedVargaSample[] {
const byTime = new Map<string, TimeLinkedVargaSample>();
const linkedIndexes = new Set<number>();
for (const link of links) {
const sample = scan.samples[link.sampleIndex];
const time = sampleClockTime(link.time);
if (!Number.isInteger(link.sampleIndex) || link.sampleIndex < 0 || !sample || !time) {
throw new TypeError("rectification packet received an invalid time-linked scan sample");
}
if (linkedIndexes.has(link.sampleIndex) || byTime.has(time)) {
throw new TypeError("rectification packet received duplicate time-linked scan samples");
}
linkedIndexes.add(link.sampleIndex);
byTime.set(time, { time, sample });
}
return [...byTime.values()];
}
function midpoint(startTime: string, endTime: string): string {
const start = timeToMinute(startTime);
let end = timeToMinute(endTime);
if (end < start) end += 1_440;
return minuteToTime(Math.round((start + end) / 2));
}
function candidateWeights(model: Readonly<Record<string, unknown>>): Readonly<Record<string, number>> {
const raw = model.candidateWeights ?? model.candidate_weights;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const entries: [string, number][] = [];
for (const [time, weight] of Object.entries(raw)) {
if (typeof weight === "number" && Number.isFinite(weight) && weight >= 0) {
entries.push([time, weight]);
}
}
return Object.fromEntries(entries.sort((left, right) => left[0].localeCompare(right[0])));
}
function eventDomain(domain: CandidateResult["evidence"][number]["domain"]): RectificationEvidenceDomain {
return domain;
}
function layerEvidence(
samples: readonly RectificationQuestionnaire["samples"][number][],
consultation: ServerComputedRectificationConsultation,
): RectificationLayerEvidence[] {
return layerFields.map(([layer, field]) => ({
layer,
values: unique(samples.map((sample) => sample[field] ?? "")),
referenceIds: unique(consultation.layerReferences[layer] ?? []),
})).filter((item) => item.values.length > 0);
}
function suggestedDomains(
layers: readonly RectificationLayerEvidence[],
samples: readonly TimeLinkedVargaSample[],
): SuggestedEvidenceDomain[] {
const ranked = layers.flatMap((item) => {
const domain = domainByLayer[item.layer as keyof typeof domainByLayer];
if (!domain) return [];
const field = layerFields.find(([layer]) => layer === item.layer)?.[1];
const values = field ? samples.map(({ sample }) => sample[field] ?? "") : [];
const adjacentChanges = values.slice(1).filter((value, index) => (
isNextMinute(samples[index]?.time ?? "", samples[index + 1]?.time ?? "")
&& value.length > 0
&& Boolean(values[index]?.length)
&& value !== values[index]
)).length;
const observedRangeDifference = item.values.length > 1;
if (adjacentChanges === 0 && !observedRangeDifference) return [];
return [{
domain,
layer: item.layer,
adjacentChanges,
valueCount: item.values.length,
reason: adjacentChanges > 0
? `${item.layer} 在相邻候选分钟中发生 ${adjacentChanges} 次实际切换,并呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件区分。`
: `${item.layer} 在候选范围内已扫描的时点呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件继续区分;这不代表相邻分钟已经发生切换。`,
}];
}).sort((left, right) => right.adjacentChanges - left.adjacentChanges
|| right.valueCount - left.valueCount
|| layerFields.findIndex(([layer]) => layer === left.layer)
- layerFields.findIndex(([layer]) => layer === right.layer));
const selected = new Set<RectificationEvidenceDomain>();
return ranked.flatMap((item) => {
if (selected.has(item.domain)) return [];
selected.add(item.domain);
return [{ domain: item.domain, layer: item.layer, reason: item.reason }];
});
}
export function buildRectificationTechnicalPacket(input: PacketInput): RectificationTechnicalPacket {
if (input.consultation.source !== "server_consultation_workflow") {
throw new TypeError("rectification packet requires server-computed consultation data");
}
const eventSegment = input.eventScore?.winningSegment;
const range = eventSegment
? { startTime: eventSegment.startTime, endTime: eventSegment.endTime }
: input.candidateDifferences.packet.currentRange;
const representativeTime = eventSegment?.representativeTime
?? midpoint(range.startTime, range.endTime);
const selectedSamples = timeLinkedSamples(input.scan, input.consultation.timeLinkedScanSamples)
.filter((item) => timeIsInsideRange(item.time, range.startTime, range.endTime))
.sort((left, right) => (
(timeToMinute(left.time) - timeToMinute(range.startTime) + 1_440) % 1_440
- (timeToMinute(right.time) - timeToMinute(range.startTime) + 1_440) % 1_440
));
if (selectedSamples.length < 2) {
throw new RectificationTechnicalPacketRangeError("insufficient_samples");
}
const layers = layerEvidence(selectedSamples.map((item) => item.sample), input.consultation);
const d1 = layers.find((item) => item.layer === "D1");
const d1Stability = !d1 ? "unavailable" : d1.values.length === 1 ? "stable" : "sensitive";
const available = new Set(input.consultation.availableLayers);
const sensitiveLayers = layers.filter((item) => item.layer !== "D1"
&& item.values.length > 1
&& available.has(item.layer));
const domains = suggestedDomains(sensitiveLayers, selectedSamples);
if (domains.length < 2) {
throw new RectificationTechnicalPacketRangeError("insufficient_domains");
}
const scoredHistoricalEvidence = (input.eventScore?.evidence ?? []).map((item) => ({
evidenceId: item.eventId,
domain: eventDomain(item.domain),
candidateTime: item.candidateTime ?? null,
score: item.points,
ruleRefs: [...item.ruleIds],
}));
const scanRange = input.candidateDifferences.packet.currentRange;
const selectedRangeMatchesScan = range.startTime === scanRange.startTime
&& range.endTime === scanRange.endTime;
const opportunityRefs = selectedRangeMatchesScan
? input.candidateDifferences.packet.opportunities.map((item) => item.opportunityId)
: [];
const ruleRefs = scoredHistoricalEvidence.flatMap((item) => item.ruleRefs);
const layerRefs = layers.flatMap((item) => item.referenceIds);
const modelVersion = input.candidateDifferences.candidateModel.version;
const candidateModelRefs = unique([
input.candidateDifferences.packet.scoringVersion,
typeof modelVersion === "string" ? modelVersion : "",
input.eventScore?.algorithmVersion ?? "",
]);
const partitionIds = unique(Object.values(input.candidateDifferences.scoringPartitions)
.flatMap((partitions) => partitions.map((partition) => partition.partitionId)));
const candidateStatus = input.eventScore?.canApply ? "ready_for_confirmation" : "pending_validation";
return {
calculationVersion: input.consultation.calculationVersion,
candidate: {
status: candidateStatus,
representativeTime,
range,
},
useBoundary: input.eventScore?.canApply
? "该候选已达到确认门槛,但必须由用户明确确认后才能替换当前排盘时间。"
: "该时间与范围仅是待验证候选,可用于比较稳定层和分钟敏感层,不能视为出生记录中的确定分钟。",
candidateModelRefs,
candidateDifferenceRefs: unique([...opportunityRefs, ...ruleRefs, ...layerRefs]),
candidateWeights: candidateWeights(input.candidateDifferences.candidateModel),
partitionIds,
d1Stability,
boundaryDistanceMinutes: input.consultation.boundaryDistanceMinutes,
sensitivityScope: {
source: "time_linked_candidate_scan_samples",
rangeStart: range.startTime,
rangeEnd: range.endTime,
sampleTimes: selectedSamples.map((item) => item.time),
},
stableLayers: d1Stability === "stable" && d1 ? [d1] : [],
sensitiveLayers,
supportedSensitiveLayers: sensitiveLayers.map((item) => item.layer),
scoredHistoricalEvidence,
suggestedDomains: domains.slice(0, 4),
referenceIds: unique([...opportunityRefs, ...ruleRefs, ...layerRefs]),
futureWindows: input.consultation.futureWindows.map((window) => ({ ...window, scoreable: false })),
expertWorkflow: buildExpertWorkflow(input.eventScore, range, candidateStatus),
};
}
export function projectRectificationTechnicalPacket(packet: RectificationTechnicalPacket) {
return {
candidate: {
status: packet.candidate.status,
representativeTime: packet.candidate.representativeTime,
rangeStart: packet.candidate.range.startTime,
rangeEnd: packet.candidate.range.endTime,
},
useBoundary: packet.useBoundary,
technicalReceipt: {
calculationVersion: packet.calculationVersion,
stableLayers: packet.stableLayers.map((item) => item.layer),
sensitiveLayers: [...packet.supportedSensitiveLayers],
sensitivityScope: {
...packet.sensitivityScope,
sampleTimes: [...packet.sensitivityScope.sampleTimes],
},
candidateDifferenceRefs: packet.candidateDifferenceRefs
.filter((reference) => reference.trim().length > 0 && reference.length <= 120)
.slice(0, 40),
},
evidenceRequest: {
domains: packet.suggestedDomains.map((item) => item.domain),
datePrecision: "month_preferred" as const,
freeTextAllowed: true as const,
},
futureWindows: packet.futureWindows.map((window) => ({ ...window })),
};
}