From 68a78af9e1871bda6a4ad664e08010724924a6a0 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 28 Aug 2026 10:20:05 +0800 Subject: [PATCH] fix(rectification): ask reverse-inference probes at engine dasha months Keep Vimshottari/Narayana start dates instead of truncating to year, so same-year month splits can appear on the choice card. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++ .../core/candidate-contrast-packet.ts | 2 +- .../v9/method-followup.ts | 16 +- .../v9/refinement-packet.ts | 5 + .../tests/rectification-choice-card.test.ts | 36 ++++ ...rectification-distinguish-contract.test.ts | 26 +++ .../tests/rectification-eight-method.test.ts | 24 +++ scripts/rectification/candidate_contrast.py | 4 +- scripts/rectification/event_probes.py | 185 ++++++++++++------ .../jyotish-birth-time-rectification/SKILL.md | 2 +- .../references/conversation-strategy.md | 6 +- .../versions/10.0.13/SKILL.md | 2 +- .../references/conversation-strategy.md | 6 +- tests/test_rectification_event_probes.py | 113 +++++++++-- 14 files changed, 359 insertions(+), 84 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 198a4750..68b2daa9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6221,3 +6221,19 @@ - 复发自:BUG-405(排序公式对,目录被投影饿死,已打开低分卡锁题) - 修复版本:待发布 +## BUG-408 | 反推题丢掉大运起点的月份,同年 3 月对 9 月问不出来 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`scripts/rectification/event_probes.py`、点选卡 `choice_frame.period`、Skill `10.0.13` 区分阶段时间范围 +- 用户现象:反推点选卡只写「YYYY 年前后」。候选分钟只差十几三十分钟时,大运/副运起点其实已经错开几个月,但题目仍按年问;同一年里 3 月对 9 月这种差被丢掉。 +- 触发条件:训练事件已够、剩余候选的 Vimshottari/Narayana 大运或副运起点相差不足一年,但月份已分开。 +- 根因:探针只取起点 `.year`,边界还要求两边年份至少差 1 年,评分固定落在当年 7 月 1 日。引擎算到的月日被扔掉。 +- 修复:边界改用大运/副运实际起点。年份不同,或同年且相差至少 45 天,按该月出题并按该月计分。标签写成「YYYY 年 M 月前后」。年龄带兜底仍只锁年。点选卡直接用探针 `year_label`,不得再从年份拼回「年前后」。开场仍不诱导用户猜月份。Skill 版本保持 `10.0.13`。 +- 验证:Python 事件探针回归锁定同年 3 月/9 月边界、微小同年漂移仍不出边界题、年龄带仍是年精度;前端点选卡与 method-followup 锁定「2018 年 3 月前后」。 +- 防复发:`dasha_boundary` 必须带引擎月份;不得把起点截成年后再比。年龄带/无起点时不得伪造月份。 +- 相关记录:BUG-405、BUG-407 +- 复发自:无 +- 修复版本:待发布 + 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 e33036cb..c5f3cd89 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -729,7 +729,7 @@ function remainingQuestion( layer: string, layerLabel: string, ): string { - return `引擎给出的区分机会绑定 ${layerLabel}。按 Opportunity 的年份、领域和 expected_outcomes 改写成自然语言,不得发明年份、事件事实或候选映射。`; + return `引擎给出的区分机会绑定 ${layerLabel}。按 Opportunity 的时间范围、领域和 expected_outcomes 改写成自然语言,不得发明年份、事件事实或候选映射,不得改写时间范围。`; } function remainingOutcomes( diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index c76128d8..2dc8e2fa 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -113,6 +113,8 @@ export type MethodFollowup = Readonly<{ semantic_key?: string; candidate_split_hash?: string; probe_year?: number; + year_label?: string; + probe_month?: number; choice_kind?: "existence" | "varga_style" | "event_quality"; candidate_ids?: readonly string[]; expected_outcomes?: DiscriminatingEventProbe["expected_outcomes"]; @@ -497,7 +499,13 @@ function followupOwnedProbe( if (styleOptions.length !== 4) return null; return { year: item.probe_year ?? 0, - year_label: item.probe_year ? `${item.probe_year} 年前后` : "当前这几个候选", + year_label: item.year_label + ?? (item.probe_year && item.probe_month + ? `${item.probe_year} 年 ${item.probe_month} 月前后` + : item.probe_year + ? `${item.probe_year} 年前后` + : "当前这几个候选"), + ...(item.probe_month ? { month: item.probe_month } : {}), domain: item.domain as EventProbeDomain, event_family: followupEventFamily(item.domain, kind), source: "dasha_activation", @@ -579,7 +587,7 @@ function agentHint( extra = "", evidence: readonly MethodFollowupEvidence[] = [], ): string { - return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用简体中文自己写一句追问;年份和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改问其他领域,不得把探针年份说成已经发生的事实。不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim(); + return `${why}本题绑定 ${varga}。${extra}${recordedKindYearHint(evidence)}点选卡只出 A/B/C/D。用简体中文自己写一句追问;时间范围和事件家族以 choice_frame.period 与探针为准,不得发明年份,不得改写时间范围,不得改问其他领域,不得把探针时间说成已经发生的事实。不要调用 set-focus。正文不要复述选项。`.replace(/\s+/g, " ").trim(); } export function shouldAttachChoiceFrame( @@ -995,6 +1003,8 @@ export function buildMethodFollowupPlan(input: { semantic_key: liveProbe.semantic_key, candidate_split_hash: liveProbe.candidate_split_hash, probe_year: liveProbe.year, + year_label: liveProbe.year_label, + probe_month: liveProbe.month, choice_kind: liveProbe.choice_kind, candidate_ids: liveProbe.candidate_ids ?? candidateIdsFromProbe(liveProbe), expected_outcomes: liveProbe.expected_outcomes, @@ -1093,6 +1103,8 @@ export function buildMethodFollowupPlan(input: { semantic_key: conflictProbe.semantic_key ?? `${conflictProbe.domain}.${conflictProbe.year}`, candidate_split_hash: conflictProbe.candidate_split_hash, probe_year: conflictProbe.year, + year_label: conflictProbe.year_label, + probe_month: conflictProbe.month, choice_kind: conflictProbe.choice_kind ?? "existence", candidate_ids: conflictProbe.candidate_ids ?? candidateIdsFromProbe(conflictProbe), expected_outcomes: conflictProbe.expected_outcomes, diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts index 634bc5b6..40314203 100644 --- a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -205,6 +205,7 @@ export type ProbeExpectedOutcome = Readonly<{ export type DiscriminatingEventProbe = Readonly<{ year: number; year_label: string; + month?: number; domain: EventProbeDomain; event_family: string; source: EventProbeSource; @@ -511,6 +512,9 @@ export function parseEventProbes( const family = asText(row?.event_family, 80); const meaning = asText(row?.user_meaning, 160); const label = asText(row?.year_label, 40); + const month = typeof row?.month === "number" && Number.isInteger(row.month) && row.month >= 1 && row.month <= 12 + ? row.month + : null; if ( !row || year === null @@ -536,6 +540,7 @@ export function parseEventProbes( const probe: DiscriminatingEventProbe = { year, year_label: label, + ...(month ? { month } : {}), domain: domain as EventProbeDomain, event_family: family, source: source as EventProbeSource, diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index efbb4c00..331580f7 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -128,6 +128,42 @@ test("choice frames ask one biographical event from a server probe, not competin assert.equal(mergeChoiceCard(frame, null), null); }); +test("choice frames keep the engine month lock instead of collapsing to a year", () => { + const frame = buildChoiceFrame( + { + method_id: "d10_career", + ask_theme: "dated_event", + domain: "career", + user_prompt_hint: "unused", + }, + { + evidence: [{ + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: null, + }], + probes: [{ + ...MOVE_PROBE, + year: 2018, + month: 3, + year_label: "2018 年 3 月前后", + domain: "career", + event_family: "入职、升职或职责明显加重", + source: "dasha_boundary", + user_meaning: "时间范围锁定 2018 年 3 月前后;领域锁定 career。", + semantic_key: "career.2018.03.dasha_boundary", + }], + }, + ); + assert.ok(frame); + assert.equal(frame.period, "2018 年 3 月前后"); + assert.match(frame.prompt, /2018 年 3 月前后/); + assert.match(frame.why, /2018 年 3 月前后/); + assert.doesNotMatch(frame.period, /^2018 年前后$/); +}); + test("server-owned card names the event family, not a generic 有没有这件事", () => { const frame = buildChoiceFrame( { diff --git a/frontend/tests/rectification-distinguish-contract.test.ts b/frontend/tests/rectification-distinguish-contract.test.ts index 3060fe59..3857959d 100644 --- a/frontend/tests/rectification-distinguish-contract.test.ts +++ b/frontend/tests/rectification-distinguish-contract.test.ts @@ -74,6 +74,32 @@ test("receipt parser drops invalid distinguish probes and known_event_quality", assert.ok((parsed[0]?.information_gain ?? 0) > 0); }); +test("receipt parser keeps engine month on dasha boundary probes", () => { + const parsed = parseDiscriminatingEventProbes([{ + year: 2018, + month: 3, + year_label: "2018 年 3 月前后", + domain: "career", + event_family: "职责变化", + source: "dasha_boundary", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "时间范围锁定 2018 年 3 月前后", + role: "distinguish", + information_gain: 0.4, + semantic_key: "career.2018.03.dasha_boundary", + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + }]); + assert.equal(parsed[0]?.month, 3); + assert.equal(parsed[0]?.year_label, "2018 年 3 月前后"); + assert.equal(parsed[0]?.source, "dasha_boundary"); +}); + test("randomized hidden mutated answers change posterior only when mapped", () => { const probe = probeFromEngine({ year: 2018, diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index daa8c83f..501fb877 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -358,6 +358,30 @@ test("dasha conflict probe jumps after four scoreable events leave three trainin }), "collect_evidence"); }); +test("dasha conflict probe keeps the engine month on the choice card", () => { + const plan = buildMethodFollowupPlan({ + evidence: [ + datedEvidence("education", "2016"), + datedEvidence("education", "2020"), + datedEvidence("relationship", "2018"), + datedEvidence("family", "2023"), + ], + eventProbes: [{ + ...CAREER_CONFLICT_PROBE, + month: 3, + year_label: "2018 年 3 月前后", + source: "dasha_boundary", + user_meaning: "时间范围锁定 2018 年 3 月前后;领域锁定 career。", + semantic_key: "career.2018.03.dasha_boundary", + }], + }); + assert.equal(plan.next_followup?.source, "event_probe"); + assert.equal(plan.next_followup?.choice_frame?.period, "2018 年 3 月前后"); + assert.equal(plan.next_followup?.year_label, "2018 年 3 月前后"); + assert.equal(plan.next_followup?.probe_month, 3); + assert.match(plan.next_followup?.user_prompt_hint ?? "", /2018 年 3 月前后/); +}); + test("three scoreable events in one domain still rotate methods instead of reverse-inferring", () => { const plan = buildMethodFollowupPlan({ evidence: [ diff --git a/scripts/rectification/candidate_contrast.py b/scripts/rectification/candidate_contrast.py index 10d8f86f..98edc1e5 100644 --- a/scripts/rectification/candidate_contrast.py +++ b/scripts/rectification/candidate_contrast.py @@ -202,6 +202,7 @@ def candidate_split_hash( domain: str, year: int, groups: Sequence[Sequence[str]], + month: int | None = None, ) -> str: version = candidate_set_version_value or candidate_set_version(groups) grouped = "|".join( @@ -209,7 +210,8 @@ def candidate_split_hash( for group in groups if group ) - payload = f"{version}:{domain}:{year}:{grouped}" + window = f"{year}-{int(month):02d}" if isinstance(month, int) and 1 <= month <= 12 else str(year) + payload = f"{version}:{domain}:{window}:{grouped}" return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24] diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 34092735..10e8c511 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -40,6 +40,7 @@ from scripts.rectification.probe_question_contract import complete_style_options from scripts.rectification.refinement_packet import match_level MAX_PROBES = 3 +MIN_BOUNDARY_DAYS = 45 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") @@ -160,10 +161,16 @@ def _event_year(event: dict[str, Any]) -> int | None: return event_year(event) -def _year_label(year: int) -> str: +def _period_label(year: int, month: int | None = None) -> str: + if isinstance(month, int) and 1 <= month <= 12: + return f"{year} 年 {month} 月前后" return f"{year} 年前后" +def _year_label(year: int) -> str: + return _period_label(year) + + def _age_band_year(birth_year: int, domain: str, today: date) -> int | None: catalog = DOMAIN_CATALOG.get(domain) if not catalog: @@ -361,19 +368,53 @@ def _tracks_present(rule_ids: Sequence[str]) -> tuple[bool, bool]: ) -def _vim_start_years(birth_date: str, moon_longitude: float, lo: int, hi: int) -> list[int]: +def _vim_start_dates(birth_date: str, moon_longitude: float, lo: int, hi: int) -> list[date]: nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(float(moon_longitude)) timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(birth_date, nakshatra, progress) - years: list[int] = [] + starts: list[date] = [] for major in timeline: start = major.get("start") if isinstance(start, datetime) and lo <= start.year <= hi: - years.append(start.year) + starts.append(start.date()) for minor in dasha_analyzer.build_antardasha(major): minor_start = minor.get("start") if isinstance(minor_start, datetime) and lo <= minor_start.year <= hi: - years.append(minor_start.year) - return years + starts.append(minor_start.date()) + return starts + + +def _vim_start_years(birth_date: str, moon_longitude: float, lo: int, hi: int) -> list[int]: + return [item.year for item in _vim_start_dates(birth_date, moon_longitude, lo, hi)] + + +def _narayana_start_dates( + ascendant_index: int, + planet_longitudes: dict[str, float], + birth_date: str, + lo: int, + hi: int, +) -> list[date] | None: + periods = narayana_dasha.calc_narayana_mahadasha(ascendant_index, planet_longitudes) + if not periods: + return None + birth = datetime.strptime(birth_date, "%Y-%m-%d") + starts: list[date] = [] + for major in periods: + start_age = major.get("start_age") + if not isinstance(start_age, (int, float)): + return None + at = (birth + timedelta(days=float(start_age) * 365.2425)).date() + if lo <= at.year <= hi: + starts.append(at) + antars = narayana_dasha.calc_narayana_antardasha(periods, int(major["sign_idx"])) + for minor in antars: + minor_age = minor.get("start_age") + if not isinstance(minor_age, (int, float)): + continue + minor_at = (birth + timedelta(days=float(minor_age) * 365.2425)).date() + if lo <= minor_at.year <= hi: + starts.append(minor_at) + return starts def _narayana_start_years( @@ -383,36 +424,41 @@ def _narayana_start_years( lo: int, hi: int, ) -> list[int] | None: - periods = narayana_dasha.calc_narayana_mahadasha(ascendant_index, planet_longitudes) - if not periods: - return None - birth = datetime.strptime(birth_date, "%Y-%m-%d") - years: list[int] = [] - for major in periods: - start_age = major.get("start_age") - if not isinstance(start_age, (int, float)): - return None - year = (birth + timedelta(days=float(start_age) * 365.2425)).year - if lo <= year <= hi: - years.append(year) - antars = narayana_dasha.calc_narayana_antardasha(periods, int(major["sign_idx"])) - for minor in antars: - minor_age = minor.get("start_age") - if not isinstance(minor_age, (int, float)): + starts = _narayana_start_dates(ascendant_index, planet_longitudes, birth_date, lo, hi) + return None if starts is None else [item.year for item in starts] + + +def _as_start_date(value: date | datetime | int) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, int) and 1900 <= value <= 2100: + return date(value, 7, 1) + return None + + +def _boundary_windows(left: Sequence[date | datetime | int], right: Sequence[date | datetime | int]) -> list[date]: + windows: list[date] = [] + seen: set[tuple[int, int]] = set() + for raw_left, raw_right in zip(left, right): + one = _as_start_date(raw_left) + two = _as_start_date(raw_right) + if one is None or two is None: + continue + if one.year == two.year and abs((one - two).days) < MIN_BOUNDARY_DAYS: + continue + for item in (one, two): + key = (item.year, item.month) + if key in seen: continue - minor_year = (birth + timedelta(days=float(minor_age) * 365.2425)).year - if lo <= minor_year <= hi: - years.append(minor_year) - return years + seen.add(key) + windows.append(item) + return windows def _boundary_years(left: list[int], right: list[int]) -> set[int]: - years: set[int] = set() - for one, two in zip(left, right): - if abs(one - two) >= 1: - years.add(one) - years.add(two) - return years + return {item.year for item in _boundary_windows(left, right)} def _score_year( @@ -421,6 +467,7 @@ def _score_year( birth_date: str, domain: str, year: int, + month: int | None = None, ) -> dict[str, Any] | None: catalog = DOMAIN_CATALOG[domain] prefixes, _ = DOMAIN_CONFIG[domain] @@ -432,13 +479,14 @@ def _score_year( moon = (context.get("planet_longitudes") or {}).get("Moon") if candidate_at is None or not isinstance(moon, (int, float)): return None - event_at = datetime(year, 7, 1) + month_value = month if isinstance(month, int) and 1 <= month <= 12 else None + event_at = datetime(year, month_value, 15) if month_value else datetime(year, 7, 1) event = { - "id": f"probe-{domain}-{year}", + "id": f"probe-{domain}-{year}" + (f"-{month_value:02d}" if month_value else ""), "domain": domain, "event_kind": catalog["kind"], - "date": f"{year}-07-01", - "precision": "year", + "date": f"{year}-{month_value:02d}-15" if month_value else f"{year}-07-01", + "precision": "month" if month_value else "year", "summary": catalog["event_family"], } try: @@ -532,6 +580,7 @@ def _public_probe( tracks_agree: bool, user_meaning: str, event_family: str, + month: int | None = None, **extra: Any, ) -> dict[str, Any]: if source == "known_event_quality": @@ -543,9 +592,10 @@ def _public_probe( else: phase = PROBE_PHASE_CANDIDATE_DISCRIMINATOR role = "distinguish" + month_value = month if isinstance(month, int) and 1 <= month <= 12 else None payload = { "year": year, - "year_label": _year_label(year), + "year_label": _period_label(year, month_value), "domain": domain, "event_family": event_family, "source": source, @@ -555,13 +605,15 @@ def _public_probe( "user_meaning": user_meaning, "role": role, "phase": phase, - "semantic_key": f"{domain}.{year}", + "semantic_key": f"{domain}.{year}.{month_value:02d}" if month_value else f"{domain}.{year}", "information_gain": 0.0, - "candidate_split_hash": f"{domain}:{year}", + "candidate_split_hash": f"{domain}:{year}" + (f"-{month_value:02d}" if month_value else ""), "expected_outcomes": [], "candidate_ids": [], "choice_kind": "event_quality" if source == "known_event_quality" else "existence", } + if month_value: + payload["month"] = month_value payload.update(extra) if payload["role"] == "distinguish": payload["candidate_ids"] = candidate_ids_from_outcomes(payload.get("expected_outcomes") or []) @@ -661,15 +713,23 @@ def _evaluate_contexts( domain: str, year: int, source: str, + month: int | None = None, clusters: Sequence[dict[str, Any]] | None = None, set_version: str | None = None, ) -> dict[str, Any] | None: scored_rows: list[tuple[str, list[str]]] = [] + month_value = month if isinstance(month, int) and 1 <= month <= 12 else None for context in contexts: time = _context_time(context) if not time: continue - scored = _score_year(context, birth_date=birth_date, domain=domain, year=year) + scored = _score_year( + context, + birth_date=birth_date, + domain=domain, + year=year, + month=month_value, + ) if scored is None: continue scored_rows.append((time, list(scored.get("rule_ids") or []))) @@ -729,22 +789,25 @@ def _evaluate_contexts( candidate_set_version_value=version, domain=domain, year=year, + month=month_value, groups=[yes_times, no_times], ) + period = _period_label(year, month_value) probe = _public_probe( year=year, + month=month_value, domain=domain, source=source, tracks=("vimshottari", "narayana"), tracks_agree=vim_hit and narayana_hit, user_meaning=_agent_brief( - year_label=_year_label(year), + year_label=period, domain=domain, family=str(DOMAIN_CATALOG[domain]["event_family"]), ), event_family=str(DOMAIN_CATALOG[domain]["event_family"]), information_gain=gain, - semantic_key=f"{domain}.{year}.{source}", + semantic_key=f"{domain}.{year}.{month_value:02d}.{source}" if month_value else f"{domain}.{year}.{source}", candidate_split_hash=split, candidate_set_version=version, expected_outcomes=outcomes, @@ -904,15 +967,24 @@ def discriminating_event_probes( left, right = reps[0], reps[-1] left_moon = float(left["planet_longitudes"]["Moon"]) right_moon = float(right["planet_longitudes"]["Moon"]) - vim_years = _boundary_years( - _vim_start_years(birth_date, left_moon, lo, hi), - _vim_start_years(birth_date, right_moon, lo, hi), + vim_windows = _boundary_windows( + _vim_start_dates(birth_date, left_moon, lo, hi), + _vim_start_dates(birth_date, right_moon, lo, hi), ) - left_narayana = _narayana_start_years(int(left["ascendant_index"]), left["planet_longitudes"], birth_date, lo, hi) - right_narayana = _narayana_start_years(int(right["ascendant_index"]), right["planet_longitudes"], birth_date, lo, hi) - narayana_years: set[int] = set() + left_narayana = _narayana_start_dates(int(left["ascendant_index"]), left["planet_longitudes"], birth_date, lo, hi) + right_narayana = _narayana_start_dates(int(right["ascendant_index"]), right["planet_longitudes"], birth_date, lo, hi) + narayana_windows: list[date] = [] if left_narayana is not None and right_narayana is not None: - narayana_years = _boundary_years(left_narayana, right_narayana) + narayana_windows = _boundary_windows(left_narayana, right_narayana) + boundary_dates: list[date] = [] + seen_windows: set[tuple[int, int]] = set() + for item in [*vim_windows, *narayana_windows]: + key = (item.year, item.month) + if key in seen_windows: + continue + seen_windows.add(key) + boundary_dates.append(item) + boundary_dates.sort() probes: list[dict[str, Any]] = [] for domain in domains: if domain not in DOMAIN_CATALOG: @@ -920,18 +992,19 @@ def discriminating_event_probes( known_years = _event_years(events, domain) blocked_years = _existence_blocked_years(domain, known_years) domain_lo = max(lo, birth_year + int(DOMAIN_CATALOG[domain]["age_lo"])) if domain == "relationship" else lo - boundary = sorted(year for year in vim_years | narayana_years if domain_lo <= year <= hi) + boundary = [item for item in boundary_dates if domain_lo <= item.year <= hi] best = None - for year in boundary: - if year in blocked_years: + for at in boundary: + if at.year in blocked_years: continue - if f"{domain}:{year}" in holdout_keys: + if f"{domain}:{at.year}" in holdout_keys: continue found = _evaluate_contexts( reps, birth_date=birth_date, domain=domain, - year=year, + year=at.year, + month=at.month, source="dasha_boundary", clusters=clusters, set_version=set_version, @@ -956,13 +1029,13 @@ def discriminating_event_probes( probes.append(best) probes.sort(key=lambda row: (-float(row.get("information_gain") or 0), str(row.get("semantic_key") or ""))) public: list[dict[str, Any]] = [] - seen: set[tuple[str, int, str]] = set() + 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: continue if distinguish_contract_errors(row): continue - key = (str(row["domain"]), int(row["year"]), str(row["source"])) + key = (str(row["domain"]), int(row["year"]), int(row.get("month") or 0), str(row["source"])) encoded = str(row) if key in seen or "points" in encoded: continue diff --git a/skills/jyotish-birth-time-rectification/SKILL.md b/skills/jyotish-birth-time-rectification/SKILL.md index 0988b9d3..99c63402 100644 --- a/skills/jyotish-birth-time-rectification/SKILL.md +++ b/skills/jyotish-birth-time-rectification/SKILL.md @@ -75,7 +75,7 @@ description: "生时校正专用 Skill(V10)。以服务器权威 Case、Conv `CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。 - 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary` 与 `method_followup_plan`。 -- 不要按 `missing_evidence_categories` 轮询迁居。财务与健康只有用户主动说才问,仍可计分。下一问只跟 `method_followup_plan.next_followup`。先走完方法覆盖(感情 → 事业 → 家人 → 职业 → 占问),再对已覆盖领域做精度追问。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 +- 不要按 `missing_evidence_categories` 轮询迁居。财务与健康只有用户主动说才问,仍可计分。下一问只跟 `method_followup_plan.next_followup`。先走完方法覆盖(感情 → 事业 → 家人 → 职业 → 占问),再对已覆盖领域做精度追问。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 - recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。 - summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。 - 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。 diff --git a/skills/jyotish-birth-time-rectification/references/conversation-strategy.md b/skills/jyotish-birth-time-rectification/references/conversation-strategy.md index e4e5c6c4..6d772a2f 100644 --- a/skills/jyotish-birth-time-rectification/references/conversation-strategy.md +++ b/skills/jyotish-birth-time-rectification/references/conversation-strategy.md @@ -1,6 +1,6 @@ # Conversation Strategy(V10) -生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定年份和事件家族,由你写成一句具体生平题干(某年是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。 +生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定时间范围和事件家族,由你写成一句具体生平题干(某年或某月是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。 ## 1. 每轮上下文优先级 @@ -23,7 +23,7 @@ recent turns 不是权威记忆,不得依赖“上一条 assistant 问了什 - 至多一个主问题;开场可以零问题。 - 不固定复述身份、流程、领域列表、证据数量要求或 opening brief 原文。 -示例方向(不是固定话术):“可以先说一件你记得大概年份的事,比如升学、考试或第一份工作。” 区分阶段的题干由你写成自然语言;年份和事件家族以服务器探针为准,不得发明年份。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把已有高考经历写成“高考的时候是否发挥失常?” +示例方向(不是固定话术):“可以先说一件你记得大概年份的事,比如升学、考试或第一份工作。” 区分阶段的题干由你写成自然语言;时间范围和事件家族以服务器探针为准,不得发明年份,不得改写时间范围。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把锁定的 2018 年 3 月写成“2018 年 3 月前后你是否入职或职责加重?”,把已有高考经历写成“高考的时候是否发挥失常?” ## 3. 一轮的基本形态 @@ -73,7 +73,7 @@ active `ConversationFocus` 是承接型意图的唯一目标来源。它由服 追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级: 1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。 -2. `method_followup_plan.next_followup` 指定的下一方法层。方法覆盖优先于对已覆盖领域的精度追问:有日期事件 → 感情 → 事业 → 家人(D12/D7/D3)→ 职业(挡出牌,独立于带日期事业事件)→ 占问(只问一次,不挡出牌)→ 再按精度阶段问关系盘/事业盘/居所(D4)/学业成就(D5,D24 换升并入同一问)。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务与健康只有用户主动说才问,仍可计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针或采用后核对前事时,`choice_frame` 才提供冲突节点;年份和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 年界差或同年激活差,没有可问年时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 +2. `method_followup_plan.next_followup` 指定的下一方法层。方法覆盖优先于对已覆盖领域的精度追问:有日期事件 → 感情 → 事业 → 家人(D12/D7/D3)→ 职业(挡出牌,独立于带日期事业事件)→ 占问(只问一次,不挡出牌)→ 再按精度阶段问关系盘/事业盘/居所(D4)/学业成就(D5,D24 换升并入同一问)。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务与健康只有用户主动说才问,仍可计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针或采用后核对前事时,`choice_frame` 才提供冲突节点;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。 4. pending revision 的一个关键歧义。 5. 已有证据的必要稳定性补强。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.13/SKILL.md b/skills/jyotish-birth-time-rectification/versions/10.0.13/SKILL.md index 0988b9d3..99c63402 100644 --- a/skills/jyotish-birth-time-rectification/versions/10.0.13/SKILL.md +++ b/skills/jyotish-birth-time-rectification/versions/10.0.13/SKILL.md @@ -75,7 +75,7 @@ description: "生时校正专用 Skill(V10)。以服务器权威 Case、Conv `CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。 - 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary` 与 `method_followup_plan`。 -- 不要按 `missing_evidence_categories` 轮询迁居。财务与健康只有用户主动说才问,仍可计分。下一问只跟 `method_followup_plan.next_followup`。先走完方法覆盖(感情 → 事业 → 家人 → 职业 → 占问),再对已覆盖领域做精度追问。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 +- 不要按 `missing_evidence_categories` 轮询迁居。财务与健康只有用户主动说才问,仍可计分。下一问只跟 `method_followup_plan.next_followup`。先走完方法覆盖(感情 → 事业 → 家人 → 职业 → 占问),再对已覆盖领域做精度追问。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 - recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。 - summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。 - 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.13/references/conversation-strategy.md b/skills/jyotish-birth-time-rectification/versions/10.0.13/references/conversation-strategy.md index e4e5c6c4..6d772a2f 100644 --- a/skills/jyotish-birth-time-rectification/versions/10.0.13/references/conversation-strategy.md +++ b/skills/jyotish-birth-time-rectification/versions/10.0.13/references/conversation-strategy.md @@ -1,6 +1,6 @@ # Conversation Strategy(V10) -生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定年份和事件家族,由你写成一句具体生平题干(某年是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。 +生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定时间范围和事件家族,由你写成一句具体生平题干(某年或某月是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。 ## 1. 每轮上下文优先级 @@ -23,7 +23,7 @@ recent turns 不是权威记忆,不得依赖“上一条 assistant 问了什 - 至多一个主问题;开场可以零问题。 - 不固定复述身份、流程、领域列表、证据数量要求或 opening brief 原文。 -示例方向(不是固定话术):“可以先说一件你记得大概年份的事,比如升学、考试或第一份工作。” 区分阶段的题干由你写成自然语言;年份和事件家族以服务器探针为准,不得发明年份。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把已有高考经历写成“高考的时候是否发挥失常?” +示例方向(不是固定话术):“可以先说一件你记得大概年份的事,比如升学、考试或第一份工作。” 区分阶段的题干由你写成自然语言;时间范围和事件家族以服务器探针为准,不得发明年份,不得改写时间范围。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把锁定的 2018 年 3 月写成“2018 年 3 月前后你是否入职或职责加重?”,把已有高考经历写成“高考的时候是否发挥失常?” ## 3. 一轮的基本形态 @@ -73,7 +73,7 @@ active `ConversationFocus` 是承接型意图的唯一目标来源。它由服 追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级: 1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。 -2. `method_followup_plan.next_followup` 指定的下一方法层。方法覆盖优先于对已覆盖领域的精度追问:有日期事件 → 感情 → 事业 → 家人(D12/D7/D3)→ 职业(挡出牌,独立于带日期事业事件)→ 占问(只问一次,不挡出牌)→ 再按精度阶段问关系盘/事业盘/居所(D4)/学业成就(D5,D24 换升并入同一问)。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务与健康只有用户主动说才问,仍可计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针或采用后核对前事时,`choice_frame` 才提供冲突节点;年份和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 年界差或同年激活差,没有可问年时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 +2. `method_followup_plan.next_followup` 指定的下一方法层。方法覆盖优先于对已覆盖领域的精度追问:有日期事件 → 感情 → 事业 → 家人(D12/D7/D3)→ 职业(挡出牌,独立于带日期事业事件)→ 占问(只问一次,不挡出牌)→ 再按精度阶段问关系盘/事业盘/居所(D4)/学业成就(D5,D24 换升并入同一问)。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务与健康只有用户主动说才问,仍可计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针或采用后核对前事时,`choice_frame` 才提供冲突节点;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。 4. pending revision 的一个关键歧义。 5. 已有证据的必要稳定性补强。 diff --git a/tests/test_rectification_event_probes.py b/tests/test_rectification_event_probes.py index 7d9decc5..744517b4 100644 --- a/tests/test_rectification_event_probes.py +++ b/tests/test_rectification_event_probes.py @@ -287,6 +287,87 @@ class EventProbesTest(unittest.TestCase): probes = _probes(_request(), built, ["05:13", "05:40"], "05:13", precision_current="d4_refine") self.assertTrue(all(item["source"] != "dasha_boundary" for item in probes)) + def test_same_year_month_apart_boundary_uses_engine_month(self) -> None: + from unittest.mock import patch + + from scripts.rectification import event_probes as probes_mod + + built = { + "static_contexts": [ + _context("05:13", d4_asc=0, sun_house=4, sun_varga_sign=3, moon=100.0), + _context("05:40", d4_asc=1, sun_house=10, sun_varga_sign=9, moon=101.0), + ] + } + + def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[date]: + return [date(2018, 3, 15)] if moon <= 100.0 else [date(2018, 9, 20)] + + def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[date]: + moon = float(planets.get("Moon") or 0) + return [date(2018, 3, 15)] if moon <= 100.0 else [date(2018, 9, 20)] + + def fake_score(context: dict, *, birth_date: str, domain: str, year: int, month: int | None = None) -> dict: + del birth_date, domain + early = probes_mod._context_time(context) == "05:13" + if year == 2018 and month == 3: + return {"rule_ids": ["vim_ad_domain_lord"] if early else ["no_domain_activation"]} + if year == 2018 and month == 9: + return {"rule_ids": ["vim_md_domain_house"] if early else ["no_domain_activation"]} + return {"rule_ids": ["no_domain_activation"]} + + with ( + patch.object(probes_mod, "_vim_start_dates", side_effect=fake_vim), + patch.object(probes_mod, "_narayana_start_dates", side_effect=fake_narayana), + patch.object(probes_mod, "_score_year", side_effect=fake_score), + ): + probes = _probes(_request(), built, ["05:13", "05:40"], "05:13") + row = next(item for item in probes if item["domain"] == "relocation") + self.assertEqual(row["source"], "dasha_boundary") + self.assertEqual(row["year"], 2018) + self.assertIn(row["month"], {3, 9}) + self.assertRegex(row["year_label"], r"2018 年 [39] 月前后") + self.assertIn("2018 年", row["user_meaning"]) + self.assertIn("月前后", row["user_meaning"]) + self.assertNotIn("points", str(row)) + + def test_vim_start_dates_keep_engine_month(self) -> None: + from scripts.rectification import event_probes as probes_mod + + starts = probes_mod._vim_start_dates("1997-08-08", 100.0, 2005, 2025) + self.assertTrue(starts) + self.assertTrue(all(isinstance(item, date) and not isinstance(item, datetime) for item in starts)) + self.assertTrue(any(item.month != 7 or item.day != 1 for item in starts)) + + def test_age_band_fallback_stays_year_precision(self) -> None: + from unittest.mock import patch + + from scripts.rectification import event_probes as probes_mod + + built = { + "static_contexts": [ + _context("05:13", d4_asc=0, sun_house=4, sun_varga_sign=3, moon=100.0), + _context("05:40", d4_asc=1, sun_house=10, sun_varga_sign=9, moon=100.0), + ] + } + + def fake_score(context: dict, *, birth_date: str, domain: str, year: int, month: int | None = None) -> dict: + del birth_date, domain, year, month + return { + "rule_ids": ["vim_md_domain_house"] + if probes_mod._context_time(context) == "05:13" + else ["no_domain_activation"] + } + + with ( + patch.object(probes_mod, "_vim_start_dates", return_value=[]), + patch.object(probes_mod, "_narayana_start_dates", return_value=[]), + patch.object(probes_mod, "_score_year", side_effect=fake_score), + ): + probes = _probes(_request(), built, ["05:13", "05:40"], "05:13") + row = next(item for item in probes if item["source"] == "dasha_activation") + self.assertNotIn("month", row) + self.assertRegex(row["year_label"], r"^\d{4} 年前后$") + def test_missing_narayana_inputs_do_not_claim_dasha_year(self) -> None: built = { "static_contexts": [ @@ -402,15 +483,15 @@ class EventProbesTest(unittest.TestCase): ] } - def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[int]: - return [2010, 2020] if moon <= 100.0 else [2009, 2019] + def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[date]: + return [date(2010, 3, 15), date(2020, 3, 15)] if moon <= 100.0 else [date(2009, 9, 15), date(2019, 9, 15)] - def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[int]: + def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[date]: moon = float(planets.get("Moon") or 0) - return [2010, 2020] if moon <= 100.0 else [2009, 2019] + return [date(2010, 3, 15), date(2020, 3, 15)] if moon <= 100.0 else [date(2009, 9, 15), date(2019, 9, 15)] - def fake_score(context: dict, *, birth_date: str, domain: str, year: int) -> dict: - del birth_date, domain + def fake_score(context: dict, *, birth_date: str, domain: str, year: int, month: int | None = None) -> dict: + del birth_date, domain, month early = probes_mod._context_time(context) == "05:13" if year == 2010: return {"rule_ids": ["vim_ad_domain_lord"] if early else ["no_domain_activation"]} @@ -419,8 +500,8 @@ class EventProbesTest(unittest.TestCase): return {"rule_ids": ["no_domain_activation"]} with ( - patch.object(probes_mod, "_vim_start_years", side_effect=fake_vim), - patch.object(probes_mod, "_narayana_start_years", side_effect=fake_narayana), + patch.object(probes_mod, "_vim_start_dates", side_effect=fake_vim), + patch.object(probes_mod, "_narayana_start_dates", side_effect=fake_narayana), patch.object(probes_mod, "_score_year", side_effect=fake_score), ): probes = _probes(_request(), built, ["05:13", "05:40"], "05:13") @@ -441,15 +522,15 @@ class EventProbesTest(unittest.TestCase): ] } - def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[int]: - return [2003, 2019] if moon <= 100.0 else [2002, 2018] + def fake_vim(_birth_date: str, moon: float, _lo: int, _hi: int) -> list[date]: + return [date(2003, 3, 15), date(2019, 3, 15)] if moon <= 100.0 else [date(2002, 9, 15), date(2018, 9, 15)] - def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[int]: + def fake_narayana(_asc: int, planets: dict, _birth_date: str, _lo: int, _hi: int) -> list[date]: moon = float(planets.get("Moon") or 0) - return [2003, 2019] if moon <= 100.0 else [2002, 2018] + return [date(2003, 3, 15), date(2019, 3, 15)] if moon <= 100.0 else [date(2002, 9, 15), date(2018, 9, 15)] - def fake_score(context: dict, *, birth_date: str, domain: str, year: int) -> dict: - del birth_date, domain, year + def fake_score(context: dict, *, birth_date: str, domain: str, year: int, month: int | None = None) -> dict: + del birth_date, domain, year, month return { "rule_ids": ["vim_md_domain_house"] if probes_mod._context_time(context) == "05:13" @@ -457,8 +538,8 @@ class EventProbesTest(unittest.TestCase): } with ( - patch.object(probes_mod, "_vim_start_years", side_effect=fake_vim), - patch.object(probes_mod, "_narayana_start_years", side_effect=fake_narayana), + patch.object(probes_mod, "_vim_start_dates", side_effect=fake_vim), + patch.object(probes_mod, "_narayana_start_dates", side_effect=fake_narayana), patch.object(probes_mod, "_score_year", side_effect=fake_score), ): probes = _probes(_request(), built, ["05:13", "05:40"], "05:13")