diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 9c56c7de..66062a8a 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -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; diff --git a/frontend/src/lib/rectification-agentic/core/snapshot-source.ts b/frontend/src/lib/rectification-agentic/core/snapshot-source.ts index ac184ca2..6812651a 100644 --- a/frontend/src/lib/rectification-agentic/core/snapshot-source.ts +++ b/frontend/src/lib/rectification-agentic/core/snapshot-source.ts @@ -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> = { @@ -23,6 +24,7 @@ export const SNAPSHOT_STALE_COPY: Readonly> 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. */ diff --git a/frontend/src/lib/rectification-agentic/core/types.ts b/frontend/src/lib/rectification-agentic/core/types.ts index 92b8aa5b..6758326c 100644 --- a/frontend/src/lib/rectification-agentic/core/types.ts +++ b/frontend/src/lib/rectification-agentic/core/types.ts @@ -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; diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 295c3e8d..54c1d7ca 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -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> | 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; diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index 5e9cee54..76c34e25 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -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); diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 474da9d9..a3c4fb9c 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -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 ?? [])], }); diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index ceb0b0ac..6bff4e28 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -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"); diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts index e60cb528..8b393e4d 100644 --- a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -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>, 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), }; } diff --git a/frontend/tests/rectification-engine-convergence.test.ts b/frontend/tests/rectification-engine-convergence.test.ts new file mode 100644 index 00000000..0f971fb7 --- /dev/null +++ b/frontend/tests/rectification-engine-convergence.test.ts @@ -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 { + return { + birthProfileFingerprint: "birth-a", + scoreableEvidenceFingerprint: "score-a", + inferenceRevision: 3, + candidateSetVersion: "set-a", + scoringPolicyVersion: "rectification-candidate-policy-v3", + ...overrides, + }; +} + +function qualityProbe(overrides: Record = {}) { + 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, []); +}); diff --git a/scripts/rectification/dasha_transition_proximity.py b/scripts/rectification/dasha_transition_proximity.py new file mode 100644 index 00000000..985031f2 --- /dev/null +++ b/scripts/rectification/dasha_transition_proximity.py @@ -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"]) diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py index cf2e54c7..40f478ec 100644 --- a/scripts/rectification/decision_policy.py +++ b/scripts/rectification/decision_policy.py @@ -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, }) diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 132126fa..ba4a0b8a 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -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 diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index 57033ecc..ed3656c2 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -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, } diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py index 5ec44d14..92332124 100644 --- a/scripts/rectification/scoring_service.py +++ b/scripts/rectification/scoring_service.py @@ -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, diff --git a/tests/test_candidate_discriminator_contract.py b/tests/test_candidate_discriminator_contract.py index 5353debd..eccd7136 100644 --- a/tests/test_candidate_discriminator_contract.py +++ b/tests/test_candidate_discriminator_contract.py @@ -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) diff --git a/tests/test_rectification_engine_convergence.py b/tests/test_rectification_engine_convergence.py new file mode 100644 index 00000000..16ddb1c3 --- /dev/null +++ b/tests/test_rectification_engine_convergence.py @@ -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() diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 5a2f5c2d..0dee4d0e 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -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) diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index d6e4caa4..df461292 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -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"])