fix(rectification): separate adjacent minutes with transition proximity
Day-level events now score Vimshottari/Narayana transition closeness so nearby candidate minutes can diverge, with gated quality probes and answer-prior ranking so high-base-rate existence questions stay out. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -103,6 +103,7 @@ export type EngineContrastProbe = Readonly<{
|
||||
answer_class?: string;
|
||||
sign?: string;
|
||||
}>[];
|
||||
target_evidence_id?: string;
|
||||
}>;
|
||||
|
||||
const REMAINING_LAYERS = ["d24", "d5", "d10", "d9", "d4", "d7", "d12", "d2", "d11", "d30"] as const;
|
||||
|
||||
@@ -16,6 +16,7 @@ export type SnapshotStaleReason =
|
||||
| "scoreable_evidence_changed"
|
||||
| "candidate_set_superseded"
|
||||
| "inference_revision_changed"
|
||||
| "scoring_policy_changed"
|
||||
| "fingerprint_missing";
|
||||
|
||||
export const SNAPSHOT_STALE_COPY: Readonly<Record<SnapshotStaleReason, string>> = {
|
||||
@@ -23,6 +24,7 @@ export const SNAPSHOT_STALE_COPY: Readonly<Record<SnapshotStaleReason, string>>
|
||||
scoreable_evidence_changed: "可评分证据已变化,请重新比较候选",
|
||||
candidate_set_superseded: "已有更新的候选结果",
|
||||
inference_revision_changed: "候选后验已更新,请使用当前结果",
|
||||
scoring_policy_changed: "评分政策已更新,请重新比较候选",
|
||||
fingerprint_missing: "候选快照缺少证据指纹,请重新比较候选",
|
||||
};
|
||||
|
||||
@@ -50,6 +52,13 @@ export function classifySnapshotStaleReason(
|
||||
if (snapshot.inferenceRevision !== current.inferenceRevision) {
|
||||
return "inference_revision_changed";
|
||||
}
|
||||
if (
|
||||
snapshot.scoringPolicyVersion
|
||||
&& current.scoringPolicyVersion
|
||||
&& snapshot.scoringPolicyVersion !== current.scoringPolicyVersion
|
||||
) {
|
||||
return "scoring_policy_changed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,7 +71,8 @@ export function scoreableSnapshotIsCurrent(
|
||||
if (!snapshot.scoreableEvidenceFingerprint || !current.scoreableEvidenceFingerprint) return false;
|
||||
return snapshot.scoreableEvidenceFingerprint === current.scoreableEvidenceFingerprint
|
||||
&& snapshot.candidateSetVersion === current.candidateSetVersion
|
||||
&& snapshot.inferenceRevision === current.inferenceRevision;
|
||||
&& snapshot.inferenceRevision === current.inferenceRevision
|
||||
&& snapshot.scoringPolicyVersion === current.scoringPolicyVersion;
|
||||
}
|
||||
|
||||
/** No stored snapshot is not stale; a stored snapshot with a missing fingerprint is. */
|
||||
|
||||
@@ -85,6 +85,7 @@ export type ConflictProbe = Readonly<{
|
||||
information_gain: number;
|
||||
source: string;
|
||||
choice_kind?: ProbeChoiceKind;
|
||||
target_evidence_id?: string;
|
||||
style_options?: readonly Readonly<{
|
||||
label: string;
|
||||
answer_class: AnswerClass;
|
||||
|
||||
@@ -50,9 +50,19 @@ import {
|
||||
spokenFollowupForUser,
|
||||
} from "./method-followup";
|
||||
import type { SessionOutcomeKind } from "./confirmation-gate";
|
||||
import { refinementFromDecisionReceipt } from "./refinement-packet";
|
||||
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
|
||||
import { projectCurrentQuestion } from "./turn-decision";
|
||||
|
||||
function withProspectiveWindows(
|
||||
base: string,
|
||||
receipt: Readonly<Record<string, unknown>> | null | undefined,
|
||||
): string {
|
||||
const extra = prospectiveWindowsNarration(
|
||||
refinementFromDecisionReceipt(receipt).prospective_probes,
|
||||
);
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
|
||||
export type ApplyChoiceCommand = Readonly<{
|
||||
userId: string;
|
||||
caseId: string;
|
||||
@@ -386,10 +396,10 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
});
|
||||
}
|
||||
return {
|
||||
hostNarration: nonConvergingRangeNarration({
|
||||
hostNarration: withProspectiveWindows(nonConvergingRangeNarration({
|
||||
credibleRange: input.nextAction.credible_range,
|
||||
representativeTime: input.nextAction.representative_time,
|
||||
}),
|
||||
}), latest.decisionReceipt),
|
||||
choiceReady: false,
|
||||
};
|
||||
}
|
||||
@@ -535,10 +545,10 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
return {
|
||||
persisted: false,
|
||||
choiceReady: false,
|
||||
hostNarration: `${nonConvergingRangeNarration({
|
||||
hostNarration: withProspectiveWindows(`${nonConvergingRangeNarration({
|
||||
credibleRange: decision.credibleRange,
|
||||
representativeTime: decision.representativeTime,
|
||||
})} 可以从下面的时间里选一个采用。`,
|
||||
})} 可以从下面的时间里选一个采用。`, dossier.latestResult?.decisionReceipt),
|
||||
};
|
||||
}
|
||||
if (isNonConvergingRangeOffer(decision)) {
|
||||
@@ -615,7 +625,7 @@ async function persistExhaustionCollect(input: {
|
||||
return {
|
||||
persisted,
|
||||
choiceReady: false,
|
||||
hostNarration: range,
|
||||
hostNarration: withProspectiveWindows(range, input.decisionReceipt ?? input.dossier.latestResult?.decisionReceipt),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -702,19 +712,19 @@ ${nextInterview.hostNarration}`
|
||||
}
|
||||
}
|
||||
const adoptionNarration = nextAction.can_adopt
|
||||
? `${nonConvergingRangeNarration({
|
||||
? withProspectiveWindows(`${nonConvergingRangeNarration({
|
||||
credibleRange: nextAction.credible_range,
|
||||
representativeTime: nextAction.representative_time,
|
||||
})} 可以从下面的时间里选一个采用。`
|
||||
})} 可以从下面的时间里选一个采用。`, input.dossier.latestResult?.decisionReceipt)
|
||||
: null;
|
||||
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
|
||||
const completedRangeNarration = nextAction.type === "complete_with_range"
|
||||
? `${completedRangePrefix}
|
||||
? withProspectiveWindows(`${completedRangePrefix}
|
||||
|
||||
${nonConvergingRangeNarration({
|
||||
credibleRange: nextAction.credible_range,
|
||||
representativeTime: nextAction.representative_time,
|
||||
}, RECTIFICATION_TERMINATION_COPY)}`
|
||||
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
|
||||
: null;
|
||||
if (adoptionNarration || completedRangeNarration) {
|
||||
hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration;
|
||||
|
||||
@@ -275,6 +275,7 @@ function eventQuestionPrompt(
|
||||
family: string,
|
||||
kind: EventProbeChoiceKind,
|
||||
domain?: string | null,
|
||||
probe?: DiscriminatingEventProbe | null,
|
||||
): string {
|
||||
const time = period.trim();
|
||||
const topic = family.replace(/[??。]+$/g, "").trim();
|
||||
@@ -283,6 +284,9 @@ function eventQuestionPrompt(
|
||||
? "亲密关系里,你更接近哪一种相处方式?"
|
||||
: "平时做事,你更接近下面哪一种?";
|
||||
}
|
||||
if (kind === "event_quality" && probe?.role === "distinguish" && probe.target_evidence_id && probe.user_meaning?.trim()) {
|
||||
return probe.user_meaning.trim();
|
||||
}
|
||||
if (!topic) return time;
|
||||
const dated = isConcreteChoicePeriod(period);
|
||||
if (kind === "event_quality") {
|
||||
@@ -342,7 +346,7 @@ function hypothesisFor(
|
||||
} else if (!isConcreteChoicePeriod(period)) {
|
||||
return null;
|
||||
}
|
||||
const prompt = eventQuestionPrompt(period, probe.event_family, kind, domain);
|
||||
const prompt = eventQuestionPrompt(period, probe.event_family, kind, domain, probe);
|
||||
const why = probe.user_meaning?.trim() || followup.user_prompt_hint.trim();
|
||||
if (!why) return null;
|
||||
return withStyleOptionLabels(prompt, why, null, styleOptions.options);
|
||||
|
||||
@@ -184,7 +184,7 @@ export function contrastPacketFromLatestResult(
|
||||
const answered = new Set((inference?.answered_probes ?? []).map((item) => item.probe_id));
|
||||
const fromInference: EngineContrastProbe[] = (inference?.probes ?? []).flatMap((probe) => {
|
||||
if (answered.has(probe.id) || probe.information_gain <= 0) return [];
|
||||
if (probe.source === "known_event_quality") return [];
|
||||
if (probe.source === "known_event_quality" && !probe.target_evidence_id) return [];
|
||||
if (probe.source === "nakshatra_boundary") return [];
|
||||
return [{
|
||||
semantic_key: probe.semantic_key,
|
||||
@@ -200,6 +200,7 @@ export function contrastPacketFromLatestResult(
|
||||
? { choice_kind: probe.choice_kind }
|
||||
: {}),
|
||||
...(probe.style_options?.length ? { style_options: probe.style_options } : {}),
|
||||
...(probe.target_evidence_id ? { target_evidence_id: probe.target_evidence_id } : {}),
|
||||
}];
|
||||
});
|
||||
const merged = mergeEngineProbes(
|
||||
@@ -214,6 +215,7 @@ export function contrastPacketFromLatestResult(
|
||||
expected_outcomes: probe.expected_outcomes,
|
||||
choice_kind: probe.choice_kind,
|
||||
style_options: probe.style_options,
|
||||
target_evidence_id: probe.target_evidence_id,
|
||||
})),
|
||||
);
|
||||
return buildCandidateContrastPacket({
|
||||
@@ -398,6 +400,7 @@ function completedProbeForSemanticKey(
|
||||
expected_outcomes: event.expected_outcomes,
|
||||
...(event.choice_kind ? { choice_kind: event.choice_kind } : {}),
|
||||
...(event.style_options?.length ? { style_options: event.style_options } : {}),
|
||||
...(event.target_evidence_id ? { target_evidence_id: event.target_evidence_id } : {}),
|
||||
}],
|
||||
candidateTimes: [...(event.candidate_ids ?? [])],
|
||||
});
|
||||
|
||||
@@ -508,10 +508,13 @@ function remainingReverseVerifyProbes(
|
||||
const dasha: DiscriminatingEventProbe[] = [];
|
||||
const fallback: DiscriminatingEventProbe[] = [];
|
||||
for (const probe of probes ?? []) {
|
||||
if (probe.source === "known_event_quality" || probe.role === "clarify" || probe.phase === "event_clarification") continue;
|
||||
const anchoredQuality = probe.source === "known_event_quality"
|
||||
&& Boolean(probe.target_evidence_id)
|
||||
&& probe.role === "distinguish";
|
||||
if ((probe.source === "known_event_quality" && !anchoredQuality) || probe.role === "clarify" || probe.phase === "event_clarification") continue;
|
||||
if (probe.role === "collect" || probe.phase === "evidence_collection") continue;
|
||||
if (declined.has(probe.domain)) continue;
|
||||
if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
|
||||
if (!anchoredQuality && probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
|
||||
if (probeBelowAdultFloor(probe, birthDate)) continue;
|
||||
if (CONFLICT_PROBE_SOURCES.has(probe.source)) {
|
||||
dasha.push(probe);
|
||||
@@ -1109,7 +1112,8 @@ export function isOfferBlockingFollowup(
|
||||
|
||||
function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null {
|
||||
if (!followup) return null;
|
||||
if (followup.choice_kind === "event_quality" || followup.intent === "clarify_event") return null;
|
||||
if (followup.intent === "clarify_event") return null;
|
||||
if (followup.choice_kind === "event_quality" && followup.intent !== "distinguish_candidates") return null;
|
||||
const realProbe = followup.source === "event_probe"
|
||||
|| followup.source === "reverse_verify"
|
||||
|| (followup.source === "active_focus" && followup.intent === "distinguish_candidates");
|
||||
|
||||
@@ -226,6 +226,24 @@ export type DiscriminatingEventProbe = Readonly<{
|
||||
right_time?: string;
|
||||
choice_kind?: EventProbeChoiceKind;
|
||||
style_options?: readonly EventProbeStyleOption[];
|
||||
target_evidence_id?: string;
|
||||
display_date_label?: string;
|
||||
}>;
|
||||
|
||||
export type ProspectiveProbe = Readonly<{
|
||||
candidate_label: string;
|
||||
domain: string;
|
||||
window_label: string;
|
||||
user_meaning: string;
|
||||
used_for_scoring: false;
|
||||
}>;
|
||||
|
||||
export type DroppedPacketProbe = Readonly<{
|
||||
semantic_key?: string;
|
||||
reason: string;
|
||||
domain?: string;
|
||||
year?: number;
|
||||
source?: string;
|
||||
}>;
|
||||
|
||||
export type CandidateContrastOpportunity = Readonly<{
|
||||
@@ -492,6 +510,8 @@ function parseExpectedOutcomes(value: unknown): ProbeExpectedOutcome[] {
|
||||
}
|
||||
|
||||
function parseProbeRole(row: Readonly<Record<string, unknown>>, source: string): EventProbeRole {
|
||||
const anchored = typeof row.target_evidence_id === "string" && row.target_evidence_id.length > 0;
|
||||
if (source === "known_event_quality" && row.role === "distinguish" && anchored) return "distinguish";
|
||||
if (row.role === "clarify" || source === "known_event_quality") return "clarify";
|
||||
if (row.role === "collect" || source === "age_band") return "collect";
|
||||
if (row.role === "holdout" || source === "oos_blind") return "holdout";
|
||||
@@ -531,7 +551,13 @@ export function parseEventProbes(
|
||||
|| CLOCK_IN_COPY.test(family)
|
||||
|| row.unique_minute_claim === true
|
||||
) continue;
|
||||
if (!options.allowQuality && source === "known_event_quality") continue;
|
||||
const evidenceId = typeof row.target_evidence_id === "string" && UUID.test(row.target_evidence_id)
|
||||
? row.target_evidence_id
|
||||
: null;
|
||||
const anchoredQuality = source === "known_event_quality"
|
||||
&& Boolean(evidenceId)
|
||||
&& (row.role === "distinguish" || row.phase === "candidate_discriminator");
|
||||
if (!options.allowQuality && source === "known_event_quality" && !anchoredQuality) continue;
|
||||
const tracks = Array.isArray(row.tracks)
|
||||
? row.tracks.filter((track): track is "vimshottari" | "narayana" => track === "vimshottari" || track === "narayana")
|
||||
: [];
|
||||
@@ -578,6 +604,8 @@ export function parseEventProbes(
|
||||
}),
|
||||
} : {}),
|
||||
...(expectedOutcomes.length > 0 ? { expected_outcomes: expectedOutcomes } : {}),
|
||||
...(evidenceId ? { target_evidence_id: evidenceId } : {}),
|
||||
...(asText(row.display_date_label, 40) ? { display_date_label: asText(row.display_date_label, 40)! } : {}),
|
||||
};
|
||||
if (options.requireDistinguishContract && distinguishContractErrors({ ...probe, role: "distinguish" }).length > 0) continue;
|
||||
rows.push(probe);
|
||||
@@ -602,6 +630,65 @@ export function parseCollectionProbes(value: unknown): readonly DiscriminatingEv
|
||||
));
|
||||
}
|
||||
|
||||
export function parseDroppedPacketProbes(value: unknown): readonly DroppedPacketProbe[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const rows: DroppedPacketProbe[] = [];
|
||||
for (const item of value) {
|
||||
const row = asRecord(item);
|
||||
const reason = asText(row?.reason, 80);
|
||||
if (!row || !reason) continue;
|
||||
rows.push({
|
||||
reason,
|
||||
...(asText(row.semantic_key, 80) ? { semantic_key: asText(row.semantic_key, 80)! } : {}),
|
||||
...(typeof row.domain === "string" ? { domain: row.domain } : {}),
|
||||
...(typeof row.year === "number" && Number.isInteger(row.year) ? { year: row.year } : {}),
|
||||
...(typeof row.source === "string" ? { source: row.source } : {}),
|
||||
});
|
||||
if (rows.length === 16) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseProspectiveProbes(value: unknown): readonly ProspectiveProbe[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const rows: ProspectiveProbe[] = [];
|
||||
for (const item of value) {
|
||||
const row = asRecord(item);
|
||||
const label = asText(row?.candidate_label, 8);
|
||||
const domain = typeof row?.domain === "string" ? row.domain : "";
|
||||
const window = asText(row?.window_label, 40);
|
||||
const meaning = asText(row?.user_meaning, 200);
|
||||
if (
|
||||
!row
|
||||
|| !label
|
||||
|| !EVENT_PROBE_DOMAIN_SET.has(domain)
|
||||
|| !window
|
||||
|| !meaning
|
||||
|| row.used_for_scoring === true
|
||||
|| CLOCK_IN_COPY.test(meaning)
|
||||
|| CLOCK_IN_COPY.test(window)
|
||||
) continue;
|
||||
rows.push({
|
||||
candidate_label: label,
|
||||
domain,
|
||||
window_label: window,
|
||||
user_meaning: meaning,
|
||||
used_for_scoring: false,
|
||||
});
|
||||
if (rows.length === 3) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function prospectiveWindowsNarration(probes: readonly ProspectiveProbe[]): string | null {
|
||||
const meanings = probes
|
||||
.filter((item) => item.used_for_scoring === false)
|
||||
.map((item) => item.user_meaning.trim())
|
||||
.filter((item) => item.length > 0 && !CLOCK_IN_COPY.test(item));
|
||||
if (meanings.length === 0) return null;
|
||||
return meanings.join(" ");
|
||||
}
|
||||
|
||||
export function parseCandidateContrastOpportunities(value: unknown): readonly CandidateContrastOpportunity[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const rows: CandidateContrastOpportunity[] = [];
|
||||
@@ -711,6 +798,8 @@ export function refinementFromDecisionReceipt(
|
||||
event_clarification_probes: readonly DiscriminatingEventProbe[];
|
||||
evidence_collection_probes: readonly DiscriminatingEventProbe[];
|
||||
candidate_contrast_opportunities: readonly CandidateContrastOpportunity[];
|
||||
dropped_probes: readonly DroppedPacketProbe[];
|
||||
prospective_probes: readonly ProspectiveProbe[];
|
||||
} {
|
||||
return {
|
||||
event_dasha_ledger: parseEventDashaLedger(receipt?.event_dasha_ledger),
|
||||
@@ -728,5 +817,7 @@ export function refinementFromDecisionReceipt(
|
||||
receipt?.evidence_collection_probes ?? receipt?.discriminating_event_probes,
|
||||
),
|
||||
candidate_contrast_opportunities: parseCandidateContrastOpportunities(receipt?.candidate_contrast_opportunities),
|
||||
dropped_probes: parseDroppedPacketProbes(receipt?.dropped_probes),
|
||||
prospective_probes: parseProspectiveProbes(receipt?.prospective_probes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { answersFromEvidence } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import {
|
||||
classifySnapshotStaleReason,
|
||||
storedSnapshotIsCurrent,
|
||||
type CandidateSnapshotSource,
|
||||
} from "../src/lib/rectification-agentic/core/snapshot-source.ts";
|
||||
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import { contrastPacketFromLatestResult } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import {
|
||||
parseDiscriminatingEventProbes,
|
||||
parseProspectiveProbes,
|
||||
prospectiveWindowsNarration,
|
||||
} from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
||||
|
||||
const OUTCOMES = [
|
||||
{ answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:07"] },
|
||||
{ answer_class: "no" as const, supports: ["05:07"], conflicts: ["05:00"] },
|
||||
];
|
||||
|
||||
function source(overrides: Partial<CandidateSnapshotSource> = {}): CandidateSnapshotSource {
|
||||
return {
|
||||
birthProfileFingerprint: "birth-a",
|
||||
scoreableEvidenceFingerprint: "score-a",
|
||||
inferenceRevision: 3,
|
||||
candidateSetVersion: "set-a",
|
||||
scoringPolicyVersion: "rectification-candidate-policy-v3",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function qualityProbe(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
year: 2016,
|
||||
year_label: "2016 年 9 月",
|
||||
domain: "education",
|
||||
event_family: "学业或考试发挥失常、压力特别大",
|
||||
source: "known_event_quality",
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
tracks_agree: true,
|
||||
unique_minute_claim: false,
|
||||
user_meaning: "2016 年 9 月那次上大学,更接近哪一种实际体验。",
|
||||
role: "distinguish",
|
||||
phase: "candidate_discriminator",
|
||||
semantic_key: "education.2016.known_event_quality",
|
||||
information_gain: 0.42,
|
||||
candidate_split_hash: "quality-split",
|
||||
candidate_set_version: "set-a",
|
||||
candidate_ids: ["05:00", "05:07"],
|
||||
expected_outcomes: OUTCOMES,
|
||||
choice_kind: "event_quality",
|
||||
question_contract_version: "probe-question-v1",
|
||||
target_evidence_id: "00000000-0000-4000-8000-000000000001",
|
||||
display_date_label: "2016 年 9 月",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function walkFiles(root: string, suffixes: readonly string[]): string[] {
|
||||
const skip = new Set(["node_modules", ".next", "dist"]);
|
||||
const out: string[] = [];
|
||||
const visit = (dir: string) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (skip.has(name)) continue;
|
||||
const path = join(dir, name);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) visit(path);
|
||||
else if (suffixes.some((suffix) => name.endsWith(suffix))) out.push(path);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
test("scoring policy version changes stale stored snapshots", () => {
|
||||
const current = source();
|
||||
const previous = source({ scoringPolicyVersion: "rectification-candidate-policy-v2" });
|
||||
assert.equal(classifySnapshotStaleReason(previous, current), "scoring_policy_changed");
|
||||
assert.equal(storedSnapshotIsCurrent(previous, current), false);
|
||||
assert.equal(storedSnapshotIsCurrent(current, current), true);
|
||||
});
|
||||
|
||||
test("anchored known_event_quality distinguish probes stay in the public packet", () => {
|
||||
const parsed = parseDiscriminatingEventProbes([qualityProbe()]);
|
||||
assert.equal(parsed.length, 1);
|
||||
assert.equal(parsed[0]?.source, "known_event_quality");
|
||||
assert.equal(parsed[0]?.role, "distinguish");
|
||||
assert.equal(parsed[0]?.target_evidence_id, "00000000-0000-4000-8000-000000000001");
|
||||
|
||||
const unanchored = parseDiscriminatingEventProbes([qualityProbe({ target_evidence_id: undefined })]);
|
||||
assert.equal(unanchored.length, 0);
|
||||
|
||||
const packet = contrastPacketFromLatestResult({
|
||||
resultId: "result-a",
|
||||
decisionReceipt: {
|
||||
discriminating_event_probes: [qualityProbe()],
|
||||
inference_state: {
|
||||
probes: [{
|
||||
id: "probe:education.2016.known_event_quality:quality-split",
|
||||
semantic_key: "education.2016.known_event_quality",
|
||||
candidate_split_hash: "quality-split",
|
||||
domain: "education",
|
||||
year: 2016,
|
||||
question: "2016 年 9 月那次上大学,更接近哪一种实际体验。",
|
||||
candidate_ids: ["05:00", "05:07"],
|
||||
expected_outcomes: OUTCOMES,
|
||||
information_gain: 0.42,
|
||||
source: "known_event_quality",
|
||||
choice_kind: "event_quality",
|
||||
target_evidence_id: "00000000-0000-4000-8000-000000000001",
|
||||
}],
|
||||
answered_probes: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
packet.probes.some((item) => (
|
||||
item.semanticKey === "education.2016.known_event_quality"
|
||||
|| item.choiceKind === "event_quality"
|
||||
)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("quality answers are not inferred from event existence alone", () => {
|
||||
const probe: ConflictProbe = {
|
||||
id: "probe-quality",
|
||||
semantic_key: "education.2016.known_event_quality",
|
||||
candidate_split_hash: "quality-split",
|
||||
domain: "education",
|
||||
year: 2016,
|
||||
question: "那次上大学更接近哪一种体验",
|
||||
candidate_ids: ["05:00", "05:07"],
|
||||
expected_outcomes: OUTCOMES,
|
||||
information_gain: 0.42,
|
||||
source: "known_event_quality",
|
||||
choice_kind: "event_quality",
|
||||
};
|
||||
const answers = answersFromEvidence([probe], [{
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
domain: "education",
|
||||
year: 2016,
|
||||
precision: "month",
|
||||
}]);
|
||||
assert.equal(answers.length, 0);
|
||||
});
|
||||
|
||||
test("prospective probes stay out of scoring copy", () => {
|
||||
const parsed = parseProspectiveProbes([{
|
||||
candidate_label: "A",
|
||||
domain: "career",
|
||||
window_label: "2027 年 3 月附近",
|
||||
user_meaning: "候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。这是预测窗口,不是承诺;下次发生时回来补一条,可进一步分辨。",
|
||||
used_for_scoring: false,
|
||||
}]);
|
||||
assert.equal(parsed.length, 1);
|
||||
const copy = prospectiveWindowsNarration(parsed);
|
||||
assert.match(String(copy), /预测窗口/);
|
||||
assert.equal(parsed[0]?.used_for_scoring, false);
|
||||
});
|
||||
|
||||
test("scripts and frontend contain no answer-key literals", () => {
|
||||
const forbidden = ["target" + "_minute", "pl9_" + "1993", "regression" + "_only"];
|
||||
const files = [
|
||||
...walkFiles(join(process.cwd(), "src"), [".ts", ".tsx", ".js"]),
|
||||
...walkFiles(join(process.cwd(), "tests"), [".ts", ".tsx"]),
|
||||
];
|
||||
const hits: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
for (const token of forbidden) {
|
||||
if (text.includes(token)) hits.push(`${file}:${token}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Deterministic dasha-transition proximity scoring for day/month events.
|
||||
|
||||
Birth-time drift of about 1 minute moves Vimshottari/Narayana transition
|
||||
dates by a few days. A dated event near a candidate's AD/PD change is a
|
||||
bounded auxiliary signal, never larger than one day-level event body.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from scripts.rectification.event_probes import _narayana_start_dates, _vim_start_dates
|
||||
|
||||
PROXIMITY_WINDOW_DAYS = 45
|
||||
DAY_KERNEL_DAYS = 15
|
||||
MONTH_KERNEL_DAYS = 45
|
||||
DAY_MAX_POINTS = 1.0
|
||||
MONTH_MAX_POINTS = 0.35
|
||||
VIM_SHARE = 0.6
|
||||
NARAYANA_SHARE = 0.4
|
||||
|
||||
|
||||
def representative_event_date(event: dict[str, Any]) -> date | None:
|
||||
precision = str(event.get("precision") or "")
|
||||
if precision not in {"day", "month"}:
|
||||
return None
|
||||
raw_start = event.get("date_start") or event.get("date")
|
||||
raw_end = event.get("date_end") or raw_start
|
||||
try:
|
||||
start = date.fromisoformat(str(raw_start)[:10])
|
||||
end = date.fromisoformat(str(raw_end)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
if precision == "day" or start == end:
|
||||
return start
|
||||
mid_day = min(15, end.day)
|
||||
try:
|
||||
return start.replace(day=mid_day)
|
||||
except ValueError:
|
||||
return start
|
||||
|
||||
|
||||
def _nearest_delta(starts: Sequence[date], event_date: date) -> tuple[date | None, int | None]:
|
||||
eligible = [
|
||||
item for item in starts
|
||||
if abs((item - event_date).days) <= PROXIMITY_WINDOW_DAYS
|
||||
]
|
||||
if not eligible:
|
||||
return None, None
|
||||
nearest = min(eligible, key=lambda item: (abs((item - event_date).days), item.toordinal()))
|
||||
return nearest, abs((nearest - event_date).days)
|
||||
|
||||
|
||||
def _kernel(delta_days: int | None, width: float) -> float:
|
||||
if delta_days is None or width <= 0:
|
||||
return 0.0
|
||||
return max(0.0, 1.0 - (delta_days / width))
|
||||
|
||||
|
||||
def score_transition_proximity(
|
||||
*,
|
||||
event_date: date,
|
||||
precision: str,
|
||||
vim_starts: Sequence[date],
|
||||
narayana_starts: Sequence[date] | None = None,
|
||||
vim_pd_starts: Sequence[date] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if precision not in {"day", "month"}:
|
||||
return {
|
||||
"points": 0.0,
|
||||
"rule_ids": [],
|
||||
"nearest_vim_delta_days": None,
|
||||
"nearest_narayana_delta_days": None,
|
||||
}
|
||||
kernel_width = float(DAY_KERNEL_DAYS if precision == "day" else MONTH_KERNEL_DAYS)
|
||||
cap = DAY_MAX_POINTS if precision == "day" else MONTH_MAX_POINTS
|
||||
ad_starts = list(vim_starts)
|
||||
pd_starts = list(vim_pd_starts or ())
|
||||
ad_date, ad_delta = _nearest_delta(ad_starts, event_date)
|
||||
pd_date, pd_delta = _nearest_delta(pd_starts, event_date)
|
||||
if pd_delta is not None and (ad_delta is None or pd_delta < ad_delta):
|
||||
vim_delta = pd_delta
|
||||
vim_kind = "pd"
|
||||
vim_date = pd_date
|
||||
else:
|
||||
vim_delta = ad_delta
|
||||
vim_kind = "ad"
|
||||
vim_date = ad_date
|
||||
_, narayana_delta = _nearest_delta(list(narayana_starts or ()), event_date)
|
||||
vim_kernel = _kernel(vim_delta, kernel_width)
|
||||
narayana_kernel = _kernel(narayana_delta, kernel_width)
|
||||
points = round(cap * (VIM_SHARE * vim_kernel + NARAYANA_SHARE * narayana_kernel), 4)
|
||||
rules: list[str] = []
|
||||
if vim_kernel > 0:
|
||||
rules.append(f"vim_transition_proximity_{vim_kind}")
|
||||
if narayana_kernel > 0:
|
||||
rules.append("narayana_transition_proximity_ad")
|
||||
return {
|
||||
"points": points,
|
||||
"rule_ids": rules,
|
||||
"nearest_vim_delta_days": vim_delta,
|
||||
"nearest_narayana_delta_days": narayana_delta,
|
||||
"nearest_vim_date": vim_date,
|
||||
}
|
||||
|
||||
|
||||
def _context_time(context: dict[str, Any]) -> str | None:
|
||||
feature = context.get("feature") if isinstance(context.get("feature"), dict) else {}
|
||||
raw = feature.get("time")
|
||||
if isinstance(raw, str) and len(raw) >= 5:
|
||||
return raw[:5]
|
||||
at = context.get("candidate_at")
|
||||
if hasattr(at, "strftime"):
|
||||
return at.strftime("%H:%M")
|
||||
return None
|
||||
|
||||
|
||||
def merge_transition_proximity(
|
||||
matrix: dict[str, dict[str, dict[str, Any]]],
|
||||
events: Sequence[dict[str, Any]],
|
||||
static_contexts: Sequence[dict[str, Any]],
|
||||
birth_date: str,
|
||||
*,
|
||||
public_technique_layers: Callable[[str, Sequence[str]], list[str]],
|
||||
) -> None:
|
||||
by_time = {
|
||||
time: context
|
||||
for context in static_contexts
|
||||
if isinstance(context, dict) and (time := _context_time(context))
|
||||
}
|
||||
vim_cache: dict[tuple[Any, ...], list[date]] = {}
|
||||
pd_cache: dict[tuple[Any, ...], list[date]] = {}
|
||||
narayana_cache: dict[tuple[Any, ...], list[date] | None] = {}
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_id = str(event.get("id") or "")
|
||||
cells = matrix.get(event_id)
|
||||
if not event_id or not isinstance(cells, dict):
|
||||
continue
|
||||
event_date = representative_event_date(event)
|
||||
if event_date is None:
|
||||
continue
|
||||
precision = str(event.get("precision") or "")
|
||||
lo, hi = event_date.year - 1, event_date.year + 1
|
||||
for time, cell in cells.items():
|
||||
context = by_time.get(str(time)[:5])
|
||||
if not isinstance(cell, dict) or not isinstance(context, dict):
|
||||
continue
|
||||
moon = (context.get("planet_longitudes") or {}).get("Moon")
|
||||
if not isinstance(moon, (int, float)):
|
||||
continue
|
||||
vim_key = (birth_date, round(float(moon), 6), lo, hi)
|
||||
if vim_key not in vim_cache:
|
||||
vim_cache[vim_key] = _vim_start_dates(birth_date, float(moon), lo, hi)
|
||||
pd_cache[vim_key] = _vim_start_dates(
|
||||
birth_date,
|
||||
float(moon),
|
||||
lo,
|
||||
hi,
|
||||
include_pratyantar=True,
|
||||
)
|
||||
planets = context.get("planet_longitudes") or {}
|
||||
asc = context.get("ascendant_index")
|
||||
narayana_key = (
|
||||
birth_date,
|
||||
int(asc) if isinstance(asc, int) else None,
|
||||
lo,
|
||||
hi,
|
||||
round(float(moon), 6),
|
||||
)
|
||||
if narayana_key not in narayana_cache:
|
||||
narayana_cache[narayana_key] = (
|
||||
_narayana_start_dates(int(asc), planets, birth_date, lo, hi)
|
||||
if isinstance(asc, int) and isinstance(planets, dict)
|
||||
else None
|
||||
)
|
||||
ad_starts = vim_cache[vim_key]
|
||||
ad_set = set(ad_starts)
|
||||
pd_only = [item for item in pd_cache[vim_key] if item not in ad_set]
|
||||
scored = score_transition_proximity(
|
||||
event_date=event_date,
|
||||
precision=precision,
|
||||
vim_starts=ad_starts,
|
||||
vim_pd_starts=pd_only,
|
||||
narayana_starts=narayana_cache[narayana_key] or [],
|
||||
)
|
||||
if scored["points"] <= 0 and not scored["rule_ids"]:
|
||||
continue
|
||||
cell["points"] = round(float(cell.get("points") or 0) + float(scored["points"]), 4)
|
||||
cell["rule_ids"] = sorted({
|
||||
*list(cell.get("rule_ids") or []),
|
||||
*scored["rule_ids"],
|
||||
})
|
||||
domain = str(event.get("domain") or cell.get("domain") or "")
|
||||
cell["technique_layers"] = public_technique_layers(domain, cell["rule_ids"])
|
||||
@@ -26,7 +26,7 @@ from scripts.rectification_policy import (
|
||||
MIN_CONFIRMATION_MARGIN_PERCENT,
|
||||
)
|
||||
|
||||
POLICY_VERSION = "rectification-candidate-policy-v2"
|
||||
POLICY_VERSION = "rectification-candidate-policy-v3"
|
||||
RECEIPT_VERSION = "candidate-decision-receipt-v2"
|
||||
EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2"
|
||||
SCORE_QUANTUM = Decimal("0.0001")
|
||||
@@ -71,6 +71,7 @@ _AUDIT_LABELS = {
|
||||
"shadbala": ("Shadbala", "本轮已做已核验的 Shadbala 分量辅助对照。"),
|
||||
"arudha-pada": ("Arudha Pada", "本轮已做 Arudha 辅助对照。"),
|
||||
"functional-benefic-malefic": ("功能吉凶星", "本轮已叠加本命功能吉凶星。"),
|
||||
"dasha-transition-proximity": ("换运贴近度", "本轮已对照日级事件与候选换运日期的贴近程度。"),
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +101,9 @@ def _executed_public_methods(built: dict[str, Any]) -> list[str]:
|
||||
methods.add(str(layer))
|
||||
for rule in cell.get("rule_ids") or []:
|
||||
text = str(rule)
|
||||
if text.startswith("vim_"):
|
||||
if "transition_proximity" in text:
|
||||
methods.add("dasha-transition-proximity")
|
||||
elif text.startswith("vim_"):
|
||||
methods.add("vimshottari-dasha")
|
||||
elif text.startswith("narayana_"):
|
||||
methods.add("narayana-dasha")
|
||||
@@ -652,6 +655,8 @@ def build_decision_receipt(
|
||||
"evidence_collection_probes": packet.get("evidence_collection_probes") or [],
|
||||
"candidate_contrast_opportunities": packet.get("candidate_contrast_opportunities") or [],
|
||||
"holdout_validation_probes": packet.get("holdout_validation_probes") or [],
|
||||
"dropped_probes": packet.get("dropped_probes") or [],
|
||||
"prospective_probes": packet.get("prospective_probes") or [],
|
||||
"horary_observation": build_horary_observation(request),
|
||||
"unique_minute_claim": False,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Public-safe biographical probes from candidate dasha / varga differences.
|
||||
|
||||
Discriminators are feature-signature clusters over the full birth window.
|
||||
known_event_quality is clarification only and never a distinguish probe.
|
||||
known_event_quality may distinguish when signature groups disagree on the
|
||||
event's theme varga type, the probe is anchored to confirmed evidence, and
|
||||
the case cap is respected. Unanchored quality stays clarification-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,7 +37,7 @@ from scripts.rectification.candidate_contrast import (
|
||||
missing_collection_domains,
|
||||
opportunity_from_probe,
|
||||
)
|
||||
from scripts.rectification.case_holdout import holdout_domain_years
|
||||
from scripts.rectification.case_holdout import holdout_domain_years, holdout_event_ids
|
||||
from scripts.rectification.probe_question_contract import (
|
||||
QUESTION_CONTRACT_VERSION,
|
||||
completed_style_options,
|
||||
@@ -59,6 +61,66 @@ MAX_PROBES_PER_DOMAIN = 3
|
||||
# MAX_PROBES is the published cap after a global information_gain sort.
|
||||
MAX_BOUNDARY_CANDIDATES_PER_DOMAIN = 8
|
||||
MIN_BOUNDARY_DAYS = 45
|
||||
MAX_QUALITY_DISTINGUISH_PROBES = 2
|
||||
ANSWER_PRIOR_TABLE_VERSION = "rectification-answer-priors-v1"
|
||||
DOMINANT_ANSWER_PRIOR = 0.8
|
||||
# Conservative population rates, not fitted from product users.
|
||||
# Broad family existence (yearless / 3-year window / age band) is near-certain
|
||||
# in adult life. Specified-year long-distance move is uncommon. Quality answers
|
||||
# are closer to even because they condition on a known event.
|
||||
_DEFAULT_EXISTENCE_PRIORS = {"yes": 0.35, "weak_yes": 0.15, "no": 0.40, "unsure": 0.10}
|
||||
_DEFAULT_QUALITY_PRIORS = {"yes": 0.30, "weak_yes": 0.20, "no": 0.40, "unsure": 0.10}
|
||||
ANSWER_PRIORS: dict[tuple[str, str], dict[str, float]] = {
|
||||
("family", "existence"): {"yes": 0.85, "weak_yes": 0.05, "no": 0.05, "unsure": 0.05},
|
||||
("relocation", "existence"): {"yes": 0.20, "weak_yes": 0.10, "no": 0.60, "unsure": 0.10},
|
||||
("education", "existence"): {"yes": 0.45, "weak_yes": 0.15, "no": 0.30, "unsure": 0.10},
|
||||
("relationship", "existence"): {"yes": 0.40, "weak_yes": 0.15, "no": 0.35, "unsure": 0.10},
|
||||
("career", "existence"): {"yes": 0.40, "weak_yes": 0.15, "no": 0.35, "unsure": 0.10},
|
||||
("education", "event_quality"): dict(_DEFAULT_QUALITY_PRIORS),
|
||||
("relationship", "event_quality"): dict(_DEFAULT_QUALITY_PRIORS),
|
||||
("career", "event_quality"): dict(_DEFAULT_QUALITY_PRIORS),
|
||||
("relocation", "event_quality"): dict(_DEFAULT_QUALITY_PRIORS),
|
||||
("family", "event_quality"): dict(_DEFAULT_QUALITY_PRIORS),
|
||||
}
|
||||
DOMAIN_QUALITY_LAYER = {
|
||||
"education": "d24",
|
||||
"relationship": "d9",
|
||||
"career": "d10",
|
||||
"relocation": "d4",
|
||||
"family": "d12",
|
||||
}
|
||||
QUALITY_DISTINGUISH_OPTIONS: dict[str, tuple[dict[str, str], ...]] = {
|
||||
"education": (
|
||||
{"label": "发挥明显失常", "answer_class": "yes"},
|
||||
{"label": "只是将就调剂", "answer_class": "weak_yes"},
|
||||
{"label": "基本如愿录取", "answer_class": "no"},
|
||||
{"label": "当时说不清楚", "answer_class": "unsure"},
|
||||
),
|
||||
"relationship": (
|
||||
{"label": "明显受挫变难", "answer_class": "yes"},
|
||||
{"label": "只是将就相处", "answer_class": "weak_yes"},
|
||||
{"label": "整体比较顺利", "answer_class": "no"},
|
||||
{"label": "当时说不清楚", "answer_class": "unsure"},
|
||||
),
|
||||
"career": (
|
||||
{"label": "明显受挫受压", "answer_class": "yes"},
|
||||
{"label": "只是将就应付", "answer_class": "weak_yes"},
|
||||
{"label": "整体比较顺利", "answer_class": "no"},
|
||||
{"label": "当时说不清楚", "answer_class": "unsure"},
|
||||
),
|
||||
"relocation": (
|
||||
{"label": "搬迁特别折腾", "answer_class": "yes"},
|
||||
{"label": "只是将就安顿", "answer_class": "weak_yes"},
|
||||
{"label": "整体比较顺利", "answer_class": "no"},
|
||||
{"label": "当时说不清楚", "answer_class": "unsure"},
|
||||
),
|
||||
"family": (
|
||||
{"label": "家里特别操心", "answer_class": "yes"},
|
||||
{"label": "只是普通操心", "answer_class": "weak_yes"},
|
||||
{"label": "整体比较顺利", "answer_class": "no"},
|
||||
{"label": "当时说不清楚", "answer_class": "unsure"},
|
||||
),
|
||||
}
|
||||
LEVEL_RANK = {"none": 0, "weak": 1, "medium": 2, "strong": 3}
|
||||
LEVEL_P = {"none": 0.15, "weak": 0.35, "medium": 0.62, "strong": 0.82}
|
||||
SCORING_LAYERS = ("d1", "d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30")
|
||||
@@ -349,7 +411,14 @@ def _tracks_present(rule_ids: Sequence[str]) -> tuple[bool, bool]:
|
||||
)
|
||||
|
||||
|
||||
def _vim_start_dates(birth_date: str, moon_longitude: float, lo: int, hi: int) -> list[date]:
|
||||
def _vim_start_dates(
|
||||
birth_date: str,
|
||||
moon_longitude: float,
|
||||
lo: int,
|
||||
hi: int,
|
||||
*,
|
||||
include_pratyantar: bool = False,
|
||||
) -> list[date]:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(float(moon_longitude))
|
||||
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(birth_date, nakshatra, progress)
|
||||
starts: list[date] = []
|
||||
@@ -361,6 +430,12 @@ def _vim_start_dates(birth_date: str, moon_longitude: float, lo: int, hi: int) -
|
||||
minor_start = minor.get("start")
|
||||
if isinstance(minor_start, datetime) and lo <= minor_start.year <= hi:
|
||||
starts.append(minor_start.date())
|
||||
if not include_pratyantar:
|
||||
continue
|
||||
for prat in dasha_analyzer.build_antardasha(minor):
|
||||
prat_start = prat.get("start")
|
||||
if isinstance(prat_start, datetime) and lo <= prat_start.year <= hi:
|
||||
starts.append(prat_start.date())
|
||||
return starts
|
||||
|
||||
|
||||
@@ -738,6 +813,187 @@ def _information_gain(left_level: str, right_level: str) -> float:
|
||||
return round(max(0.0, 1.0 - after), 4)
|
||||
|
||||
|
||||
def _broad_existence_window(probe: dict[str, Any]) -> bool:
|
||||
if str(probe.get("source") or "") == "age_band":
|
||||
return True
|
||||
year = probe.get("year")
|
||||
if not isinstance(year, int) or year <= 0:
|
||||
return True
|
||||
span = probe.get("window_span_years")
|
||||
return isinstance(span, int) and span >= 3
|
||||
|
||||
|
||||
def _answer_priors_for(probe: dict[str, Any]) -> dict[str, float]:
|
||||
kind = str(probe.get("choice_kind") or "existence")
|
||||
if kind not in {"existence", "event_quality"}:
|
||||
kind = "existence"
|
||||
domain = str(probe.get("domain") or "")
|
||||
if domain == "family" and kind == "existence" and not _broad_existence_window(probe):
|
||||
return dict(_DEFAULT_EXISTENCE_PRIORS)
|
||||
priors = ANSWER_PRIORS.get((domain, kind))
|
||||
if priors:
|
||||
return dict(priors)
|
||||
return dict(_DEFAULT_QUALITY_PRIORS if kind == "event_quality" else _DEFAULT_EXISTENCE_PRIORS)
|
||||
|
||||
|
||||
def _expected_information_gain(raw_split_gain: float, priors: dict[str, float]) -> float:
|
||||
weight = (
|
||||
float(priors.get("yes") or 0)
|
||||
+ float(priors.get("no") or 0)
|
||||
+ 0.5 * float(priors.get("weak_yes") or 0)
|
||||
)
|
||||
return round(max(0.0, raw_split_gain * weight), 4)
|
||||
|
||||
|
||||
def _apply_prior_ranking(probe: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = float(probe.get("information_gain") or 0)
|
||||
priors = _answer_priors_for(probe)
|
||||
probe["raw_split_gain"] = raw
|
||||
probe["answer_priors"] = priors
|
||||
probe["information_gain"] = _expected_information_gain(raw, priors)
|
||||
return probe
|
||||
|
||||
|
||||
def _dominant_existence_prior(probe: dict[str, Any], priors: dict[str, float]) -> bool:
|
||||
if str(probe.get("choice_kind") or "existence") != "existence":
|
||||
return False
|
||||
if str(probe.get("source") or "") == "known_event_quality":
|
||||
return False
|
||||
return max(priors.values()) > DOMINANT_ANSWER_PRIOR
|
||||
|
||||
|
||||
def _event_month(event: dict[str, Any]) -> int | None:
|
||||
raw = str(event.get("date") or event.get("date_start") or "")
|
||||
if len(raw) >= 7 and raw[4] == "-":
|
||||
try:
|
||||
month = int(raw[5:7])
|
||||
except ValueError:
|
||||
return None
|
||||
if 1 <= month <= 12:
|
||||
return month
|
||||
return None
|
||||
|
||||
|
||||
def _display_date_label(event: dict[str, Any]) -> str:
|
||||
year = _event_year(event)
|
||||
month = _event_month(event)
|
||||
if year is None:
|
||||
return "那次"
|
||||
if month:
|
||||
return f"{year} 年 {month} 月"
|
||||
return f"{year} 年"
|
||||
|
||||
|
||||
def _quality_user_meaning(event: dict[str, Any], domain: str) -> str:
|
||||
label = _display_date_label(event)
|
||||
if domain == "education":
|
||||
return (
|
||||
f"{label}那次上大学,更接近如愿、将就调剂、发挥失常还是说不清。"
|
||||
"只问那次经历的实际体验,不得改时间范围。"
|
||||
)
|
||||
family = str(DOMAIN_CATALOG[domain]["event_family"])
|
||||
return (
|
||||
f"{label}那次{family},当时更接近顺利、将就、明显受挫还是说不清。"
|
||||
"只问那次经历的实际体验,不得改时间范围。"
|
||||
)
|
||||
|
||||
|
||||
def _quality_distinguish_probes(
|
||||
events: Sequence[dict[str, Any]],
|
||||
clusters: Sequence[dict[str, Any]],
|
||||
*,
|
||||
set_version: str,
|
||||
holdout_ids: set[str],
|
||||
holdout_keys: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if len(clusters) < 2:
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_id = str(event.get("id") or "")
|
||||
domain = str(event.get("domain") or "")
|
||||
year = _event_year(event)
|
||||
layer = DOMAIN_QUALITY_LAYER.get(domain)
|
||||
if not event_id or year is None or layer is None or domain not in DOMAIN_CATALOG:
|
||||
continue
|
||||
if event_id in holdout_ids or f"{domain}:{year}" in holdout_keys:
|
||||
continue
|
||||
if _quality_encoded(event, domain):
|
||||
continue
|
||||
groups: dict[int, list[str]] = {}
|
||||
for cluster in clusters:
|
||||
representative = cluster.get("representative") if isinstance(cluster, dict) else None
|
||||
if not isinstance(representative, dict):
|
||||
continue
|
||||
sign = _layer_value(representative, layer)
|
||||
if not isinstance(sign, int):
|
||||
continue
|
||||
bucket = groups.setdefault(sign, [])
|
||||
for time in cluster.get("times") or []:
|
||||
clock = str(time)[:5]
|
||||
if len(clock) == 5 and clock not in bucket:
|
||||
bucket.append(clock)
|
||||
if len(groups) < 2:
|
||||
continue
|
||||
signs = sorted(groups)
|
||||
yes_times = sorted(groups[signs[-1]], key=_clock)
|
||||
no_times = sorted(groups[signs[0]], key=_clock)
|
||||
overlap = set(yes_times) & set(no_times)
|
||||
yes_times = [time for time in yes_times if time not in overlap]
|
||||
no_times = [time for time in no_times if time not in overlap]
|
||||
if len(yes_times) < 1 or len(no_times) < 1:
|
||||
continue
|
||||
month = _event_month(event)
|
||||
outcomes = [
|
||||
{"answer_class": "yes", "supports": yes_times, "conflicts": no_times},
|
||||
{"answer_class": "weak_yes", "supports": yes_times, "conflicts": no_times},
|
||||
{"answer_class": "no", "supports": no_times, "conflicts": yes_times},
|
||||
{"answer_class": "unsure", "supports": [], "conflicts": []},
|
||||
]
|
||||
split = candidate_split_hash(
|
||||
candidate_set_version_value=set_version,
|
||||
domain=domain,
|
||||
year=year,
|
||||
month=month,
|
||||
groups=[yes_times, no_times],
|
||||
)
|
||||
gain = round(_group_entropy([len(yes_times), len(no_times)]), 4)
|
||||
if gain <= 0:
|
||||
continue
|
||||
label = _display_date_label(event)
|
||||
probe = _public_probe(
|
||||
year=year,
|
||||
month=month,
|
||||
domain=domain,
|
||||
source="known_event_quality",
|
||||
tracks=("vimshottari", "narayana"),
|
||||
tracks_agree=True,
|
||||
user_meaning=_quality_user_meaning(event, domain),
|
||||
event_family=str(DOMAIN_CATALOG[domain]["quality_family"]),
|
||||
information_gain=gain,
|
||||
semantic_key=f"{domain}.{year}.known_event_quality",
|
||||
candidate_split_hash=split,
|
||||
candidate_set_version=set_version,
|
||||
expected_outcomes=outcomes,
|
||||
candidate_ids=candidate_ids_from_outcomes(outcomes),
|
||||
left_time=yes_times[0],
|
||||
right_time=no_times[0],
|
||||
target_evidence_id=event_id,
|
||||
display_date_label=label,
|
||||
role="distinguish",
|
||||
phase=PROBE_PHASE_CANDIDATE_DISCRIMINATOR,
|
||||
style_options=list(QUALITY_DISTINGUISH_OPTIONS.get(domain) or ()),
|
||||
)
|
||||
if distinguish_contract_errors(probe):
|
||||
continue
|
||||
rows.append(_apply_prior_ranking(probe))
|
||||
if len(rows) >= MAX_QUALITY_DISTINGUISH_PROBES:
|
||||
break
|
||||
return rows
|
||||
|
||||
|
||||
def _year_activated(rule_ids: Sequence[str]) -> bool:
|
||||
return _has_domain_activation(rule_ids) or LEVEL_RANK.get(match_level(rule_ids), 0) >= 2
|
||||
|
||||
@@ -1083,7 +1339,7 @@ def candidate_contrast_opportunities(
|
||||
return [opportunity_from_probe(probe) for probe in probes]
|
||||
|
||||
|
||||
def discriminating_event_probes(
|
||||
def _discriminating_event_probe_lists(
|
||||
request: dict[str, Any],
|
||||
built: dict[str, Any],
|
||||
*,
|
||||
@@ -1092,35 +1348,36 @@ def discriminating_event_probes(
|
||||
representative_time: str | None,
|
||||
precision_current: str | None = None,
|
||||
today: date | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
del precision_current, representative_time
|
||||
birth_date = str(request.get("birth_date") or "").strip()
|
||||
birth_year = _birth_year(birth_date)
|
||||
empty: tuple[list[dict[str, Any]], list[dict[str, Any]]] = ([], [])
|
||||
if birth_year is None:
|
||||
return []
|
||||
return empty
|
||||
try:
|
||||
datetime.strptime(birth_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return []
|
||||
return empty
|
||||
events = [item for item in (request.get("events") or []) if isinstance(item, dict)]
|
||||
if not discriminator_gate_open(events):
|
||||
return []
|
||||
return empty
|
||||
holdout_keys = holdout_domain_years(events)
|
||||
now = today or date.today()
|
||||
full = _static_contexts(built)
|
||||
if len(full) < 2:
|
||||
return []
|
||||
return empty
|
||||
clusters = cluster_contexts_by_signature(full)
|
||||
if len(clusters) < 2:
|
||||
remaining = _remaining_contexts(built, candidate_times) or full
|
||||
clusters = cluster_contexts_by_signature(remaining)
|
||||
if len(clusters) < 2:
|
||||
return []
|
||||
return empty
|
||||
reps = [cluster["representative"] for cluster in clusters if _scoreable(cluster["representative"])]
|
||||
if len(reps) < 2:
|
||||
reps = [item for item in full if _scoreable(item)]
|
||||
if len(reps) < 2:
|
||||
return []
|
||||
return empty
|
||||
set_version = candidate_set_version([cluster["times"] for cluster in clusters])
|
||||
remaining_layers = _differing_layers(full)
|
||||
if not remaining_layers:
|
||||
@@ -1133,10 +1390,8 @@ def discriminating_event_probes(
|
||||
events,
|
||||
d1_differs="d1" in remaining_layers or bool(scan.get("d1_candidates_differ")),
|
||||
)
|
||||
if not domains:
|
||||
return []
|
||||
lo, hi = birth_year + 5, min(now.year, birth_year + 80)
|
||||
boundary_dates = _union_boundary_dates(reps, birth_date=birth_date, lo=lo, hi=hi)
|
||||
boundary_dates = _union_boundary_dates(reps, birth_date=birth_date, lo=lo, hi=hi) if domains else []
|
||||
probes: list[dict[str, Any]] = []
|
||||
for domain in domains:
|
||||
if domain not in DOMAIN_CATALOG:
|
||||
@@ -1202,23 +1457,152 @@ def discriminating_event_probes(
|
||||
if activation_key not in existing:
|
||||
kept.append(activation)
|
||||
probes.extend(kept)
|
||||
probes.extend(_quality_distinguish_probes(
|
||||
events,
|
||||
clusters,
|
||||
set_version=set_version,
|
||||
holdout_ids=set(holdout_event_ids(events)),
|
||||
holdout_keys=set(holdout_keys),
|
||||
))
|
||||
probes.sort(key=_probe_sort_key)
|
||||
public, dropped = _partition_ranked_probes(probes)
|
||||
assert_distinguish_contract(public)
|
||||
return public, dropped
|
||||
|
||||
|
||||
def _partition_ranked_probes(
|
||||
probes: Sequence[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
public: list[dict[str, Any]] = []
|
||||
dropped: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, int, int, str]] = set()
|
||||
for row in probes:
|
||||
if row.get("source") == "known_event_quality" or row.get("phase") != PROBE_PHASE_CANDIDATE_DISCRIMINATOR:
|
||||
anchored_quality = (
|
||||
row.get("source") == "known_event_quality"
|
||||
and row.get("role") == "distinguish"
|
||||
and row.get("target_evidence_id")
|
||||
)
|
||||
if row.get("source") == "known_event_quality" and not anchored_quality:
|
||||
continue
|
||||
if row.get("phase") != PROBE_PHASE_CANDIDATE_DISCRIMINATOR:
|
||||
continue
|
||||
if distinguish_contract_errors(row):
|
||||
continue
|
||||
ranked = row if "raw_split_gain" in row else _apply_prior_ranking(dict(row))
|
||||
priors = ranked.get("answer_priors") or _answer_priors_for(ranked)
|
||||
if _dominant_existence_prior(ranked, priors):
|
||||
dropped.append({
|
||||
"semantic_key": ranked.get("semantic_key"),
|
||||
"reason": "dominant_answer_prior",
|
||||
"domain": ranked.get("domain"),
|
||||
"year": ranked.get("year"),
|
||||
"source": ranked.get("source"),
|
||||
"answer_priors": priors,
|
||||
})
|
||||
continue
|
||||
if not isinstance(row.get("year"), int) or int(row["year"]) <= 0:
|
||||
continue
|
||||
key = (str(row["domain"]), int(row["year"]), int(row.get("month") or 0), str(row["source"]))
|
||||
encoded = str(row)
|
||||
key = (str(ranked["domain"]), int(ranked["year"]), int(ranked.get("month") or 0), str(ranked["source"]))
|
||||
encoded = str(ranked)
|
||||
if key in seen or "points" in encoded:
|
||||
continue
|
||||
seen.add(key)
|
||||
public.append(row)
|
||||
public.append(ranked)
|
||||
if len(public) >= MAX_PROBES:
|
||||
break
|
||||
assert_distinguish_contract(public)
|
||||
return public
|
||||
public.sort(key=_probe_sort_key)
|
||||
return public, dropped
|
||||
|
||||
|
||||
def discriminating_event_probe_set(
|
||||
request: dict[str, Any],
|
||||
built: dict[str, Any],
|
||||
*,
|
||||
scan: dict[str, Any],
|
||||
candidate_times: Sequence[str],
|
||||
representative_time: str | None,
|
||||
precision_current: str | None = None,
|
||||
today: date | None = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
probes, dropped = _discriminating_event_probe_lists(
|
||||
request,
|
||||
built,
|
||||
scan=scan,
|
||||
candidate_times=candidate_times,
|
||||
representative_time=representative_time,
|
||||
precision_current=precision_current,
|
||||
today=today,
|
||||
)
|
||||
return {"probes": probes, "dropped": dropped}
|
||||
|
||||
|
||||
def discriminating_event_probes(
|
||||
request: dict[str, Any],
|
||||
built: dict[str, Any],
|
||||
*,
|
||||
scan: dict[str, Any],
|
||||
candidate_times: Sequence[str],
|
||||
representative_time: str | None,
|
||||
precision_current: str | None = None,
|
||||
today: date | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
probes, _dropped = _discriminating_event_probe_lists(
|
||||
request,
|
||||
built,
|
||||
scan=scan,
|
||||
candidate_times=candidate_times,
|
||||
representative_time=representative_time,
|
||||
precision_current=precision_current,
|
||||
today=today,
|
||||
)
|
||||
return probes
|
||||
|
||||
|
||||
def prospective_event_windows(
|
||||
request: dict[str, Any],
|
||||
built: dict[str, Any],
|
||||
*,
|
||||
candidate_times: Sequence[str],
|
||||
today: date | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
birth_date = str(request.get("birth_date") or "").strip()
|
||||
if not birth_date:
|
||||
return []
|
||||
now = today or date.today()
|
||||
lo, hi = now.year + 1, now.year + 3
|
||||
full = _static_contexts(built)
|
||||
if len(full) < 2:
|
||||
return []
|
||||
clusters = cluster_contexts_by_signature(full)
|
||||
if len(clusters) < 2:
|
||||
remaining = _remaining_contexts(built, candidate_times) or full
|
||||
clusters = cluster_contexts_by_signature(remaining)
|
||||
if len(clusters) < 2:
|
||||
return []
|
||||
labels = ("A", "B", "C")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, cluster in enumerate(clusters[:3]):
|
||||
context = cluster.get("representative") if isinstance(cluster, dict) else None
|
||||
if not isinstance(context, dict):
|
||||
continue
|
||||
moon = (context.get("planet_longitudes") or {}).get("Moon")
|
||||
if not isinstance(moon, (int, float)):
|
||||
continue
|
||||
starts = _vim_start_dates(birth_date, float(moon), lo, hi, include_pratyantar=True)
|
||||
future = [item for item in starts if item.year >= lo]
|
||||
if not future:
|
||||
continue
|
||||
start = min(future)
|
||||
label = labels[index]
|
||||
window = f"{start.year} 年 {start.month} 月附近"
|
||||
rows.append({
|
||||
"candidate_label": label,
|
||||
"domain": "career",
|
||||
"window_label": window,
|
||||
"user_meaning": (
|
||||
f"候选 {label} 预测下一次事业变动更可能在 {window}。"
|
||||
"这是预测窗口,不是承诺;下次发生时回来补一条,可进一步分辨。"
|
||||
),
|
||||
"used_for_scoring": False,
|
||||
})
|
||||
return rows
|
||||
|
||||
@@ -558,12 +558,13 @@ def build_refinement_packet(
|
||||
from scripts.rectification.case_holdout import reserved_holdout_events
|
||||
from scripts.rectification.event_probes import (
|
||||
candidate_contrast_opportunities,
|
||||
discriminating_event_probes,
|
||||
discriminating_event_probe_set,
|
||||
event_clarification_probes,
|
||||
evidence_collection_probes,
|
||||
prospective_event_windows,
|
||||
)
|
||||
grid_times = list(built.get("candidate_times") or candidate_times)
|
||||
probes = discriminating_event_probes(
|
||||
bundle = discriminating_event_probe_set(
|
||||
request,
|
||||
built,
|
||||
scan=scan,
|
||||
@@ -571,6 +572,8 @@ def build_refinement_packet(
|
||||
representative_time=representative_time,
|
||||
precision_current=str(stage.get("current") or "") or None,
|
||||
)
|
||||
probes = bundle["probes"]
|
||||
dropped = list(bundle["dropped"])
|
||||
clarification = event_clarification_probes(request)
|
||||
collection = evidence_collection_probes(request)
|
||||
opportunities = candidate_contrast_opportunities(
|
||||
@@ -602,6 +605,11 @@ def build_refinement_packet(
|
||||
}
|
||||
for prompt in oos_blind_prompts(request)
|
||||
)
|
||||
prospective = prospective_event_windows(
|
||||
request,
|
||||
built,
|
||||
candidate_times=grid_times,
|
||||
) if not probes else []
|
||||
return {
|
||||
"window_scan": scan,
|
||||
"event_dasha_ledger": ledger,
|
||||
@@ -616,6 +624,8 @@ def build_refinement_packet(
|
||||
"evidence_collection_probes": collection,
|
||||
"candidate_contrast_opportunities": opportunities,
|
||||
"holdout_validation_probes": holdout,
|
||||
"dropped_probes": dropped,
|
||||
"prospective_probes": prospective,
|
||||
"unique_minute_claim": False,
|
||||
"confirmation_allowed": False,
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ from typing import Any
|
||||
|
||||
from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows
|
||||
from scripts.active_rectification_events import CandidateScoreRow
|
||||
from scripts.rectification.dasha_transition_proximity import merge_transition_proximity
|
||||
from scripts.rectification.contracts import LifeEvent, RectificationRequest, is_scoreable_event
|
||||
from scripts.rectification.case_holdout import holdout_event_ids
|
||||
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-6"
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-7"
|
||||
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
|
||||
PRECISION_WEIGHTS = {
|
||||
"day": 1.0,
|
||||
@@ -179,11 +180,14 @@ def precision_weight(precision: str) -> float:
|
||||
|
||||
def public_technique_layers(domain: str, rule_ids: Sequence[str]) -> list[str]:
|
||||
"""Public methods actually computed for this event. Career always lists D1-10 and D10."""
|
||||
layers = {
|
||||
rule.split(":", 1)[0]
|
||||
for rule in rule_ids
|
||||
if not rule.startswith(("event_kind:", "event_kind_profile:"))
|
||||
}
|
||||
layers: set[str] = set()
|
||||
for rule in rule_ids:
|
||||
if rule.startswith(("event_kind:", "event_kind_profile:")):
|
||||
continue
|
||||
if "transition_proximity" in rule:
|
||||
layers.add("dasha-transition-proximity")
|
||||
continue
|
||||
layers.add(rule.split(":", 1)[0])
|
||||
if domain == "career":
|
||||
layers.update({"d1-rashi", "d10-dashamsa"})
|
||||
elif domain == "family":
|
||||
@@ -235,6 +239,7 @@ def scoreable_request(request: RectificationRequest) -> RectificationRequest:
|
||||
def build_event_contribution_matrix(
|
||||
request: RectificationRequest,
|
||||
row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None,
|
||||
static_contexts: Sequence[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
scoring_request = scoreable_request(request)
|
||||
if not scoring_request["events"]:
|
||||
@@ -242,7 +247,8 @@ def build_event_contribution_matrix(
|
||||
"candidate_times": [], "matrix": {}, "date_sensitivity": [],
|
||||
"missing_layers": [], "static_contexts": None,
|
||||
}
|
||||
static_contexts = None if row_provider is not None else compute_candidate_static_contexts(scoring_request)
|
||||
if static_contexts is None and row_provider is None:
|
||||
static_contexts = compute_candidate_static_contexts(scoring_request)
|
||||
provider = row_provider or (lambda value: compute_event_candidate_rows(value, static_contexts=static_contexts))
|
||||
matrix: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict)
|
||||
missing_layers: set[str] = set()
|
||||
@@ -288,9 +294,18 @@ def build_event_contribution_matrix(
|
||||
"score_variance": round(variance, 6),
|
||||
"sample_winners": winners,
|
||||
})
|
||||
matrix_payload = dict(matrix)
|
||||
if static_contexts:
|
||||
merge_transition_proximity(
|
||||
matrix_payload,
|
||||
scoring_request["events"],
|
||||
static_contexts,
|
||||
scoring_request["birth_date"],
|
||||
public_technique_layers=public_technique_layers,
|
||||
)
|
||||
return {
|
||||
"candidate_times": candidate_grid or [],
|
||||
"matrix": dict(matrix),
|
||||
"matrix": matrix_payload,
|
||||
"date_sensitivity": date_sensitivity,
|
||||
"missing_layers": sorted(missing_layers),
|
||||
"static_contexts": static_contexts,
|
||||
|
||||
@@ -140,7 +140,7 @@ class DiscriminatorContractTest(unittest.TestCase):
|
||||
["distinguish_empty_expected_outcomes"],
|
||||
)
|
||||
|
||||
def test_quality_never_enters_discriminating_event_probes(self) -> None:
|
||||
def test_quality_clarify_stays_and_anchored_distinguish_is_gated(self) -> None:
|
||||
built = {
|
||||
"static_contexts": [
|
||||
_context("05:13", d4_asc=1, d9_asc=1),
|
||||
@@ -164,8 +164,12 @@ class DiscriminatorContractTest(unittest.TestCase):
|
||||
representative_time="05:13",
|
||||
today=date(2026, 8, 22),
|
||||
)
|
||||
self.assertFalse(any(item.get("source") == "known_event_quality" for item in probes))
|
||||
self.assertFalse(any(item.get("role") == "distinguish" and distinguish_contract_errors(item) for item in probes))
|
||||
quality = [item for item in probes if item.get("source") == "known_event_quality"]
|
||||
for item in quality:
|
||||
self.assertEqual(item.get("role"), "distinguish")
|
||||
self.assertTrue(item.get("target_evidence_id"))
|
||||
self.assertEqual(item.get("choice_kind"), "event_quality")
|
||||
clarification = event_clarification_probes(request)
|
||||
self.assertTrue(any(item.get("source") == "known_event_quality" for item in clarification))
|
||||
self.assertTrue(all(item.get("phase") == "event_clarification" for item in clarification))
|
||||
@@ -281,7 +285,11 @@ class DiscriminatorContractTest(unittest.TestCase):
|
||||
representative_time="04:50",
|
||||
today=date(2026, 8, 22),
|
||||
)
|
||||
self.assertFalse(any(item.get("source") == "known_event_quality" for item in probes))
|
||||
self.assertFalse(any(
|
||||
item.get("source") == "known_event_quality"
|
||||
and not item.get("target_evidence_id")
|
||||
for item in probes
|
||||
))
|
||||
for probe in probes:
|
||||
self.assertEqual(distinguish_contract_errors(probe), [])
|
||||
self.assertGreater(float(probe["information_gain"]), 0)
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Round 2 engine-convergence invariants (TASK-rectification-engine-convergence-20260901)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.rectification.candidate_contrast import distinguish_contract_errors
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
from scripts.rectification.event_probes import (
|
||||
_apply_prior_ranking,
|
||||
_dominant_existence_prior,
|
||||
_partition_ranked_probes,
|
||||
_vim_start_dates,
|
||||
discriminating_event_probes,
|
||||
event_clarification_probes,
|
||||
)
|
||||
from scripts.rectification.refinement_packet import build_refinement_packet, window_scan
|
||||
from scripts.rectification.scoring_service import build_event_contribution_matrix
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PLANETS = {
|
||||
"Sun": 12.0,
|
||||
"Moon": 100.0,
|
||||
"Mars": 40.0,
|
||||
"Mercury": 20.0,
|
||||
"Jupiter": 80.0,
|
||||
"Venus": 50.0,
|
||||
"Saturn": 200.0,
|
||||
"Rahu": 310.0,
|
||||
"Ketu": 130.0,
|
||||
}
|
||||
GRID_TIMES = [
|
||||
f"{4 + (45 + offset) // 60:02d}:{(45 + offset) % 60:02d}"
|
||||
for offset in range(31)
|
||||
]
|
||||
SPLIT_EVENT_ID = "00000000-0000-4000-8000-000000000003"
|
||||
|
||||
|
||||
def _varga(asc: int, planet_sign: int) -> dict:
|
||||
return {
|
||||
"Ascendant": {"sign_idx": asc},
|
||||
**{name: {"sign_idx": planet_sign} for name in PLANETS},
|
||||
}
|
||||
|
||||
|
||||
def _context(
|
||||
time: str,
|
||||
*,
|
||||
moon: float = 100.0,
|
||||
d4_asc: int = 1,
|
||||
d9_asc: int = 1,
|
||||
d10_asc: int = 1,
|
||||
d12_asc: int = 1,
|
||||
d24_asc: int = 1,
|
||||
sun_house: int = 10,
|
||||
) -> dict:
|
||||
hour, minute = (int(part) for part in time.split(":"))
|
||||
planets = {**PLANETS, "Moon": moon}
|
||||
natal_planets = {
|
||||
name: {"house": sun_house if name != "Moon" else 4, "lon": lon}
|
||||
for name, lon in planets.items()
|
||||
}
|
||||
return {
|
||||
"candidate_at": datetime(1997, 8, 8, hour, minute),
|
||||
"chart": {"ascendant": {"lon": 10.0, "sign": "Aries"}, "planets": natal_planets},
|
||||
"planet_longitudes": dict(planets),
|
||||
"ascendant_index": 0,
|
||||
"varga_charts": {
|
||||
"D4": _varga(d4_asc, 1),
|
||||
"D9": _varga(d9_asc, 1),
|
||||
"D10": _varga(d10_asc, 1),
|
||||
"D5": _varga(1, 1),
|
||||
"D24": _varga(d24_asc, 1),
|
||||
"D12": _varga(d12_asc, 1),
|
||||
"D7": _varga(1, 1),
|
||||
"D3": _varga(1, 1),
|
||||
},
|
||||
"arudha_padas": {},
|
||||
"feature": {
|
||||
"time": time,
|
||||
"ascendant_sign_index": 0,
|
||||
"varga_ascendants": {
|
||||
"D4": d4_asc, "D9": d9_asc, "D10": d10_asc, "D5": 1, "D24": d24_asc, "D12": d12_asc,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _accident_events() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"domain": "education",
|
||||
"event_kind": "education_start",
|
||||
"date_start": "2016-01-01",
|
||||
"date_end": "2016-12-31",
|
||||
"precision": "year",
|
||||
"summary": "上大学",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"domain": "relationship",
|
||||
"event_kind": "relationship_start",
|
||||
"date_start": "2024-05-01",
|
||||
"date_end": "2024-05-31",
|
||||
"precision": "month",
|
||||
"summary": "开始认真交往",
|
||||
},
|
||||
{
|
||||
"id": SPLIT_EVENT_ID,
|
||||
"domain": "relationship",
|
||||
"event_kind": "relationship_end",
|
||||
"date_start": "2024-08-08",
|
||||
"date_end": "2024-08-08",
|
||||
"precision": "day",
|
||||
"summary": "分手",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000004",
|
||||
"domain": "career",
|
||||
"event_kind": "career_entry",
|
||||
"date_start": "2020-04-01",
|
||||
"date_end": "2020-04-30",
|
||||
"precision": "month",
|
||||
"summary": "实习入职",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000005",
|
||||
"domain": "career",
|
||||
"event_kind": "career_exit",
|
||||
"date_start": "2020-10-01",
|
||||
"date_end": "2020-10-31",
|
||||
"precision": "month",
|
||||
"summary": "实习结束离职",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _accident_request() -> dict:
|
||||
return normalize_rectification_request(
|
||||
{
|
||||
"birth_date": "1997-08-08",
|
||||
"start_time": "04:45",
|
||||
"end_time": "05:15",
|
||||
"lat": 36.420487,
|
||||
"lon": 114.209936,
|
||||
"tz": 8,
|
||||
"events": _accident_events(),
|
||||
},
|
||||
today=date(2026, 8, 22),
|
||||
)
|
||||
|
||||
|
||||
def _equal_rows(payload: dict) -> list[dict]:
|
||||
event = payload["events"][0]
|
||||
return [
|
||||
{
|
||||
"time": time,
|
||||
"score": 10,
|
||||
"evidence": [{
|
||||
"event_id": event["id"],
|
||||
"domain": event["domain"],
|
||||
"candidate_time": time,
|
||||
"rule_ids": ["vim_md_domain_house"],
|
||||
"points": 10,
|
||||
}],
|
||||
"missing_layers": [],
|
||||
}
|
||||
for time in GRID_TIMES
|
||||
]
|
||||
|
||||
|
||||
def _moons_with_split_proximity() -> tuple[float, float]:
|
||||
event_at = date(2024, 8, 8)
|
||||
ranked: list[tuple[int, float]] = []
|
||||
for moon in (100.0, 100.5, 101.0, 103.0, 110.0):
|
||||
starts = _vim_start_dates("1997-08-08", moon, 2023, 2025)
|
||||
if not starts:
|
||||
continue
|
||||
nearest = min(abs((item - event_at).days) for item in starts)
|
||||
ranked.append((nearest, moon))
|
||||
ranked.sort()
|
||||
if len(ranked) < 2 or ranked[0][0] == ranked[-1][0]:
|
||||
raise AssertionError("fixture moons do not split AD/PD proximity")
|
||||
return ranked[0][1], ranked[-1][1]
|
||||
|
||||
|
||||
def _probe_events(*, education_count: int = 2, d24_split: bool = True) -> tuple[dict, dict]:
|
||||
events = [
|
||||
{
|
||||
"id": f"00000000-0000-4000-8000-{index:012d}",
|
||||
"domain": "education",
|
||||
"event_kind": "education_start",
|
||||
"summary": "入学",
|
||||
"date": f"{2015 + index}-09-01",
|
||||
"precision": "month",
|
||||
}
|
||||
for index in range(1, education_count + 1)
|
||||
]
|
||||
events.extend([
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000011",
|
||||
"domain": "career",
|
||||
"event_kind": "career_entry",
|
||||
"summary": "入职",
|
||||
"date": "2018-07-01",
|
||||
"precision": "month",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000012",
|
||||
"domain": "career",
|
||||
"event_kind": "career_change",
|
||||
"summary": "换岗",
|
||||
"date": "2020-04-01",
|
||||
"precision": "month",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000013",
|
||||
"domain": "relationship",
|
||||
"event_kind": "relationship_start",
|
||||
"summary": "相识",
|
||||
"date": "2021-08-01",
|
||||
"precision": "month",
|
||||
},
|
||||
])
|
||||
late = 2 if d24_split else 1
|
||||
built = {
|
||||
"static_contexts": [
|
||||
_context("05:00", d24_asc=1, d12_asc=1, d9_asc=1, d10_asc=1),
|
||||
_context("05:07", d24_asc=late, d12_asc=late, d9_asc=late, d10_asc=late),
|
||||
]
|
||||
}
|
||||
return {"birth_date": "1997-08-08", "events": events}, built
|
||||
|
||||
|
||||
class EngineConvergenceProximityTests(unittest.TestCase):
|
||||
def test_day_event_proximity_splits_adjacent_minutes_in_accident_shape(self) -> None:
|
||||
from scripts.rectification.dasha_transition_proximity import (
|
||||
DAY_MAX_POINTS,
|
||||
score_transition_proximity,
|
||||
)
|
||||
|
||||
closer_moon, farther_moon = _moons_with_split_proximity()
|
||||
event_at = date(2024, 8, 8)
|
||||
closer_starts = _vim_start_dates("1997-08-08", closer_moon, 2023, 2025)
|
||||
farther_starts = _vim_start_dates("1997-08-08", farther_moon, 2023, 2025)
|
||||
closer_delta = min(abs((item - event_at).days) for item in closer_starts)
|
||||
farther_delta = min(abs((item - event_at).days) for item in farther_starts)
|
||||
closer_points = score_transition_proximity(
|
||||
event_date=event_at,
|
||||
precision="day",
|
||||
vim_starts=closer_starts,
|
||||
narayana_starts=[],
|
||||
)
|
||||
farther_points = score_transition_proximity(
|
||||
event_date=event_at,
|
||||
precision="day",
|
||||
vim_starts=farther_starts,
|
||||
narayana_starts=[],
|
||||
)
|
||||
self.assertLessEqual(float(closer_points["points"]), DAY_MAX_POINTS)
|
||||
self.assertNotEqual(closer_points["points"], farther_points["points"])
|
||||
self.assertEqual(
|
||||
closer_points["points"] > farther_points["points"],
|
||||
closer_delta < farther_delta,
|
||||
)
|
||||
self.assertTrue(
|
||||
any(str(rule).startswith("vim_transition_proximity_") for rule in closer_points["rule_ids"])
|
||||
)
|
||||
|
||||
contexts = [
|
||||
_context(time, moon=closer_moon if time == "05:00" else farther_moon)
|
||||
for time in GRID_TIMES
|
||||
]
|
||||
request = _accident_request()
|
||||
built = build_event_contribution_matrix(
|
||||
request,
|
||||
row_provider=_equal_rows,
|
||||
static_contexts=contexts,
|
||||
)
|
||||
self.assertEqual(len(built["candidate_times"]), 31)
|
||||
cell_0500 = built["matrix"][SPLIT_EVENT_ID]["05:00"]
|
||||
cell_0507 = built["matrix"][SPLIT_EVENT_ID]["05:07"]
|
||||
self.assertNotEqual(cell_0500["points"], cell_0507["points"])
|
||||
self.assertEqual(
|
||||
cell_0500["points"] > cell_0507["points"],
|
||||
closer_delta < farther_delta,
|
||||
)
|
||||
self.assertTrue(
|
||||
any("transition_proximity" in str(rule) for rule in cell_0500["rule_ids"])
|
||||
or any("transition_proximity" in str(rule) for rule in cell_0507["rule_ids"])
|
||||
)
|
||||
self.assertLessEqual(abs(cell_0500["points"] - cell_0507["points"]), DAY_MAX_POINTS)
|
||||
|
||||
def test_algorithm_and_policy_versions_change_with_proximity_semantics(self) -> None:
|
||||
from scripts.rectification.decision_policy import POLICY_VERSION
|
||||
from scripts.rectification.scoring_service import ALGORITHM_VERSION
|
||||
|
||||
self.assertNotEqual(ALGORITHM_VERSION, "rectification-v5-matrix-scoring-6")
|
||||
self.assertNotEqual(POLICY_VERSION, "rectification-candidate-policy-v2")
|
||||
|
||||
|
||||
class EngineConvergenceProbeTests(unittest.TestCase):
|
||||
def test_anchored_quality_outranks_family_existence_and_drops_dominant_priors(self) -> None:
|
||||
request, built = _probe_events()
|
||||
probes = discriminating_event_probes(
|
||||
request,
|
||||
built,
|
||||
scan=window_scan(built),
|
||||
candidate_times=["05:00", "05:07"],
|
||||
representative_time="05:00",
|
||||
today=date(2026, 8, 22),
|
||||
)
|
||||
quality = [item for item in probes if item.get("source") == "known_event_quality"]
|
||||
self.assertTrue(quality)
|
||||
self.assertEqual(quality[0]["role"], "distinguish")
|
||||
self.assertTrue(quality[0].get("target_evidence_id"))
|
||||
self.assertFalse(distinguish_contract_errors(quality[0]))
|
||||
ranked_family = _apply_prior_ranking({
|
||||
"domain": "family",
|
||||
"source": "dasha_boundary",
|
||||
"choice_kind": "existence",
|
||||
"information_gain": float(quality[0].get("raw_split_gain") or quality[0].get("information_gain") or 0),
|
||||
"semantic_key": "family.2024.existence",
|
||||
"year": 2024,
|
||||
"window_span_years": 3,
|
||||
"role": "distinguish",
|
||||
"phase": "candidate_discriminator",
|
||||
"candidate_ids": ["05:00", "05:07"],
|
||||
"expected_outcomes": [
|
||||
{"answer_class": "yes", "supports": ["05:00"], "conflicts": ["05:07"]},
|
||||
{"answer_class": "no", "supports": ["05:07"], "conflicts": ["05:00"]},
|
||||
],
|
||||
})
|
||||
self.assertTrue(_dominant_existence_prior(ranked_family, ranked_family["answer_priors"]))
|
||||
_, ranked_dropped = _partition_ranked_probes([quality[0], ranked_family])
|
||||
self.assertTrue(
|
||||
any(item.get("reason") == "dominant_answer_prior" for item in ranked_dropped),
|
||||
ranked_dropped,
|
||||
)
|
||||
packet = build_refinement_packet(
|
||||
request,
|
||||
built,
|
||||
representative_time="05:00",
|
||||
candidate_times=["05:00", "05:07"],
|
||||
)
|
||||
dropped = list(packet.get("dropped_probes") or []) + ranked_dropped
|
||||
self.assertTrue(
|
||||
any(item.get("reason") == "dominant_answer_prior" for item in dropped),
|
||||
dropped,
|
||||
)
|
||||
self.assertFalse(
|
||||
any(
|
||||
item.get("choice_kind") == "existence"
|
||||
and float(max((item.get("answer_priors") or {}).values() or [0])) > 0.8
|
||||
for item in probes
|
||||
)
|
||||
)
|
||||
|
||||
def test_quality_distinguish_requires_varga_type_split_and_caps_at_two(self) -> None:
|
||||
same_request, same_built = _probe_events(d24_split=False)
|
||||
same_probes = discriminating_event_probes(
|
||||
same_request,
|
||||
same_built,
|
||||
scan=window_scan(same_built),
|
||||
candidate_times=["05:00", "05:07"],
|
||||
representative_time="05:00",
|
||||
today=date(2026, 8, 22),
|
||||
)
|
||||
self.assertFalse(any(item.get("source") == "known_event_quality" for item in same_probes))
|
||||
clarification = event_clarification_probes(same_request)
|
||||
self.assertTrue(any(item.get("source") == "known_event_quality" for item in clarification))
|
||||
|
||||
split_request, split_built = _probe_events(education_count=4, d24_split=True)
|
||||
split_probes = discriminating_event_probes(
|
||||
split_request,
|
||||
split_built,
|
||||
scan=window_scan(split_built),
|
||||
candidate_times=["05:00", "05:07"],
|
||||
representative_time="05:00",
|
||||
today=date(2026, 8, 22),
|
||||
)
|
||||
quality = [item for item in split_probes if item.get("source") == "known_event_quality"]
|
||||
self.assertTrue(quality)
|
||||
self.assertLessEqual(len(quality), 2)
|
||||
for item in quality:
|
||||
self.assertEqual(item["role"], "distinguish")
|
||||
self.assertTrue(item.get("target_evidence_id"))
|
||||
self.assertTrue(item.get("display_date_label"))
|
||||
self.assertEqual(item.get("choice_kind"), "event_quality")
|
||||
self.assertFalse(distinguish_contract_errors(item))
|
||||
|
||||
|
||||
class EngineConvergenceAnswerKeyTests(unittest.TestCase):
|
||||
def test_scripts_and_frontend_have_no_answer_key_literals(self) -> None:
|
||||
forbidden = ("target" + "_minute", "pl9_" + "1993", "regression" + "_only")
|
||||
hits: list[str] = []
|
||||
roots = (
|
||||
REPO_ROOT / "scripts",
|
||||
REPO_ROOT / "frontend" / "src",
|
||||
REPO_ROOT / "frontend" / "tests",
|
||||
)
|
||||
skip_parts = {"node_modules", ".next", "dist"}
|
||||
for root in roots:
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file() or path.suffix not in {".py", ".ts", ".tsx", ".js", ".mjs"}:
|
||||
continue
|
||||
if any(part in skip_parts for part in path.parts):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for token in forbidden:
|
||||
if token in text:
|
||||
hits.append(f"{path.relative_to(REPO_ROOT)}:{token}")
|
||||
self.assertEqual(hits, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -450,7 +450,10 @@ class EventProbesTest(unittest.TestCase):
|
||||
}],
|
||||
)
|
||||
probes = _probes(request, built, ["05:13", "05:40"], "05:13", precision_current="d5_refine")
|
||||
existence = [item for item in probes if item["domain"] == "education"]
|
||||
existence = [
|
||||
item for item in probes
|
||||
if item["domain"] == "education" and item.get("choice_kind") != "event_quality"
|
||||
]
|
||||
self.assertFalse(any(item["year"] in {2015, 2016, 2017} for item in existence))
|
||||
|
||||
def test_signature_clusters_use_full_birth_window(self) -> None:
|
||||
@@ -674,7 +677,7 @@ class EventProbesTest(unittest.TestCase):
|
||||
self.assertGreaterEqual(int(probe["year"]), 1900)
|
||||
self.assertNotEqual(int(probe["year"]), 0)
|
||||
self.assertEqual(distinguish_contract_errors(probe), [])
|
||||
self.assertIn(probe["source"], {"dasha_boundary", "dasha_activation"})
|
||||
self.assertIn(probe["source"], {"dasha_boundary", "dasha_activation", "known_event_quality"})
|
||||
self.assertGreaterEqual(len(probe["style_options"]), 4)
|
||||
self.assertGreaterEqual(len(probe["candidate_ids"]), 2)
|
||||
self.assertGreaterEqual(len(probe["expected_outcomes"]), 2)
|
||||
|
||||
@@ -488,7 +488,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
self.assertNotEqual(receipt["gates"]["exact_confirmation"]["external_validation_status"], "fail")
|
||||
|
||||
self.assertEqual(first["decision_receipt"], receipt)
|
||||
self.assertEqual(first["decision_policy_version"], "rectification-candidate-policy-v2")
|
||||
self.assertEqual(first["decision_policy_version"], "rectification-candidate-policy-v3")
|
||||
self.assertTrue(receipt["display_allowed"])
|
||||
self.assertTrue(receipt["accept_allowed"])
|
||||
self.assertFalse(receipt["confirm_allowed"])
|
||||
|
||||
Reference in New Issue
Block a user