Files
Jyotisha/frontend/src/lib/rectification-board-model.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
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
2026-09-29 10:06:57 +08:00

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;
}