Files
Jyotisha/frontend/src/lib/rectification-agentic/nearest-boundary.ts
T

155 lines
4.9 KiB
TypeScript

/**
* Nearest divisional-chart change, from window-scan transitions only.
* Question order is a switch and stays off unless the env flag is "on".
*/
export const CHART_LAYERS = ["d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1"] as const;
const LAYER_DOMAIN: Readonly<Record<string, string>> = {
d9: "relationship",
d10: "career",
d4: "relocation",
d5: "education",
d24: "education",
d7: "family",
d12: "family",
d2: "finance",
d11: "finance",
d30: "health_pressure",
};
const LAYER_TOPIC: Readonly<Record<string, string>> = {
d9: "婚恋",
d10: "工作",
d4: "住处",
d5: "学业",
d24: "学业",
d7: "子女",
d12: "父母",
d2: "钱",
d11: "钱",
d30: "身体",
};
/** Not adopted. The 77-case gate was not cleared, so the switch stays off. */
export const NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5;
export type BoundaryLayer = Readonly<{
layer: string;
fromSign: string;
toSign: string;
topic: string | null;
domain: string | null;
}>;
export type NearestBoundary = Readonly<{
found: boolean;
reason: "unavailable" | "no_transition" | null;
at: string | null;
distanceMinutes: number | null;
layers: readonly BoundaryLayer[];
topics: readonly string[];
domains: readonly string[];
}>;
const EMPTY: NearestBoundary = {
found: false, reason: "unavailable", at: null, distanceMinutes: null, layers: [], topics: [], domains: [],
};
function clock(value: string | null | undefined): number | null {
const raw = value?.trim().slice(0, 5) ?? "";
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(raw)) return null;
return Number(raw.slice(0, 2)) * 60 + Number(raw.slice(3, 5));
}
function distance(left: number, right: number): number {
const span = Math.abs(left - right);
return Math.min(span, 1440 - span);
}
export function nearestBoundaryFromTransitions(
transitions: readonly Readonly<{ layer?: string; at?: string; from_sign?: string; to_sign?: string }>[] | null | undefined,
recordedTime: string | null | undefined,
): NearestBoundary {
const recorded = clock(recordedTime);
if (recorded === null || !transitions) return EMPTY;
const changes: { at: number; layer: string; fromSign: string; toSign: string }[] = [];
for (const row of transitions) {
const layer = row.layer ?? "";
if (!CHART_LAYERS.includes(layer as (typeof CHART_LAYERS)[number])) continue;
const before = row.from_sign;
const after = row.to_sign;
if (!before || !after || before === after) continue;
const at = clock(row.at);
if (at === null) continue;
changes.push({ at, layer, fromSign: before, toSign: after });
}
if (!changes.length) return { ...EMPTY, reason: "no_transition" };
const bestAt = changes.reduce((best, row) => {
const next = distance(row.at, recorded);
const current = distance(best, recorded);
return next < current || (next === current && row.at < best) ? row.at : best;
}, changes[0]!.at);
const layers: BoundaryLayer[] = [];
const seen = new Set<string>();
for (const layer of CHART_LAYERS) {
const row = changes.find((item) => item.at === bestAt && item.layer === layer);
if (!row || seen.has(layer)) continue;
seen.add(layer);
layers.push({
layer,
fromSign: row.fromSign,
toSign: row.toSign,
topic: LAYER_TOPIC[layer] ?? null,
domain: LAYER_DOMAIN[layer] ?? null,
});
}
const topics = topicsForLayers(layers.map((row) => row.layer));
const domains: string[] = [];
for (const row of layers) {
if (row.domain && !domains.includes(row.domain)) domains.push(row.domain);
}
return {
found: true,
reason: null,
at: `${String(Math.floor(bestAt / 60)).padStart(2, "0")}:${String(bestAt % 60).padStart(2, "0")}`,
distanceMinutes: distance(bestAt, recorded),
layers,
topics,
domains,
};
}
/** Spoken topics, in chart order. Unknown layers and D1 add nothing. */
export function topicsForLayers(layers: readonly string[]): string[] {
const topics: string[] = [];
for (const layer of CHART_LAYERS) {
if (!layers.includes(layer)) continue;
const topic = LAYER_TOPIC[layer];
if (topic && !topics.includes(topic)) topics.push(topic);
}
return topics;
}
export function nearestBoundaryProbesEnabled(flag: string | undefined = process.env.RECTIFICATION_NEAREST_BOUNDARY_PROBES): boolean {
return flag === "on";
}
/** Boundary-domain questions first; everything else keeps its incoming order. */
export function orderByBoundaryDomains<T>(
probes: readonly T[],
domains: readonly string[],
domainOf: (probe: T) => string,
): readonly T[] {
if (!domains.length || probes.length < 2) return probes;
const wanted = new Set(domains);
const first: T[] = [];
const rest: T[] = [];
for (const probe of probes) {
const domain = domainOf(probe);
(domain && wanted.has(domain) ? first : rest).push(probe);
}
if (!first.length || !rest.length) return probes;
return [...first, ...rest];
}