Once the discriminator training gate is open, only choice cards are asked and the range card goes out when they are exhausted; targeted lines, their re-ask and guided windows no longer hold the card or invite more events. Delivery body says how many choice questions were used instead of the event fit percent; narration names an excluded cluster instead of "range unchanged"; a delivered turn no longer carries a collect question. Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every cell; guided-window injections give the same width in truth and opposite directions, so red line 1 was revised by product to truth-in-range only. Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
223 lines
8.0 KiB
TypeScript
223 lines
8.0 KiB
TypeScript
import {
|
|
isWindowScanDisplayLayer,
|
|
WINDOW_SCAN_DISPLAY_LAYER_ORDER,
|
|
WINDOW_SCAN_SCORING_LAYER_ORDER,
|
|
type WindowScanLayer,
|
|
type WindowScanTransition,
|
|
} from "./rectification-agentic/v9/refinement-packet";
|
|
import type { PrecisionStage, PrecisionStageId } from "./rectification-agentic/v9/refinement-packet";
|
|
import {
|
|
workingRectificationHouseTable,
|
|
type RectificationCandidateResult,
|
|
type RectificationHouseTable,
|
|
} from "./rectification-candidate-result";
|
|
import { boardDeclaredTimePeek } from "./rectification-agentic/birth-time-provenance";
|
|
|
|
export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 768;
|
|
|
|
export type GroupedWindowMinute = Readonly<{
|
|
at: string;
|
|
scoringLayers: readonly WindowScanLayer[];
|
|
displayLayers: readonly WindowScanLayer[];
|
|
}>;
|
|
|
|
export type RectificationBoardDiff = Readonly<{
|
|
houseNumbers: readonly number[];
|
|
lagnaChanged: boolean;
|
|
timeChanged: boolean;
|
|
transitionMinutes: readonly string[];
|
|
liveMessage: string;
|
|
}>;
|
|
|
|
const SCORING_RANK = new Map<WindowScanLayer, number>(
|
|
WINDOW_SCAN_SCORING_LAYER_ORDER.map((layer, index) => [layer, index]),
|
|
);
|
|
const DISPLAY_RANK = new Map<WindowScanLayer, number>(
|
|
WINDOW_SCAN_DISPLAY_LAYER_ORDER.map((layer, index) => [layer, index]),
|
|
);
|
|
|
|
const EMPTY_DIFF: RectificationBoardDiff = {
|
|
houseNumbers: [],
|
|
lagnaChanged: false,
|
|
timeChanged: false,
|
|
transitionMinutes: [],
|
|
liveMessage: "",
|
|
};
|
|
|
|
function sortLayers(
|
|
layers: readonly WindowScanLayer[],
|
|
rank: ReadonlyMap<WindowScanLayer, number>,
|
|
): readonly WindowScanLayer[] {
|
|
return [...layers].sort((left, right) => (rank.get(left) ?? 99) - (rank.get(right) ?? 99));
|
|
}
|
|
|
|
export function groupWindowTransitions(
|
|
transitions: readonly WindowScanTransition[],
|
|
): readonly GroupedWindowMinute[] {
|
|
const byMinute = new Map<string, { scoring: Set<WindowScanLayer>; display: Set<WindowScanLayer> }>();
|
|
for (const item of transitions) {
|
|
const bucket = byMinute.get(item.at) ?? { scoring: new Set<WindowScanLayer>(), display: new Set<WindowScanLayer>() };
|
|
if (isWindowScanDisplayLayer(item.layer)) bucket.display.add(item.layer);
|
|
else bucket.scoring.add(item.layer);
|
|
byMinute.set(item.at, bucket);
|
|
}
|
|
return [...byMinute.entries()]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([at, bucket]) => ({
|
|
at,
|
|
scoringLayers: sortLayers([...bucket.scoring], SCORING_RANK),
|
|
displayLayers: sortLayers([...bucket.display], DISPLAY_RANK),
|
|
}));
|
|
}
|
|
|
|
export function changedHouseNumbers(
|
|
previous: RectificationHouseTable | null,
|
|
next: RectificationHouseTable | null,
|
|
): readonly number[] {
|
|
if (!previous || !next) return [];
|
|
const changed: number[] = [];
|
|
for (let index = 0; index < 12; index += 1) {
|
|
const before = previous.houses[index];
|
|
const after = next.houses[index];
|
|
if (!before || !after) continue;
|
|
if (before.sign !== after.sign || before.occupants.join("\0") !== after.occupants.join("\0")) {
|
|
changed.push(after.house);
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function groupedMinuteKey(row: GroupedWindowMinute): string {
|
|
return `${row.at}|${row.scoringLayers.join(",")}|${row.displayLayers.join(",")}`;
|
|
}
|
|
|
|
export function changedTransitionMinutes(
|
|
previous: readonly WindowScanTransition[],
|
|
next: readonly WindowScanTransition[],
|
|
): readonly string[] {
|
|
const before = new Map(groupWindowTransitions(previous).map((row) => [row.at, groupedMinuteKey(row)]));
|
|
const changed: string[] = [];
|
|
for (const row of groupWindowTransitions(next)) {
|
|
if (before.get(row.at) !== groupedMinuteKey(row)) changed.push(row.at);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function joinHouses(houses: readonly number[]): string {
|
|
return houses.map((house) => `第${house}宫`).join("、");
|
|
}
|
|
|
|
export function rectificationBoardLiveMessage(input: Readonly<{
|
|
previous: RectificationCandidateResult | null;
|
|
next: RectificationCandidateResult | null;
|
|
houseNumbers: readonly number[];
|
|
lagnaChanged: boolean;
|
|
timeChanged: boolean;
|
|
transitionMinutes: readonly string[];
|
|
}>): string {
|
|
const nextTable = input.next ? workingRectificationHouseTable(input.next) : null;
|
|
if (!nextTable) return "";
|
|
if (!input.previous) {
|
|
return `当前本命宫位已按 ${nextTable.time} 排出,上升${nextTable.lagna}。`;
|
|
}
|
|
const parts: string[] = [];
|
|
if (input.timeChanged || input.lagnaChanged) {
|
|
parts.push(`本命宫位已按 ${nextTable.time} 重算(上升 ${nextTable.lagna})。`);
|
|
}
|
|
if (input.houseNumbers.length > 0 && !(input.timeChanged && input.houseNumbers.length === 12)) {
|
|
parts.push(`${joinHouses(input.houseNumbers)}已更新。`);
|
|
}
|
|
if (input.transitionMinutes.length > 0) {
|
|
parts.push("换升时刻已更新。");
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
export function diffRectificationBoard(
|
|
previous: RectificationCandidateResult | null,
|
|
next: RectificationCandidateResult | null,
|
|
): RectificationBoardDiff {
|
|
if (!next) return EMPTY_DIFF;
|
|
const previousTable = previous ? workingRectificationHouseTable(previous) : null;
|
|
const nextTable = workingRectificationHouseTable(next);
|
|
const houseNumbers = changedHouseNumbers(previousTable, nextTable);
|
|
const lagnaChanged = Boolean(previousTable && nextTable && previousTable.lagna !== nextTable.lagna);
|
|
const timeChanged = Boolean(previousTable && nextTable && previousTable.time !== nextTable.time);
|
|
const transitionMinutes = previous
|
|
? changedTransitionMinutes(previous.windowTransitions, next.windowTransitions)
|
|
: [];
|
|
return {
|
|
houseNumbers,
|
|
lagnaChanged,
|
|
timeChanged,
|
|
transitionMinutes,
|
|
liveMessage: rectificationBoardLiveMessage({
|
|
previous,
|
|
next,
|
|
houseNumbers,
|
|
lagnaChanged,
|
|
timeChanged,
|
|
transitionMinutes,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function rectificationBoardPeekCopy(
|
|
result: RectificationCandidateResult | null,
|
|
declaredTime: string | null = null,
|
|
source?: string | null,
|
|
): Readonly<{
|
|
title: string;
|
|
detail: string | null;
|
|
}> {
|
|
const table = result ? workingRectificationHouseTable(result) : null;
|
|
if (!table) {
|
|
// Before any candidate exists the board still has a time to show: the one
|
|
// the reader declared. Without one, say what fills it later.
|
|
return {
|
|
title: "当前盘面",
|
|
detail: declaredTime ? boardDeclaredTimePeek(declaredTime, source) : "补充经历后会在这里更新",
|
|
};
|
|
}
|
|
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
|
|
}
|
|
|
|
export function workingRectificationTime(result: RectificationCandidateResult | null): string | null {
|
|
if (!result) return null;
|
|
return result.selectedTime ?? result.representativeTime ?? workingRectificationHouseTable(result)?.time ?? null;
|
|
}
|
|
|
|
export function houseTableToNorthIndianChart(table: RectificationHouseTable): {
|
|
houses: { houseNumber: number; sign: string; occupants: string[] }[];
|
|
} {
|
|
return {
|
|
houses: table.houses.map((house) => ({
|
|
houseNumber: house.house,
|
|
sign: house.sign,
|
|
occupants: [...house.occupants],
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* BUG-1084 / D1 (2026-09-29): the engine's `precision_stage.user_meaning`
|
|
* ends with 「可再补一件…」, but typed events do not narrow the range once the
|
|
* training gate is open. The board picks its own copy by stage id; the engine
|
|
* text (a frozen scoring file) is not edited and not pattern-matched.
|
|
* `collect_events` is before the gate, so the engine's collect sentence stays.
|
|
*/
|
|
export const PRECISION_STAGE_BOARD_COPY: Readonly<Partial<Record<PrecisionStageId, string>>> = {
|
|
lagna_frame: "本命上升还可能落在两段里。",
|
|
d9_refine: "本命上升已较稳,关系盘仍会换升。",
|
|
d10_refine: "关系盘已较稳,事业盘仍会换升。",
|
|
d4_refine: "事业盘已较稳,居所盘仍会换升。",
|
|
d5_refine: "居所盘已较稳,成就盘或学业盘仍会换升。",
|
|
theme_refine: "核心分盘已较稳,主题盘仍会换升。",
|
|
ready_to_adopt: "核心分盘已不再换升,可以按代表时间看盘。",
|
|
};
|
|
|
|
export function precisionStageBoardCopy(stage: PrecisionStage | null | undefined): string | null {
|
|
if (!stage) return null;
|
|
return PRECISION_STAGE_BOARD_COPY[stage.current] ?? stage.user_meaning ?? null;
|
|
}
|