Skipped or already-reported health must not be asked again as health_pressure, and occupation declined as other must still close the occupation line. Co-authored-by: Cursor <cursoragent@cursor.com>
995 lines
35 KiB
TypeScript
995 lines
35 KiB
TypeScript
/**
|
||
* Chart differences that can actually eliminate candidates.
|
||
* Window-scan prose in the final report is not a substitute for this packet.
|
||
*/
|
||
|
||
import type { AnswerClass, ConflictProbe } from "./types.ts";
|
||
import { signFromTransitions } from "./sign-from-transitions.ts";
|
||
import {
|
||
D9_TYPE_TABLE,
|
||
D10_TYPE_TABLE,
|
||
d9StyleLabel,
|
||
d10StyleLabel,
|
||
signKey,
|
||
} from "../v9/varga-type-tables.ts";
|
||
import {
|
||
completeStyleOptions,
|
||
informationGainAmongActive,
|
||
isRenderableProbe,
|
||
type DroppedProbe,
|
||
rankDiscriminatorScore,
|
||
askedKeysForSameYearDedup,
|
||
sameYearProbeAsked,
|
||
SEMANTIC_YEAR_KEY,
|
||
} from "../v9/probe-question-contract.ts";
|
||
import { probeBelowAdultFloor } from "../v9/adult-floor.ts";
|
||
import { canonicalCollectDomain, sameCollectDomain } from "../v9/domain-alias.ts";
|
||
|
||
export type ContrastChoiceKind = "existence" | "varga_style" | "event_quality";
|
||
|
||
export type ContrastStyleOption = Readonly<{
|
||
label: string;
|
||
answerClass: AnswerClass;
|
||
sign?: string;
|
||
}>;
|
||
|
||
export type ContrastExpectedOutcome = Readonly<{
|
||
outcomeId: string;
|
||
supportsCandidateIds: readonly string[];
|
||
conflictsCandidateIds: readonly string[];
|
||
}>;
|
||
|
||
export type ContrastSourceFeature = Readonly<{
|
||
technique: string;
|
||
calculationResultId: string | null;
|
||
}>;
|
||
|
||
export type CandidateDiscriminatorProbe = Readonly<{
|
||
probeId: string;
|
||
candidateSetVersion: string;
|
||
question: string;
|
||
authoringHint?: string;
|
||
expectedOutcomes: readonly ContrastExpectedOutcome[];
|
||
candidateSplitHash: string;
|
||
informationGain: number;
|
||
sourceFeatures: readonly ContrastSourceFeature[];
|
||
domain: string | null;
|
||
year: number | null;
|
||
semanticKey: string;
|
||
choiceKind?: ContrastChoiceKind;
|
||
styleOptions?: readonly ContrastStyleOption[];
|
||
}>;
|
||
|
||
export type VargaDifference = Readonly<{
|
||
layer: string;
|
||
signs: readonly string[];
|
||
}>;
|
||
|
||
export type WindowScanTransition = Readonly<{
|
||
layer: string;
|
||
at: string;
|
||
from_sign?: string;
|
||
to_sign?: string;
|
||
}>;
|
||
|
||
export type RemainingVargaSplit = Readonly<{
|
||
layer: string;
|
||
groups: readonly (readonly string[])[];
|
||
signs: readonly (string | null)[];
|
||
entropy: number;
|
||
}>;
|
||
|
||
export type CandidateContrastPacket = Readonly<{
|
||
candidateSetVersion: string;
|
||
probes: readonly CandidateDiscriminatorProbe[];
|
||
vargaDifferences: readonly VargaDifference[];
|
||
}>;
|
||
|
||
export type EngineContrastProbe = Readonly<{
|
||
semantic_key?: string;
|
||
candidate_split_hash?: string;
|
||
domain?: string;
|
||
year?: number;
|
||
question?: string;
|
||
user_meaning?: string;
|
||
information_gain?: number;
|
||
expected_outcomes?: readonly Readonly<{
|
||
answer_class?: string;
|
||
outcomeId?: string;
|
||
supports?: readonly string[];
|
||
supportsCandidateIds?: readonly string[];
|
||
conflicts?: readonly string[];
|
||
conflictsCandidateIds?: readonly string[];
|
||
}>[];
|
||
left_time?: string;
|
||
right_time?: string;
|
||
choice_kind?: ContrastChoiceKind;
|
||
style_options?: readonly Readonly<{
|
||
label?: string;
|
||
answer_class?: string;
|
||
sign?: string;
|
||
}>[];
|
||
target_evidence_id?: string;
|
||
}>;
|
||
|
||
const REMAINING_LAYERS = ["d24", "d5", "d10", "d9", "d4", "d7", "d12", "d2", "d11", "d30"] as const;
|
||
const VOLUNTEER_LAYER_DOMAIN: Readonly<Record<string, string>> = {
|
||
d2: "finance",
|
||
d11: "finance",
|
||
d30: "health_pressure",
|
||
};
|
||
const DUTY_ANSWERED_RE = /技术执行|算法|分析|数据处理|系统维护|组织型|第三个|照顾、家庭|台前|带人|公开担责|程序员|前端|工程师|开发/;
|
||
const EXAM_QUALITY_RE = /失利|失常|复读|没考好|考砸|发挥不好|发挥失常|发挥异常|压力很大/;
|
||
const RELOCATION_RE = /搬家|离乡|迁居|长期异地|离开家/;
|
||
const FAMILY_RE = /家人|父母|子女|兄弟|亲戚/;
|
||
const FINANCE_RE = /收入|资产|财务|欠债|投资/;
|
||
const HEALTH_RE = /健康|住院|手术|事故|持续压力/;
|
||
const LIVE_EVIDENCE = new Set(["confirmed", "draft", "pending_confirmation"]);
|
||
const ANSWER_CLASSES: ReadonlySet<string> = new Set(["yes", "weak_yes", "no", "unsure"]);
|
||
|
||
function clockMinutes(time: string): number {
|
||
const [hour, minute] = time.split(":").map(Number);
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
export function remainingLayerGroups(
|
||
candidateTimes: readonly string[],
|
||
transitions: readonly WindowScanTransition[],
|
||
layer: string,
|
||
): readonly (readonly string[])[] {
|
||
const changes = transitions
|
||
.filter((item) => item.layer === layer)
|
||
.map((item) => item.at)
|
||
.filter((at) => /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(at))
|
||
.sort((left, right) => clockMinutes(left) - clockMinutes(right));
|
||
const groups = new Map<number, string[]>();
|
||
for (const time of candidateTimes) {
|
||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time)) continue;
|
||
const point = clockMinutes(time);
|
||
let index = 0;
|
||
for (const at of changes) {
|
||
if (point >= clockMinutes(at)) index += 1;
|
||
}
|
||
const row = groups.get(index) ?? [];
|
||
row.push(time);
|
||
groups.set(index, row);
|
||
}
|
||
return [...groups.entries()]
|
||
.sort((left, right) => left[0] - right[0])
|
||
.map(([, times]) => times);
|
||
}
|
||
|
||
export function remainingVargaSplits(
|
||
candidateTimes: readonly string[],
|
||
transitions: readonly WindowScanTransition[],
|
||
volunteeredDomains: readonly string[] = [],
|
||
): readonly RemainingVargaSplit[] {
|
||
if (candidateTimes.length < 2) return [];
|
||
const volunteered = new Set(volunteeredDomains);
|
||
const rows: RemainingVargaSplit[] = [];
|
||
for (const layer of REMAINING_LAYERS) {
|
||
const volunteerDomain = VOLUNTEER_LAYER_DOMAIN[layer];
|
||
if (volunteerDomain && !volunteered.has(volunteerDomain)) continue;
|
||
const groups = remainingLayerGroups(candidateTimes, transitions, layer);
|
||
if (groups.length < 2) continue;
|
||
rows.push({
|
||
layer,
|
||
groups,
|
||
signs: remainingGroupSigns(groups, transitions, layer),
|
||
entropy: groupEntropy(groups),
|
||
});
|
||
}
|
||
return rows.sort((left, right) => (
|
||
right.entropy - left.entropy
|
||
|| right.groups.length - left.groups.length
|
||
|| left.layer.localeCompare(right.layer)
|
||
));
|
||
}
|
||
|
||
function groupEntropy(groups: readonly (readonly string[])[]): number {
|
||
const sizes = groups.map((group) => group.length);
|
||
const total = sizes.reduce((sum, size) => sum + size, 0);
|
||
if (total <= 0) return 0;
|
||
return -sizes.reduce((sum, size) => (
|
||
size > 0 ? sum + (size / total) * Math.log2(size / total) : sum
|
||
), 0);
|
||
}
|
||
|
||
function remainingGroupSigns(
|
||
groups: readonly (readonly string[])[],
|
||
transitions: readonly WindowScanTransition[],
|
||
layer: string,
|
||
): readonly (string | null)[] {
|
||
return groups.map((group) => {
|
||
const time = group[0];
|
||
if (!time) return null;
|
||
return signFromTransitions(transitions, layer, time);
|
||
});
|
||
}
|
||
|
||
export function mentionedVargaKeysFromLedgerEvidence(
|
||
evidence: readonly Readonly<{
|
||
status?: string | null;
|
||
domain?: string | null;
|
||
eventKind?: string | null;
|
||
summary?: string | null;
|
||
}>[],
|
||
): string[] {
|
||
const keys = new Set<string>();
|
||
for (const item of evidence) {
|
||
if (item.status && !LIVE_EVIDENCE.has(item.status)) continue;
|
||
const summary = item.summary ?? "";
|
||
const occupation = item.domain === "occupation" || item.eventKind === "occupation_note";
|
||
if (occupation || DUTY_ANSWERED_RE.test(summary)) keys.add("varga.d10");
|
||
if (item.domain === "education" && EXAM_QUALITY_RE.test(summary)) {
|
||
keys.add("varga.d24");
|
||
keys.add("varga.d5");
|
||
}
|
||
const relocation = item.domain === "relocation" || item.eventKind === "home_change";
|
||
if (relocation || RELOCATION_RE.test(summary)) keys.add("varga.d4");
|
||
const family = item.domain === "family" || FAMILY_RE.test(summary);
|
||
if (family) {
|
||
keys.add("varga.d7");
|
||
keys.add("varga.d12");
|
||
}
|
||
if (item.domain === "finance" || FINANCE_RE.test(summary)) {
|
||
keys.add("varga.d2");
|
||
keys.add("varga.d11");
|
||
}
|
||
if (sameCollectDomain(item.domain, "health_pressure") || HEALTH_RE.test(summary)) keys.add("varga.d30");
|
||
}
|
||
return [...keys];
|
||
}
|
||
|
||
export function askedEventProbeKeysFromLedgerEvidence(
|
||
evidence: readonly Readonly<{
|
||
status?: string | null;
|
||
domain?: string | null;
|
||
occurredFrom?: string | null;
|
||
occurredTo?: string | null;
|
||
}>[],
|
||
): string[] {
|
||
const keys = new Set<string>();
|
||
for (const item of evidence) {
|
||
if (item.status && !LIVE_EVIDENCE.has(item.status)) continue;
|
||
if (!item.domain) continue;
|
||
for (const date of [item.occurredFrom, item.occurredTo]) {
|
||
const year = date?.match(/^(\d{4})/)?.[1];
|
||
if (year) keys.add(`${canonicalCollectDomain(item.domain)}.${year}`);
|
||
}
|
||
}
|
||
return [...keys];
|
||
}
|
||
|
||
export function datedDomainsFromEvidence(
|
||
evidence: readonly Readonly<{
|
||
status?: string | null;
|
||
domain?: string | null;
|
||
occurredFrom?: string | null;
|
||
occurredTo?: string | null;
|
||
}>[],
|
||
): string[] {
|
||
const domains = new Set<string>();
|
||
for (const item of evidence) {
|
||
if (item.status && !LIVE_EVIDENCE.has(item.status)) continue;
|
||
if (!item.domain) continue;
|
||
const dated = [item.occurredFrom, item.occurredTo].some((value) => /^\d{4}/.test(value ?? ""));
|
||
if (dated) domains.add(canonicalCollectDomain(item.domain));
|
||
}
|
||
return [...domains];
|
||
}
|
||
|
||
export function volunteeredDomainsFromEvidence(
|
||
evidence: readonly Readonly<{
|
||
status?: string | null;
|
||
domain?: string | null;
|
||
}>[],
|
||
): string[] {
|
||
const domains = new Set<string>();
|
||
for (const item of evidence) {
|
||
if (item.status && !LIVE_EVIDENCE.has(item.status)) continue;
|
||
if (item.domain) domains.add(canonicalCollectDomain(item.domain));
|
||
}
|
||
return [...domains];
|
||
}
|
||
|
||
export function askedKeysFromLedgerEvidence(
|
||
evidence: readonly Readonly<{
|
||
status?: string | null;
|
||
domain?: string | null;
|
||
eventKind?: string | null;
|
||
summary?: string | null;
|
||
}>[],
|
||
): string[] {
|
||
return mentionedVargaKeysFromLedgerEvidence(evidence);
|
||
}
|
||
|
||
export function askedKeysFromOccupationEvidence(
|
||
evidence: readonly Readonly<{
|
||
status?: string | null;
|
||
domain?: string | null;
|
||
eventKind?: string | null;
|
||
summary?: string | null;
|
||
}>[],
|
||
): string[] {
|
||
return mentionedVargaKeysFromLedgerEvidence(evidence);
|
||
}
|
||
|
||
export function buildCandidateContrastPacket(input: {
|
||
candidateSetVersion: string;
|
||
calculationResultId?: string | null;
|
||
engineProbes?: readonly EngineContrastProbe[];
|
||
vargaDifferences?: readonly VargaDifference[];
|
||
remainingSplits?: readonly RemainingVargaSplit[];
|
||
candidateTimes?: readonly string[];
|
||
transitions?: readonly WindowScanTransition[];
|
||
askedKeys?: readonly string[];
|
||
mentionedKeys?: readonly string[];
|
||
volunteeredDomains?: readonly string[];
|
||
providedDomains?: readonly string[];
|
||
}): CandidateContrastPacket {
|
||
const asked = new Set(input.askedKeys ?? []);
|
||
// mentionedKeys are ranking-only; passing them here must not skip remaining varga probes.
|
||
const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item)));
|
||
const fromEngine = (input.engineProbes ?? []).flatMap((probe) => {
|
||
const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null);
|
||
if (!built) return [];
|
||
const eventKey = built.domain && built.year
|
||
? `${canonicalCollectDomain(built.domain)}.${built.year}`
|
||
: null;
|
||
const isStructured = isStructuredDiscriminator(built);
|
||
if (
|
||
asked.has(built.semanticKey)
|
||
|| asked.has(built.candidateSplitHash)
|
||
|| asked.has(built.probeId)
|
||
|| (!isStructured && eventKey && asked.has(eventKey))
|
||
|| (!isStructured && !(built.year && built.year > 0) && built.domain && provided.has(canonicalCollectDomain(built.domain)))
|
||
) {
|
||
return [];
|
||
}
|
||
return [built];
|
||
});
|
||
const remainingSplits = input.remainingSplits
|
||
?? remainingVargaSplits(
|
||
input.candidateTimes ?? [],
|
||
input.transitions ?? [],
|
||
input.volunteeredDomains ?? [],
|
||
);
|
||
const vargaDifferences = vargaDifferencesForPacket({
|
||
remainingSplits,
|
||
windowDifferences: input.vargaDifferences ?? [],
|
||
candidateTimes: input.candidateTimes ?? [],
|
||
transitions: input.transitions ?? [],
|
||
});
|
||
const presentKeys = new Set(fromEngine.map((item) => item.semanticKey));
|
||
const fromVarga = vargaProbes(
|
||
remainingSplits,
|
||
input.candidateSetVersion,
|
||
input.calculationResultId ?? null,
|
||
new Set([...asked, ...presentKeys]),
|
||
);
|
||
const probes = [...fromEngine, ...fromVarga]
|
||
.sort((left, right) => right.informationGain - left.informationGain);
|
||
return {
|
||
candidateSetVersion: input.candidateSetVersion,
|
||
probes,
|
||
vargaDifferences,
|
||
};
|
||
}
|
||
|
||
function vargaDifferencesForPacket(input: {
|
||
remainingSplits: readonly RemainingVargaSplit[];
|
||
windowDifferences: readonly VargaDifference[];
|
||
candidateTimes: readonly string[];
|
||
transitions: readonly WindowScanTransition[];
|
||
}): readonly VargaDifference[] {
|
||
if (input.remainingSplits.length > 0) {
|
||
return input.remainingSplits.map((split) => ({
|
||
layer: split.layer,
|
||
signs: split.groups.map((group) => group.join("|")),
|
||
}));
|
||
}
|
||
if (input.candidateTimes.length >= 2 && input.transitions.length > 0) {
|
||
return [];
|
||
}
|
||
return input.windowDifferences;
|
||
}
|
||
|
||
export function vargaLayerCovered(keys: ReadonlySet<string>, layer: string): boolean {
|
||
if (keys.has(`varga.${layer}`) || keys.has(layer)) return true;
|
||
for (const key of keys) {
|
||
if (key.startsWith(`varga.${layer}.`)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function vargaLayerFromSemanticKey(key: string): string | null {
|
||
return key.match(/^varga\.(d\d+)/)?.[1] ?? null;
|
||
}
|
||
|
||
function probeDomainYear(probe: Pick<CandidateDiscriminatorProbe, "domain" | "year" | "semanticKey">): {
|
||
domain: string;
|
||
year: number;
|
||
} | null {
|
||
if (probe.domain && probe.year && probe.year > 0) {
|
||
return { domain: probe.domain, year: probe.year };
|
||
}
|
||
const match = probe.semanticKey.match(SEMANTIC_YEAR_KEY);
|
||
if (!match) return null;
|
||
return { domain: match[1], year: Number(match[2]) };
|
||
}
|
||
|
||
export function inspectDiscriminatorProbes(
|
||
packet: CandidateContrastPacket | null | undefined,
|
||
options?: {
|
||
askedKeys?: readonly string[];
|
||
mentionedKeys?: readonly string[];
|
||
topCandidateTimes?: readonly string[];
|
||
birthDate?: string | null;
|
||
},
|
||
): {
|
||
selected: CandidateDiscriminatorProbe | null;
|
||
dropped: DroppedProbe[];
|
||
} {
|
||
const asked = new Set(options?.askedKeys ?? []);
|
||
const mentioned = new Set(options?.mentionedKeys ?? []);
|
||
const dropped: DroppedProbe[] = [];
|
||
const ranked = (packet?.probes ?? []).flatMap((probe) => {
|
||
const completed = withCompletedContrastOptions(probe);
|
||
if (!completed.ok) {
|
||
dropped.push({
|
||
semantic_key: probe.semanticKey,
|
||
information_gain: probe.informationGain,
|
||
reason: completed.reason,
|
||
});
|
||
return [];
|
||
}
|
||
const ids = [...new Set(completed.probe.expectedOutcomes.flatMap((row) => [
|
||
...row.supportsCandidateIds,
|
||
...row.conflictsCandidateIds,
|
||
]))];
|
||
const renderable = isRenderableProbe({
|
||
informationGain: completed.probe.informationGain,
|
||
candidateIds: ids,
|
||
expectedOutcomeCount: completed.probe.expectedOutcomes.length,
|
||
choiceKind: completed.probe.choiceKind,
|
||
styleOptions: completed.probe.styleOptions,
|
||
year: completed.probe.year,
|
||
});
|
||
if (!renderable.ok) {
|
||
dropped.push({
|
||
semantic_key: completed.probe.semanticKey,
|
||
information_gain: completed.probe.informationGain,
|
||
reason: renderable.reason,
|
||
});
|
||
return [];
|
||
}
|
||
const top = options?.topCandidateTimes ?? [];
|
||
const activeGain = top.length >= 2
|
||
? informationGainAmongActive(completed.probe.expectedOutcomes, top)
|
||
: { splits: true, informationGain: completed.probe.informationGain };
|
||
if (!activeGain.splits) {
|
||
dropped.push({
|
||
semantic_key: completed.probe.semanticKey,
|
||
information_gain: completed.probe.informationGain,
|
||
reason: "no_split_among_active",
|
||
});
|
||
return [];
|
||
}
|
||
const domainYear = probeDomainYear(completed.probe);
|
||
const yearAsked = askedKeysForSameYearDedup(asked, completed.probe);
|
||
if (domainYear && sameYearProbeAsked(yearAsked, domainYear.domain, domainYear.year)) {
|
||
dropped.push({
|
||
semantic_key: completed.probe.semanticKey,
|
||
information_gain: completed.probe.informationGain,
|
||
reason: "same_year_asked",
|
||
});
|
||
return [];
|
||
}
|
||
if (domainYear && probeBelowAdultFloor(domainYear, options?.birthDate)) {
|
||
dropped.push({
|
||
semantic_key: completed.probe.semanticKey,
|
||
information_gain: completed.probe.informationGain,
|
||
reason: "below_adult_floor",
|
||
});
|
||
return [];
|
||
}
|
||
const layer = vargaLayerFromSemanticKey(completed.probe.semanticKey);
|
||
const askedAlready = asked.has(completed.probe.semanticKey)
|
||
|| asked.has(completed.probe.candidateSplitHash)
|
||
|| asked.has(completed.probe.probeId)
|
||
|| (layer ? vargaLayerCovered(mentioned, layer) : false);
|
||
return [{
|
||
probe: completed.probe,
|
||
score: rankDiscriminatorScore({
|
||
informationGain: activeGain.informationGain,
|
||
asked: askedAlready,
|
||
candidateIds: ids,
|
||
topCandidateTimes: options?.topCandidateTimes,
|
||
}),
|
||
}];
|
||
}).sort((left, right) => right.score - left.score || right.probe.informationGain - left.probe.informationGain);
|
||
return { selected: ranked[0]?.probe ?? null, dropped };
|
||
}
|
||
|
||
export function selectDiscriminatorProbe(
|
||
packet: CandidateContrastPacket | null | undefined,
|
||
options?: {
|
||
askedKeys?: readonly string[];
|
||
mentionedKeys?: readonly string[];
|
||
topCandidateTimes?: readonly string[];
|
||
birthDate?: string | null;
|
||
},
|
||
): CandidateDiscriminatorProbe | null {
|
||
return inspectDiscriminatorProbes(packet, options).selected;
|
||
}
|
||
|
||
export function withCompletedContrastOptions(
|
||
probe: CandidateDiscriminatorProbe,
|
||
): { ok: true; probe: CandidateDiscriminatorProbe } | { ok: false; reason: DroppedProbe["reason"] } {
|
||
const mapped = probe.styleOptions?.map((item) => ({
|
||
label: item.label,
|
||
answer_class: item.answerClass,
|
||
...(item.sign ? { sign: item.sign } : {}),
|
||
})) ?? [];
|
||
const incoming = mapped.length >= 2 ? mapped : [...mapped, ...inferredVargaStyleIncoming(probe)];
|
||
const choiceKind = effectiveContrastChoiceKind({
|
||
...probe,
|
||
styleOptions: incoming.map((item) => ({
|
||
label: item.label,
|
||
answerClass: item.answer_class as AnswerClass,
|
||
...(item.sign ? { sign: item.sign } : {}),
|
||
})),
|
||
});
|
||
const styleOptions = completeStyleOptions({
|
||
choiceKind,
|
||
styleOptions: incoming,
|
||
});
|
||
if (!styleOptions.ok) return { ok: false, reason: styleOptions.reason };
|
||
const outcomes = withUnsureOutcome(probe.expectedOutcomes);
|
||
return {
|
||
ok: true,
|
||
probe: {
|
||
...probe,
|
||
choiceKind,
|
||
expectedOutcomes: outcomes,
|
||
styleOptions: styleOptions.options.map((item) => ({
|
||
label: item.label,
|
||
answerClass: item.answer_class,
|
||
...(item.sign ? { sign: item.sign } : {}),
|
||
})),
|
||
},
|
||
};
|
||
}
|
||
|
||
function inferredVargaStyleIncoming(
|
||
probe: CandidateDiscriminatorProbe,
|
||
): Array<{ label: string; answer_class: AnswerClass; sign?: string }> {
|
||
const parsed = signsFromVargaProbe(probe);
|
||
if (!parsed) return [];
|
||
const labelFor = parsed.layer === "d9" ? d9StyleLabel : d10StyleLabel;
|
||
const classes = ["yes", "weak_yes", "no"] as const;
|
||
return parsed.signs.slice(0, 3).flatMap((sign, index) => {
|
||
const label = labelFor(sign);
|
||
const answerClass = classes[index];
|
||
if (!label || !answerClass) return [];
|
||
return [{ label, answer_class: answerClass, sign }];
|
||
});
|
||
}
|
||
|
||
function knownVargaSigns(layer: "d9" | "d10", tokens: readonly string[]): string[] {
|
||
const table = layer === "d9" ? D9_TYPE_TABLE : D10_TYPE_TABLE;
|
||
return tokens.flatMap((token) => {
|
||
const key = signKey(token);
|
||
return table[key] ? [key] : [];
|
||
});
|
||
}
|
||
|
||
function signsFromVargaProbe(
|
||
probe: CandidateDiscriminatorProbe,
|
||
): { layer: "d9" | "d10"; signs: string[] } | null {
|
||
const match = probe.semanticKey.match(/^varga\.(d9|d10)\.(.+)$/);
|
||
const layer = match?.[1] === "d9" || match?.[1] === "d10" ? match[1] : null;
|
||
if (!layer) return null;
|
||
const fromKey = knownVargaSigns(
|
||
layer,
|
||
match?.[2]
|
||
?.split(/[|/]/)
|
||
.map((item) => item.trim())
|
||
.filter((item) => item && !/^\d{1,2}:\d{2}$/.test(item))
|
||
?? [],
|
||
);
|
||
const fromOutcomes = knownVargaSigns(
|
||
layer,
|
||
probe.expectedOutcomes.flatMap((row) => {
|
||
const token = row.outcomeId.replace(/^supports_/, "").trim();
|
||
return token && !ANSWER_CLASSES.has(token) && !/^\d{1,2}:\d{2}$/.test(token) ? [token] : [];
|
||
}),
|
||
);
|
||
const signs = (fromKey.length >= 2 ? fromKey : fromOutcomes).slice(0, 3);
|
||
if (signs.length < 2) return null;
|
||
return { layer, signs };
|
||
}
|
||
|
||
function effectiveContrastChoiceKind(probe: CandidateDiscriminatorProbe): ContrastChoiceKind {
|
||
const key = probe.semanticKey;
|
||
if (probe.choiceKind === "varga_style" && (probe.styleOptions?.length ?? 0) < 2) {
|
||
if (key.startsWith("varga.d24") || key.startsWith("varga.d5")) return "event_quality";
|
||
return "existence";
|
||
}
|
||
if (probe.choiceKind === "varga_style" || probe.choiceKind === "event_quality" || probe.choiceKind === "existence") {
|
||
return probe.choiceKind;
|
||
}
|
||
if (key.startsWith("varga.d24") || key.startsWith("varga.d5")) return "event_quality";
|
||
if (key.startsWith("varga.d9") || key.startsWith("varga.d10")) return "varga_style";
|
||
return "existence";
|
||
}
|
||
|
||
function withUnsureOutcome(
|
||
outcomes: readonly ContrastExpectedOutcome[],
|
||
): readonly ContrastExpectedOutcome[] {
|
||
const rows = [...outcomes];
|
||
if (!rows.some((row) => row.outcomeId === "unsure")) {
|
||
rows.push({ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] });
|
||
}
|
||
if (!rows.some((row) => row.outcomeId === "no") && rows.some((row) => row.outcomeId === "weak_yes")) {
|
||
rows.push({ outcomeId: "no", supportsCandidateIds: [], conflictsCandidateIds: [] });
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function conflictStyleOptions(
|
||
probe: CandidateDiscriminatorProbe,
|
||
): ConflictProbe["style_options"] {
|
||
const rows = probe.styleOptions?.flatMap((item) => {
|
||
const label = item.label.trim();
|
||
if (!label) return [];
|
||
return [{
|
||
label,
|
||
answer_class: item.answerClass,
|
||
...(item.sign ? { sign: item.sign } : {}),
|
||
}];
|
||
}) ?? [];
|
||
return rows.length > 0 ? rows : undefined;
|
||
}
|
||
|
||
export function conflictProbesFromContrast(
|
||
packet: CandidateContrastPacket | null | undefined,
|
||
): ConflictProbe[] {
|
||
return (packet?.probes ?? []).flatMap((probe) => {
|
||
if (!probe.semanticKey.startsWith("varga.")) return [];
|
||
const completed = withCompletedContrastOptions(probe);
|
||
const working = completed.ok ? completed.probe : probe;
|
||
const outcomes = working.expectedOutcomes.flatMap((row, index) => {
|
||
const answer = ANSWER_CLASSES.has(row.outcomeId)
|
||
? row.outcomeId as AnswerClass
|
||
: (["yes", "weak_yes", "no"][index] as AnswerClass | undefined);
|
||
if (!answer) return [];
|
||
return [{
|
||
answer_class: answer,
|
||
supports: row.supportsCandidateIds,
|
||
conflicts: row.conflictsCandidateIds,
|
||
}];
|
||
});
|
||
if (outcomes.length < 2) return [];
|
||
if (working.informationGain <= 0) return [];
|
||
const candidateIds = [...new Set(working.expectedOutcomes.flatMap((row) => [
|
||
...row.supportsCandidateIds,
|
||
...row.conflictsCandidateIds,
|
||
]))];
|
||
if (candidateIds.length < 2) return [];
|
||
const choiceKind = completed.ok
|
||
? (working.choiceKind ?? effectiveContrastChoiceKind(working))
|
||
: effectiveContrastChoiceKind(probe);
|
||
const styleOptions = conflictStyleOptions(working);
|
||
return [{
|
||
id: working.probeId,
|
||
semantic_key: working.semanticKey,
|
||
candidate_split_hash: working.candidateSplitHash,
|
||
domain: working.domain ?? "career",
|
||
year: working.year ?? 0,
|
||
question: working.question,
|
||
candidate_ids: candidateIds,
|
||
expected_outcomes: outcomes,
|
||
information_gain: working.informationGain,
|
||
source: "varga_contrast",
|
||
...(choiceKind === "varga_style"
|
||
|| choiceKind === "event_quality"
|
||
|| choiceKind === "existence"
|
||
? { choice_kind: choiceKind }
|
||
: {}),
|
||
...(styleOptions ? { style_options: styleOptions } : {}),
|
||
}];
|
||
});
|
||
}
|
||
|
||
function inferredContrastChoiceKind(
|
||
semanticKey: string,
|
||
explicit?: ContrastChoiceKind,
|
||
): ContrastChoiceKind {
|
||
if (explicit === "varga_style" || explicit === "event_quality" || explicit === "existence") {
|
||
return explicit;
|
||
}
|
||
const layer = semanticKey.match(/^varga\.(d\d+)/)?.[1];
|
||
if (layer === "d9" || layer === "d10") return "varga_style";
|
||
if (layer === "d24" || layer === "d5") return "event_quality";
|
||
return "existence";
|
||
}
|
||
|
||
/**
|
||
* Split hashes are `<candidate set id>:<semantic key>` for varga contrasts and
|
||
* `<candidate set id>:<opaque engine hash>` for dated event probes. A stored
|
||
* probe can carry the set id of an earlier scoring round; re-prefixing such a
|
||
* hash would stack prefixes and make the persisted focus schema unmatchable, so
|
||
* a stale set prefix is re-minted against the current set instead.
|
||
*/
|
||
export function splitHashForCandidateSet(
|
||
rawHash: string | null | undefined,
|
||
semanticKey: string,
|
||
candidateSetVersion: string,
|
||
): string {
|
||
const scoped = `${candidateSetVersion}:${semanticKey}`;
|
||
const hash = rawHash?.trim() ?? "";
|
||
if (!hash) return scoped;
|
||
if (hash === scoped) return hash;
|
||
if (hash.endsWith(`:${semanticKey}`)) return scoped;
|
||
if (hash.startsWith(`${candidateSetVersion}:`)) return hash;
|
||
return `${candidateSetVersion}:${hash}`;
|
||
}
|
||
|
||
/**
|
||
* Two hashes describe the same split when they are equal, or when both are
|
||
* `<some candidate set id>:<the same semantic key>`. The semantic key of a
|
||
* varga contrast already encodes its candidate groups, so only the set prefix
|
||
* can differ, and a stale prefix must not hide an otherwise valid card.
|
||
*/
|
||
export function isSameCandidateSplit(
|
||
left: string | null | undefined,
|
||
right: string | null | undefined,
|
||
semanticKey: string | null | undefined,
|
||
): boolean {
|
||
const a = left?.trim() ?? "";
|
||
const b = right?.trim() ?? "";
|
||
if (a === b) return true;
|
||
const key = semanticKey?.trim() ?? "";
|
||
if (!key || !a || !b) return false;
|
||
return a.endsWith(`:${key}`) && b.endsWith(`:${key}`);
|
||
}
|
||
|
||
function probeFromEngine(
|
||
probe: EngineContrastProbe,
|
||
candidateSetVersion: string,
|
||
calculationResultId: string | null,
|
||
): CandidateDiscriminatorProbe | null {
|
||
const outcomes = (probe.expected_outcomes ?? []).flatMap((row) => {
|
||
const outcomeId = typeof row.answer_class === "string" && row.answer_class.trim()
|
||
? row.answer_class
|
||
: typeof row.outcomeId === "string" ? row.outcomeId : "";
|
||
const supports = row.supports ?? row.supportsCandidateIds ?? [];
|
||
const conflicts = row.conflicts ?? row.conflictsCandidateIds ?? [];
|
||
if (!outcomeId) return [];
|
||
return [{ outcomeId, supportsCandidateIds: supports, conflictsCandidateIds: conflicts }];
|
||
});
|
||
if (outcomes.length < 2) return null;
|
||
if ((probe.information_gain ?? 0) <= 0) return null;
|
||
const semanticKey = probe.semantic_key ?? `${probe.domain ?? "career"}.${probe.year ?? "contrast"}`;
|
||
const split = splitHashForCandidateSet(
|
||
probe.candidate_split_hash,
|
||
semanticKey,
|
||
candidateSetVersion,
|
||
);
|
||
const question = probe.question ?? probe.user_meaning ?? "";
|
||
if (!question.trim()) return null;
|
||
const candidateIds = [...new Set(outcomes.flatMap((row) => [
|
||
...row.supportsCandidateIds,
|
||
...row.conflictsCandidateIds,
|
||
]))];
|
||
if (candidateIds.length < 2) return null;
|
||
return {
|
||
probeId: `contrast:${semanticKey}:${split}`,
|
||
candidateSetVersion,
|
||
question,
|
||
expectedOutcomes: outcomes,
|
||
candidateSplitHash: split,
|
||
informationGain: probe.information_gain ?? 0,
|
||
sourceFeatures: [{ technique: probe.domain ?? "event_probe", calculationResultId }],
|
||
domain: probe.domain ?? null,
|
||
year: probe.year ?? null,
|
||
semanticKey,
|
||
choiceKind: inferredContrastChoiceKind(semanticKey, probe.choice_kind),
|
||
styleOptions: styleOptionsFromEngine(probe.style_options),
|
||
};
|
||
}
|
||
|
||
function styleOptionsFromEngine(
|
||
rows: EngineContrastProbe["style_options"],
|
||
): readonly ContrastStyleOption[] | undefined {
|
||
if (!rows?.length) return undefined;
|
||
const parsed = rows.flatMap((row) => {
|
||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||
const answerClass = typeof row.answer_class === "string" && ANSWER_CLASSES.has(row.answer_class)
|
||
? row.answer_class as AnswerClass
|
||
: null;
|
||
if (!label || !answerClass) return [];
|
||
return [{
|
||
label,
|
||
answerClass,
|
||
...(typeof row.sign === "string" && row.sign.trim() ? { sign: row.sign.trim() } : {}),
|
||
}];
|
||
});
|
||
return parsed.length > 0 ? parsed : undefined;
|
||
}
|
||
|
||
export function isStructuredDiscriminator(probe: Pick<CandidateDiscriminatorProbe, "choiceKind" | "semanticKey">): boolean {
|
||
return probe.choiceKind === "varga_style"
|
||
|| probe.choiceKind === "event_quality"
|
||
|| probe.semanticKey.startsWith("varga.");
|
||
}
|
||
|
||
const CLOCK_TOKEN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||
|
||
export function vargaSignPartitionKey(
|
||
layer: string,
|
||
signs: readonly (string | null | undefined)[],
|
||
): string {
|
||
const parts = signs.flatMap((item) => {
|
||
const raw = typeof item === "string" ? item.trim() : "";
|
||
if (!raw || CLOCK_TOKEN.test(raw)) return [];
|
||
return [signKey(raw)];
|
||
});
|
||
if (parts.length >= 2) return `varga.${layer}.${parts.join("|")}`;
|
||
return `varga.${layer}.unsigned`;
|
||
}
|
||
|
||
function vargaProbes(
|
||
remainingSplits: readonly RemainingVargaSplit[],
|
||
candidateSetVersion: string,
|
||
calculationResultId: string | null,
|
||
asked: ReadonlySet<string>,
|
||
): CandidateDiscriminatorProbe[] {
|
||
return remainingSplits.flatMap((item) => {
|
||
if (vargaLayerAsked(asked, item.layer)) return [];
|
||
const probe = vargaProbeFromRemaining(item, candidateSetVersion, calculationResultId);
|
||
return probe ? [probe] : [];
|
||
});
|
||
}
|
||
|
||
function vargaLayerAsked(asked: ReadonlySet<string>, layer: string): boolean {
|
||
return vargaLayerCovered(asked, layer);
|
||
}
|
||
|
||
function vargaProbeFromRemaining(
|
||
split: RemainingVargaSplit,
|
||
candidateSetVersion: string,
|
||
calculationResultId: string | null,
|
||
): CandidateDiscriminatorProbe | null {
|
||
if (split.entropy <= 0) return null;
|
||
const allMinutes = split.groups.flat();
|
||
let choiceKind = remainingChoiceKind(split.layer);
|
||
let styleOptions = remainingStyleOptions(split, choiceKind);
|
||
if (choiceKind === "varga_style" && (styleOptions?.length ?? 0) < 2) {
|
||
choiceKind = "existence";
|
||
styleOptions = remainingStyleOptions(split, choiceKind);
|
||
}
|
||
const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind);
|
||
const ids = new Set(outcomes.flatMap((row) => [...row.supportsCandidateIds, ...row.conflictsCandidateIds]));
|
||
if (outcomes.length < 2 || ids.size < 2) return null;
|
||
const semanticKey = vargaSignPartitionKey(split.layer, split.signs);
|
||
const layerLabel = split.layer.toUpperCase();
|
||
const domain = remainingDomain(split.layer);
|
||
return {
|
||
probeId: `contrast:${semanticKey}`,
|
||
candidateSetVersion,
|
||
question: remainingQuestion(split.layer),
|
||
authoringHint: remainingAuthoringHint(layerLabel),
|
||
expectedOutcomes: outcomes,
|
||
candidateSplitHash: `${candidateSetVersion}:${semanticKey}`,
|
||
informationGain: split.entropy,
|
||
sourceFeatures: [{ technique: layerLabel, calculationResultId }],
|
||
domain,
|
||
year: null,
|
||
semanticKey,
|
||
choiceKind,
|
||
...(styleOptions ? { styleOptions } : {}),
|
||
};
|
||
}
|
||
|
||
function remainingChoiceKind(layer: string): ContrastChoiceKind {
|
||
if (layer === "d10" || layer === "d9") return "varga_style";
|
||
if (layer === "d24" || layer === "d5") return "event_quality";
|
||
return "existence";
|
||
}
|
||
|
||
function remainingStyleOptions(
|
||
split: RemainingVargaSplit,
|
||
kind: ContrastChoiceKind,
|
||
): readonly ContrastStyleOption[] | undefined {
|
||
const incoming = kind === "varga_style"
|
||
? split.groups.slice(0, 3).flatMap((group, index) => {
|
||
const sign = split.signs[index];
|
||
if (!sign) return [];
|
||
const label = split.layer === "d10" ? d10StyleLabel(sign) : d9StyleLabel(sign);
|
||
const classes = ["yes", "weak_yes", "no"] as const;
|
||
return [{ label, answer_class: classes[index] ?? "unsure", sign }];
|
||
})
|
||
: [];
|
||
const completed = completeStyleOptions({ choiceKind: kind, styleOptions: incoming });
|
||
if (!completed.ok) return undefined;
|
||
return uniquifyStyleLabels(completed.options.map((item) => ({
|
||
label: item.label,
|
||
answerClass: item.answer_class,
|
||
...(item.sign ? { sign: item.sign } : {}),
|
||
})));
|
||
}
|
||
|
||
function uniquifyStyleLabels(
|
||
options: readonly ContrastStyleOption[],
|
||
): readonly ContrastStyleOption[] {
|
||
const seen = new Set<string>();
|
||
return options.map((option) => {
|
||
let label = option.label;
|
||
if (seen.has(label) && option.sign) label = `${label}(${option.sign})`;
|
||
seen.add(label);
|
||
return { ...option, label };
|
||
});
|
||
}
|
||
|
||
function remainingDomain(layer: string): string {
|
||
if (layer === "d24" || layer === "d5") return "education";
|
||
if (layer === "d10") return "career";
|
||
if (layer === "d4") return "relocation";
|
||
if (layer === "d7" || layer === "d12") return "family";
|
||
if (layer === "d2" || layer === "d11") return "finance";
|
||
if (layer === "d30") return "health_pressure";
|
||
return "relationship";
|
||
}
|
||
|
||
function remainingQuestion(layer: string): string {
|
||
if (layer === "d10") return "平时做事,你更接近下面哪一种?";
|
||
if (layer === "d9") return "亲密关系里,你更接近哪一种相处方式?";
|
||
if (layer === "d24" || layer === "d5") return "有没有学业或考试发挥明显失常、压力特别大的时候?";
|
||
if (layer === "d4") return "有没有搬家或长期住到外地?";
|
||
if (layer === "d7" || layer === "d12") return "家里有没有结婚、添丁或住院这类事?";
|
||
if (layer === "d2" || layer === "d11") return "有没有收入明显变化、大笔支出或欠债?";
|
||
if (layer === "d30") return "有没有生病、受伤或压力特别大的时候?";
|
||
return "有没有下面这类事发生过?";
|
||
}
|
||
|
||
function remainingAuthoringHint(layerLabel: string): string {
|
||
return `引擎给出的区分机会绑定 ${layerLabel}。按 Opportunity 的时间范围、领域和 expected_outcomes 改写成自然语言,不得发明年份、事件事实或候选映射,不得改写时间范围。`;
|
||
}
|
||
|
||
function remainingOutcomes(
|
||
groups: readonly (readonly string[])[],
|
||
allMinutes: readonly string[],
|
||
kind: ContrastChoiceKind,
|
||
): ContrastExpectedOutcome[] {
|
||
let rows: ContrastExpectedOutcome[];
|
||
if (kind === "varga_style" && groups.length === 2) {
|
||
rows = [
|
||
{ outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] },
|
||
{ outcomeId: "weak_yes", supportsCandidateIds: groups[1], conflictsCandidateIds: groups[0] },
|
||
{ outcomeId: "no", supportsCandidateIds: [], conflictsCandidateIds: [] },
|
||
{ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] },
|
||
];
|
||
} else if (groups.length === 2) {
|
||
rows = [
|
||
{ outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] },
|
||
{ outcomeId: "weak_yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] },
|
||
{ outcomeId: "no", supportsCandidateIds: groups[1], conflictsCandidateIds: groups[0] },
|
||
{ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] },
|
||
];
|
||
} else {
|
||
const classes = ["yes", "weak_yes", "no"] as const;
|
||
rows = groups.slice(0, 3).map((group, index) => ({
|
||
outcomeId: classes[index] ?? `group_${index}`,
|
||
supportsCandidateIds: group,
|
||
conflictsCandidateIds: allMinutes.filter((time) => !group.includes(time)),
|
||
}));
|
||
if (!rows.some((row) => row.outcomeId === "unsure")) {
|
||
rows.push({ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] });
|
||
}
|
||
}
|
||
return rows;
|
||
}
|