fix(web): rank remaining-minute probes by split and match choice kind
Independent Staging Quality Gate / validate (push) Failing after 22m30s
Independent Staging Quality Gate / publish (push) Has been skipped

Choice cards used a hardcoded domain menu and always asked existence.
Rank scoring layers by remaining-minute entropy, keep finance and health
volunteer-only, and ask D9/D10 style or exam quality so taps match outcomes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 20:44:55 +08:00
co-authored by Cursor
parent 7a4360d848
commit f5e73ef326
21 changed files with 921 additions and 114 deletions
+16
View File
@@ -5563,6 +5563,22 @@
- 复发自:BUG-376(关掉思考正文后,进度条只剩工具名,技法句不再出现;回到最新仍按咨询页浮层)
- 修复版本:待发布
## BUG-382 | 点选题与剩余切开不是同一题型,选题不是按剩余最大切开
- 状态:resolved
- 首次发现:2026-08-25
- 最近更新:2026-08-25
- 影响面:`discriminating_event_probes`、`candidate_contrast_packet`、`choice_card`、`method_followup_plan`、window_scan transitions
- 用户现象:生时纠正点选卡一律问「某年前后有没有这类事」。口语在问相处或职责时,卡仍问存在性。剩余分钟其实还能被家人盘切开时,下一问仍按写死的学业/事业/感情/搬家顺序出。
- 触发条件:覆盖已齐、候选仍并列;剩余 D9/D10 换升,或 D12/D7 切开而 D9 已齐;或大运年界与剩余切开同时存在。
- 根因:(1) 选择题走 `eventHypothesis` 存在性模板,D10 内部职责三分仍显示成有没有这件事。(2) Python 探针按整窗 `*_candidates_differ` 和精度阶段优先选题,每个领域取第一个能分开的年。(3) TS 剩余层写死 `D24→D5→D10→D9→D4`,并用假 IG 让学业压过事业/感情。
- 修复:计分领域按当前剩余分钟仍切开的层进池,财务/健康仍要用户先提过。Python 对剩余分钟打分,按分组熵加置信度取最高年。TS 按分组熵选题。年界差问存在性;D9/D10 换升问类型表相处/职责;学业问考试质量。两路 style 的「都不像」计 `unsure`,不把另一组分钟当「没有这件事」。不改 Skill `10.0.11`。
- 验证:`tests/test_rectification_event_probes.py`、`tests/test_rectification_refinement_packet.py`、`frontend/tests/rectification-candidate-contrast-packet.test.ts`、`frontend/tests/rectification-choice-card.test.ts`、`frontend/tests/rectification-inference-machine.test.ts`。
- 防复发:剩余分钟选题不得写死学业优先。点选卡题型必须跟 `choice_kind` 与 `expected_outcomes` 一致。两路 style 的 C 不得淘汰另一组分钟。不得改已哈希 Skill `10.0.11`。
- 相关记录:BUG-375、BUG-379、BUG-380
- 复发自:BUG-375(区分卡已落到剩余分钟,但题型仍是存在性,选题顺序仍写死)
- 修复版本:待发布
## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学
- 状态:resolved
@@ -251,13 +251,27 @@ export function answersFromEvidence(
});
}
export function classifyChoiceAnswer(key: string): AnswerClass {
export function classifyChoiceAnswer(key: string, schema?: unknown): AnswerClass {
if (key === "A") return "yes";
if (key === "B") return "weak_yes";
if (key === "C") return "no";
if (key === "C") {
if (schemaMapsCToUnsure(schema)) return "unsure";
return "no";
}
return "unsure";
}
function schemaMapsCToUnsure(schema: unknown): boolean {
if (!schema || typeof schema !== "object") return false;
const row = schema as Record<string, unknown>;
if (row.choice_kind !== "varga_style") return false;
const choice = row.choice && typeof row.choice === "object" && !Array.isArray(row.choice)
? row.choice as Record<string, unknown>
: row;
const optionC = typeof choice.option_c === "string" ? choice.option_c : "";
return optionC.includes("都不像");
}
function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswer[]): InferenceState {
return buildInferenceState({
range_start: state.range_start,
@@ -4,6 +4,15 @@
*/
import type { AnswerClass, ConflictProbe } from "./types.ts";
import { d9StyleLabel, d10StyleLabel } from "../v9/varga-type-tables.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;
@@ -27,6 +36,8 @@ export type CandidateDiscriminatorProbe = Readonly<{
domain: string | null;
year: number | null;
semanticKey: string;
choiceKind?: ContrastChoiceKind;
styleOptions?: readonly ContrastStyleOption[];
}>;
export type VargaDifference = Readonly<{
@@ -37,11 +48,15 @@ export type VargaDifference = Readonly<{
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<{
@@ -65,12 +80,26 @@ export type EngineContrastProbe = Readonly<{
}>[];
left_time?: string;
right_time?: string;
choice_kind?: ContrastChoiceKind;
style_options?: readonly Readonly<{
label?: string;
answer_class?: string;
sign?: string;
}>[];
}>;
const REMAINING_LAYER_ORDER = ["d24", "d5", "d10", "d9", "d4"] as const;
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"]);
@@ -109,15 +138,55 @@ export function remainingLayerGroups(
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_LAYER_ORDER) {
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 });
rows.push({
layer,
groups,
signs: remainingGroupSigns(groups, transitions, layer),
entropy: groupEntropy(groups),
});
}
return rows;
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)[] {
const changes = transitions
.filter((item) => item.layer === layer)
.filter((item) => /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(item.at))
.sort((left, right) => clockMinutes(left.at) - clockMinutes(right.at));
return groups.map((group) => {
const time = group[0];
if (!time) return null;
const crossed = changes.filter((item) => clockMinutes(item.at) <= clockMinutes(time));
if (crossed.length === 0) return changes[0]?.from_sign ?? null;
return crossed[crossed.length - 1]?.to_sign ?? changes[0]?.from_sign ?? null;
});
}
export function askedKeysFromLedgerEvidence(
@@ -140,10 +209,34 @@ export function askedKeysFromLedgerEvidence(
}
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 (item.domain === "health_pressure" || HEALTH_RE.test(summary)) keys.add("varga.d30");
}
return [...keys];
}
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(item.domain);
}
return [...domains];
}
export function askedKeysFromOccupationEvidence(
evidence: readonly Readonly<{
status?: string | null;
@@ -164,6 +257,7 @@ export function buildCandidateContrastPacket(input: {
candidateTimes?: readonly string[];
transitions?: readonly WindowScanTransition[];
askedKeys?: readonly string[];
volunteeredDomains?: readonly string[];
}): CandidateContrastPacket {
const asked = new Set(input.askedKeys ?? []);
const fromEngine = (input.engineProbes ?? []).flatMap((probe) => {
@@ -175,7 +269,11 @@ export function buildCandidateContrastPacket(input: {
return [built];
});
const remainingSplits = input.remainingSplits
?? remainingVargaSplits(input.candidateTimes ?? [], input.transitions ?? []);
?? remainingVargaSplits(
input.candidateTimes ?? [],
input.transitions ?? [],
input.volunteeredDomains ?? [],
);
const vargaDifferences = vargaDifferencesForPacket({
remainingSplits,
windowDifferences: input.vargaDifferences ?? [],
@@ -292,9 +390,30 @@ function probeFromEngine(
domain: probe.domain ?? null,
year: probe.year ?? null,
semanticKey,
choiceKind: 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;
}
function vargaProbe(
remainingSplits: readonly RemainingVargaSplit[],
candidateSetVersion: string,
@@ -320,49 +439,115 @@ function vargaProbeFromRemaining(
calculationResultId: string | null,
): CandidateDiscriminatorProbe {
const allMinutes = split.groups.flat();
const outcomes = remainingOutcomes(split.groups, allMinutes);
const choiceKind = remainingChoiceKind(split.layer);
const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind);
const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`;
const layerLabel = split.layer.toUpperCase();
const domain = remainingDomain(split.layer);
const question = remainingQuestion(split.layer, layerLabel);
const styleOptions = remainingStyleOptions(split, choiceKind);
const question = remainingQuestion(split.layer, layerLabel, styleOptions);
return {
probeId: `contrast:${semanticKey}`,
candidateSetVersion,
question,
expectedOutcomes: outcomes,
candidateSplitHash: semanticKey,
informationGain: split.layer === "d24" || split.layer === "d5" ? 0.16 : 0.12,
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 {
if (kind !== "varga_style") return undefined;
const classes = ["yes", "weak_yes", "no"] as const;
const options = 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);
return [{ label, answerClass: classes[index] ?? "unsure", sign }];
});
return options.length >= 2 ? uniquifyStyleLabels(options) : undefined;
}
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, layerLabel: string): string {
function remainingQuestion(
layer: string,
layerLabel: string,
styleOptions?: readonly ContrastStyleOption[],
): string {
if (layer === "d24" || layer === "d5") {
return "当前几个候选在学业盘上还分得开。请核对一段还没用进评分的学业前事:那次高考或重要考试有没有发挥明显失常、压力很大?";
}
if (layer === "d10") {
return "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:长期更接近照顾或家庭,还是台前带人,还是技术执行或分析?";
return styleOptions?.length
? "当前几个候选在事业盘上还分得开。长期工作更接近哪一类?"
: "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事:长期更接近照顾或家庭,还是台前带人,还是技术执行或分析?";
}
if (layer === "d9") {
return "当前几个候选在关系盘上还分得开。这段关系更接近哪一种相处?";
}
if (layer === "d4") {
return "当前几个候选在居所盘上还分得开。请核对一段还没用进评分的搬家或离乡:那几年有没有明显搬家、离乡或长期异地?";
}
if (layer === "d7" || layer === "d12") {
return "当前几个候选在家人盘上还分得开。那几年有没有家人相关的明显变化?";
}
if (layer === "d2" || layer === "d11") {
return "当前几个候选在财帛盘上还分得开。那几年有没有收入、资产或财务明显变化?";
}
if (layer === "d30") {
return "当前几个候选在健康盘上还分得开。那几年有没有健康、事故或持续压力明显变化?";
}
return `当前几个候选在关系盘上还分得开。请核对一段还没用进评分的感情前事,用来对照 ${layerLabel} 差异。`;
}
function remainingOutcomes(
groups: readonly (readonly string[])[],
allMinutes: readonly string[],
kind: ContrastChoiceKind,
): ContrastExpectedOutcome[] {
if (kind === "varga_style" && groups.length === 2) {
return [
{ outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] },
{ outcomeId: "weak_yes", supportsCandidateIds: groups[1], conflictsCandidateIds: groups[0] },
{ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] },
];
}
if (groups.length === 2) {
return [
{ outcomeId: "yes", supportsCandidateIds: groups[0], conflictsCandidateIds: groups[1] },
@@ -119,7 +119,7 @@ export async function applyRectificationChoice(
optionId,
scoring,
appliedInference: false,
answerClass: outcomeIdForOption(optionId),
answerClass: outcomeIdForOption(optionId, schema),
sourceQuote: optionQuoteFromSchema(schema, optionId),
year: null,
expectedRevision: command.expectedRevision,
@@ -6,6 +6,7 @@
*/
import type { AnswerClass } from "../core/types";
import { classifyChoiceAnswer } from "../core/build-state";
import type { ChoiceKey, RectificationChoiceCard } from "./choice-card";
export const CHOICE_ACTION = "answer_choice" as const;
@@ -30,8 +31,8 @@ export type StructuredProbeDerivedContext = Readonly<{
answerClass: AnswerClass | null;
}>;
export function outcomeIdForOption(optionId: ChoiceKey): AnswerClass {
return OPTION_ANSWER_CLASS[optionId];
export function outcomeIdForOption(optionId: ChoiceKey, schema?: unknown): AnswerClass {
return classifyChoiceAnswer(optionId, schema);
}
export function focusStatusForOption(optionId: ChoiceOptionId): "resolved" | "declined" | "skipped" {
@@ -7,7 +7,7 @@
* option copy, and never parses A/B/C/D out of assistant prose.
*/
import type { DiscriminatingEventProbe } from "./refinement-packet";
import type { DiscriminatingEventProbe, EventProbeChoiceKind, EventProbeStyleOption } from "./refinement-packet";
import type { InternalVargaObservation } from "./varga-observations";
export const CHOICE_MODE = "A/B/C/D";
@@ -39,6 +39,7 @@ export type RectificationChoiceFrame = Readonly<{
stop_label: string;
stop_message: string;
scoring: boolean;
choice_kind?: EventProbeChoiceKind;
}>;
export type AgentChoiceCopy = Readonly<{
@@ -63,6 +64,7 @@ export type RectificationChoiceCard = Readonly<{
probe_id: string | null;
case_revision: number | null;
focus_id: string | null;
choice_kind?: EventProbeChoiceKind;
}>;
export type ChoiceCardFollowup = Readonly<{
@@ -70,6 +72,8 @@ export type ChoiceCardFollowup = Readonly<{
ask_theme: string;
domain: string | null;
user_prompt_hint: string;
choice_kind?: EventProbeChoiceKind;
style_options?: readonly EventProbeStyleOption[];
}>;
export type ChoiceCardEvidence = Readonly<{
@@ -84,6 +88,10 @@ const PRIMARY_C = "没有明显发生";
const SECONDARY_D = "不记得 / 不确定";
const OPTION_A = "是,大概就在那段时间";
const OPTION_B = "有类似,但年份不对或不够重大";
const STYLE_NEITHER = "两边都不像";
const QUALITY_A = "有,失常或压力很大";
const QUALITY_B = "有压力,但不算失常";
const QUALITY_C = "没有明显失常";
const AGE_BAND: Record<string, { lo: number; hi: number; family: string; varga: string | null }> = {
education: { lo: 16, hi: 18, family: "升学、高考、转学或学习环境变化", varga: "D5 / D24" },
@@ -253,6 +261,8 @@ function hypothesisFor(
}
const domain = followupDomain(followup);
const probe = pickProbe(probes, domain);
const kind = followup.choice_kind ?? probe?.choice_kind ?? "existence";
const styleOptions = followup.style_options ?? probe?.style_options ?? [];
const family = probe?.event_family
?? (domain ? AGE_BAND[domain]?.family : null)
?? "带大概年份的经历";
@@ -271,6 +281,40 @@ function hypothesisFor(
: probe?.user_meaning
?? "用一件带年份的具体生平分开还在比的时间窗。题干自己写,年份不得发明。";
void observations;
if (kind === "varga_style" && styleOptions.length >= 2) {
const career = domain === "career" || followup.ask_theme === "career_style";
if (styleOptions.length >= 3) {
return {
prompt: career ? "长期工作更接近哪一类?" : "这段关系更接近哪一种相处?",
why,
varga,
a: styleOptions[0].label,
b: styleOptions[1].label,
neither: styleOptions[2].label,
};
}
return {
prompt: career ? "长期工作更接近哪一类?" : "这段关系更接近哪一种相处?",
why,
varga,
a: styleOptions[0].label,
b: styleOptions[1].label,
neither: STYLE_NEITHER,
};
}
if (kind === "event_quality") {
const dated = probe?.year_label && probe.year_label !== "那段时间";
return {
prompt: dated
? `${probe.year_label},那次高考或重要考试有没有发挥明显失常、压力很大?`
: "那次高考或重要考试有没有发挥明显失常、压力很大?",
why,
varga: varga ?? "D5 / D24",
a: QUALITY_A,
b: QUALITY_B,
neither: QUALITY_C,
};
}
return eventHypothesis(period, family, why, varga);
}
@@ -308,9 +352,19 @@ export function buildChoiceFrame(
stop_label: CHOICE_STOP_LABEL,
stop_message: CHOICE_STOP_MESSAGE,
scoring,
choice_kind: hypothesisKind(followup, input.probes),
};
}
function hypothesisKind(
followup: ChoiceCardFollowup,
probes?: readonly DiscriminatingEventProbe[],
): EventProbeChoiceKind {
return followup.choice_kind
?? pickProbe(probes, followupDomain(followup))?.choice_kind
?? "existence";
}
function clippedCopy(value: unknown, min: number, max: number): string | null {
if (typeof value !== "string") return null;
const text = value.trim().replace(/\s+/g, " ");
@@ -391,6 +445,7 @@ export function mergeChoiceCard(
probe_id: meta.probe_id ?? null,
case_revision: meta.case_revision ?? null,
focus_id: meta.focus_id ?? null,
choice_kind: frame.choice_kind,
};
}
@@ -454,5 +509,8 @@ export function parseRectificationChoiceCard(value: unknown): RectificationChoic
? row.case_revision
: null,
focus_id: typeof row.focus_id === "string" && row.focus_id.trim() ? row.focus_id : null,
...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality"
? { choice_kind: row.choice_kind }
: {}),
};
}
@@ -315,7 +315,7 @@ export function applyChoiceWithoutEvidence(
}
const openProbeId = nextProbe(state)?.id ?? null;
const lastAnsweredId = state.answered_probes.at(-1)?.probe_id ?? null;
const answerClass = classifyChoiceAnswer(choiceKey);
const answerClass = classifyChoiceAnswer(choiceKey, input.schema);
if (
(submittedProbeId && submittedProbeId !== openProbeId && submittedProbeId !== lastAnsweredId && submittedProbeId !== probe.id && submittedProbeId !== probe.semantic_key && submittedProbeId !== `probe:${probe.semantic_key}`)
|| (openProbeId && probe.id !== openProbeId && probe.id !== lastAnsweredId)
@@ -8,6 +8,7 @@
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
volunteeredDomainsFromEvidence,
} from "../core/candidate-contrast-packet.ts";
import { evaluateCandidateSeparation } from "../core/candidate-separation.ts";
import { askedProbeKeysFromReceipt, previousInferenceFromReceipt } from "./inference-adapter";
@@ -88,6 +89,7 @@ export function choiceCardFromCaseDossier(dossier: {
candidateTimes: candidateScores.map((item) => item.time),
transitions: windowScan?.transitions ?? [],
askedKeys: askedProbeKeys,
volunteeredDomains: volunteeredDomainsFromEvidence(dossier.evidence),
});
const userStopped = latestUserStoppedCollecting(dossier.turns ?? []);
return projectRectificationChoiceCard({
@@ -94,6 +94,12 @@ export type MethodFollowup = Readonly<{
semantic_key?: string;
candidate_split_hash?: string;
probe_year?: number;
choice_kind?: "existence" | "varga_style" | "event_quality";
style_options?: readonly Readonly<{
label: string;
answer_class: string;
sign?: string;
}>[];
}>;
export type MethodFollowupPlan = Readonly<{
@@ -470,6 +476,12 @@ function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDi
domain: followup.domain,
year: followup.probe_year ?? null,
semanticKey: followup.semantic_key ?? followup.method_id,
choiceKind: followup.choice_kind,
styleOptions: followup.style_options?.map((item) => ({
label: item.label,
answerClass: item.answer_class as "yes" | "weak_yes" | "no" | "unsure",
...(item.sign ? { sign: item.sign } : {}),
})),
};
}
@@ -785,6 +797,8 @@ export function buildMethodFollowupPlan(input: {
semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`,
candidate_split_hash: conflictProbe.candidate_split_hash,
probe_year: conflictProbe.year,
choice_kind: conflictProbe.choice_kind ?? "existence",
style_options: conflictProbe.style_options,
}, true, true);
} else if (!relationshipCovered && !declined.has("relationship")) {
next = makeFollowup({
@@ -860,6 +874,12 @@ export function buildMethodFollowupPlan(input: {
semantic_key: contrastProbe.semanticKey,
candidate_split_hash: contrastProbe.candidateSplitHash,
probe_year: contrastProbe.year ?? undefined,
choice_kind: contrastProbe.choiceKind ?? "existence",
style_options: contrastProbe.styleOptions?.map((item) => ({
label: item.label,
answer_class: item.answerClass,
...(item.sign ? { sign: item.sign } : {}),
})),
}, true, true);
} else if (stage === "lagna_frame") {
next = makeFollowup({
@@ -92,6 +92,8 @@ export type WindowScanTransition = Readonly<{
layer: WindowScanLayer;
at: string;
user_meaning: string;
from_sign?: string;
to_sign?: string;
}>;
export type EventDashaLedgerRow = Readonly<{
@@ -179,6 +181,14 @@ export type EventProbeSource =
export type EventProbeRole = "distinguish" | "reverse_verify";
export type EventProbeChoiceKind = "existence" | "varga_style" | "event_quality";
export type EventProbeStyleOption = Readonly<{
label: string;
answer_class: string;
sign?: string;
}>;
export type DiscriminatingEventProbe = Readonly<{
year: number;
year_label: string;
@@ -200,6 +210,8 @@ export type DiscriminatingEventProbe = Readonly<{
}>[];
left_time?: string;
right_time?: string;
choice_kind?: EventProbeChoiceKind;
style_options?: readonly EventProbeStyleOption[];
}>;
function asRecord(value: unknown): Readonly<Record<string, unknown>> | null {
@@ -262,6 +274,8 @@ export function parseWindowScanTransitions(value: unknown): readonly WindowScanT
layer,
at,
user_meaning: `${LAYER_LABEL[layer]} 在 ${at} 发生变化`,
...(asText(row.from_sign, 12) ? { from_sign: asText(row.from_sign, 12)! } : {}),
...(asText(row.to_sign, 12) ? { to_sign: asText(row.to_sign, 12)! } : {}),
});
}
return rows;
@@ -474,6 +488,22 @@ export function parseDiscriminatingEventProbes(value: unknown): readonly Discrim
...(asText(row.candidate_split_hash, 120) ? { candidate_split_hash: asText(row.candidate_split_hash, 120)! } : {}),
...(asTime(row.left_time) ? { left_time: asTime(row.left_time)! } : {}),
...(asTime(row.right_time) ? { right_time: asTime(row.right_time)! } : {}),
...(row.choice_kind === "existence" || row.choice_kind === "varga_style" || row.choice_kind === "event_quality"
? { choice_kind: row.choice_kind }
: {}),
...(Array.isArray(row.style_options) ? {
style_options: row.style_options.flatMap((item) => {
const option = asRecord(item);
const label = asText(option?.label, 80);
const answer = typeof option?.answer_class === "string" ? option.answer_class : "";
if (!option || !label || !answer) return [];
return [{
label,
answer_class: answer,
...(asText(option.sign, 12) ? { sign: asText(option.sign, 12)! } : {}),
}];
}),
} : {}),
...(Array.isArray(row.expected_outcomes) ? {
expected_outcomes: row.expected_outcomes.flatMap((item) => {
const outcome = asRecord(item);
@@ -69,6 +69,7 @@ function expectedAnswerSchemaFor(
},
semantic_key: followup.semantic_key ?? null,
candidate_split_hash: followup.candidate_split_hash ?? null,
choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence",
};
return stampChoiceSchemaWithProbe(
schema,
@@ -9,42 +9,7 @@
import type { EventDashaLedgerRow } from "./refinement-packet";
import type { WindowScan } from "./varga-observations";
import type { CandidateSeparation } from "../core/candidate-separation";
const D9_TYPE_TABLE: Readonly<Record<string, { trait: string; spouse: string; marriage: string }>> = {
白羊座: { trait: "主动、热情、冲动", spouse: "独立、有活力", marriage: "早期婚姻、激情型" },
金牛座: { trait: "稳定、务实、占有欲强", spouse: "稳重、有经济基础", marriage: "晚婚、稳定型" },
双子座: { trait: "沟通、多变、好奇心强", spouse: "聪明、善交流", marriage: "多次恋爱、友谊型" },
巨蟹座: { trait: "情感丰富、家庭导向", spouse: "温柔、顾家", marriage: "家庭型" },
狮子座: { trait: "骄傲、戏剧性、领导欲", spouse: "有魅力、有地位", marriage: "戏剧性" },
处女座: { trait: "完美主义、挑剔、服务型", spouse: "细致、有技能", marriage: "晚婚、服务型" },
天秤座: { trait: "和谐、美感、合作", spouse: "优雅、有艺术气质", marriage: "美满、合作型" },
天蝎座: { trait: "深刻、占有欲、转化", spouse: "神秘、有深度", marriage: "深刻、转化型" },
射手座: { trait: "自由、哲学、冒险", spouse: "开放、有学识", marriage: "自由型、精神伴侣" },
摩羯座: { trait: "务实、责任、延迟", spouse: "成熟、有事业", marriage: "晚婚、责任型" },
水瓶座: { trait: "独立、非传统、友谊", spouse: "独特、有理想", marriage: "非传统、友谊型" },
双鱼座: { trait: "浪漫、牺牲、灵性", spouse: "灵性、有艺术天赋", marriage: "灵性、牺牲型" },
};
const D10_TYPE_TABLE: Readonly<Record<string, { trait: string; occupation: string; style: string }>> = {
白羊座: { trait: "领导、创业、竞争", occupation: "创业者、运动员、军人", style: "主动、竞争" },
金牛座: { trait: "稳定、财富、艺术", occupation: "金融、艺术、农业", style: "稳定、务实" },
双子座: { trait: "沟通、写作、教育", occupation: "教师、作家、销售", style: "多变、沟通" },
巨蟹座: { trait: "照顾、家庭、情感", occupation: "护理、餐饮、房地产", style: "照顾、情感" },
狮子座: { trait: "领导、表演、创意", occupation: "管理、娱乐、政治", style: "领导、表演" },
处女座: { trait: "服务、分析、健康", occupation: "医疗、分析、服务", style: "细致、服务" },
天秤座: { trait: "合作、美学、法律", occupation: "法律、艺术、咨询", style: "合作、和谐" },
天蝎座: { trait: "研究、转化、危机", occupation: "研究、心理学、危机管理", style: "深度、转化" },
射手座: { trait: "教育、哲学、国际", occupation: "教育、出版、国际事务", style: "自由、哲学" },
摩羯座: { trait: "管理、责任、延迟", occupation: "管理、政府、建筑", style: "务实、责任" },
水瓶座: { trait: "创新、科技、人道", occupation: "科技、人道主义、创新", style: "创新、独立" },
双鱼座: { trait: "灵性、艺术、服务", occupation: "艺术、灵性、医疗", style: "灵性、服务" },
};
function signKey(value: string): string {
const trimmed = value.trim();
if (trimmed.endsWith("座")) return trimmed;
return `${trimmed}座`;
}
import { D9_TYPE_TABLE, D10_TYPE_TABLE, signKey } from "./varga-type-tables";
function typeRow(sign: string, table: "d9" | "d10"): string {
const key = signKey(sign);
@@ -0,0 +1,52 @@
/**
* D9/D10 type tables for rectification contrast, not fate promises.
*/
export type D9TypeRow = Readonly<{ trait: string; spouse: string; marriage: string }>;
export type D10TypeRow = Readonly<{ trait: string; occupation: string; style: string }>;
export const D9_TYPE_TABLE: Readonly<Record<string, D9TypeRow>> = {
白羊座: { trait: "主动、热情、冲动", spouse: "独立、有活力", marriage: "早期婚姻、激情型" },
金牛座: { trait: "稳定、务实、占有欲强", spouse: "稳重、有经济基础", marriage: "晚婚、稳定型" },
双子座: { trait: "沟通、多变、好奇心强", spouse: "聪明、善交流", marriage: "多次恋爱、友谊型" },
巨蟹座: { trait: "情感丰富、家庭导向", spouse: "温柔、顾家", marriage: "家庭型" },
狮子座: { trait: "骄傲、戏剧性、领导欲", spouse: "有魅力、有地位", marriage: "戏剧性" },
处女座: { trait: "完美主义、挑剔、服务型", spouse: "细致、有技能", marriage: "晚婚、服务型" },
天秤座: { trait: "和谐、美感、合作", spouse: "优雅、有艺术气质", marriage: "美满、合作型" },
天蝎座: { trait: "深刻、占有欲、转化", spouse: "神秘、有深度", marriage: "深刻、转化型" },
射手座: { trait: "自由、哲学、冒险", spouse: "开放、有学识", marriage: "自由型、精神伴侣" },
摩羯座: { trait: "务实、责任、延迟", spouse: "成熟、有事业", marriage: "晚婚、责任型" },
水瓶座: { trait: "独立、非传统、友谊", spouse: "独特、有理想", marriage: "非传统、友谊型" },
双鱼座: { trait: "浪漫、牺牲、灵性", spouse: "灵性、有艺术天赋", marriage: "灵性、牺牲型" },
};
export const D10_TYPE_TABLE: Readonly<Record<string, D10TypeRow>> = {
白羊座: { trait: "领导、创业、竞争", occupation: "创业者、运动员、军人", style: "主动、竞争" },
金牛座: { trait: "稳定、财富、艺术", occupation: "金融、艺术、农业", style: "稳定、务实" },
双子座: { trait: "沟通、写作、教育", occupation: "教师、作家、销售", style: "多变、沟通" },
巨蟹座: { trait: "照顾、家庭、情感", occupation: "护理、餐饮、房地产", style: "照顾、情感" },
狮子座: { trait: "领导、表演、创意", occupation: "管理、娱乐、政治", style: "领导、表演" },
处女座: { trait: "服务、分析、健康", occupation: "医疗、分析、服务", style: "细致、服务" },
天秤座: { trait: "合作、美学、法律", occupation: "法律、艺术、咨询", style: "合作、和谐" },
天蝎座: { trait: "研究、转化、危机", occupation: "研究、心理学、危机管理", style: "深度、转化" },
射手座: { trait: "教育、哲学、国际", occupation: "教育、出版、国际事务", style: "自由、哲学" },
摩羯座: { trait: "管理、责任、延迟", occupation: "管理、政府、建筑", style: "务实、责任" },
水瓶座: { trait: "创新、科技、人道", occupation: "科技、人道主义、创新", style: "创新、独立" },
双鱼座: { trait: "灵性、艺术、服务", occupation: "艺术、灵性、医疗", style: "灵性、服务" },
};
export function signKey(value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
return trimmed.endsWith("座") ? trimmed : `${trimmed}座`;
}
export function d9StyleLabel(sign: string): string {
const row = D9_TYPE_TABLE[signKey(sign)];
return row?.trait ?? `${signKey(sign)}相处`;
}
export function d10StyleLabel(sign: string): string {
const row = D10_TYPE_TABLE[signKey(sign)];
return row?.style ?? `${signKey(sign)}职责`;
}
@@ -85,6 +85,7 @@ import {
buildCandidateContrastPacket,
conflictProbesFromContrast,
selectDiscriminatorProbe,
volunteeredDomainsFromEvidence,
} from "@/lib/rectification-agentic/core/candidate-contrast-packet";
import { evaluateCandidateSeparation } from "@/lib/rectification-agentic/core/candidate-separation";
import { offerSessionKinds } from "@/lib/rectification-agentic/core/decide-next-action";
@@ -205,6 +206,7 @@ function contrastPacketFromLatest(
candidateTimes,
transitions: windowScan?.transitions ?? [],
askedKeys: askedDiscriminatorKeys(latest?.decisionReceipt, evidence),
volunteeredDomains: volunteeredDomainsFromEvidence(evidence),
});
}
@@ -879,6 +881,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
candidateTimes: score.candidates.map((item) => item.time),
transitions: windowScan?.transitions ?? [],
askedKeys: askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, parsed.evidence),
volunteeredDomains: volunteeredDomainsFromEvidence(parsed.evidence),
});
const inference = buildCaseInferenceState({
range: parsed.case.candidateRange,
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildCandidateContrastPacket,
remainingVargaSplits,
selectDiscriminatorProbe,
volunteeredDomainsFromEvidence,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
test("remaining D10 three-way outranks a skewed D24 split", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: ["05:00", "05:03", "05:04"],
transitions: [
{ layer: "d10", at: "05:03", from_sign: "巨蟹座", to_sign: "狮子座" },
{ layer: "d10", at: "05:04", from_sign: "狮子座", to_sign: "处女座" },
{ layer: "d24", at: "05:04", from_sign: "白羊座", to_sign: "金牛座" },
],
});
const probe = selectDiscriminatorProbe(packet);
assert.ok(probe);
assert.equal(probe.domain, "career");
assert.equal(probe.choiceKind, "varga_style");
assert.match(probe.semanticKey, /varga\.d10/);
assert.doesNotMatch(probe.semanticKey, /varga\.d24/);
assert.equal(probe.styleOptions?.length, 3);
assert.equal(probe.expectedOutcomes.length, 3);
assert.deepEqual(probe.expectedOutcomes.map((row) => row.supportsCandidateIds), [
["05:00"],
["05:03"],
["05:04"],
]);
});
test("two-way remaining D9 maps C to unsure, not the other minute group", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: ["05:00", "05:04"],
transitions: [
{ layer: "d9", at: "05:04", from_sign: "巨蟹座", to_sign: "狮子座" },
],
});
const probe = selectDiscriminatorProbe(packet);
assert.ok(probe);
assert.equal(probe.choiceKind, "varga_style");
assert.equal(probe.styleOptions?.length, 2);
assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "yes")?.supportsCandidateIds, ["05:00"]);
assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "weak_yes")?.supportsCandidateIds, ["05:04"]);
assert.equal(probe.expectedOutcomes.some((row) => row.outcomeId === "no"), false);
});
test("remaining D7 enters the pool as family existence", () => {
const splits = remainingVargaSplits(
["05:00", "05:04"],
[{ layer: "d7", at: "05:04", from_sign: "金牛座", to_sign: "双子座" }],
);
assert.equal(splits[0]?.layer, "d7");
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: ["05:00", "05:04"],
remainingSplits: splits,
});
const probe = selectDiscriminatorProbe(packet);
assert.equal(probe?.domain, "family");
assert.equal(probe?.choiceKind, "existence");
assert.match(probe?.question ?? "", /家人/);
});
test("finance remaining splits stay out unless volunteered", () => {
const hidden = remainingVargaSplits(
["05:00", "05:04"],
[{ layer: "d2", at: "05:04" }],
);
assert.equal(hidden.length, 0);
const shown = remainingVargaSplits(
["05:00", "05:04"],
[{ layer: "d2", at: "05:04" }],
volunteeredDomainsFromEvidence([{ status: "confirmed", domain: "finance" }]),
);
assert.equal(shown[0]?.layer, "d2");
});
@@ -685,3 +685,91 @@ test("GET choice_card stays hidden after 没有了 when selection is allowed", (
});
assert.equal(card, null);
});
test("remaining D10 style card uses type-table labels instead of existence", () => {
const frame = buildChoiceFrame({
method_id: "d10_career",
ask_theme: "career_style",
domain: "career",
user_prompt_hint: "unused",
choice_kind: "varga_style",
style_options: [
{ label: "照顾、情感", answer_class: "yes", sign: "巨蟹座" },
{ label: "领导、表演", answer_class: "weak_yes", sign: "狮子座" },
{ label: "细致、服务", answer_class: "no", sign: "处女座" },
],
});
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.equal(frame.choice_kind, "varga_style");
assert.match(copy.prompt, /长期工作更接近哪一类/);
assert.doesNotMatch(copy.prompt, /有没有明显/);
assert.equal(copy.option_a, "照顾、情感");
assert.equal(copy.option_b, "领导、表演");
assert.equal(copy.option_c, "细致、服务");
});
test("two-way remaining D9 puts 都不像 on C", () => {
const frame = buildChoiceFrame({
method_id: "d9_relationship",
ask_theme: "relationship_style",
domain: "relationship",
user_prompt_hint: "unused",
choice_kind: "varga_style",
style_options: [
{ label: "情感丰富、家庭导向", answer_class: "yes", sign: "巨蟹座" },
{ label: "骄傲、戏剧性、领导欲", answer_class: "weak_yes", sign: "狮子座" },
],
});
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.match(copy.prompt, /相处/);
assert.match(copy.option_c, /都不像/);
assert.match(copy.option_d, /不确定/);
});
test("known exam quality asks 失常, not enrollment existence", () => {
const frame = buildChoiceFrame({
method_id: "d5_education",
ask_theme: "education_style",
domain: "education",
user_prompt_hint: "unused",
choice_kind: "event_quality",
}, {
probes: [{
year: 2015,
year_label: "2015 年前后",
domain: "education",
event_family: "高考或重要考试发挥明显失常、压力很大",
source: "known_event_quality",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: "年份锁定 2015 年前后。已有高考或考试经历。请写成一句自然语言,问那次是否发挥失常或压力特别大。不得改年份。",
role: "distinguish",
choice_kind: "event_quality",
}],
});
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.match(copy.prompt, /2015 年前后/);
assert.match(copy.prompt, /失常/);
assert.doesNotMatch(copy.prompt, /升学、高考、转学/);
assert.match(copy.option_a, /失常或压力很大/);
assert.match(copy.option_c, /没有明显失常/);
});
test("dasha existence cards still ask whether the event happened", () => {
const frame = buildChoiceFrame({
method_id: "d4_home",
ask_theme: "home_change",
domain: "relocation",
user_prompt_hint: "unused",
choice_kind: "existence",
}, { probes: [MOVE_PROBE] });
const copy = serverOwnedChoiceCopy(frame);
assert.ok(copy);
assert.match(copy.prompt, /有没有明显搬家/);
assert.match(copy.option_a, /是,大概就在那段时间/);
assert.match(copy.option_c, /没有明显发生/);
});
@@ -461,6 +461,51 @@ test("A/B/C/D on a remaining-minute contrast probe moves the posterior", () => {
> (applied.state.candidates.find((item) => item.time === "05:00")?.posterior_score ?? 0));
});
test("two-way style C 都不像 does not promote the other minute group", () => {
const contrast = {
id: "contrast:varga.d9.05:00/05:04",
semantic_key: "varga.d9.05:00/05:04",
candidate_split_hash: "varga.d9.05:00/05:04",
domain: "relationship",
year: 0,
question: "这段关系更接近哪一种相处?",
candidate_ids: ["05:00", "05:04"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:04"] },
{ answer_class: "weak_yes" as const, supports: ["05:04"], conflicts: ["05:00"] },
{ answer_class: "unsure" as const, supports: [] as string[], conflicts: [] as string[] },
],
information_gain: 1,
source: "varga_contrast",
};
const state = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 34 },
{ id: "05:04", time: "05:04", relative_support: 33 },
],
events: [{ id: "e1", domain: "relationship", year: 2024, precision: "year" }],
probes: [contrast],
});
const before = posteriorMap(state.candidates);
const applied = applyChoiceWithoutEvidence(state, {
choiceKey: "C",
schema: {
choice: {
prompt: "这段关系更接近哪一种相处?",
option_c: "两边都不像",
},
choice_kind: "varga_style",
probe_id: contrast.id,
semantic_key: contrast.semantic_key,
},
});
assert.equal(applied.applied, true);
assert.equal(applied.answerClass, "unsure");
assert.deepEqual(posteriorMap(applied.state.candidates), before);
});
test("holdout and collection declines do not write a probe answer", () => {
const conflict = probe({
id: "p-holdout",
+192 -57
View File
@@ -37,14 +37,19 @@ LAYER_DOMAIN = {
"d11": "finance",
"d30": "health_pressure",
}
STAGE_DOMAIN = {
"d9_refine": "relationship",
"d10_refine": "career",
"d4_refine": "relocation",
"theme_refine": "relocation",
"d5_refine": "education",
}
VOLUNTEER_ONLY = frozenset({"finance", "health_pressure"})
LAYER_VARGA = {
"d9": "D9",
"d10": "D10",
"d4": "D4",
"d5": "D5",
"d24": "D24",
"d7": "D7",
"d12": "D12",
"d2": "D2",
"d11": "D11",
"d30": "D30",
}
DOMAIN_CATALOG: dict[str, dict[str, Any]] = {
"education": {
"event_family": "升学、高考、转学或学习环境变化",
@@ -158,10 +163,43 @@ def _age_band_year(birth_year: int, domain: str, today: date) -> int | None:
return year
def _layer_value(context: dict[str, Any], layer: str) -> int | None:
feature = context.get("feature") if isinstance(context.get("feature"), dict) else {}
if layer == "d1":
raw = feature.get("ascendant_sign_index")
if isinstance(raw, int):
return raw
index = context.get("ascendant_index")
return index if isinstance(index, int) else None
name = LAYER_VARGA.get(layer)
if not name:
return None
vargas = feature.get("varga_ascendants") if isinstance(feature.get("varga_ascendants"), dict) else {}
raw = vargas.get(name)
if isinstance(raw, int):
return raw
charts = context.get("varga_charts") if isinstance(context.get("varga_charts"), dict) else {}
chart = charts.get(name) if isinstance(charts.get(name), dict) else {}
ascendant = chart.get("Ascendant") if isinstance(chart.get("Ascendant"), dict) else {}
index = ascendant.get("sign_idx")
return index if isinstance(index, int) else None
def _differing_layers(contexts: Sequence[dict[str, Any]]) -> set[str]:
values: dict[str, set[int]] = {layer: set() for layer in SCORING_LAYERS}
for context in contexts:
for layer in SCORING_LAYERS:
value = _layer_value(context, layer)
if isinstance(value, int):
values[layer].add(value)
return {layer for layer, bucket in values.items() if len(bucket) > 1}
def _probe_domains(
scan: dict[str, Any],
precision_current: str | None,
remaining_layers: set[str],
events: Sequence[dict[str, Any]],
*,
d1_differs: bool = False,
) -> list[str]:
volunteered = {
str(event.get("domain"))
@@ -169,19 +207,16 @@ def _probe_domains(
if isinstance(event, dict) and event.get("domain")
}
ordered: list[str] = []
stage_domain = STAGE_DOMAIN.get(str(precision_current or ""))
if stage_domain:
ordered.append(stage_domain)
for layer in SCORING_LAYERS:
domain = LAYER_DOMAIN.get(layer)
if not domain or domain in ordered:
continue
if not scan.get(f"{layer}_candidates_differ"):
if layer not in remaining_layers:
continue
if domain in VOLUNTEER_ONLY and domain not in volunteered:
continue
ordered.append(domain)
if not ordered and scan.get("d1_candidates_differ"):
if not ordered and d1_differs:
ordered.append("education")
return ordered
@@ -195,6 +230,20 @@ def _static_contexts(built: dict[str, Any]) -> list[dict[str, Any]]:
return rows
def _remaining_contexts(built: dict[str, Any], candidate_times: Sequence[str]) -> list[dict[str, Any]]:
by_time = {_context_time(item): item for item in _static_contexts(built)}
remaining: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in candidate_times:
time = str(raw or "")[:5]
context = by_time.get(time)
if context is None or time in seen:
continue
seen.add(time)
remaining.append(context)
return remaining
def _pick_representatives(
built: dict[str, Any],
scan: dict[str, Any],
@@ -440,6 +489,13 @@ def _pair_entropy(left: float, right: float) -> float:
return _binary_entropy(left / total)
def _group_entropy(sizes: Sequence[int]) -> float:
total = sum(int(item) for item in sizes)
if total <= 0:
return 0.0
return round(-sum((item / total) * log2(item / total) for item in sizes if item > 0), 4)
def _information_gain(left_level: str, right_level: str) -> float:
left_p = LEVEL_P.get(left_level, 0.5)
right_p = LEVEL_P.get(right_level, 0.5)
@@ -448,6 +504,10 @@ def _information_gain(left_level: str, right_level: str) -> float:
return round(max(0.0, 1.0 - after), 4)
def _year_activated(rule_ids: Sequence[str]) -> bool:
return _has_domain_activation(rule_ids) or LEVEL_RANK.get(match_level(rule_ids), 0) >= 2
def _public_probe(
*,
year: int,
@@ -474,6 +534,7 @@ def _public_probe(
"information_gain": 0.0,
"candidate_split_hash": f"{domain}:{year}",
"expected_outcomes": [],
"choice_kind": "event_quality" if source == "known_event_quality" else "existence",
}
payload.update(extra)
return payload
@@ -558,33 +619,54 @@ def _quality_probes(
return rows
def _evaluate_year(
left: dict[str, Any],
right: dict[str, Any],
def _evaluate_contexts(
contexts: Sequence[dict[str, Any]],
*,
birth_date: str,
domain: str,
year: int,
source: str,
) -> dict[str, Any] | None:
scored_left = _score_year(left, birth_date=birth_date, domain=domain, year=year)
scored_right = _score_year(right, birth_date=birth_date, domain=domain, year=year)
if scored_left is None or scored_right is None:
scored_rows: list[tuple[str, list[str]]] = []
for context in contexts:
time = _context_time(context)
if not time:
continue
scored = _score_year(context, birth_date=birth_date, domain=domain, year=year)
if scored is None:
continue
scored_rows.append((time, list(scored.get("rule_ids") or [])))
if len(scored_rows) < 2:
return None
left_rules = scored_left.get("rule_ids") or []
right_rules = scored_right.get("rule_ids") or []
if not _discriminates(left_rules, right_rules):
yes: list[tuple[str, list[str]]] = []
no: list[tuple[str, list[str]]] = []
for time, rules in scored_rows:
if _year_activated(rules):
yes.append((time, rules))
else:
no.append((time, rules))
if not yes or not no:
ranks = [
(time, LEVEL_RANK.get(match_level(rules), 0), rules)
for time, rules in scored_rows
]
highest = max(item[1] for item in ranks)
lowest = min(item[1] for item in ranks)
if highest - lowest < 2:
return None
yes = [(time, rules) for time, rank, rules in ranks if rank == highest]
no = [(time, rules) for time, rank, rules in ranks if rank < highest]
if not yes or not no:
return None
if not any(_discriminates(left, right) for _, left in yes for _, right in no):
return None
left_level = match_level(left_rules)
right_level = match_level(right_rules)
stronger = left_rules if LEVEL_RANK[left_level] >= LEVEL_RANK[right_level] else right_rules
yes_times = sorted((time for time, _ in yes), key=_clock)
no_times = sorted((time for time, _ in no), key=_clock)
yes_level = max((match_level(rules) for _, rules in yes), key=lambda item: LEVEL_RANK[item])
no_level = min((match_level(rules) for _, rules in no), key=lambda item: LEVEL_RANK[item])
stronger = next(rules for _, rules in yes if match_level(rules) == yes_level)
vim_hit, narayana_hit = _tracks_present(stronger)
left_time = _context_time(left)
right_time = _context_time(right)
left_stronger = LEVEL_RANK[left_level] >= LEVEL_RANK[right_level]
yes_supports = [time for time in ([left_time] if left_stronger else [right_time]) if time]
yes_conflicts = [time for time in ([right_time] if left_stronger else [left_time]) if time]
split = f"{domain}:{year}:{ '|'.join(sorted(yes_supports + yes_conflicts)) }"
split = f"{domain}:{year}:{ '|'.join(sorted(yes_times + no_times)) }"
return _public_probe(
year=year,
domain=domain,
@@ -596,16 +678,37 @@ def _evaluate_year(
family=str(DOMAIN_CATALOG[domain]["event_family"]),
),
event_family=str(DOMAIN_CATALOG[domain]["event_family"]),
information_gain=_information_gain(left_level, right_level),
information_gain=round(
_group_entropy([len(yes_times), len(no_times)]) + _information_gain(yes_level, no_level),
4,
),
semantic_key=f"{domain}.{year}.{source}",
candidate_split_hash=split,
expected_outcomes=[
{"answer_class": "yes", "supports": yes_supports, "conflicts": yes_conflicts},
{"answer_class": "no", "supports": yes_conflicts, "conflicts": yes_supports},
{"answer_class": "yes", "supports": yes_times, "conflicts": no_times},
{"answer_class": "no", "supports": no_times, "conflicts": yes_times},
{"answer_class": "unsure", "supports": [], "conflicts": []},
],
left_time=left_time,
right_time=right_time,
left_time=yes_times[0],
right_time=no_times[0],
)
def _evaluate_year(
left: dict[str, Any],
right: dict[str, Any],
*,
birth_date: str,
domain: str,
year: int,
source: str,
) -> dict[str, Any] | None:
return _evaluate_contexts(
[left, right],
birth_date=birth_date,
domain=domain,
year=year,
source=source,
)
@@ -619,6 +722,7 @@ def discriminating_event_probes(
precision_current: str | None = None,
today: date | None = None,
) -> list[dict[str, Any]]:
del precision_current
birth_date = str(request.get("birth_date") or "").strip()
birth_year = _birth_year(birth_date)
if birth_year is None:
@@ -629,21 +733,42 @@ def discriminating_event_probes(
return []
now = today or date.today()
events = [item for item in (request.get("events") or []) if isinstance(item, dict)]
domains = _probe_domains(scan, precision_current, events)
if not domains:
remaining = _remaining_contexts(built, candidate_times)
if len(remaining) < 2:
remaining = _static_contexts(built)
remaining_layers = _differing_layers(remaining) if len(remaining) >= 2 else set()
if not remaining_layers:
remaining_layers = {
layer for layer in SCORING_LAYERS
if scan.get(f"{layer}_candidates_differ")
}
domains = _probe_domains(
remaining_layers,
events,
d1_differs="d1" in remaining_layers or bool(scan.get("d1_candidates_differ")),
)
known_domains = [
str(event.get("domain"))
for event in events
if str(event.get("domain") or "") in DOMAIN_CATALOG
]
if not domains and not known_domains:
return []
probes: list[dict[str, Any]] = []
covered_domains: set[str] = set()
pair = _pick_representatives(built, scan, candidate_times, representative_time)
scoreable_remaining = [item for item in remaining if _scoreable(item)]
if len(scoreable_remaining) >= 2:
score_contexts = scoreable_remaining
elif pair is not None and _scoreable(pair[0]) and _scoreable(pair[1]):
score_contexts = [pair[0], pair[1]]
else:
score_contexts = []
lo, hi = birth_year + 5, min(now.year, birth_year + 80)
can_score = (
pair is not None
and _scoreable(pair[0])
and _scoreable(pair[1])
)
can_score = len(score_contexts) >= 2
dasha_domains: set[str] = set()
if can_score and pair is not None:
left, right = pair
if can_score:
left, right = score_contexts[0], score_contexts[-1]
left_moon = float(left["planet_longitudes"]["Moon"])
right_moon = float(right["planet_longitudes"]["Moon"])
vim_years = _boundary_years(
@@ -661,26 +786,36 @@ def discriminating_event_probes(
known_years = _event_years(events, domain)
blocked_years = _existence_blocked_years(domain, known_years)
boundary = sorted((vim_years | narayana_years) & set(range(lo, hi + 1)))
found = None
best = None
for year in boundary:
if year in blocked_years:
continue
found = _evaluate_year(
left, right, birth_date=birth_date, domain=domain, year=year, source="dasha_boundary",
found = _evaluate_contexts(
score_contexts,
birth_date=birth_date,
domain=domain,
year=year,
source="dasha_boundary",
)
if found:
break
if found is None:
if found is None:
continue
if best is None or float(found["information_gain"]) > float(best["information_gain"]):
best = found
if best is None:
midpoint = _age_band_year(birth_year, domain, now)
if midpoint is not None and midpoint not in blocked_years:
found = _evaluate_year(
left, right, birth_date=birth_date, domain=domain, year=midpoint, source="dasha_activation",
best = _evaluate_contexts(
score_contexts,
birth_date=birth_date,
domain=domain,
year=midpoint,
source="dasha_activation",
)
if found:
probes.append(found)
if best:
probes.append(best)
dasha_domains.add(domain)
covered_domains.add(domain)
quality = _quality_probes(events, domains)
quality = _quality_probes(events, known_domains or domains)
for row in quality:
if row["domain"] in dasha_domains:
continue
+17 -4
View File
@@ -77,11 +77,18 @@ def _features(built: dict[str, Any]) -> list[dict[str, Any]]:
return rows
def _sign_name(index: int | None) -> str | None:
if isinstance(index, int) and 0 <= index <= 11:
return SIGNS_CN[SIGNS[index]]
return None
def _sign_names(indices: set[int]) -> list[str]:
names: list[str] = []
for idx in sorted(indices):
if isinstance(idx, int) and 0 <= idx <= 11:
names.append(SIGNS_CN[SIGNS[idx]])
name = _sign_name(idx)
if name:
names.append(name)
return names
@@ -207,11 +214,17 @@ def window_scan(
before = previous[layer]
after = current[layer]
if isinstance(before, int) and isinstance(after, int) and before != after:
transitions.append({
row = {
"layer": layer,
"at": time,
"user_meaning": f"{label} 在 {time} 发生变化",
})
}
from_sign = _sign_name(before)
to_sign = _sign_name(after)
if from_sign and to_sign:
row["from_sign"] = from_sign
row["to_sign"] = to_sign
transitions.append(row)
previous = current
payload: dict[str, Any] = {
"scanned": True,
+95
View File
@@ -345,6 +345,101 @@ class EventProbesTest(unittest.TestCase):
self.assertTrue(times <= {"05:00", "05:06", "05:07"})
self.assertIn("05:00", times)
self.assertTrue(times & {"05:06", "05:07"})
covered = set(row["expected_outcomes"][0]["supports"] + row["expected_outcomes"][0]["conflicts"])
self.assertEqual(covered, {"05:00", "05:06", "05:07"})
def test_remaining_family_layer_outranks_stable_relationship(self) -> None:
built = {
"static_contexts": [
{
"feature": {
"time": "05:13",
"varga_ascendants": {"D9": 1, "D10": 1, "D4": 1, "D12": 1, "D7": 1},
}
},
{
"feature": {
"time": "05:14",
"varga_ascendants": {"D9": 1, "D10": 1, "D4": 1, "D12": 2, "D7": 2},
}
},
]
}
probes = discriminating_event_probes(
_request(),
built,
scan=window_scan(built),
candidate_times=["05:13", "05:14"],
representative_time="05:13",
precision_current="d9_refine",
today=date(2026, 8, 22),
)
self.assertTrue(probes)
self.assertEqual(probes[0]["domain"], "family")
self.assertFalse(any(item["domain"] == "relationship" for item in probes))
self.assertFalse(any(item["domain"] == "finance" for item in probes))
def test_finance_layer_stays_volunteer_only(self) -> None:
built = {
"static_contexts": [
{"feature": {"time": "05:13", "varga_ascendants": {"D2": 1, "D9": 1, "D10": 1}}},
{"feature": {"time": "05:14", "varga_ascendants": {"D2": 2, "D9": 1, "D10": 1}}},
]
}
probes = discriminating_event_probes(
_request(),
built,
scan=window_scan(built),
candidate_times=["05:13", "05:14"],
representative_time="05:13",
today=date(2026, 8, 22),
)
self.assertFalse(any(item["domain"] == "finance" for item in probes))
def test_highest_gain_year_is_kept_not_first_hit(self) -> None:
from unittest.mock import patch
from scripts.rectification import event_probes as probes_mod
built = {
"static_contexts": [
_context("05:13", d4_asc=0, sun_house=4, sun_varga_sign=3, moon=100.0),
_context("05:14", d4_asc=1, sun_house=10, sun_varga_sign=9, moon=101.0),
]
}
def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[int]:
return [2010, 2020] if moon <= 100.0 else [2009, 2019]
def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[int]:
moon = float(planets.get("Moon") or 0)
return [2010, 2020] if moon <= 100.0 else [2009, 2019]
def fake_score(context: dict, *, birth_date: str, domain: str, year: int) -> dict:
del birth_date, domain
early = probes_mod._context_time(context) == "05:13"
if year == 2010:
return {"rule_ids": ["vim_ad_domain_lord"] if early else ["no_domain_activation"]}
if year == 2020:
return {"rule_ids": ["vim_md_domain_house"] if early else ["no_domain_activation"]}
return {"rule_ids": ["no_domain_activation"]}
with (
patch.object(probes_mod, "_vim_start_years", side_effect=fake_vim),
patch.object(probes_mod, "_narayana_start_years", side_effect=fake_narayana),
patch.object(probes_mod, "_score_year", side_effect=fake_score),
):
probes = discriminating_event_probes(
_request(),
built,
scan=window_scan(built),
candidate_times=["05:13", "05:14"],
representative_time="05:13",
today=date(2026, 8, 22),
)
row = next(item for item in probes if item["domain"] == "relocation")
self.assertEqual(row["year"], 2020)
self.assertEqual(row["source"], "dasha_boundary")
if __name__ == "__main__":
@@ -101,6 +101,8 @@ class RefinementPacketTest(unittest.TestCase):
"layer": "d9",
"at": "05:14",
"user_meaning": "D9 在 05:14 发生变化",
"from_sign": "金牛座",
"to_sign": "天蝎座",
}])
encoded = str(scan)
self.assertNotIn("Aries", encoded)