fix(rectification): downgrade yearless personality probes to tie-breakers (BUG-629)
Independent Staging Quality Gate / validate (push) Successful in 11m54s
Independent Staging Quality Gate / publish (push) Successful in 8m14s

Ask dated dasha probes first; D9/D10 and nakshatra wait until that pool is empty, score at half weight, and never eliminate. Skill 10.0.21.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 00:26:58 +08:00
co-authored by Cursor
parent eb8a3120e5
commit fef54b6e42
41 changed files with 1452 additions and 70 deletions
@@ -5,6 +5,7 @@ import {
type TransitionSignLookup,
} from "./sign-from-transitions.ts";
import {
PROBE_WEIGHT,
SCORE_DELTA,
STRONG_CONFLICT_ELIMINATION_COUNT,
type AnswerClass,
@@ -16,11 +17,17 @@ import {
export type ProbeApplyResult = Readonly<{
scores: Readonly<Record<string, number>>;
eliminated_ids: readonly string[];
kind: "informative" | "low_information";
kind: "informative" | "low_information" | "tie_break";
deltas: Readonly<Record<string, number>>;
strong_conflict_counts: Readonly<Record<string, number>>;
}>;
export function isYearlessPersonalityProbe(
probe: Pick<ConflictProbe, "choice_kind" | "source">,
): boolean {
return probe.choice_kind === "varga_style" || probe.source === "nakshatra_boundary";
}
export type ProbeDirectionContext = Readonly<{
probe?: ConflictProbe;
transitions?: readonly TransitionSignLookup[];
@@ -181,15 +188,19 @@ export function applyProbeOutcome(
};
}
const context: ProbeDirectionContext = { probe, transitions: options.transitions };
const yearless = isYearlessPersonalityProbe(probe);
const weight = yearless ? PROBE_WEIGHT.yearless : PROBE_WEIGHT.dated;
for (const [id, score] of Object.entries(scores)) {
const direction = directionFor(id, outcome, probe.choice_kind, context);
if (direction === "conflict") conflictCounts[id] = (conflictCounts[id] ?? 0) + 1;
if (direction === "conflict" && !yearless) {
conflictCounts[id] = (conflictCounts[id] ?? 0) + 1;
}
if (eliminated.has(id)) {
next[id] = score;
deltas[id] = 0;
continue;
}
const delta = SCORE_DELTA[direction];
const delta = SCORE_DELTA[direction] * weight;
deltas[id] = delta;
next[id] = score + delta;
}
@@ -210,7 +221,7 @@ export function applyProbeOutcome(
return {
scores: next,
eliminated_ids: [...eliminated],
kind: changed ? "informative" : "low_information",
kind: yearless ? "tie_break" : (changed ? "informative" : "low_information"),
deltas,
strong_conflict_counts: conflictCounts,
};
@@ -16,7 +16,7 @@ export type InferenceTransitionSnapshot = Readonly<{
entropyBefore?: number | null;
entropyAfter?: number | null;
eliminatedCandidateIds?: readonly string[];
roundKind?: "informative" | "low_information" | null;
roundKind?: "informative" | "low_information" | "tie_break" | null;
}>;
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
@@ -34,7 +34,7 @@ const EVENT_USAGES = new Set(["training", "holdout", "unused"]);
const ANSWER_CLASSES = new Set(["yes", "weak_yes", "no", "unsure"]);
const PROBE_CHOICE_KINDS = new Set(["existence", "varga_style", "event_quality"]);
const ANSWER_SOURCES = new Set(["choice", "evidence", "declined"]);
const ROUND_KINDS = new Set(["informative", "low_information"]);
const ROUND_KINDS = new Set(["informative", "low_information", "tie_break"]);
const SCORE_DIRECTIONS = new Set(["support", "weak_support", "neutral", "weak_conflict", "conflict"]);
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
@@ -47,6 +47,12 @@ export const SCORE_DELTA: Readonly<Record<ScoreDirection, number>> = {
conflict: -2,
};
/** Dated distinguish probes keep full SCORE_DELTA. Yearless personality is tie-break only. */
export const PROBE_WEIGHT = {
dated: 1,
yearless: 0.5,
} as const;
export type InferenceCandidate = Readonly<{
id: string;
time: string;
@@ -116,7 +122,7 @@ export type RoundTrace = Readonly<{
entropy_after: number;
eliminated_ids: readonly string[];
winner_id: string | null;
kind: "informative" | "low_information";
kind: "informative" | "low_information" | "tie_break";
}>;
export type InferenceState = Readonly<{
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.20";
export const RECTIFICATION_SKILL_VERSION = "10.0.21";
@@ -25,6 +25,7 @@ import {
type HoldoutValidationStatus,
type RectificationDecision,
} from "../core/rectification-decision.ts";
import { evaluateCandidateSeparation } from "../core/candidate-separation.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import {
askedDiscriminatorKeys,
@@ -277,6 +278,11 @@ export function contrastPacketFromDossier(dossier: DecisionDossier): CandidateCo
return contrastPacketFromLatestResult(dossier.latestResult, dossier.evidence);
}
function dossierCandidatesSeparated(dossier: DecisionDossier): boolean {
const separation = evaluateCandidateSeparation(candidateScoresFromDossier(dossier.latestResult));
return separation.status === "separated" || separation.status === "sole_candidate";
}
export function rectificationFollowupCatalog(
latest: DecisionDossier["latestResult"] | undefined,
evidence: DecisionDossier["evidence"] = [],
@@ -474,7 +480,7 @@ function discriminatorProbeIfFollowupCanAsk(input: {
...(input.contrastPacket ? { contrastPacket: input.contrastPacket } : {}),
...(input.askedKeys ? { askedProbeKeys: input.askedKeys } : {}),
...(input.birthDate ? { birthDate: input.birthDate } : {}),
candidatesSeparated: false,
candidatesSeparated: dossierCandidatesSeparated(input.dossier),
});
const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes);
if (!followupAsksRenderableDiscriminator(plan.next_followup)) {
@@ -544,7 +550,7 @@ function nakshatraProbeIfFollowupCanAsk(input: {
oosBlindPrompts: catalog.oosBlindPrompts,
holdoutEvents: catalog.holdoutEvents,
...(input.birthDate ? { birthDate: input.birthDate } : {}),
candidatesSeparated: false,
candidatesSeparated: dossierCandidatesSeparated(input.dossier),
});
if (!followupAsksRenderableDiscriminator(plan.next_followup)) {
return { probe: null, dropped: mergeDroppedProbes(inspected.dropped, plan.dropped_probes) };
@@ -43,9 +43,9 @@
* Scoring A/B/C/D reverse-inference needs the engine year/month.
* Yearless varga splits do not borrow a ledger year. Pick the next
* dated discriminator, or collect a dated event in that domain.
* Remaining chart discriminators (D9/D10 style and dated dasha
* probes) stay in the pool; the highest information-gain renderable
* probe is asked next, with no preferred domain.
* Remaining dated dasha distinguish probes are asked first. Yearless
* D9/D10 style and nakshatra_boundary wait until that pool is empty and
* at least two active candidates are still unseparated (BUG-629).
* If holdout is already reserved but training is still short,
* keep collecting a dated event instead of discriminating.
* Once blocking methods are covered, move into candidate discrimination.
@@ -1082,11 +1082,35 @@ function discriminatorChoiceKind(row: RankedDiscriminator): string {
}
function discriminatorLocksScoringPeriod(row: RankedDiscriminator): boolean {
if (discriminatorChoiceKind(row) === "varga_style") return true;
if (discriminatorChoiceKind(row) === "varga_style") return false;
const year = row.eventProbe?.year ?? row.contrastProbe?.year ?? 0;
return year > 0;
}
function isYearlessPersonalityRow(row: RankedDiscriminator): boolean {
return discriminatorChoiceKind(row) === "varga_style";
}
function yearlessPersonalityCanAsk(input: {
datedCount: number;
candidatesSeparated: boolean;
topCandidateTimes?: readonly string[];
}): boolean {
if (input.datedCount > 0 || input.candidatesSeparated) return false;
const times = input.topCandidateTimes;
if (times !== undefined && times.length < 2) return false;
return true;
}
function emptyRankedCatalog(): {
locked: RankedDiscriminator[];
personality: RankedDiscriminator[];
yearless: RankedDiscriminator[];
dropped: DroppedProbe[];
} {
return { locked: [], personality: [], yearless: [], dropped: [] };
}
function rankRenderableDiscriminators(input: {
eventProbes: readonly DiscriminatingEventProbe[];
contrastProbes: readonly CandidateDiscriminatorProbe[];
@@ -1096,7 +1120,7 @@ function rankRenderableDiscriminators(input: {
providedDomains?: readonly string[];
evidence?: readonly MethodFollowupEvidence[];
birthDate?: string | null;
}): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } {
}): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } {
const top = input.topCandidateTimes ?? [];
const provided = new Set(input.providedDomains ?? []);
const mentioned = input.mentionedKeys ?? new Set();
@@ -1146,7 +1170,8 @@ function rankRenderableDiscriminators(input: {
const sorted = rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0));
return {
locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)),
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && discriminatorChoiceKind(row) !== "varga_style"),
personality: sorted.filter((row) => isYearlessPersonalityRow(row)),
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)),
dropped,
};
}
@@ -1992,7 +2017,7 @@ export function buildMethodFollowupPlan(input: {
...(input.askedProbeKeys ?? []),
]);
const rankedCatalog = input.caseStage === "block_scan"
? { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] }
? emptyRankedCatalog()
: dashaCovered && meetsAcceptanceEventQuality(input.evidence)
? rankRenderableDiscriminators({
eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys, input.birthDate),
@@ -2004,8 +2029,9 @@ export function buildMethodFollowupPlan(input: {
evidence: input.evidence,
birthDate: input.birthDate,
})
: { locked: [] as RankedDiscriminator[], yearless: [] as RankedDiscriminator[], dropped: [] as DroppedProbe[] };
: emptyRankedCatalog();
const rankedDiscriminators = rankedCatalog.locked;
const personalityDiscriminators = rankedCatalog.personality;
const yearlessDiscriminators = rankedCatalog.yearless;
const bestDiscriminator = rankedDiscriminators[0] ?? null;
const catalogWinnerKey = rankedDiscriminatorKey(bestDiscriminator);
@@ -2298,6 +2324,30 @@ export function buildMethodFollowupPlan(input: {
}
}
}
const personalityAllowed = yearlessPersonalityCanAsk({
datedCount: rankedDiscriminators.length,
candidatesSeparated,
topCandidateTimes: input.topCandidateTimes,
});
if (dashaCovered && personalityAllowed && !next) {
const allowLowGainDiscriminator = !coverageComplete || !candidatesSeparated;
for (const ranked of personalityDiscriminators) {
if (!allowLowGainDiscriminator && ranked.score < 0.08) continue;
const candidate = followupFromRanked(ranked);
if (candidate.choice_frame) {
next = candidate;
break;
}
}
} else {
for (const ranked of personalityDiscriminators) {
extraDropped.push(droppedFromProbe(
ranked.eventProbe?.semantic_key ?? ranked.contrastProbe?.semanticKey ?? "varga_style",
ranked.eventProbe?.information_gain ?? ranked.contrastProbe?.informationGain ?? ranked.score,
"yearless_deferred",
));
}
}
if (!next && !dashaCovered) {
next = makeFollowup({
method_id: "dasha_events",
@@ -2341,6 +2391,7 @@ export function buildMethodFollowupPlan(input: {
}
if (
!next
&& personalityAllowed
&& input.holdoutValidation !== "not_started"
&& !datedMethodCollectOpen(methods)
&& input.nakshatraProbe
@@ -2373,6 +2424,12 @@ export function buildMethodFollowupPlan(input: {
reason: "not_renderable",
});
}
} else if (input.nakshatraProbe && !personalityAllowed) {
extraDropped.push(droppedFromProbe(
input.nakshatraProbe.semantic_key,
input.nakshatraProbe.information_gain,
"yearless_deferred",
));
}
const sameDomainYearlessCard = (domain: string): MethodFollowup | null => {
const ranked = yearlessDiscriminators.find((row) => (
@@ -36,6 +36,7 @@ export type ProbeRejectReason =
| "yearless_ungrounded_contrast"
| "no_split_among_active"
| "unanchored_varga_style"
| "yearless_deferred"
| "frameless_distinguish"
| "same_year_asked"
| "below_adult_floor";
@@ -129,21 +129,38 @@ export function buildSkillVerificationPacket(input: SkillVerificationReportInput
const d10Table = d10TableSigns.length > 0
? ["| D10上升 | 事业特质 | 职业类型 | 工作风格 |", "|---|---|---|---|", ...d10TableSigns.map((sign) => typeRow(sign, "d10"))]
: ["当前窗还没有可对照的 D10 上升名,类型表待候选换升后填写。"];
const PERSONALITY_AUDIT_NOTE = "性格自评,只作排序参考,不参与淘汰";
function isPersonalityAuditTechnique(name: string): boolean {
return /D9\s*\/\s*D10|类型对照|月宿/.test(name);
}
const audit = (input.techniqueAuditTable ?? []).slice(0, 16);
const mappedAudit = audit.map((row) => {
const technique = row.technique ?? "技法";
if (isPersonalityAuditTechnique(technique)) {
return `| ${technique} | reference | ${PERSONALITY_AUDIT_NOTE} |`;
}
const executed = row.status === "executed" && Boolean(row.calculation_result_id);
const status = executed ? "executed" : (row.status === "executed" ? "input_covered" : (row.status ?? "blocked"));
const note = executed
? (row.note ?? "")
: (row.note || "该方法所需资料已覆盖,没有可追溯的 calculationResultId,不能写成已执行。");
return `| ${technique} | ${status} | ${note} |`;
});
const auditText = mappedAudit.join("\n");
const withPersonalityRows = [
mappedAudit,
auditText.includes("D9 / D10 类型对照") ? [] : [`| D9 / D10 类型对照 | reference | ${PERSONALITY_AUDIT_NOTE} |`],
auditText.includes("月宿边界") ? [] : [`| 月宿边界 | reference | ${PERSONALITY_AUDIT_NOTE} |`],
].flat();
const auditRows = audit.length > 0
? audit.map((row) => {
const executed = row.status === "executed" && Boolean(row.calculation_result_id);
const status = executed ? "executed" : (row.status === "executed" ? "input_covered" : (row.status ?? "blocked"));
const note = executed
? (row.note ?? "")
: (row.note || "该方法所需资料已覆盖,没有可追溯的 calculationResultId,不能写成已执行。");
return `| ${row.technique ?? "技法"} | ${status} | ${note} |`;
})
? withPersonalityRows
: [
"| Vimshottari + 受控行运 | input_covered | 没有 calculationResultId 时不能写成已执行 |",
"| D9/D10 分盘 | input_covered | 类型表是校时方法;差异要进入区分探针 |",
`| D9 / D10 类型对照 | reference | ${PERSONALITY_AUDIT_NOTE} |`,
"| 宫位 / 六亲六步 | input_covered | 后三步方法论层 |",
"| Nakshatra Pada | observation_only | 不确认唯一分钟 |",
`| 月宿边界 | reference | ${PERSONALITY_AUDIT_NOTE} |`,
"| KP / D60 / Gulika | observation_only | 不进提出门或确认门计分 |",
"| 占问 | observation_only | AI 暂不支持独立占问;有问起时间则观察 |",
];
@@ -1679,7 +1679,9 @@ function parseTransitionSnapshot(value: unknown): InferenceTransitionSnapshot |
eliminatedCandidateIds: Array.isArray(row.eliminated_candidate_ids)
? row.eliminated_candidate_ids.filter((item): item is string => typeof item === "string")
: [],
roundKind: row.round_kind === "informative" || row.round_kind === "low_information"
roundKind: row.round_kind === "informative"
|| row.round_kind === "low_information"
|| row.round_kind === "tie_break"
? row.round_kind
: null,
};