fix(web): restore rectification discriminator cards and stop-offer path

Coverage-complete ties never persisted A/B/C/D because contrast probes were stamped with an answered education quality probe, remaining minutes were asked as window D10 signs, and 「没有了」 missed the stop pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 14:41:48 +08:00
co-authored by Cursor
parent 20ede7cadf
commit d7afe5b50d
19 changed files with 924 additions and 48 deletions
@@ -239,6 +239,7 @@ export function answersFromEvidence(
events: readonly EngineEventInput[],
): ProbeAnswer[] {
return probes.flatMap((probe) => {
if (probe.source === "known_event_quality" || probe.source === "varga_contrast") return [];
if (!events.some((item) => item.domain === probe.domain && item.year === probe.year)) return [];
return [{
probe_id: probe.id,
@@ -3,6 +3,8 @@
* Window-scan prose in the final report is not a substitute for this packet.
*/
import type { AnswerClass, ConflictProbe } from "./types.ts";
export type ContrastExpectedOutcome = Readonly<{
outcomeId: string;
supportsCandidateIds: readonly string[];
@@ -32,6 +34,16 @@ export type VargaDifference = Readonly<{
signs: readonly string[];
}>;
export type WindowScanTransition = Readonly<{
layer: string;
at: string;
}>;
export type RemainingVargaSplit = Readonly<{
layer: string;
groups: readonly (readonly string[])[];
}>;
export type CandidateContrastPacket = Readonly<{
candidateSetVersion: string;
probes: readonly CandidateDiscriminatorProbe[];
@@ -70,15 +82,83 @@ const D10_PREDICTIONS: Readonly<Record<string, string>> = {
: "服务、艺术或界限更模糊的工作",
};
const REMAINING_LAYER_ORDER = ["d24", "d5", "d10", "d9"] as const;
const DUTY_ANSWERED_RE = /技术执行|算法|分析|数据处理|系统维护|组织型|第三个|照顾、家庭|台前|带人|公开担责/;
const ANSWER_CLASSES: ReadonlySet<string> = new Set(["yes", "weak_yes", "no", "unsure"]);
function signKey(value: string): string {
return value.replace(/座$/, "").trim();
}
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[],
): readonly RemainingVargaSplit[] {
if (candidateTimes.length < 2) return [];
const rows: RemainingVargaSplit[] = [];
for (const layer of REMAINING_LAYER_ORDER) {
const groups = remainingLayerGroups(candidateTimes, transitions, layer);
if (groups.length < 2) continue;
rows.push({ layer, groups });
}
return rows;
}
export function askedKeysFromOccupationEvidence(
evidence: readonly Readonly<{
domain?: string | null;
eventKind?: string | null;
summary?: string | null;
}>[],
): string[] {
for (const item of evidence) {
const occupation = item.domain === "occupation" || item.eventKind === "occupation_note";
if (!occupation) continue;
if (DUTY_ANSWERED_RE.test(item.summary ?? "")) return ["varga.d10"];
}
return [];
}
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[];
}): CandidateContrastPacket {
const asked = new Set(input.askedKeys ?? []);
@@ -90,8 +170,21 @@ export function buildCandidateContrastPacket(input: {
}
return [built];
});
const vargaDifferences = input.vargaDifferences ?? [];
const fromVarga = vargaProbe(vargaDifferences, input.candidateSetVersion, input.calculationResultId ?? null, asked);
const remainingSplits = input.remainingSplits
?? remainingVargaSplits(input.candidateTimes ?? [], input.transitions ?? []);
const vargaDifferences = vargaDifferencesForPacket({
remainingSplits,
windowDifferences: input.vargaDifferences ?? [],
candidateTimes: input.candidateTimes ?? [],
transitions: input.transitions ?? [],
});
const fromVarga = vargaProbe(
vargaDifferences,
remainingSplits,
input.candidateSetVersion,
input.calculationResultId ?? null,
asked,
);
const probes = [...fromEngine, ...(fromVarga ? [fromVarga] : [])]
.sort((left, right) => right.informationGain - left.informationGain);
return {
@@ -101,6 +194,24 @@ export function buildCandidateContrastPacket(input: {
};
}
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 selectDiscriminatorProbe(
packet: CandidateContrastPacket | null | undefined,
): CandidateDiscriminatorProbe | null {
@@ -108,6 +219,42 @@ export function selectDiscriminatorProbe(
return ranked[0] ?? null;
}
export function conflictProbesFromContrast(
packet: CandidateContrastPacket | null | undefined,
): ConflictProbe[] {
return (packet?.probes ?? []).flatMap((probe) => {
if (!probe.semanticKey.startsWith("varga.")) return [];
const outcomes = probe.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 [];
const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [
...row.supportsCandidateIds,
...row.conflictsCandidateIds,
]))];
return [{
id: probe.probeId,
semantic_key: probe.semanticKey,
candidate_split_hash: probe.candidateSplitHash,
domain: probe.domain ?? "career",
year: probe.year ?? 0,
question: probe.question,
candidate_ids: candidateIds,
expected_outcomes: outcomes,
information_gain: probe.informationGain,
source: "varga_contrast",
}];
});
}
function probeFromEngine(
probe: EngineContrastProbe,
candidateSetVersion: string,
@@ -147,13 +294,22 @@ function probeFromEngine(
function vargaProbe(
differences: readonly VargaDifference[],
remainingSplits: readonly RemainingVargaSplit[],
candidateSetVersion: string,
calculationResultId: string | null,
asked: ReadonlySet<string>,
): CandidateDiscriminatorProbe | null {
const remaining = remainingSplits.find((item) => !vargaLayerAsked(asked, item.layer));
if (remaining) {
return vargaProbeFromRemaining(remaining, candidateSetVersion, calculationResultId);
}
const d10 = differences.find((item) => item.layer === "d10" && item.signs.length >= 2);
const d9 = differences.find((item) => item.layer === "d9" && item.signs.length >= 2);
const chosen = d10 ?? d9;
const chosen = d10 && !vargaLayerAsked(asked, "d10")
? d10
: d9 && !vargaLayerAsked(asked, "d9")
? d9
: null;
if (!chosen) return null;
const semanticKey = `varga.${chosen.layer}.${chosen.signs.join("|")}`;
if (asked.has(semanticKey)) return null;
@@ -180,3 +336,59 @@ function vargaProbe(
semanticKey,
};
}
function vargaLayerAsked(asked: ReadonlySet<string>, layer: string): boolean {
if (asked.has(`varga.${layer}`)) return true;
for (const key of asked) {
if (key === layer || key.startsWith(`varga.${layer}.`)) return true;
}
return false;
}
function vargaProbeFromRemaining(
split: RemainingVargaSplit,
candidateSetVersion: string,
calculationResultId: string | null,
): CandidateDiscriminatorProbe {
const allMinutes = split.groups.flat();
const outcomes = remainingOutcomes(split.groups, allMinutes);
const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`;
const layerLabel = split.layer.toUpperCase();
const education = split.layer === "d24" || split.layer === "d5";
const question = education
? "当前几个候选在学业盘上还分得开。请核对一段还没用进评分的学业前事:那次高考或重要考试有没有发挥明显失常、压力很大?"
: split.layer === "d10"
? "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:长期更接近照顾或家庭,还是台前带人,还是技术执行或分析?"
: `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`;
return {
probeId: `contrast:${semanticKey}`,
candidateSetVersion,
question,
expectedOutcomes: outcomes,
candidateSplitHash: semanticKey,
informationGain: split.layer === "d24" || split.layer === "d5" ? 0.16 : 0.12,
sourceFeatures: [{ technique: layerLabel, calculationResultId }],
domain: education ? "education" : split.layer === "d10" ? "career" : "relationship",
year: null,
semanticKey,
};
}
function remainingOutcomes(
groups: readonly (readonly string[])[],
allMinutes: readonly string[],
): ContrastExpectedOutcome[] {
if (groups.length === 2) {
return [
{ 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] },
];
}
const classes = ["yes", "weak_yes", "no"] as const;
return groups.slice(0, 3).map((group, index) => ({
outcomeId: classes[index] ?? `group_${index}`,
supportsCandidateIds: group,
conflictsCandidateIds: allMinutes.filter((time) => !group.includes(time)),
}));
}