fix(web): stop adopting tied rectification candidates after method coverage
Coverage complete only unlocks discrimination. A 34/33/33 window plus an occupation note must ask a D9/D10 contrast probe instead of offering a stale winner card. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Chart differences that can actually eliminate candidates.
|
||||
* Window-scan prose in the final report is not a substitute for this packet.
|
||||
*/
|
||||
|
||||
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;
|
||||
expectedOutcomes: readonly ContrastExpectedOutcome[];
|
||||
candidateSplitHash: string;
|
||||
informationGain: number;
|
||||
sourceFeatures: readonly ContrastSourceFeature[];
|
||||
domain: string | null;
|
||||
year: number | null;
|
||||
semanticKey: string;
|
||||
}>;
|
||||
|
||||
export type VargaDifference = Readonly<{
|
||||
layer: string;
|
||||
signs: readonly string[];
|
||||
}>;
|
||||
|
||||
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;
|
||||
supports?: readonly string[];
|
||||
conflicts?: readonly string[];
|
||||
}>[];
|
||||
left_time?: string;
|
||||
right_time?: string;
|
||||
}>;
|
||||
|
||||
const D10_PREDICTIONS: Readonly<Record<string, string>> = {
|
||||
白羊: "某一阶段身份独立、承担主导职责",
|
||||
金牛: "稳定技术或事务执行、在组织内慢慢积累",
|
||||
双子: "沟通、写作或频繁切换任务",
|
||||
巨蟹: "照顾、家庭或情感劳动更重的职责",
|
||||
狮子: "站到台前、带人、或公开担责",
|
||||
处女: "稳定技术执行、组织内分析或服务",
|
||||
天秤: "跨领域合作、工作环境或合作模式变化",
|
||||
天蝎: "研究、转化、或把一件事做深",
|
||||
射手: "跨领域、教学或更开阔的工作环境",
|
||||
摩羯: "管理职责、长期结构、延迟回报",
|
||||
水瓶: "独立技术路线、非传统协作",
|
||||
双鱼: "服务、艺术或界限更模糊的工作",
|
||||
};
|
||||
|
||||
function signKey(value: string): string {
|
||||
return value.replace(/座$/, "").trim();
|
||||
}
|
||||
|
||||
export function buildCandidateContrastPacket(input: {
|
||||
candidateSetVersion: string;
|
||||
calculationResultId?: string | null;
|
||||
engineProbes?: readonly EngineContrastProbe[];
|
||||
vargaDifferences?: readonly VargaDifference[];
|
||||
askedKeys?: readonly string[];
|
||||
}): CandidateContrastPacket {
|
||||
const asked = new Set(input.askedKeys ?? []);
|
||||
const fromEngine = (input.engineProbes ?? []).flatMap((probe) => {
|
||||
const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null);
|
||||
if (!built) return [];
|
||||
if (asked.has(built.semanticKey) || asked.has(built.candidateSplitHash) || asked.has(built.probeId)) {
|
||||
return [];
|
||||
}
|
||||
return [built];
|
||||
});
|
||||
const vargaDifferences = input.vargaDifferences ?? [];
|
||||
const fromVarga = vargaProbe(vargaDifferences, input.candidateSetVersion, input.calculationResultId ?? null, asked);
|
||||
const probes = [...fromEngine, ...(fromVarga ? [fromVarga] : [])]
|
||||
.sort((left, right) => right.informationGain - left.informationGain);
|
||||
return {
|
||||
candidateSetVersion: input.candidateSetVersion,
|
||||
probes,
|
||||
vargaDifferences,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectDiscriminatorProbe(
|
||||
packet: CandidateContrastPacket | null | undefined,
|
||||
): CandidateDiscriminatorProbe | null {
|
||||
const ranked = (packet?.probes ?? []).filter((probe) => probe.expectedOutcomes.length >= 2);
|
||||
return ranked[0] ?? null;
|
||||
}
|
||||
|
||||
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 : "";
|
||||
const supports = row.supports ?? [];
|
||||
const conflicts = row.conflicts ?? [];
|
||||
if (!outcomeId) return [];
|
||||
return [{ outcomeId, supportsCandidateIds: supports, conflictsCandidateIds: conflicts }];
|
||||
});
|
||||
if (outcomes.length < 2 && probe.left_time && probe.right_time && probe.left_time !== probe.right_time) {
|
||||
outcomes.push(
|
||||
{ outcomeId: "yes", supportsCandidateIds: [probe.left_time], conflictsCandidateIds: [probe.right_time] },
|
||||
{ outcomeId: "no", supportsCandidateIds: [probe.right_time], conflictsCandidateIds: [probe.left_time] },
|
||||
);
|
||||
}
|
||||
if (outcomes.length < 2) return null;
|
||||
const semanticKey = probe.semantic_key ?? `${probe.domain ?? "career"}.${probe.year ?? "contrast"}`;
|
||||
const split = probe.candidate_split_hash ?? semanticKey;
|
||||
const question = probe.question ?? probe.user_meaning ?? "";
|
||||
if (!question.trim()) 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,
|
||||
};
|
||||
}
|
||||
|
||||
function vargaProbe(
|
||||
differences: readonly VargaDifference[],
|
||||
candidateSetVersion: string,
|
||||
calculationResultId: string | null,
|
||||
asked: ReadonlySet<string>,
|
||||
): CandidateDiscriminatorProbe | null {
|
||||
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;
|
||||
if (!chosen) return null;
|
||||
const semanticKey = `varga.${chosen.layer}.${chosen.signs.join("|")}`;
|
||||
if (asked.has(semanticKey)) return null;
|
||||
const predictions = chosen.signs.map((sign) => D10_PREDICTIONS[signKey(sign)] ?? `${sign} 这一段更常见的前事`);
|
||||
const outcomes: ContrastExpectedOutcome[] = chosen.signs.map((sign, index) => ({
|
||||
outcomeId: `supports_${signKey(sign)}`,
|
||||
supportsCandidateIds: [sign],
|
||||
conflictsCandidateIds: chosen.signs.filter((_, other) => other !== index),
|
||||
}));
|
||||
const layerLabel = chosen.layer.toUpperCase();
|
||||
const question = chosen.layer === "d10"
|
||||
? `当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:更接近${predictions.join(",还是")}?`
|
||||
: `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`;
|
||||
return {
|
||||
probeId: `contrast:${semanticKey}`,
|
||||
candidateSetVersion,
|
||||
question,
|
||||
expectedOutcomes: outcomes,
|
||||
candidateSplitHash: semanticKey,
|
||||
informationGain: 0.12,
|
||||
sourceFeatures: [{ technique: layerLabel, calculationResultId }],
|
||||
domain: chosen.layer === "d10" ? "career" : "relationship",
|
||||
year: null,
|
||||
semanticKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Candidate separation is not event-fit and not method coverage.
|
||||
* 34/33/33 is a tie. A 1-point engine lead is not a winner.
|
||||
*/
|
||||
|
||||
export const MIN_SEPARATION_LEAD = 8;
|
||||
|
||||
export type SeparationStatus = "not_separated" | "weak_lead" | "separated";
|
||||
|
||||
export type CandidateScoreRow = Readonly<{
|
||||
id?: string;
|
||||
time: string;
|
||||
score: number;
|
||||
}>;
|
||||
|
||||
export type CandidateSeparation = Readonly<{
|
||||
sufficient: boolean;
|
||||
status: SeparationStatus;
|
||||
lead: number;
|
||||
topShare: number;
|
||||
representativeTime: string | null;
|
||||
credibleRange: readonly string[];
|
||||
ranked: readonly CandidateScoreRow[];
|
||||
}>;
|
||||
|
||||
export function evaluateCandidateSeparation(
|
||||
candidates: readonly CandidateScoreRow[],
|
||||
): CandidateSeparation {
|
||||
const ranked = [...candidates]
|
||||
.filter((item) => Number.isFinite(item.score))
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return left.time.localeCompare(right.time);
|
||||
});
|
||||
const top = ranked[0] ?? null;
|
||||
const runnerUp = ranked[1] ?? null;
|
||||
const total = ranked.reduce((sum, item) => sum + Math.max(item.score, 0), 0);
|
||||
const lead = top && runnerUp ? top.score - runnerUp.score : (top ? MIN_SEPARATION_LEAD : 0);
|
||||
const topShare = top && total > 0 ? Math.max(top.score, 0) / total : 0;
|
||||
const status: SeparationStatus = !top || ranked.length < 2 || lead < MIN_SEPARATION_LEAD
|
||||
? "not_separated"
|
||||
: lead >= 20
|
||||
? "separated"
|
||||
: "weak_lead";
|
||||
const peak = top?.score ?? 0;
|
||||
const credibleRange = ranked
|
||||
.filter((item) => peak - item.score < MIN_SEPARATION_LEAD)
|
||||
.map((item) => item.time);
|
||||
return {
|
||||
sufficient: status !== "not_separated",
|
||||
status,
|
||||
lead,
|
||||
topShare,
|
||||
representativeTime: top?.time ?? null,
|
||||
credibleRange: credibleRange.length > 0 ? credibleRange : (top ? [top.time] : []),
|
||||
ranked,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
evaluateCandidateSeparation,
|
||||
type CandidateScoreRow,
|
||||
type CandidateSeparation,
|
||||
} from "./candidate-separation.ts";
|
||||
import type { CandidateDiscriminatorProbe } from "./candidate-contrast-packet.ts";
|
||||
|
||||
export type RectificationNextActionType =
|
||||
| "ask_fact_collection"
|
||||
| "ask_candidate_discriminator"
|
||||
| "ask_holdout_validation"
|
||||
| "offer_provisional_range"
|
||||
| "ready_to_adopt";
|
||||
|
||||
export type HoldoutValidationStatus = "not_started" | "passed" | "failed" | "unavailable";
|
||||
|
||||
export type DecideNextActionInput = Readonly<{
|
||||
methodCoverageAll: boolean;
|
||||
proposeAllowed: boolean;
|
||||
confirmationAllowed?: boolean;
|
||||
userStopped?: boolean;
|
||||
selectionAllowed?: boolean;
|
||||
snapshotCurrent?: boolean;
|
||||
candidateScores: readonly CandidateScoreRow[];
|
||||
discriminatorProbe?: CandidateDiscriminatorProbe | null;
|
||||
holdoutValidation?: HoldoutValidationStatus;
|
||||
}>;
|
||||
|
||||
export type RectificationNextAction = Readonly<{
|
||||
type: RectificationNextActionType;
|
||||
separation: CandidateSeparation;
|
||||
probe: CandidateDiscriminatorProbe | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Coverage complete only unlocks discrimination. It never grants adoption.
|
||||
*/
|
||||
export function decideNextAction(input: DecideNextActionInput): RectificationNextAction {
|
||||
const separation = evaluateCandidateSeparation(input.candidateScores);
|
||||
const probe = input.discriminatorProbe ?? null;
|
||||
const holdout = input.holdoutValidation ?? "unavailable";
|
||||
const userStopped = input.userStopped === true;
|
||||
|
||||
if (input.confirmationAllowed === true) {
|
||||
return { type: "ready_to_adopt", separation, probe: null };
|
||||
}
|
||||
if (userStopped && input.selectionAllowed === true) {
|
||||
if (!separation.sufficient) {
|
||||
return { type: "offer_provisional_range", separation, probe: null };
|
||||
}
|
||||
return { type: "ready_to_adopt", separation, probe: null };
|
||||
}
|
||||
if (!input.methodCoverageAll || input.snapshotCurrent === false) {
|
||||
return { type: "ask_fact_collection", separation, probe: null };
|
||||
}
|
||||
if (!separation.sufficient) {
|
||||
if (probe) {
|
||||
return { type: "ask_candidate_discriminator", separation, probe };
|
||||
}
|
||||
return { type: "offer_provisional_range", separation, probe: null };
|
||||
}
|
||||
if (holdout === "not_started") {
|
||||
return { type: "ask_holdout_validation", separation, probe: null };
|
||||
}
|
||||
if (holdout === "failed") {
|
||||
if (probe) {
|
||||
return { type: "ask_candidate_discriminator", separation, probe };
|
||||
}
|
||||
return { type: "offer_provisional_range", separation, probe: null };
|
||||
}
|
||||
if (input.proposeAllowed !== true) {
|
||||
return { type: "ask_fact_collection", separation, probe: null };
|
||||
}
|
||||
return { type: "ready_to_adopt", separation, probe: null };
|
||||
}
|
||||
|
||||
export function sessionKindFromNextAction(
|
||||
type: RectificationNextActionType,
|
||||
): "collect_evidence" | "discriminate_candidates" | "validate_holdout" | "provisional_range" | "adopt_representative" {
|
||||
if (type === "ask_fact_collection") return "collect_evidence";
|
||||
if (type === "ask_candidate_discriminator") return "discriminate_candidates";
|
||||
if (type === "ask_holdout_validation") return "validate_holdout";
|
||||
if (type === "offer_provisional_range") return "provisional_range";
|
||||
return "adopt_representative";
|
||||
}
|
||||
|
||||
export function offerSessionKinds(): readonly string[] {
|
||||
return ["adopt_representative", "awaiting_confirmation", "provisional_range"];
|
||||
}
|
||||
@@ -10,3 +10,7 @@ export * from "./build-state.ts";
|
||||
export * from "./probes-from-engine.ts";
|
||||
export * from "./decision-fingerprint.ts";
|
||||
export * from "./compose-receipt.ts";
|
||||
export * from "./candidate-separation.ts";
|
||||
export * from "./decide-next-action.ts";
|
||||
export * from "./candidate-contrast-packet.ts";
|
||||
export * from "./snapshot-source.ts";
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Candidate cards are keyed only on scoreable facts + birth profile + inference.
|
||||
* An occupation_note that does not score must not stale the snapshot.
|
||||
*/
|
||||
|
||||
export type CandidateSnapshotSource = Readonly<{
|
||||
birthProfileFingerprint: string;
|
||||
scoreableEvidenceFingerprint: string;
|
||||
inferenceRevision: number;
|
||||
candidateSetVersion: string;
|
||||
scoringPolicyVersion: string;
|
||||
}>;
|
||||
|
||||
export type SnapshotStaleReason =
|
||||
| "birth_profile_changed"
|
||||
| "scoreable_evidence_changed"
|
||||
| "candidate_set_superseded"
|
||||
| "inference_revision_changed";
|
||||
|
||||
export const SNAPSHOT_STALE_COPY: Readonly<Record<SnapshotStaleReason, string>> = {
|
||||
birth_profile_changed: "出生资料已变化,候选已失效",
|
||||
scoreable_evidence_changed: "可评分证据已变化,请重新比较候选",
|
||||
candidate_set_superseded: "已有更新的候选结果",
|
||||
inference_revision_changed: "候选后验已更新,请使用当前结果",
|
||||
};
|
||||
|
||||
export function classifySnapshotStaleReason(
|
||||
snapshot: CandidateSnapshotSource | null | undefined,
|
||||
current: CandidateSnapshotSource,
|
||||
): SnapshotStaleReason | null {
|
||||
if (!snapshot) return "candidate_set_superseded";
|
||||
if (
|
||||
snapshot.birthProfileFingerprint
|
||||
&& current.birthProfileFingerprint
|
||||
&& snapshot.birthProfileFingerprint !== current.birthProfileFingerprint
|
||||
) {
|
||||
return "birth_profile_changed";
|
||||
}
|
||||
if (snapshot.scoreableEvidenceFingerprint !== current.scoreableEvidenceFingerprint) {
|
||||
return "scoreable_evidence_changed";
|
||||
}
|
||||
if (snapshot.candidateSetVersion !== current.candidateSetVersion) {
|
||||
return "candidate_set_superseded";
|
||||
}
|
||||
if (snapshot.inferenceRevision !== current.inferenceRevision) {
|
||||
return "inference_revision_changed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Cards stay current when only non-scoreable notes (occupation_note) change. */
|
||||
export function scoreableSnapshotIsCurrent(
|
||||
snapshot: CandidateSnapshotSource | null | undefined,
|
||||
current: CandidateSnapshotSource,
|
||||
): boolean {
|
||||
if (!snapshot) return false;
|
||||
return snapshot.scoreableEvidenceFingerprint === current.scoreableEvidenceFingerprint
|
||||
&& snapshot.candidateSetVersion === current.candidateSetVersion
|
||||
&& snapshot.inferenceRevision === current.inferenceRevision;
|
||||
}
|
||||
|
||||
export function snapshotIsCurrent(
|
||||
snapshot: CandidateSnapshotSource | null | undefined,
|
||||
current: CandidateSnapshotSource,
|
||||
): boolean {
|
||||
return classifySnapshotStaleReason(snapshot, current) === null;
|
||||
}
|
||||
|
||||
export function candidateSnapshotSource(input: CandidateSnapshotSource): CandidateSnapshotSource {
|
||||
return {
|
||||
birthProfileFingerprint: input.birthProfileFingerprint,
|
||||
scoreableEvidenceFingerprint: input.scoreableEvidenceFingerprint,
|
||||
inferenceRevision: input.inferenceRevision,
|
||||
candidateSetVersion: input.candidateSetVersion,
|
||||
scoringPolicyVersion: input.scoringPolicyVersion,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user