/** * Public-safe P0/P1 refinement fields from the decision receipt. * * Scores, weights and event IDs stay out of public copy. D9/D10 sign names * may appear for the skill type-table verification report. D1 sign names may * appear on lagna contrast, matching the natal house table. */ import { distinguishContractErrors } from "../core/distinguish-contract.ts"; const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const DENIED = /lon|latitude|longitude|degree|score|weight|fingerprint|\bpoints\b/i; const LAYER_LABEL: Record = { d1: "本命上升", d9: "D9", d10: "D10", d4: "D4", d5: "D5", d7: "D7", d12: "D12", d24: "D24", d2: "D2", d11: "D11", d30: "D30", pada: "Nakshatra pada", hora: "Hora Lagna", ghati: "Ghati Lagna", bhava: "Bhava Lagna", pranapada: "Pranapada Lagna", kp1: "KP 1宫子主", kp4: "KP 4宫子主", kp7: "KP 7宫子主", kp10: "KP 10宫子主", }; export const WINDOW_SCAN_SCORING_LAYER_ORDER = [ "d1", "d9", "d10", "d4", "d5", "d7", "d12", "d24", "d2", "d11", "d30", ] as const satisfies readonly WindowScanLayer[]; export const WINDOW_SCAN_DISPLAY_LAYER_ORDER = [ "pada", "hora", "ghati", "bhava", "pranapada", "kp1", "kp4", "kp7", "kp10", ] as const satisfies readonly WindowScanLayer[]; export function windowScanLayerLabel(layer: WindowScanLayer): string { return LAYER_LABEL[layer]; } export function isWindowScanDisplayLayer(layer: WindowScanLayer): boolean { return (WINDOW_SCAN_DISPLAY_LAYER_ORDER as readonly WindowScanLayer[]).includes(layer); } export type WindowScanLayer = | "d1" | "d9" | "d10" | "d4" | "d5" | "d7" | "d12" | "d24" | "d2" | "d11" | "d30" | "pada" | "hora" | "ghati" | "bhava" | "pranapada" | "kp1" | "kp4" | "kp7" | "kp10"; export type WindowScanTransition = Readonly<{ layer: WindowScanLayer; at: string; user_meaning: string; from_sign?: string; to_sign?: string; }>; export type EventDashaLedgerRow = Readonly<{ summary: string; match: "strong" | "medium" | "weak" | "none"; match_label: string; user_meaning: string; gochara: "activated" | "not_seen" | null; }>; export type DashaAgreement = Readonly<{ status: "agree" | "conflict" | "partial" | "unavailable"; vimshottari_top: string | null; narayana_top: string | null; user_meaning: string; }>; export type LagnaContrastInterval = Readonly<{ start: string; end: string; lagna: string; lords: Readonly<{ l1: string | null; l4: string | null; l7: string | null; l10: string | null }>; }>; export type LagnaContrast = Readonly<{ intervals: readonly LagnaContrastInterval[]; user_meaning: string; unique_minute_claim: false; }>; export type NakshatraOption = Readonly<{ key: "A" | "B"; time_bias: "earlier" | "later"; traits: readonly string[]; }>; export type NakshatraBoundary = Readonly<{ near_boundary: boolean; user_meaning: string | null; options: readonly NakshatraOption[]; }>; export type PrecisionStageId = | "collect_events" | "lagna_frame" | "d9_refine" | "d10_refine" | "d4_refine" | "d5_refine" | "theme_refine" | "ready_to_adopt"; export type PrecisionStage = Readonly<{ current: PrecisionStageId; can_stop: boolean; user_meaning: string; unique_minute_claim: false; }>; export type OosBlindPrompt = Readonly<{ domain: string; user_meaning: string; used_for_scoring: false; }>; export const EVENT_PROBE_DOMAINS = [ "education", "relocation", "relationship", "career", "family", "finance", "health_pressure", ] as const; export type EventProbeDomain = (typeof EVENT_PROBE_DOMAINS)[number]; export type EventProbeSource = | "dasha_activation" | "dasha_boundary" | "age_band" | "known_event_quality" | "oos_blind"; export type EventProbeRole = "distinguish" | "reverse_verify" | "clarify" | "collect" | "holdout"; export type ProbePhase = | "evidence_collection" | "event_clarification" | "candidate_discriminator" | "holdout_validation"; export type EventProbeChoiceKind = "existence" | "varga_style" | "event_quality"; export type EventProbeStyleOption = Readonly<{ label: string; answer_class: string; sign?: string; }>; export type ProbeExpectedOutcome = Readonly<{ answer_class: string; supports: readonly string[]; conflicts: readonly string[]; }>; export type DiscriminatingEventProbe = Readonly<{ year: number; year_label: string; month?: number; domain: EventProbeDomain; event_family: string; source: EventProbeSource; tracks: readonly ("vimshottari" | "narayana")[]; tracks_agree: boolean; unique_minute_claim: false; user_meaning: string; role: EventProbeRole; phase?: ProbePhase; information_gain?: number; semantic_key?: string; candidate_split_hash?: string; candidate_set_version?: string; candidate_ids?: readonly string[]; expected_outcomes?: readonly ProbeExpectedOutcome[]; left_time?: string; right_time?: string; choice_kind?: EventProbeChoiceKind; style_options?: readonly EventProbeStyleOption[]; }>; export type CandidateContrastOpportunity = Readonly<{ domain: string; time_window: Readonly<{ year: number | null; year_label: string | null }>; candidate_groups: readonly (readonly string[])[]; expected_outcomes: readonly ProbeExpectedOutcome[]; information_gain: number; source_features: readonly Readonly<{ technique: string; layers?: readonly string[] }>[]; semantic_key?: string; candidate_split_hash?: string; candidate_set_version?: string; event_family?: string; phase: "candidate_discriminator"; }>; function asRecord(value: unknown): Readonly> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Readonly> : null; } function asTime(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.slice(0, 5); return TIME.test(normalized) ? normalized : null; } function asText(value: unknown, maximum = 160): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed || trimmed.length > maximum || DENIED.test(trimmed) || UUID.test(trimmed)) return null; return trimmed; } export function parseWindowScanTransitions(value: unknown): readonly WindowScanTransition[] { if (!Array.isArray(value)) return []; const rows: WindowScanTransition[] = []; const seen = new Set(); for (const item of value) { const row = asRecord(item); const layer = row?.layer; const at = asTime(row?.at); if ( !row || ( layer !== "d1" && layer !== "d9" && layer !== "d10" && layer !== "d4" && layer !== "d5" && layer !== "d7" && layer !== "d12" && layer !== "d24" && layer !== "d2" && layer !== "d11" && layer !== "d30" && layer !== "pada" && layer !== "hora" && layer !== "ghati" && layer !== "bhava" && layer !== "pranapada" && layer !== "kp1" && layer !== "kp4" && layer !== "kp7" && layer !== "kp10" ) || !at ) continue; const key = `${layer}:${at}`; if (seen.has(key)) continue; seen.add(key); rows.push({ layer, at, user_meaning: `${LAYER_LABEL[layer]} 在 ${at} 发生变化`, ...(asText(row.from_sign, 12) ? { from_sign: asText(row.from_sign, 12)! } : {}), ...(asText(row.to_sign, 12) ? { to_sign: asText(row.to_sign, 12)! } : {}), }); } return rows; } export function parseEventDashaLedger(value: unknown): readonly EventDashaLedgerRow[] { if (!Array.isArray(value)) return []; const rows: EventDashaLedgerRow[] = []; for (const item of value) { const row = asRecord(item); const match = row?.match; if ( !row || (match !== "strong" && match !== "medium" && match !== "weak" && match !== "none") ) continue; const summary = asText(row.summary, 80); if (!summary) continue; const matchLabel = match === "strong" ? "强相关" : match === "medium" ? "有关联" : match === "weak" ? "弱关联" : "未见对应"; const gochara = row.gochara === "activated" || row.gochara === "not_seen" ? row.gochara : null; const gocharaNote = gochara === "activated" ? ";Gochara 激活相关宫" : gochara === "not_seen" ? ";Gochara 未见对应" : ""; rows.push({ summary, match, match_label: matchLabel, user_meaning: `${summary}:${matchLabel}${gocharaNote}`, gochara, }); } return rows; } export function parseDashaAgreement(value: unknown): DashaAgreement | null { const row = asRecord(value); const status = row?.status; if ( !row || (status !== "agree" && status !== "conflict" && status !== "partial" && status !== "unavailable") ) return null; const meaning = asText(row.user_meaning, 220) ?? (status === "conflict" ? "主限和分盘大运偏向不同时间。冲突时不能按更高把握收口。" : status === "agree" ? "主限和分盘大运都更支持同一段代表性时间。这仍不是唯一分钟确认。" : "还没有足够的大运对照。"); return { status, vimshottari_top: asTime(row.vimshottari_top), narayana_top: asTime(row.narayana_top), user_meaning: meaning, }; } function parseLords(value: unknown): LagnaContrastInterval["lords"] | null { const row = asRecord(value); if (!row) return null; const read = (key: "l1" | "l4" | "l7" | "l10"): string | null => { const label = asText(row[key], 12); return label; }; return { l1: read("l1"), l4: read("l4"), l7: read("l7"), l10: read("l10") }; } export function parseLagnaContrast(value: unknown): LagnaContrast | null { const row = asRecord(value); if (!row || !Array.isArray(row.intervals) || row.intervals.length < 2) return null; const intervals: LagnaContrastInterval[] = []; for (const item of row.intervals.slice(0, 3)) { const interval = asRecord(item); const start = asTime(interval?.start); const end = asTime(interval?.end); const lagna = asText(interval?.lagna, 12); const lords = parseLords(interval?.lords); if (!interval || !start || !end || !lagna || !lords) return null; intervals.push({ start, end, lagna, lords }); } if (intervals.length < 2) return null; const meaning = asText(row.user_meaning, 220) ?? `窗口里出现两段本命上升:${intervals[0].start}-${intervals[0].end} 为${intervals[0].lagna},${intervals[1].start}-${intervals[1].end} 为${intervals[1].lagna}。`; return { intervals, user_meaning: meaning, unique_minute_claim: false, }; } export function parseNakshatraBoundary(value: unknown): NakshatraBoundary | null { const row = asRecord(value); if (!row || typeof row.near_boundary !== "boolean") return null; if (!row.near_boundary) { return { near_boundary: false, user_meaning: null, options: [] }; } const options: NakshatraOption[] = []; if (Array.isArray(row.options)) { for (const item of row.options) { const option = asRecord(item); const key = option?.key; const bias = option?.time_bias; if ( !option || (key !== "A" && key !== "B") || (bias !== "earlier" && bias !== "later") || !Array.isArray(option.traits) ) continue; const traits = option.traits .map((trait) => asText(trait, 40)) .filter((trait): trait is string => Boolean(trait)) .slice(0, 2); if (traits.length === 0) continue; options.push({ key, time_bias: bias, traits }); } } if (options.length < 2) return { near_boundary: false, user_meaning: null, options: [] }; return { near_boundary: true, user_meaning: asText(row.user_meaning, 400) ?? "升点靠近两段日常节奏的交界。哪一组更像你近年的处事方式?这只用来偏置时间窗,不能确认唯一分钟。", options: options.slice(0, 2), }; } export function parsePrecisionStage(value: unknown): PrecisionStage | null { const row = asRecord(value); const current = row?.current; if ( !row || ( current !== "collect_events" && current !== "lagna_frame" && current !== "d9_refine" && current !== "d10_refine" && current !== "d4_refine" && current !== "d5_refine" && current !== "theme_refine" && current !== "ready_to_adopt" ) ) return null; const meaning = asText(row.user_meaning, 220) ?? "继续用带日期的经历缩小窗口。"; return { current, can_stop: row.can_stop === true || current === "ready_to_adopt", user_meaning: meaning, unique_minute_claim: false, }; } const EVENT_PROBE_DOMAIN_SET = new Set(EVENT_PROBE_DOMAINS); const EVENT_PROBE_SOURCES = new Set([ "dasha_activation", "dasha_boundary", "age_band", "known_event_quality", "oos_blind", ]); const PROBE_PHASES = new Set([ "evidence_collection", "event_clarification", "candidate_discriminator", "holdout_validation", ]); const CLOCK_IN_COPY = /(?:[01]?\d|2[0-3]):[0-5]\d/; function parseExpectedOutcomes(value: unknown): ProbeExpectedOutcome[] { if (!Array.isArray(value)) return []; return value.flatMap((item) => { const outcome = asRecord(item); const answer = typeof outcome?.answer_class === "string" ? outcome.answer_class : ""; if (!outcome || !answer) return []; return [{ answer_class: answer, supports: Array.isArray(outcome.supports) ? outcome.supports.filter((entry): entry is string => typeof entry === "string") : [], conflicts: Array.isArray(outcome.conflicts) ? outcome.conflicts.filter((entry): entry is string => typeof entry === "string") : [], }]; }); } function parseProbeRole(row: Readonly>, source: string): EventProbeRole { if (row.role === "clarify" || source === "known_event_quality") return "clarify"; if (row.role === "collect" || source === "age_band") return "collect"; if (row.role === "holdout" || source === "oos_blind") return "holdout"; if (row.role === "distinguish" || row.phase === "candidate_discriminator") return "distinguish"; return "reverse_verify"; } export function parseEventProbes( value: unknown, options: { allowQuality?: boolean; requireDistinguishContract?: boolean } = {}, ): readonly DiscriminatingEventProbe[] { if (!Array.isArray(value)) return []; const rows: DiscriminatingEventProbe[] = []; for (const item of value) { const row = asRecord(item); const year = typeof row?.year === "number" && Number.isInteger(row.year) ? row.year : null; const domain = typeof row?.domain === "string" ? row.domain : ""; const source = typeof row?.source === "string" ? row.source : ""; const family = asText(row?.event_family, 80); const meaning = asText(row?.user_meaning, 160); const label = asText(row?.year_label, 40); const month = typeof row?.month === "number" && Number.isInteger(row.month) && row.month >= 1 && row.month <= 12 ? row.month : null; if ( !row || year === null || year < 1900 || year > 2100 || !EVENT_PROBE_DOMAIN_SET.has(domain) || !EVENT_PROBE_SOURCES.has(source) || !family || !meaning || !label || CLOCK_IN_COPY.test(meaning) || CLOCK_IN_COPY.test(family) || row.unique_minute_claim === true ) continue; if (!options.allowQuality && source === "known_event_quality") continue; const tracks = Array.isArray(row.tracks) ? row.tracks.filter((track): track is "vimshottari" | "narayana" => track === "vimshottari" || track === "narayana") : []; const expectedOutcomes = parseExpectedOutcomes(row.expected_outcomes); const candidateIds = Array.isArray(row.candidate_ids) ? row.candidate_ids.filter((entry): entry is string => typeof entry === "string" && TIME.test(entry.slice(0, 5))) : []; const probe: DiscriminatingEventProbe = { year, year_label: label, ...(month ? { month } : {}), domain: domain as EventProbeDomain, event_family: family, source: source as EventProbeSource, tracks, tracks_agree: row.tracks_agree === true, unique_minute_claim: false, user_meaning: meaning, role: parseProbeRole(row, source), ...(typeof row.phase === "string" && PROBE_PHASES.has(row.phase) ? { phase: row.phase as ProbePhase } : {}), ...(typeof row.information_gain === "number" && Number.isFinite(row.information_gain) ? { information_gain: row.information_gain } : {}), ...(asText(row.semantic_key, 80) ? { semantic_key: asText(row.semantic_key, 80)! } : {}), ...(asText(row.candidate_split_hash, 120) ? { candidate_split_hash: asText(row.candidate_split_hash, 120)! } : {}), ...(asText(row.candidate_set_version, 40) ? { candidate_set_version: asText(row.candidate_set_version, 40)! } : {}), ...(candidateIds.length > 0 ? { candidate_ids: candidateIds } : {}), ...(asTime(row.left_time) ? { left_time: asTime(row.left_time)! } : {}), ...(asTime(row.right_time) ? { right_time: asTime(row.right_time)! } : {}), ...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality" ? { choice_kind: row.choice_kind } : {}), ...(Array.isArray(row.style_options) ? { style_options: row.style_options.flatMap((optionItem) => { const option = asRecord(optionItem); const optionLabel = asText(option?.label, 80); const answer = typeof option?.answer_class === "string" ? option.answer_class : ""; if (!option || !optionLabel || !answer) return []; return [{ label: optionLabel, answer_class: answer, ...(asText(option.sign, 12) ? { sign: asText(option.sign, 12)! } : {}), }]; }), } : {}), ...(expectedOutcomes.length > 0 ? { expected_outcomes: expectedOutcomes } : {}), }; if (options.requireDistinguishContract && distinguishContractErrors({ ...probe, role: "distinguish" }).length > 0) continue; rows.push(probe); if (rows.length === 8) break; } return rows; } export function parseDiscriminatingEventProbes(value: unknown): readonly DiscriminatingEventProbe[] { return parseEventProbes(value, { requireDistinguishContract: true }); } export function parseClarificationProbes(value: unknown): readonly DiscriminatingEventProbe[] { return parseEventProbes(value, { allowQuality: true }).filter((item) => ( item.source === "known_event_quality" || item.phase === "event_clarification" || item.role === "clarify" )); } export function parseCollectionProbes(value: unknown): readonly DiscriminatingEventProbe[] { return parseEventProbes(value).filter((item) => ( item.phase === "evidence_collection" || item.role === "collect" || item.source === "age_band" )); } export function parseCandidateContrastOpportunities(value: unknown): readonly CandidateContrastOpportunity[] { if (!Array.isArray(value)) return []; const rows: CandidateContrastOpportunity[] = []; for (const item of value) { const row = asRecord(item); if (!row) continue; const domain = typeof row.domain === "string" ? row.domain : ""; const gain = typeof row.information_gain === "number" && Number.isFinite(row.information_gain) ? row.information_gain : 0; const window = asRecord(row.time_window); const groups = Array.isArray(row.candidate_groups) ? row.candidate_groups.flatMap((group) => ( Array.isArray(group) ? [group.filter((entry): entry is string => typeof entry === "string")] : [] )) : []; const outcomes = parseExpectedOutcomes(row.expected_outcomes); if (!domain || gain <= 0 || groups.length < 2 || outcomes.length < 2) continue; rows.push({ domain, time_window: { year: typeof window?.year === "number" && Number.isInteger(window.year) ? window.year : null, year_label: asText(window?.year_label, 40), }, candidate_groups: groups, expected_outcomes: outcomes, information_gain: gain, source_features: Array.isArray(row.source_features) ? row.source_features.flatMap((feature) => { const parsed = asRecord(feature); const technique = asText(parsed?.technique, 80); if (!parsed || !technique) return []; return [{ technique, ...(Array.isArray(parsed.layers) ? { layers: parsed.layers.filter((layer): layer is string => typeof layer === "string") } : {}), }]; }) : [], phase: "candidate_discriminator", ...(asText(row.semantic_key, 80) ? { semantic_key: asText(row.semantic_key, 80)! } : {}), ...(asText(row.candidate_split_hash, 120) ? { candidate_split_hash: asText(row.candidate_split_hash, 120)! } : {}), ...(asText(row.candidate_set_version, 40) ? { candidate_set_version: asText(row.candidate_set_version, 40)! } : {}), ...(asText(row.event_family, 80) ? { event_family: asText(row.event_family, 80)! } : {}), }); if (rows.length === 8) break; } return rows; } export function parseOosBlindPrompts(value: unknown): readonly OosBlindPrompt[] { if (!Array.isArray(value)) return []; const rows: OosBlindPrompt[] = []; const allowed = new Set(["relationship", "career", "family", "education", "finance", "health_pressure"]); for (const item of value) { const row = asRecord(item); const domain = typeof row?.domain === "string" ? row.domain : ""; const meaning = asText(row?.user_meaning, 180); if (!row || !allowed.has(domain) || !meaning) continue; rows.push({ domain, user_meaning: meaning, used_for_scoring: false }); if (rows.length === 3) break; } return rows; } export type EventFitRate = Readonly<{ matched: number; total: number; percent: number | null; label: string; user_meaning: string; unique_minute_claim: false; }>; export function parseEventFitRate(value: unknown): EventFitRate | null { const row = asRecord(value); if (!row) return null; const matched = typeof row.matched === "number" && Number.isInteger(row.matched) ? row.matched : null; const total = typeof row.total === "number" && Number.isInteger(row.total) ? row.total : null; if (matched === null || total === null) return null; const percent = typeof row.percent === "number" && Number.isFinite(row.percent) ? row.percent : null; return { matched, total, percent, label: asText(row.label, 80) ?? "事件吻合率", user_meaning: asText(row.user_meaning, 280) ?? "这是相对拟合,不是已确认唯一出生分钟。", unique_minute_claim: false, }; } export function refinementFromDecisionReceipt( receipt: Readonly> | null | undefined, ): { event_dasha_ledger: readonly EventDashaLedgerRow[]; event_fit_rate: EventFitRate | null; dasha_agreement: DashaAgreement | null; lagna_contrast: LagnaContrast | null; nakshatra_boundary: NakshatraBoundary | null; precision_stage: PrecisionStage | null; oos_blind_prompts: readonly OosBlindPrompt[]; discriminating_event_probes: readonly DiscriminatingEventProbe[]; event_clarification_probes: readonly DiscriminatingEventProbe[]; evidence_collection_probes: readonly DiscriminatingEventProbe[]; candidate_contrast_opportunities: readonly CandidateContrastOpportunity[]; } { return { event_dasha_ledger: parseEventDashaLedger(receipt?.event_dasha_ledger), event_fit_rate: parseEventFitRate(receipt?.event_fit_rate), dasha_agreement: parseDashaAgreement(receipt?.dasha_agreement), lagna_contrast: parseLagnaContrast(receipt?.lagna_contrast), nakshatra_boundary: parseNakshatraBoundary(receipt?.nakshatra_boundary), precision_stage: parsePrecisionStage(receipt?.precision_stage), oos_blind_prompts: parseOosBlindPrompts(receipt?.oos_blind_prompts), discriminating_event_probes: parseDiscriminatingEventProbes(receipt?.discriminating_event_probes), event_clarification_probes: parseClarificationProbes( receipt?.event_clarification_probes ?? receipt?.discriminating_event_probes, ), evidence_collection_probes: parseCollectionProbes( receipt?.evidence_collection_probes ?? receipt?.discriminating_event_probes, ), candidate_contrast_opportunities: parseCandidateContrastOpportunities(receipt?.candidate_contrast_opportunities), }; }