From ab1ade59b78b9e163e941d6b26dc2284dcad489c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 13 Sep 2026 18:19:46 +0800 Subject: [PATCH] fix(rectification): emit finer dated probes on refresh only (BUG-664/665) After six dated answers, refresh can use 30-day same-year windows plus pratyantar and D9/D10 Narayana. First-round probe rules stay at 45 days. Co-authored-by: Cursor --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 32 +++ ...SS-rectification-refresh-r3-r4-20260913.md | 36 +++ docs/tasks/README.md | 2 +- .../rectification-scenarios-20260907.md | 3 +- scripts/rectification/event_probes.py | 125 ++++++--- tests/test_rectification_refresh_r3_r4.py | 243 ++++++++++++++++++ 7 files changed, 403 insertions(+), 43 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-refresh-r3-r4-20260913.md create mode 100644 tests/test_rectification_refresh_r3_r4.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9f261e..827759b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-13 — 六道带年月题问完后,刷新还能再问更细的换运题 + +生时校正里,带年月选择题问完后,会按还剩下的候选再出一批带年月题。这一批现在能用更短的同年换运间隔(30 天),也会看第三级换运和 D9/D10 上升的分盘大运。问不出时仍一条一条问定向补事,不会停在「没有拿到下一个问题」。首轮出题规则不变。Skill 版本不变(10.0.25)。 + + ## 2026-09-13 — 定向补事改成逐条点选;范围提示说剩余候选;卡下可再答参考题 生时校正里,带年月选择题问完、引擎也没有新题时,会一条一条问还能切开剩余候选的线:先问有没有(有 / 没有 / 记不清),说「有」再问大概哪年几月。说「没有」只记下这条线近年无事,不计分。提示写「现在还剩某段时间里 N 个候选」,不再写「能把两端钟点分开」。四条线都问完才给出目前范围。卡片下面可以再点「再答两道参考题微调排序」,不点不出。Skill 10.0.25。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2c36358b..1c98b4f0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10324,3 +10324,35 @@ - 相关记录:BUG-629、BUG-651、BUG-661 - 复发自:BUG-629(性格题降权后没有可选入口) - 修复版本:待发布 + +## BUG-664 | 刷新阶段同年换运间隔可收到 30 天,首轮仍是 45 天 + +- 状态:resolved +- 首次发现:2026-09-13 +- 最近更新:2026-09-13 +- 影响面:`scripts/rectification/event_probes.py` `_boundary_windows` / `_union_boundary_dates` / `discriminating_event_probes` +- 用户现象:六道带年月题答完后,刷新经常立刻 `no_new_probes`,即使剩余候选的同年换运只差三十多天。 +- 触发条件:训练门已开、带年月池空、`refresh_probes=true`、剩余候选上存在 30–44 天的同年边界。 +- 根因:生产 `MIN_BOUNDARY_DAYS=45` 对首轮和刷新共用。离线 20 例测量里,只把刷新阈值降到 30 平均多 1.0 个独立划分、头名不降;首轮保持 45。 +- 修复:新增 `REFRESH_MIN_BOUNDARY_DAYS=30`。仅 `request.refresh_probes is True` 时传给 `_union_boundary_dates`;首轮调用签名与阈值不变。不改 `EXISTENCE_NEARBY_YEARS`、R1/R2、`SCORE_DELTA`。 +- 验证:`tests/test_rectification_refresh_r3_r4.py`:40 天同年窗首轮不出、刷新出 `dasha_boundary`。既有 `tests/test_rectification_event_probes.py`、`tests/test_candidate_discriminator_contract.py` 首轮断言不放宽。 +- 防复发:默认 `MIN_BOUNDARY_DAYS` 必须仍是 45;不得把研究脚本接到 API;刷新仍走 distinguish 合同。 +- 相关记录:BUG-653、BUG-657、BUG-665 +- 复发自:BUG-653(刷新已按剩余候选出题,但边界阈值与首轮相同) +- 修复版本:待发布 + +## BUG-665 | 刷新阶段并入 pratyantar 与 D9/D10 上升 Narayana 边界 + +- 状态:resolved +- 首次发现:2026-09-13 +- 最近更新:2026-09-13 +- 影响面:`scripts/rectification/event_probes.py` `_vim_start_dates` / `_union_boundary_dates` +- 用户现象:六道带年月题答完后,本命上升相同、D9/D10 或第三级换运已不同时,刷新仍无新的带年月题。 +- 触发条件:`refresh_probes=true`,剩余候选 Vimshottari pratyantar 或 D9/D10 上升 Narayana 有可分辨边界。 +- 根因:首轮并集只用 Vimshottari MD/AD 与本命上升 Narayana。`_vim_start_dates(..., include_pratyantar=)` 已存在但刷新默认仍为 false。离线测量 R4 单独 +1.084 独立划分,头名不降。 +- 修复:仅刷新阶段 `include_pratyantar=True`,并对剩余候选 D9/D10 上升再算 Narayana 边界并入并集。首轮不传这些参数,`_vim_start_dates` 调用保持原签名以免旧 mock 失败。本命上升 Narayana 与 MD/AD 不变。 +- 验证:`tests/test_rectification_refresh_r3_r4.py`:pratyantar 与 D9 上升不同时刷新出新年月、首轮不出。研究脚本 `scripts/research/probe_supply_after_six.py` 仍离线。 +- 防复发:不得对首轮打开 pratyantar 或分盘 Narayana;不得全局改 `MIN_BOUNDARY_DAYS`。刷新 0 题时仍走定向补事(BUG-654 / BUG-661),不得报「没有拿到下一个问题」。 +- 相关记录:BUG-653、BUG-654、BUG-661、BUG-664 +- 复发自:BUG-653(刷新只复用首轮边界层) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-refresh-r3-r4-20260913.md b/docs/tasks/PROGRESS-rectification-refresh-r3-r4-20260913.md new file mode 100644 index 00000000..9e810fc5 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-refresh-r3-r4-20260913.md @@ -0,0 +1,36 @@ +# 进度 · 刷新阶段放宽换运边界 R3 + R4(2026-09-13) + +## 范围 + +- 分支:`codex/rectification-refresh-r3-r4-20260913`(基于 `origin/staging` @ `530f260f`) +- 任务单:`docs/tasks/TASK-rectification-refresh-r3-r4-20260913.md` +- 研究结论:`docs/research/probe_supply_after_six_2026_09_13.md`;只实现 R3 + R4 +- BUG-664:刷新 `MIN_BOUNDARY_DAYS` 45→30 +- BUG-665:刷新并入 pratyantar 与 D9/D10 上升 Narayana +- 不实现 R1/R2;不改 `EXISTENCE_NEARBY_YEARS`、`SCORE_DELTA`、确认门、性格题计分(BUG-629) +- 研究脚本 `scripts/research/probe_supply_after_six.py` 保持离线 +- Skill 不升版:用户路径仍是「刷新带年月题 → 定向补事 → 目前范围」,只是刷新可能多几道同类题;10.0.25 口径已覆盖 + +## 完成 + +- T1 `REFRESH_MIN_BOUNDARY_DAYS=30`;仅 `refresh_probes is True` 时传入 `_union_boundary_dates`。首轮仍调用 `_union_boundary_dates(reps, birth_date=..., lo=lo, hi=hi)`,`MIN_BOUNDARY_DAYS` 仍为 45。 +- T2 仅刷新:`include_pratyantar=True`(首轮不把该关键字传给 `_vim_start_dates`,以免旧 mock 失败);D9/D10 上升 Narayana 与本命上升并集。 +- T3 `tests/test_rectification_refresh_r3_r4.py`:40 天同年窗、pratyantar、D9 上升不同;年份用 2019,避开搬家/婚恋年龄下限与邻近年封锁。 +- T4 BUG-664/665、CHANGELOG、本文件、任务板、手测 0b/0c 补「刷新有题则先出带年月题」。 + +## 验收(本机,未部署) + +- `tests/test_rectification_refresh_r3_r4.py`:4 passed +- `tests/test_rectification_refresh_r3_r4.py` + `tests/test_rectification_event_probes.py` + `tests/test_candidate_discriminator_contract.py`:51 passed +- `tests/test_rectification_*.py`:171 passed / 0 failed +- `run_quality_gate.py --profile quick`:738 passed / 1 skipped / 1 failed + - 失败项:`test_block_scan_seven_events_finishes_within_fifteen_seconds` 壁钟 46.2s > 15s + - 与本单无关(未改 `block_scan`);任务板 `TASK-rectification-capability-fix-20260907.md` 已记为 P2 间歇红 +- 未做登录态真机 0b(无本地会话) + +## 未做 + +- 未把研究脚本接到 API +- 未改首轮出题、未实现 R1/R2 +- 未升 Skill 10.0.26 +- 未做登录态真机 0b(无本地会话) diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 56640c4d..5475e073 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -178,7 +178,7 @@ | `TASK-rectification-after-pool-empty-decision-20260913.md` | — | 决策单草案:六道带年月题问完、引擎刷新无新题后,定向补事是开放式口述题,体感死板。给出 A 逐条点选 / B 放宽引擎出题(先离线量收益)/ C 参考题可选入口 / D 文案 四个方案,建议 A+D(+C) | 已拍板 2026-09-13:A+D+C 进 `TASK-rectification-targeted-collect-cards-20260913.md`,B 进研究单 | — | | `TASK-rectification-targeted-collect-cards-20260913.md` | `PROGRESS-rectification-targeted-collect-cards-20260913.md` | 定向补事改逐条点选卡(有/没有/记不清 → 有则口述年月;没有不计分只防复问;全没有出卡)+ 文案说清剩余候选与线 + 卡下「再答两道参考题」入口(BUG-661~663,Skill 10.0.25) | 待验收 | `codex/rectification-targeted-collect-cards-20260913` | | `TASK-rectification-probe-supply-research-20260913.md` | — | 研究单:六题后引擎在剩余候选上再出带年月题的四种放宽规则,20 例公开 AA 数据离线量收益,有收益才立实现单 | 已合入(离线测量,不改线上) | `b063c668` | -| `TASK-rectification-refresh-r3-r4-20260913.md` | — | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 待领取 | — | +| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 待验收 | `codex/rectification-refresh-r3-r4-20260913` | ## 命名与归档 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index b3413e80..8c8ce60b 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -28,6 +28,7 @@ 期望: - 第六题答完不得直接出卡;先出现新的带年月题(家人 / 财务 / 迁居等换运边界),或一张定向补事点选卡(先问有没有) +- 若剩余候选只在 D9/D10 或更短的同年换运上有差别,第六题后仍应先看到带年月选择题,而不是立刻跳到「结过婚或订过婚吗?」;仍无题才走定向补事 - 刷新后若引擎给出新的带年月题,下一张卡必须真正出现;不得只是决策选中了探针、落库被拒后停在「没有拿到下一个问题」 - 答一件家人事后应重算并回到带年月题;答「没有了 / 就这些」后才出卡 - 卡片标题为「目前范围 …(对照了 N 件经历)」,卡下有「还能再收窄:如果记得 …」 @@ -41,7 +42,7 @@ 期望: -- 第六题答完必须看到定向补事点选卡(例如「结过婚或订过婚吗?」);旁白写剩余范围和候选个数,不得写「能把两端钟点分开」;题干种类必须能写入焦点表,不得静默失败 +- 第六题答完必须看到下一问:优先新的带年月选择题;引擎没有新题时才是定向补事点选卡(例如「结过婚或订过婚吗?」);旁白写剩余范围和候选个数,不得写「能把两端钟点分开」;题干种类必须能写入焦点表,不得静默失败 - 引擎若没有新的带年月题,界面走定向补事或已有载体;同一候选集、同一已答数下引擎刷新只调用一次,不得反复打约 20 秒的刷新 - 引擎若有新题,下一问必须出现;不得「GET 已有探针、界面没有下一问」 - 刷新不得把候选集改空或换成另一套 `candidate_set_id` diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 5a435b72..2a8fa686 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -66,6 +66,7 @@ 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 +REFRESH_MIN_BOUNDARY_DAYS = 30 MAX_QUALITY_DISTINGUISH_PROBES = 2 QUALITY_DISTINGUISH_EVENT_KINDS = frozenset({ "education_start", @@ -525,15 +526,21 @@ def _as_start_date(value: date | datetime | int) -> date | None: return None -def _boundary_windows(left: Sequence[date | datetime | int], right: Sequence[date | datetime | int]) -> list[date]: +def _boundary_windows( + left: Sequence[date | datetime | int], + right: Sequence[date | datetime | int], + *, + min_days: int | None = None, +) -> list[date]: windows: list[date] = [] seen: set[tuple[int, int]] = set() + threshold = MIN_BOUNDARY_DAYS if min_days is None else min_days 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: + if one.year == two.year and abs((one - two).days) < threshold: continue for item in (one, two): key = (item.year, item.month) @@ -605,54 +612,77 @@ def _union_boundary_dates( birth_date: str, lo: int, hi: int, + min_boundary_days: int | None = None, + include_pratyantar: bool = False, + varga_narayana: bool = False, ) -> list[date]: + threshold = MIN_BOUNDARY_DAYS if min_boundary_days is None else min_boundary_days vim_cache: dict[tuple[Any, ...], list[date]] = {} narayana_cache: dict[tuple[Any, ...], list[date] | None] = {} dates_by_key: dict[tuple[int, int], date] = {} for left, right in _representative_pairs(reps): left_moon = float(left["planet_longitudes"]["Moon"]) right_moon = float(right["planet_longitudes"]["Moon"]) - left_vim_key = _vim_cache_key(birth_date, left_moon, lo, hi) - right_vim_key = _vim_cache_key(birth_date, right_moon, lo, hi) + left_vim_key = (*_vim_cache_key(birth_date, left_moon, lo, hi), include_pratyantar) + right_vim_key = (*_vim_cache_key(birth_date, right_moon, lo, hi), include_pratyantar) if left_vim_key not in vim_cache: - vim_cache[left_vim_key] = _vim_start_dates(birth_date, left_moon, lo, hi) + vim_cache[left_vim_key] = ( + _vim_start_dates( + birth_date, left_moon, lo, hi, include_pratyantar=True, + ) + if include_pratyantar + else _vim_start_dates(birth_date, left_moon, lo, hi) + ) if right_vim_key not in vim_cache: - vim_cache[right_vim_key] = _vim_start_dates(birth_date, right_moon, lo, hi) - windows = list(_boundary_windows(vim_cache[left_vim_key], vim_cache[right_vim_key])) - left_nara_key = _narayana_cache_key( - int(left["ascendant_index"]), - left["planet_longitudes"], - birth_date, - lo, - hi, - ) - right_nara_key = _narayana_cache_key( - int(right["ascendant_index"]), - right["planet_longitudes"], - birth_date, - lo, - hi, - ) - if left_nara_key not in narayana_cache: - narayana_cache[left_nara_key] = _narayana_start_dates( - int(left["ascendant_index"]), - left["planet_longitudes"], - birth_date, - lo, - hi, + vim_cache[right_vim_key] = ( + _vim_start_dates( + birth_date, right_moon, lo, hi, include_pratyantar=True, + ) + if include_pratyantar + else _vim_start_dates(birth_date, right_moon, lo, hi) ) - if right_nara_key not in narayana_cache: - narayana_cache[right_nara_key] = _narayana_start_dates( - int(right["ascendant_index"]), - right["planet_longitudes"], - birth_date, - lo, - hi, - ) - left_narayana = narayana_cache[left_nara_key] - right_narayana = narayana_cache[right_nara_key] - if left_narayana is not None and right_narayana is not None: - windows.extend(_boundary_windows(left_narayana, right_narayana)) + window_kw = {} if threshold == MIN_BOUNDARY_DAYS else {"min_days": threshold} + windows = list(_boundary_windows( + vim_cache[left_vim_key], + vim_cache[right_vim_key], + **window_kw, + )) + layers: list[str | None] = [None] + if varga_narayana: + layers.extend(["d9", "d10"]) + for layer in layers: + if layer is None: + left_asc = int(left["ascendant_index"]) + right_asc = int(right["ascendant_index"]) + else: + left_raw = _layer_value(left, layer) + right_raw = _layer_value(right, layer) + if not isinstance(left_raw, int) or not isinstance(right_raw, int): + continue + left_asc = left_raw + right_asc = right_raw + left_nara_key = (*_narayana_cache_key( + left_asc, left["planet_longitudes"], birth_date, lo, hi, + ), layer) + right_nara_key = (*_narayana_cache_key( + right_asc, right["planet_longitudes"], birth_date, lo, hi, + ), layer) + if left_nara_key not in narayana_cache: + narayana_cache[left_nara_key] = _narayana_start_dates( + left_asc, left["planet_longitudes"], birth_date, lo, hi, + ) + if right_nara_key not in narayana_cache: + narayana_cache[right_nara_key] = _narayana_start_dates( + right_asc, right["planet_longitudes"], birth_date, lo, hi, + ) + left_narayana = narayana_cache[left_nara_key] + right_narayana = narayana_cache[right_nara_key] + if left_narayana is not None and right_narayana is not None: + windows.extend(_boundary_windows( + left_narayana, + right_narayana, + **window_kw, + )) for item in windows: dates_by_key.setdefault((item.year, item.month), item) return sorted(dates_by_key.values(), key=lambda item: (item.year, item.month)) @@ -1567,7 +1597,20 @@ def _discriminating_event_probe_lists( d1_differs="d1" in remaining_layers or bool(scan.get("d1_candidates_differ")), ) 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) if domains else [] + union_kw: dict[str, Any] = {} + if refresh: + union_kw = { + "min_boundary_days": REFRESH_MIN_BOUNDARY_DAYS, + "include_pratyantar": True, + "varga_narayana": True, + } + boundary_dates = _union_boundary_dates( + reps, + birth_date=birth_date, + lo=lo, + hi=hi, + **union_kw, + ) if domains else [] probes: list[dict[str, Any]] = [] for domain in domains: if domain not in DOMAIN_CATALOG: diff --git a/tests/test_rectification_refresh_r3_r4.py b/tests/test_rectification_refresh_r3_r4.py new file mode 100644 index 00000000..5cec99bc --- /dev/null +++ b/tests/test_rectification_refresh_r3_r4.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import unittest +from datetime import date, datetime +from unittest.mock import patch + +from scripts.rectification.event_probes import ( + MIN_BOUNDARY_DAYS, + REFRESH_MIN_BOUNDARY_DAYS, + _boundary_windows, + discriminating_event_probes, +) +from scripts.rectification.refinement_packet import window_scan + +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, +} + +# Relocation age_lo=18 → 2015; asked relocation.2015 blocks 2014–2016. +# Relationship age_lo=21 → 2018; asked relationship.2023 / event 2021 leave 2019 open. +ADULT_YEAR = 2019 + +TIMES = ["04:48", "04:53", "04:59", "05:06", "05:07"] +ASKED = [ + "career.2020", + "career.2023", + "education.2016", + "education.2020", + "relationship.2023", + "relocation.2015", +] + + +def _varga(asc: int) -> dict: + return { + "Ascendant": {"sign_idx": asc}, + **{name: {"sign_idx": 1} for name in PLANETS}, + } + + +def _context(time: str, *, moon: float, d4_asc: int, d9_asc: int, d10_asc: int) -> dict: + hour, minute = (int(part) for part in time.split(":")) + planets = {**PLANETS, "Moon": moon} + return { + "candidate_at": datetime(1997, 8, 8, hour, minute), + "chart": { + "ascendant": {"lon": 10.0, "sign": "Aries"}, + "planets": { + name: {"house": 4 if name == "Moon" else 10, "lon": lon} + for name, lon in planets.items() + }, + }, + "planet_longitudes": dict(planets), + "ascendant_index": 0, + "varga_charts": { + "D4": _varga(d4_asc), + "D9": _varga(d9_asc), + "D10": _varga(d10_asc), + "D5": _varga(1), + "D24": _varga(1), + "D12": _varga(1), + "D7": _varga(1), + "D3": _varga(1), + }, + "arudha_padas": {}, + "feature": { + "time": time, + "ascendant_sign_index": 0, + "varga_ascendants": { + "D4": d4_asc, "D9": d9_asc, "D10": d10_asc, "D5": 1, "D24": 1, "D12": 1, + }, + }, + } + + +def _gate_events() -> list[dict]: + return [ + { + "id": "00000000-0000-4000-8000-000000000011", + "domain": "education", + "event_kind": "education_start", + "summary": "入学", + "date": "2014-09-01", + "precision": "month", + }, + { + "id": "00000000-0000-4000-8000-000000000012", + "domain": "education", + "event_kind": "education_completion", + "summary": "毕业", + "date": "2017-06-01", + "precision": "month", + }, + { + "id": "00000000-0000-4000-8000-000000000013", + "domain": "career", + "event_kind": "career_entry", + "summary": "入职", + "date": "2018-07-01", + "precision": "month", + }, + { + "id": "00000000-0000-4000-8000-000000000014", + "domain": "relationship", + "event_kind": "relationship_start", + "summary": "相识", + "date": "2021-08-01", + "precision": "month", + }, + ] + + +def _built(*, d4_left: int = 0, d4_right: int = 1, d9_left: int = 1, d9_right: int = 1, d10_left: int = 1, d10_right: int = 1) -> dict: + early = dict(moon=100.0, d4_asc=d4_left, d9_asc=d9_left, d10_asc=d10_left) + late = dict(moon=101.0, d4_asc=d4_right, d9_asc=d9_right, d10_asc=d10_right) + return { + "static_contexts": [ + _context("04:48", **early), + _context("04:53", **early), + _context("04:59", **early), + _context("05:06", **late), + _context("05:07", **late), + ] + } + + +def _score_months(target_year: int, months: set[int], rule: str = "vim_ad_domain_lord"): + def fake_score(context: dict, *, birth_date: str, domain: str, year: int, month: int | None = None) -> dict: + del birth_date, domain + early = str(context.get("feature", {}).get("time") or "")[:5] < "05:00" + if year == target_year and month in months: + return {"rule_ids": [rule] if early else ["no_domain_activation"]} + return {"rule_ids": ["no_domain_activation"]} + + return fake_score + + +def _probes(*, refresh: bool, built: dict, vim, narayana, score): + from scripts.rectification import event_probes as probes_mod + + request = { + "birth_date": "1997-08-08", + "events": _gate_events(), + "asked_probe_keys": ASKED, + "refresh_probes": refresh, + } + with ( + patch.object(probes_mod, "_vim_start_dates", side_effect=vim), + patch.object(probes_mod, "_narayana_start_dates", side_effect=narayana), + patch.object(probes_mod, "_score_year", side_effect=score), + ): + return discriminating_event_probes( + request, + built, + scan=window_scan(built), + candidate_times=TIMES, + representative_time="04:53", + today=date(2026, 9, 13), + ) + + +class RefreshR3R4Test(unittest.TestCase): + def test_default_boundary_threshold_stays_45(self) -> None: + self.assertEqual(MIN_BOUNDARY_DAYS, 45) + self.assertEqual(REFRESH_MIN_BOUNDARY_DAYS, 30) + left = [date(ADULT_YEAR, 3, 1)] + right = [date(ADULT_YEAR, 4, 10)] + self.assertEqual(_boundary_windows(left, right), []) + self.assertEqual( + [item.month for item in _boundary_windows(left, right, min_days=30)], + [3, 4], + ) + + def test_first_round_drops_a_40_day_same_year_window(self) -> None: + def fake_vim(_birth: str, moon: float, _lo: int, _hi: int, **_kwargs: object) -> list[date]: + return [date(ADULT_YEAR, 3, 1)] if moon <= 100.0 else [date(ADULT_YEAR, 4, 10)] + + def fake_narayana(_asc: int, _planets: dict, _birth: str, _lo: int, _hi: int) -> list[date]: + return [] + + score = _score_months(ADULT_YEAR, {3, 4}) + built = _built() + first = _probes(refresh=False, built=built, vim=fake_vim, narayana=fake_narayana, score=score) + refresh = _probes(refresh=True, built=built, vim=fake_vim, narayana=fake_narayana, score=score) + self.assertFalse(any(item.get("year") == ADULT_YEAR for item in first)) + self.assertTrue(any( + item.get("year") == ADULT_YEAR and item.get("source") == "dasha_boundary" + for item in refresh + ), [item.get("semantic_key") for item in refresh]) + + def test_refresh_adds_pratyantar_windows_first_round_does_not(self) -> None: + def fake_vim(_birth: str, moon: float, _lo: int, _hi: int, *, include_pratyantar: bool = False) -> list[date]: + md = [date(2011, 1, 1)] + if not include_pratyantar: + return md + extra = date(ADULT_YEAR, 2, 1) if moon <= 100.0 else date(ADULT_YEAR, 4, 1) + return [*md, extra] + + def fake_narayana(_asc: int, _planets: dict, _birth: str, _lo: int, _hi: int) -> list[date]: + return [] + + score = _score_months(ADULT_YEAR, {2, 4}) + built = _built() + first = _probes(refresh=False, built=built, vim=fake_vim, narayana=fake_narayana, score=score) + refresh = _probes(refresh=True, built=built, vim=fake_vim, narayana=fake_narayana, score=score) + self.assertFalse(any(item.get("year") == ADULT_YEAR for item in first)) + self.assertTrue( + any(item.get("year") == ADULT_YEAR for item in refresh), + [item.get("semantic_key") for item in refresh], + ) + + def test_refresh_adds_d9_narayana_when_natal_lagna_matches(self) -> None: + def fake_vim(_birth: str, _moon: float, _lo: int, _hi: int, **_kwargs: object) -> list[date]: + return [date(2011, 1, 1)] + + def fake_narayana(asc: int, _planets: dict, _birth: str, _lo: int, _hi: int) -> list[date]: + if asc == 1: + return [date(ADULT_YEAR, 6, 1)] + if asc == 2: + return [date(ADULT_YEAR, 7, 21)] + return [date(2011, 1, 1)] + + score = _score_months(ADULT_YEAR, {6, 7}, rule="narayana_ad_domain_lord") + built = _built(d4_left=0, d4_right=0, d9_left=1, d9_right=2) + first = _probes(refresh=False, built=built, vim=fake_vim, narayana=fake_narayana, score=score) + refresh = _probes(refresh=True, built=built, vim=fake_vim, narayana=fake_narayana, score=score) + self.assertFalse(any(item.get("year") == ADULT_YEAR for item in first)) + self.assertTrue( + any(item.get("year") == ADULT_YEAR for item in refresh), + [item.get("semantic_key") for item in refresh], + ) + + +if __name__ == "__main__": + unittest.main()