research(rectification): nadi second-level study helpers and N0 checks (BUG-1240)
Five-level Vimshottari and equal-division D150 helpers, plus a runner that refuses to score until the preregistration file exists. First three lords match the production chain on the v5 events. No production files changed.
This commit is contained in:
@@ -16627,3 +16627,19 @@
|
||||
- 相关记录:BUG-995(前端测试要同时看 cancelled 和退出码)。
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-1240 | 秒级 / 纳迪校准从未经过检验
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现:2026-10-05
|
||||
- 最近更新:2026-10-05
|
||||
- 影响面:生时校正对外精度口径;`scripts/active_rectification_event_engine.py` 的 Vimshottari 计分只使用前三层。Sookshma、Prana 与 D150 不在评价集内。
|
||||
- 用户现象:校正结果不能被说成秒级。记录时间多数取整到 5 分钟,问前事对上某一天也不能单独证明某一秒。
|
||||
- 触发条件:无用户路径。离线研究,任务书 `docs/tasks/TASK-rectification-nadi-seconds-research-20261005.md`。
|
||||
- 根因:未知。已知的是主链不评第 4、第 5 层,且公开评价集的记录时钟没有秒。纳迪式规则能否胜过安慰剂日期,要等预先登记的检验跑完才能写。
|
||||
- 修复:尚未改生产代码。研究代码在 `scripts/research/nadi_seconds_lib.py` 与 `scripts/research/nadi_seconds_run.py`。
|
||||
- 验证:N0 对账测试已通过(前三层与主链 0 差)。N1–N5 尚未在 v5 上跑。
|
||||
- 防复发:规则、阈值和种子先写入预登记再跑;看过结果后改的规则不能用来判过门。不得重调 BUG-1091 已关闭的权重。
|
||||
- 相关记录:BUG-560、BUG-1090、BUG-1091、BUG-1105。
|
||||
- 复发自:无
|
||||
- 修复版本:研究分支 `codex/rectification-nadi-seconds-research-20261005`(未推送、未部署)
|
||||
|
||||
@@ -422,4 +422,4 @@
|
||||
| `TASK-astrologer-rulings-batch6-20261004.md` | `PROGRESS-astrologer-rulings-batch6-20261004.md` | 第七轮裁定(共享仓书面回复,产品采用;问 1 选 A):Rath 版双主星按 p.43 (a)–(e)(BUG-1227 解除 blocked,推翻第五批红线 2 的 Table 17 年数底线);第 5 级宫主度数只倒算计都(p.71 脚注 42);罗计旺陷 ±1 年;同宫两主比经度;BUG 从 1228 起 | 待验收 | 分支 `codex/astrologer-rulings-batch6-20261004`(BUG-1227~1229;校正分数文件未改、未升版本) |
|
||||
| `TASK-gate-log-volume-20261004.md` | `PROGRESS-gate-log-volume-20261004.md` | 门禁 validate 单步日志 4.4 万行 / 2 MB 网页打不开(run 3170):快速门只打摘要(失败给末 200 行 + 日志文件)、前端测试门禁上 dot + 失败汇总(本机仍 TAP)、拆分 validate 为 7 个 step(产品授权改 workflow,只限拆分与重定向);检查一项不少 | **已实现待验收**(BUG-1230;未推送、未部署;Gitea 各 step 页面是否打得开留待推送后由产品确认) | 分支 `codex/gate-log-volume-20261004` |
|
||||
| `TASK-consult-latency-quickwins-20261005.md` | `PROGRESS-consult-latency-quickwins-20261005.md` | 普通对话耗时两项快修:咨询链校正闸不再同步白等 VedAstro 官方快照子进程(每域约 4 s,复用顶层缓存 + 负缓存,不推翻 BUG-301);补分段计时(第 0/1 步耗时、推理 token、分类耗时进日志与 usage)。推理强度/精简说明待模型 key 另单(BUG 从 1231 起) | 待领取 | 分支 `codex/consult-latency-quickwins-20261005` |
|
||||
| `TASK-rectification-nadi-seconds-research-20261005.md` | `PROGRESS-rectification-nadi-seconds-research-20261005.md` | **「纳迪秒级校准」可证伪检验(离线)**:竞品宣传「问前事到天 → 秒级」。本仓主链只用三层小运,Sookshma / Prana 与 D150 从未进评价集;v5 真值 52/77 是整 5 分钟(秒级无真值可对)。N0 五层小运 + D150 底座(前三层与主链对账 0 差)、N1 拟合率 vs 安慰剂日期(核心)、N2 留一件预测、N3 六题后区间内再细分能否提头名、N4 岁差 / 坐标 / 时间扰动的噪声地板、N5 D150 结构层(原文比对 blocked)、N6 结论 + 对外口径草稿。规则先登记再跑;不改生产代码;不得重调 BUG-1091 已关的权重 | 待领取 | 分支 `codex/rectification-nadi-seconds-research-20261005`(BUG-1240) |
|
||||
| `TASK-rectification-nadi-seconds-research-20261005.md` | `PROGRESS-rectification-nadi-seconds-research-20261005.md` | **「纳迪秒级校准」可证伪检验(离线)**:竞品宣传「问前事到天 → 秒级」。本仓主链只用三层小运,Sookshma / Prana 与 D150 从未进评价集;v5 真值 52/77 是整 5 分钟(秒级无真值可对)。N0 五层小运 + D150 底座(前三层与主链对账 0 差)、N1 拟合率 vs 安慰剂日期(核心)、N2 留一件预测、N3 六题后区间内再细分能否提头名、N4 岁差 / 坐标 / 时间扰动的噪声地板、N5 D150 结构层(原文比对 blocked)、N6 结论 + 对外口径草稿。规则先登记再跑;不改生产代码;不得重调 BUG-1091 已关的权重 | 执行中 | 分支 `codex/rectification-nadi-seconds-research-20261005`(BUG-1240;未推送、未部署) |
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline falsification helpers for second-scale nadi / sookshma / prana claims.
|
||||
|
||||
Research only. This module imports production chart and Vimshottari functions
|
||||
and does not change them. Fitters and rankers never read ``true_minute``.
|
||||
|
||||
The five-level lords are the production chain
|
||||
(``dasha_analyzer.build_antardasha`` applied twice more past pratyantar).
|
||||
That is the same recursion ``_active_vimshottari`` uses for the first three
|
||||
levels, including its midnight date anchor and 365.25-day year.
|
||||
``calculate_five_level_dasha`` stays available as a side check; it is not the
|
||||
scoring chain, because its 365.25636-day year does not reconcile to zero
|
||||
against the production lords.
|
||||
|
||||
Classical D150 order (movable direct, fixed reverse, dual from the middle)
|
||||
is marked ``variant_unverified``. No Chandra Kala Nadi verse text is stored.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from random import Random
|
||||
from typing import Any
|
||||
|
||||
import dasha_analyzer
|
||||
import divisional_charts_extended
|
||||
import domain_calculation_service
|
||||
import narayana_dasha
|
||||
from scripts.active_rectification_event_engine import (
|
||||
AYANAMSA,
|
||||
DOMAIN_CONFIG,
|
||||
NODE_MODE,
|
||||
_active_vimshottari,
|
||||
_event_datetime,
|
||||
_house_lords,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HOLDOUT_V5 = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v5.json"
|
||||
PREREG_PATH = ROOT / "docs" / "research" / "nadi_seconds_preregistration_2026_10_05.json"
|
||||
EVENT_CUTOFF = date(2026, 9, 14)
|
||||
LEVELS = ("md", "ad", "pd", "sookshma", "prana")
|
||||
# Structural reading only. See classical_nadi_index.
|
||||
CLASSICAL_NADI_STATUS = "variant_unverified"
|
||||
KM_PER_DEGREE_LAT = 111.32
|
||||
|
||||
_VARGA = divisional_charts_extended.DivisionalChartsCalculator()
|
||||
_SKY_CACHE: dict[tuple, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def load_cases(path: Path = HOLDOUT_V5) -> list[dict[str, Any]]:
|
||||
return list(json.loads(path.read_text(encoding="utf-8"))["cases"])
|
||||
|
||||
|
||||
def canonical_bytes(payload: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(raw: bytes) -> str:
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def round6(value: float) -> float:
|
||||
return round(float(value), 6)
|
||||
|
||||
|
||||
def subdivide(period: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
"""One production antardasha split. Adjacent rows abut by construction."""
|
||||
return list(dasha_analyzer.build_antardasha(dict(period)))
|
||||
|
||||
|
||||
def chain_at(birth_date: str, moon_longitude: float, event_at: datetime) -> list[dict[str, Any]]:
|
||||
"""Five nested production periods active at ``event_at``.
|
||||
|
||||
``birth_date`` is the candidate's local calendar date (YYYY-MM-DD), the
|
||||
same anchor ``_active_vimshottari`` passes through.
|
||||
"""
|
||||
return _chain_from_timeline(_timeline(birth_date, moon_longitude), event_at)
|
||||
|
||||
|
||||
def five_lords(birth_date: str, moon_longitude: float, event_at: datetime) -> tuple[str, str, str, str, str]:
|
||||
return tuple(str(period["lord"]) for period in chain_at(birth_date, moon_longitude, event_at)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def production_three(birth_date: str, moon_longitude: float, event_at: datetime) -> tuple[str, str, str]:
|
||||
return _active_vimshottari(birth_date, float(moon_longitude) % 360.0, event_at)
|
||||
|
||||
|
||||
def d150_equal(longitude: float) -> dict[str, Any]:
|
||||
"""Equal-slice D150 via the repository ``calc_custom_varga`` (N=150)."""
|
||||
row = _VARGA.calc_custom_varga(float(longitude) % 360.0, 150)
|
||||
sign = str(row["sign"])
|
||||
return {
|
||||
"part_index": int(row["part_index"]),
|
||||
"sign": sign,
|
||||
"sign_idx": int(row["sign_idx"]),
|
||||
"sign_lord": narayana_dasha.SIGN_LORDS[sign],
|
||||
"source": "calc_custom_varga",
|
||||
}
|
||||
|
||||
|
||||
def classical_nadi_index(longitude: float) -> dict[str, Any]:
|
||||
"""Reorder the 150 equal slices. Not a gate input.
|
||||
|
||||
Movable / fixed / dual follow ``DivisionalChartsCalculator`` sign classes.
|
||||
Movable: part 0..149 in longitude order. Fixed: 149 down to 0.
|
||||
Dual: start at the middle index 75 and wrap. The dual start is a
|
||||
structural reading of "从中段起排", not a quotation, so the status is
|
||||
``variant_unverified``. Named nadi verses are not used.
|
||||
"""
|
||||
longitude = float(longitude) % 360.0
|
||||
sign_index = int(longitude // 30.0) % 12
|
||||
part = int(d150_equal(longitude)["part_index"])
|
||||
if sign_index in _VARGA.MOVABLE_SIGNS:
|
||||
index = part
|
||||
order = "direct"
|
||||
elif sign_index in _VARGA.FIXED_SIGNS:
|
||||
index = 149 - part
|
||||
order = "reverse"
|
||||
else:
|
||||
index = (part + 75) % 150
|
||||
order = "from_middle"
|
||||
return {
|
||||
"index": index,
|
||||
"equal_part": part,
|
||||
"sign_index": sign_index,
|
||||
"order": order,
|
||||
"status": CLASSICAL_NADI_STATUS,
|
||||
}
|
||||
|
||||
|
||||
def domain_target_lords(ascendant_index: int, domain: str) -> frozenset[str]:
|
||||
houses = DOMAIN_CONFIG[domain][1]
|
||||
return frozenset(_house_lords(int(ascendant_index), houses))
|
||||
|
||||
|
||||
def explained(lords: Sequence[str], targets: frozenset[str], rule: str, segment_lord: str | None = None) -> bool:
|
||||
"""Binary 'this second explains this event' for one pre-registered rule.
|
||||
|
||||
A4 / A5: level 4 or 5 lord is a domain house lord.
|
||||
B1..B5: at least that many of the five levels are domain house lords.
|
||||
N5A: equal-division D150 lagna sign lord is a domain house lord.
|
||||
N5B: that same sign lord appears in any of the five levels.
|
||||
"""
|
||||
lords = tuple(lords)
|
||||
if rule == "A4":
|
||||
return lords[3] in targets
|
||||
if rule == "A5":
|
||||
return lords[4] in targets
|
||||
if rule.startswith("B") and rule[1:].isdigit():
|
||||
need = int(rule[1:])
|
||||
return sum(lord in targets for lord in lords) >= need
|
||||
if rule == "N5A":
|
||||
return segment_lord is not None and segment_lord in targets
|
||||
if rule == "N5B":
|
||||
return segment_lord is not None and segment_lord in set(lords)
|
||||
raise ValueError(f"unknown rule {rule}")
|
||||
|
||||
|
||||
def fit_count(
|
||||
lord_rows: Sequence[Sequence[str]],
|
||||
domains: Sequence[str],
|
||||
ascendant_index: int,
|
||||
rule: str,
|
||||
segment_lord: str | None = None,
|
||||
) -> int:
|
||||
"""How many events ``rule`` explains. No clock and no truth label."""
|
||||
total = 0
|
||||
for lords, domain in zip(lord_rows, domains, strict=True):
|
||||
targets = domain_target_lords(ascendant_index, domain)
|
||||
if explained(lords, targets, rule, segment_lord):
|
||||
total += 1
|
||||
return total
|
||||
|
||||
|
||||
def rank_offsets(counts: Mapping[int, int]) -> list[int]:
|
||||
"""Offsets tied for the highest count, ascending. Ties are all kept."""
|
||||
if not counts:
|
||||
return []
|
||||
best = max(counts.values())
|
||||
return sorted(offset for offset, count in counts.items() if count == best)
|
||||
|
||||
|
||||
def candidate_moment(birth: Mapping[str, Any], offset_seconds: int) -> datetime:
|
||||
base = datetime.strptime(f"{birth['date']} {str(birth['time'])[:5]}", "%Y-%m-%d %H:%M")
|
||||
return base + timedelta(seconds=int(offset_seconds))
|
||||
|
||||
|
||||
def sky_at(
|
||||
birth: Mapping[str, Any],
|
||||
moment: datetime,
|
||||
*,
|
||||
ayanamsa: str = AYANAMSA,
|
||||
node_mode: str = NODE_MODE,
|
||||
longitude: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Moon and ascendant from the production chart. Seconds are passed through."""
|
||||
lat = round(float(birth["latitude"]), 6)
|
||||
lon = round(float(birth["longitude"] if longitude is None else longitude), 6)
|
||||
tz = round(float(birth["timezone_offset"]), 4)
|
||||
key = (moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, lat, lon, tz, str(ayanamsa), str(node_mode))
|
||||
cached = _SKY_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
"year": moment.year,
|
||||
"month": moment.month,
|
||||
"day": moment.day,
|
||||
"hour": moment.hour,
|
||||
"minute": moment.minute,
|
||||
"second": moment.second,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"tz": tz,
|
||||
"ayanamsa": ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
})
|
||||
moon = float(chart["planets"]["Moon"]["lon"]) % 360.0
|
||||
asc = float(chart["ascendant"]["lon"]) % 360.0
|
||||
rahu = chart.get("planets", {}).get("Rahu") or {}
|
||||
rahu_lon = float(rahu["lon"]) % 360.0 if isinstance(rahu.get("lon"), (int, float)) else None
|
||||
sky = {
|
||||
"birth_date": moment.date().isoformat(),
|
||||
"moon_lon": moon,
|
||||
"asc_lon": asc,
|
||||
"asc_index": int(asc // 30.0) % 12,
|
||||
"rahu_lon": rahu_lon,
|
||||
}
|
||||
_SKY_CACHE[key] = sky
|
||||
return sky
|
||||
|
||||
|
||||
def shift_longitude_km(longitude: float, latitude: float, kilometers_east: float) -> float:
|
||||
"""Move the birth longitude by an east-west ground distance.
|
||||
|
||||
One degree of longitude is ``111.32 * cos(latitude)`` kilometres.
|
||||
Near a pole the east-west degree collapses and the shift is refused.
|
||||
"""
|
||||
scale = KM_PER_DEGREE_LAT * math.cos(math.radians(float(latitude)))
|
||||
if abs(scale) < 1e-3:
|
||||
raise ValueError("east-west shift is undefined this close to a pole")
|
||||
moved = float(longitude) + float(kilometers_east) / scale
|
||||
return ((moved + 180.0) % 360.0) - 180.0
|
||||
|
||||
|
||||
def day_events(case: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Day-precision events copied without truth labels or narrative text."""
|
||||
rows = []
|
||||
for event in case["events"]:
|
||||
if event.get("precision") != "day":
|
||||
continue
|
||||
rows.append({
|
||||
"id": str(event["id"]),
|
||||
"domain": str(event["domain"]),
|
||||
"date": str(event["date"])[:10],
|
||||
"precision": "day",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _clamp_life_date(day: date, birth: date, cutoff: date) -> date:
|
||||
guard = 0
|
||||
while day <= birth and guard < 160:
|
||||
day = day + timedelta(days=365)
|
||||
guard += 1
|
||||
guard = 0
|
||||
while day > cutoff and guard < 160:
|
||||
day = day - timedelta(days=365)
|
||||
guard += 1
|
||||
if birth < day <= cutoff:
|
||||
return day
|
||||
fallback = birth + timedelta(days=400)
|
||||
if fallback > cutoff:
|
||||
fallback = cutoff
|
||||
if fallback <= birth:
|
||||
fallback = birth + timedelta(days=1)
|
||||
return fallback
|
||||
|
||||
|
||||
def placebo_shift_dates(
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
birth_date: str,
|
||||
*,
|
||||
seed: str,
|
||||
low_days: int,
|
||||
high_days: int,
|
||||
cutoff: date = EVENT_CUTOFF,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shift each day-event date by a seeded ±[low, high] day offset.
|
||||
|
||||
Domain stays. A result outside (birth, cutoff] is flipped, then repaired
|
||||
by 365-day steps. The seed includes the case id so case order cannot
|
||||
change one case's offsets.
|
||||
"""
|
||||
rng = Random(seed)
|
||||
birth = date.fromisoformat(birth_date)
|
||||
shifted = []
|
||||
for event in events:
|
||||
original = date.fromisoformat(str(event["date"])[:10])
|
||||
magnitude = rng.randint(int(low_days), int(high_days))
|
||||
sign = rng.choice((-1, 1))
|
||||
candidate = original + timedelta(days=sign * magnitude)
|
||||
if not (birth < candidate <= cutoff):
|
||||
candidate = original + timedelta(days=-sign * magnitude)
|
||||
if not (birth < candidate <= cutoff):
|
||||
candidate = _clamp_life_date(original + timedelta(days=magnitude), birth, cutoff)
|
||||
shifted.append({**event, "date": candidate.isoformat()})
|
||||
return shifted
|
||||
|
||||
|
||||
def placebo_swap_dates(
|
||||
cases: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
seed: int,
|
||||
cutoff: date = EVENT_CUTOFF,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Permute day-event dates across cases. Domains stay on the recipient event."""
|
||||
slots: list[tuple[str, dict[str, Any]]] = []
|
||||
for case in cases:
|
||||
for event in day_events(case):
|
||||
slots.append((str(case["case_id"]), event))
|
||||
slots.sort(key=lambda item: (item[0], item[1]["id"]))
|
||||
dates = [item[1]["date"] for item in slots]
|
||||
order = list(range(len(dates)))
|
||||
Random(int(seed)).shuffle(order)
|
||||
by_case: dict[str, list[dict[str, Any]]] = {}
|
||||
births = {str(case["case_id"]): str(case["birth"]["date"]) for case in cases}
|
||||
for index, (case_id, event) in enumerate(slots):
|
||||
raw = date.fromisoformat(dates[order[index]])
|
||||
clamped = _clamp_life_date(raw, date.fromisoformat(births[case_id]), cutoff)
|
||||
by_case.setdefault(case_id, []).append({**event, "date": clamped.isoformat()})
|
||||
return by_case
|
||||
|
||||
|
||||
def event_moments(events: Sequence[Mapping[str, Any]]) -> list[datetime]:
|
||||
return [_event_datetime(event) for event in events] # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _timeline(birth_date: str, moon_longitude: float) -> list[dict[str, Any]]:
|
||||
nakshatra, progress, _pada = dasha_analyzer.lon_to_nakshatra(float(moon_longitude) % 360.0)
|
||||
timeline, _elapsed, _remaining, _lord = dasha_analyzer.build_dasha_timeline(
|
||||
birth_date, nakshatra, progress,
|
||||
)
|
||||
return timeline
|
||||
|
||||
|
||||
def _chain_from_timeline(timeline: Sequence[Mapping[str, Any]], event_at: datetime) -> list[dict[str, Any]]:
|
||||
_index, major = dasha_analyzer.find_current(list(timeline), event_at)
|
||||
levels = [major]
|
||||
for _depth in range(4):
|
||||
levels.append(dasha_analyzer.find_current_sub(subdivide(levels[-1]), event_at))
|
||||
return levels
|
||||
|
||||
|
||||
def lords_for_events(
|
||||
birth_date: str,
|
||||
moon_longitude: float,
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
) -> list[tuple[str, str, str, str, str]]:
|
||||
"""Five lords for every event. The mahadasha timeline is built once."""
|
||||
timeline = _timeline(birth_date, moon_longitude)
|
||||
rows = []
|
||||
for moment in event_moments(events):
|
||||
rows.append(tuple(str(period["lord"]) for period in _chain_from_timeline(timeline, moment))) # type: ignore[arg-type]
|
||||
return rows
|
||||
|
||||
|
||||
def counts_by_offset(
|
||||
skies: Mapping[int, Mapping[str, Any]],
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
rule: str,
|
||||
*,
|
||||
use_segment_lord: bool,
|
||||
) -> dict[int, int]:
|
||||
domains = [str(event["domain"]) for event in events]
|
||||
counts: dict[int, int] = {}
|
||||
for offset, sky in skies.items():
|
||||
rows = lords_for_events(str(sky["birth_date"]), float(sky["moon_lon"]), events)
|
||||
segment_lord = str(d150_equal(float(sky["asc_lon"]))["sign_lord"]) if use_segment_lord else None
|
||||
counts[int(offset)] = fit_count(rows, domains, int(sky["asc_index"]), rule, segment_lord)
|
||||
return counts
|
||||
|
||||
|
||||
def mean_rate(counts: Mapping[int, int], offsets: Sequence[int], event_count: int) -> float | None:
|
||||
if event_count <= 0 or not offsets:
|
||||
return None
|
||||
total = sum(counts[offset] for offset in offsets)
|
||||
return total / (len(list(offsets)) * event_count)
|
||||
|
||||
|
||||
def fraction_explaining_all(counts: Mapping[int, int], offsets: Sequence[int], event_count: int) -> float | None:
|
||||
if event_count <= 0 or not offsets:
|
||||
return None
|
||||
hits = sum(1 for offset in offsets if counts[offset] == event_count)
|
||||
return hits / len(list(offsets))
|
||||
|
||||
|
||||
def paired_sign_flip_p(
|
||||
differences: Sequence[float],
|
||||
*,
|
||||
seed: int,
|
||||
permutations: int,
|
||||
) -> dict[str, Any]:
|
||||
"""One-sided paired permutation: share of sign-flips with mean >= observed.
|
||||
|
||||
p = (count + 1) / (permutations + 1), counting the observed assignment.
|
||||
"""
|
||||
values = [float(item) for item in differences]
|
||||
n = len(values)
|
||||
if n == 0:
|
||||
return {"n": 0, "mean": None, "p": None, "extreme": None, "permutations": permutations}
|
||||
observed = sum(values) / n
|
||||
rng = Random(int(seed))
|
||||
extreme = 0
|
||||
for _ in range(int(permutations)):
|
||||
total = 0.0
|
||||
for value in values:
|
||||
total += value if rng.randrange(2) == 0 else -value
|
||||
if total / n >= observed - 1e-15:
|
||||
extreme += 1
|
||||
return {
|
||||
"n": n,
|
||||
"mean": round6(observed),
|
||||
"p": round6((extreme + 1) / (int(permutations) + 1)),
|
||||
"extreme": extreme,
|
||||
"permutations": int(permutations),
|
||||
}
|
||||
|
||||
|
||||
def bootstrap_mean_ci(
|
||||
values: Sequence[float],
|
||||
*,
|
||||
seed: int,
|
||||
resamples: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Percentile 95% interval of the mean. Passes only when the lower bound is > 0."""
|
||||
pool = [float(item) for item in values]
|
||||
n = len(pool)
|
||||
if n == 0:
|
||||
return {"n": 0, "mean": None, "low": None, "high": None, "excludes_zero_positive": False}
|
||||
rng = Random(int(seed))
|
||||
means = []
|
||||
for _ in range(int(resamples)):
|
||||
draw = 0.0
|
||||
for _item in range(n):
|
||||
draw += pool[rng.randrange(n)]
|
||||
means.append(draw / n)
|
||||
means.sort()
|
||||
low_index = int(0.025 * (len(means) - 1))
|
||||
high_index = int(0.975 * (len(means) - 1))
|
||||
low = means[low_index]
|
||||
high = means[high_index]
|
||||
return {
|
||||
"n": n,
|
||||
"mean": round6(sum(pool) / n),
|
||||
"low": round6(low),
|
||||
"high": round6(high),
|
||||
"excludes_zero_positive": bool(low > 0.0),
|
||||
}
|
||||
|
||||
|
||||
def minute_of(clock: str) -> int:
|
||||
return int(str(clock)[3:5])
|
||||
|
||||
|
||||
def rounded_to_five_minutes(clock: str) -> bool:
|
||||
return minute_of(clock) % 5 == 0
|
||||
|
||||
|
||||
def clock_delta_minutes(stamp: str, recorded: str) -> int:
|
||||
def _clock(value: str) -> int:
|
||||
hours, minutes = str(value)[:5].split(":")
|
||||
return int(hours) * 60 + int(minutes)
|
||||
|
||||
delta = (_clock(stamp) - _clock(recorded)) % 1440
|
||||
return delta - 1440 if delta > 720 else delta
|
||||
|
||||
|
||||
def leaders_within(offsets: Sequence[int], limit_seconds: int) -> bool:
|
||||
"""True only when every tied leader is inside the limit. An empty tie is a miss."""
|
||||
return bool(offsets) and all(abs(int(offset)) <= int(limit_seconds) for offset in offsets)
|
||||
|
||||
|
||||
def truth_audit(cases: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
"""Evaluator-side audit. This function is allowed to read the recorded clock.
|
||||
|
||||
It does not read ``true_minute``; the recorded minute is ``birth.time``.
|
||||
"""
|
||||
rounded: list[str] = []
|
||||
unrounded: list[str] = []
|
||||
minute_hist: dict[str, int] = {}
|
||||
per_case = []
|
||||
for case in cases:
|
||||
clock = str(case["birth"]["time"])[:5]
|
||||
minute = f"{minute_of(clock):02d}"
|
||||
minute_hist[minute] = minute_hist.get(minute, 0) + 1
|
||||
case_id = str(case["case_id"])
|
||||
(rounded if rounded_to_five_minutes(clock) else unrounded).append(case_id)
|
||||
day_count = sum(1 for event in case["events"] if event.get("precision") == "day")
|
||||
per_case.append({"case_id": case_id, "minute": minute, "day_events": day_count})
|
||||
day_counts = [row["day_events"] for row in per_case]
|
||||
return {
|
||||
"cases": len(per_case),
|
||||
"rounded_5min_count": len(rounded),
|
||||
"unrounded_count": len(unrounded),
|
||||
"rounded_5min_case_ids": rounded,
|
||||
"unrounded_case_ids": unrounded,
|
||||
"minute_histogram": minute_hist,
|
||||
"day_event_total": sum(day_counts),
|
||||
"day_events_ge_3": sum(1 for count in day_counts if count >= 3),
|
||||
"day_events_ge_4": sum(1 for count in day_counts if count >= 4),
|
||||
"day_events_ge_5": sum(1 for count in day_counts if count >= 5),
|
||||
"day_events_zero": sum(1 for count in day_counts if count == 0),
|
||||
"per_case": per_case,
|
||||
}
|
||||
|
||||
|
||||
def reconcile_case(case: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""First three lords versus ``_active_vimshottari`` for every event at the recorded second."""
|
||||
moment = candidate_moment(case["birth"], 0)
|
||||
sky = sky_at(case["birth"], moment)
|
||||
mismatches = []
|
||||
for event in case["events"]:
|
||||
event_at = _event_datetime(event) # type: ignore[arg-type]
|
||||
ours = five_lords(sky["birth_date"], sky["moon_lon"], event_at)
|
||||
theirs = production_three(sky["birth_date"], sky["moon_lon"], event_at)
|
||||
if ours[:3] != theirs:
|
||||
mismatches.append(str(event["id"]))
|
||||
return {
|
||||
"case_id": str(case["case_id"]),
|
||||
"events": len(case["events"]),
|
||||
"mismatches": mismatches,
|
||||
}
|
||||
|
||||
|
||||
def public_fields(case: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Copy the birth clock, place, and events a fitter may see."""
|
||||
birth = case["birth"]
|
||||
return {
|
||||
"case_id": str(case["case_id"]),
|
||||
"birth": {
|
||||
"date": str(birth["date"]),
|
||||
"time": str(birth["time"])[:5],
|
||||
"latitude": float(birth["latitude"]),
|
||||
"longitude": float(birth["longitude"]),
|
||||
"timezone_offset": float(birth["timezone_offset"]),
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"id": str(event["id"]),
|
||||
"domain": str(event["domain"]),
|
||||
"date": str(event["date"]),
|
||||
"precision": str(event["precision"]),
|
||||
}
|
||||
for event in case["events"]
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the pre-registered nadi / sookshma / prana falsification study.
|
||||
|
||||
Reads ``docs/research/nadi_seconds_preregistration_2026_10_05.json`` and
|
||||
refuses to score without it. The output JSON has no timestamps, so two runs
|
||||
with ``PYTHONHASHSEED=0`` are byte-identical. This script does not import
|
||||
``true_minute``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from random import Random
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research import nadi_seconds_lib as lib # noqa: E402
|
||||
|
||||
REPORT = ROOT / "docs" / "research" / "nadi_seconds_results_2026_10_05.json"
|
||||
|
||||
|
||||
def load_prereg() -> dict[str, Any]:
|
||||
if not lib.PREREG_PATH.is_file():
|
||||
raise SystemExit(f"missing preregistration: {lib.PREREG_PATH}")
|
||||
return json.loads(lib.PREREG_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def offsets_of(radius: int, step: int) -> list[int]:
|
||||
return list(range(-int(radius), int(radius) + 1, int(step)))
|
||||
|
||||
|
||||
def truth_minute_offsets() -> list[int]:
|
||||
return list(range(0, 60))
|
||||
|
||||
|
||||
def collect_skies(birth: Mapping[str, Any], offsets: Sequence[int], **kwargs: Any) -> dict[int, dict[str, Any]]:
|
||||
return {int(offset): lib.sky_at(birth, lib.candidate_moment(birth, int(offset)), **kwargs) for offset in offsets}
|
||||
|
||||
|
||||
def lord_bundle(skies: Mapping[int, Mapping[str, Any]], events: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
lords: dict[int, list] = {}
|
||||
asc: dict[int, int] = {}
|
||||
segment: dict[int, str] = {}
|
||||
part: dict[int, int] = {}
|
||||
for offset, sky in skies.items():
|
||||
lords[int(offset)] = lib.lords_for_events(str(sky["birth_date"]), float(sky["moon_lon"]), events)
|
||||
asc[int(offset)] = int(sky["asc_index"])
|
||||
row = lib.d150_equal(float(sky["asc_lon"]))
|
||||
segment[int(offset)] = str(row["sign_lord"])
|
||||
part[int(offset)] = int(row["part_index"])
|
||||
return {"lords": lords, "asc": asc, "segment": segment, "part": part}
|
||||
|
||||
|
||||
def rule_counts(bundle: Mapping[str, Any], events: Sequence[Mapping[str, Any]], rule: str) -> dict[int, int]:
|
||||
domains = [str(event["domain"]) for event in events]
|
||||
use_segment = rule.startswith("N5")
|
||||
counts: dict[int, int] = {}
|
||||
for offset, rows in bundle["lords"].items():
|
||||
targets = [lib.domain_target_lords(bundle["asc"][offset], domain) for domain in domains]
|
||||
segment = bundle["segment"][offset] if use_segment else None
|
||||
counts[offset] = sum(
|
||||
lib.explained(lords, target, rule, segment)
|
||||
for lords, target in zip(rows, targets, strict=True)
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def rule_bits(bundle: Mapping[str, Any], events: Sequence[Mapping[str, Any]], rule: str) -> dict[int, list[bool]]:
|
||||
domains = [str(event["domain"]) for event in events]
|
||||
use_segment = rule.startswith("N5")
|
||||
bits: dict[int, list[bool]] = {}
|
||||
for offset, rows in bundle["lords"].items():
|
||||
targets = [lib.domain_target_lords(bundle["asc"][offset], domain) for domain in domains]
|
||||
segment = bundle["segment"][offset] if use_segment else None
|
||||
bits[offset] = [
|
||||
lib.explained(lords, target, rule, segment)
|
||||
for lords, target in zip(rows, targets, strict=True)
|
||||
]
|
||||
return bits
|
||||
|
||||
|
||||
def mean_defined(values: Sequence[float | None]) -> float | None:
|
||||
nums = [float(value) for value in values if value is not None]
|
||||
if not nums:
|
||||
return None
|
||||
return lib.round6(sum(nums) / len(nums))
|
||||
|
||||
|
||||
def rate(flags: Sequence[bool]) -> float | None:
|
||||
if not flags:
|
||||
return None
|
||||
return lib.round6(sum(bool(flag) for flag in flags) / len(flags))
|
||||
|
||||
|
||||
def split_rates(rows: Sequence[Mapping[str, Any]], key: str, rounded: set[str]) -> dict[str, float | None]:
|
||||
return {
|
||||
"all": rate([bool(row[key]) for row in rows]),
|
||||
"rounded_5min": rate([bool(row[key]) for row in rows if row["case_id"] in rounded]),
|
||||
"unrounded": rate([bool(row[key]) for row in rows if row["case_id"] not in rounded]),
|
||||
"n_all": len(rows),
|
||||
"n_rounded_5min": sum(1 for row in rows if row["case_id"] in rounded),
|
||||
"n_unrounded": sum(1 for row in rows if row["case_id"] not in rounded),
|
||||
}
|
||||
|
||||
|
||||
def delivery_offsets(start: str | None, end: str | None, recorded: str, radius_minutes: int) -> list[int]:
|
||||
"""One-second offsets covering each delivered clock minute, clipped to the search radius."""
|
||||
if not start or not end:
|
||||
return []
|
||||
cap = int(radius_minutes) * 60
|
||||
start_min = lib.clock_delta_minutes(start, recorded)
|
||||
end_min = lib.clock_delta_minutes(end, recorded)
|
||||
|
||||
def clip(lo: int, hi: int) -> list[int]:
|
||||
lo = max(lo, -cap)
|
||||
hi = min(hi, cap)
|
||||
if lo > hi:
|
||||
return []
|
||||
return list(range(lo, hi + 1))
|
||||
|
||||
if start_min <= end_min:
|
||||
return clip(start_min * 60, end_min * 60 + 59)
|
||||
return sorted(set(clip(start_min * 60, cap) + clip(-cap, end_min * 60 + 59)))
|
||||
|
||||
|
||||
def six_probe(case: Mapping[str, Any], radius: int) -> dict[str, Any]:
|
||||
"""futile_collect_stop_replay d1: six probes, no guided-window injection."""
|
||||
from scripts.active_rectification_event_engine import AYANAMSA, NODE_MODE, compute_candidate_static_contexts
|
||||
from scripts.rectification.event_probes import discriminating_event_probes
|
||||
from scripts.rectification.refinement_packet import window_scan
|
||||
from scripts.rectification.scoring_service import build_event_contribution_matrix, score_from_matrix
|
||||
from scripts.research.fewer_probes_card_replay import _outcome
|
||||
from scripts.research.guided_collect_holdout_replay import _hhmm, posterior_state
|
||||
from scripts.research.minute_resolution_sweep import MINUTE_STEP, scoring_request_for
|
||||
from scripts.research.probe_supply_after_six import ASK_COUNT, TODAY
|
||||
|
||||
recorded = str(case["birth"]["time"])[:5]
|
||||
request = scoring_request_for(dict(case), radius)
|
||||
request["ayanamsa"] = AYANAMSA
|
||||
request["node_mode"] = NODE_MODE
|
||||
request["minute_step"] = MINUTE_STEP
|
||||
static_contexts = compute_candidate_static_contexts(request)
|
||||
built = build_event_contribution_matrix(request, static_contexts=static_contexts)
|
||||
rows = score_from_matrix(request, built)
|
||||
times = [stamp for row in rows if (stamp := _hhmm(row.get("time")))]
|
||||
probes = discriminating_event_probes(
|
||||
{**request, "refresh_probes": False, "asked_probe_keys": []},
|
||||
built,
|
||||
scan=window_scan(built),
|
||||
candidate_times=times,
|
||||
representative_time=recorded,
|
||||
today=TODAY,
|
||||
)[:ASK_COUNT]
|
||||
state = posterior_state(rows=rows, contexts=static_contexts, probes=probes, true_time=recorded)
|
||||
outcome = _outcome(state, recorded)
|
||||
scores = state["scores"]
|
||||
valid = list(state["valid"])
|
||||
if not valid:
|
||||
leaders: list[str] = []
|
||||
else:
|
||||
def _score(row: Mapping[str, Any]) -> float:
|
||||
stamp = _hhmm(row.get("time")) or ""
|
||||
return float(scores.get(stamp, row.get("score") or 0))
|
||||
|
||||
best = max(_score(row) for row in valid)
|
||||
leaders = sorted({
|
||||
str(row.get("time"))[:5]
|
||||
for row in valid
|
||||
if abs(_score(row) - best) <= 1e-9 and str(row.get("time") or "")[:5]
|
||||
})
|
||||
deltas = [lib.clock_delta_minutes(stamp, recorded) for stamp in leaders]
|
||||
return {
|
||||
"start": outcome["start"],
|
||||
"end": outcome["end"],
|
||||
"width": outcome["width"],
|
||||
"truth_in_range": bool(outcome["truth_in_range"]),
|
||||
"leader_times": leaders,
|
||||
"all_within_1min": bool(deltas) and all(abs(delta) <= 1 for delta in deltas),
|
||||
"any_exact": recorded in leaders,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def gate_p(result: Mapping[str, Any]) -> bool:
|
||||
if result.get("mean") is None or result.get("extreme") is None:
|
||||
return False
|
||||
exact = (int(result["extreme"]) + 1) / (int(result["permutations"]) + 1)
|
||||
return bool(result["mean"] > 0 and exact < 0.05)
|
||||
|
||||
|
||||
def summarize_rule(
|
||||
rows: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
seed: int,
|
||||
permutations: int,
|
||||
) -> dict[str, Any]:
|
||||
diffs = [float(row["diff"]) for row in rows]
|
||||
test = lib.paired_sign_flip_p(diffs, seed=seed, permutations=permutations)
|
||||
return {
|
||||
**test,
|
||||
"pass": gate_p(test),
|
||||
"truth_mean": mean_defined([row["truth"] for row in rows]),
|
||||
"placebo_mean": mean_defined([row["placebo"] for row in rows]),
|
||||
"window_mean": mean_defined([row["window"] for row in rows]),
|
||||
"fraction_grid_explains_all_mean": mean_defined([row["fraction_all"] for row in rows]),
|
||||
}
|
||||
|
||||
|
||||
def n2_case(
|
||||
bits: Mapping[int, Sequence[bool]],
|
||||
coarse: Sequence[int],
|
||||
events: Sequence[Mapping[str, Any]],
|
||||
placebo_bits: Mapping[int, Sequence[bool]],
|
||||
*,
|
||||
case_id: str,
|
||||
seed: str,
|
||||
) -> dict[str, Any]:
|
||||
deltas_random = []
|
||||
deltas_placebo = []
|
||||
held_rates = []
|
||||
for index, event in enumerate(events):
|
||||
counts = {
|
||||
offset: sum(flag for event_index, flag in enumerate(flags) if event_index != index)
|
||||
for offset, flags in bits.items()
|
||||
if offset in coarse
|
||||
}
|
||||
leaders = [offset for offset in lib.rank_offsets(counts) if offset in set(coarse)]
|
||||
if not leaders:
|
||||
continue
|
||||
held = sum(1 for offset in leaders if bits[offset][index]) / len(leaders)
|
||||
rng = Random(f"{seed}:{case_id}:{event['id']}")
|
||||
if len(leaders) >= len(coarse):
|
||||
sample = list(coarse)
|
||||
else:
|
||||
sample = rng.sample(list(coarse), len(leaders))
|
||||
random_rate = sum(1 for offset in sample if bits[offset][index]) / len(sample)
|
||||
placebo_rate = sum(1 for offset in leaders if placebo_bits[offset][index]) / len(leaders)
|
||||
held_rates.append(held)
|
||||
deltas_random.append(held - random_rate)
|
||||
deltas_placebo.append(held - placebo_rate)
|
||||
return {
|
||||
"held_rate": mean_defined(held_rates),
|
||||
"delta_random": mean_defined(deltas_random),
|
||||
"delta_placebo": mean_defined(deltas_placebo),
|
||||
}
|
||||
|
||||
|
||||
def level_change(base: Sequence[Sequence[str]], other: Sequence[Sequence[str]], index: int) -> float | None:
|
||||
if not base or len(base) != len(other):
|
||||
return None
|
||||
changed = sum(left[index] != right[index] for left, right in zip(base, other, strict=True))
|
||||
return lib.round6(changed / len(base))
|
||||
|
||||
|
||||
def build(prereg: Mapping[str, Any], *, limit: int, radii: Sequence[int]) -> dict[str, Any]:
|
||||
cases = [lib.public_fields(case) for case in lib.load_cases()]
|
||||
if limit:
|
||||
cases = cases[:limit]
|
||||
grids = prereg["grids"]
|
||||
coarse = offsets_of(grids["coarse_radius_seconds"], grids["coarse_step_seconds"])
|
||||
fine = offsets_of(grids["fine_radius_seconds"], grids["fine_step_seconds"])
|
||||
truth_offsets = truth_minute_offsets()
|
||||
union = sorted(set(coarse) | set(fine) | set(truth_offsets))
|
||||
rules = list(prereg["rules"]["computed"])
|
||||
primary = list(prereg["rules"]["n1_primary"])
|
||||
n1_min = int(prereg["samples"]["n1_min_day_events"])
|
||||
n2_min = int(prereg["samples"]["n2_min_day_events"])
|
||||
permutations = int(prereg["tests"]["permutations"])
|
||||
resamples = int(prereg["tests"]["bootstrap_resamples"])
|
||||
shift_low = int(prereg["placebo"]["shift_low_days"])
|
||||
shift_high = int(prereg["placebo"]["shift_high_days"])
|
||||
swap = lib.placebo_swap_dates(cases, seed=int(prereg["placebo"]["swap_seed"]))
|
||||
rounded = {case_id for case_id in lib.truth_audit(cases)["rounded_5min_case_ids"]}
|
||||
audit = lib.truth_audit(cases)
|
||||
recon = [lib.reconcile_case(case) for case in cases]
|
||||
|
||||
n1_rows: dict[str, list[dict[str, Any]]] = {rule: [] for rule in primary}
|
||||
n1_placebos = ("P1", "P2")
|
||||
n5_rows: dict[str, list[dict[str, Any]]] = {rule: [] for rule in prereg["rules"]["n5_primary"]}
|
||||
n2_rows = []
|
||||
n3a_rows = []
|
||||
n4_rows = []
|
||||
fraction_rows: dict[str, list[float]] = {rule: [] for rule in ("A4", "A5", "B1", "B2")}
|
||||
case_fit: list[dict[str, Any]] = []
|
||||
|
||||
for index, case in enumerate(cases, start=1):
|
||||
events = lib.day_events(case)
|
||||
birth = case["birth"]
|
||||
print(f"nadi {index}/{len(cases)} {case['case_id']} day_events={len(events)}", flush=True)
|
||||
skies = collect_skies(birth, union)
|
||||
real = lord_bundle(skies, events) if events else None
|
||||
shifted = lib.placebo_shift_dates(
|
||||
events,
|
||||
birth["date"],
|
||||
seed=prereg["placebo"]["shift_seed_template"].format(case_id=case["case_id"]),
|
||||
low_days=shift_low,
|
||||
high_days=shift_high,
|
||||
) if events else []
|
||||
p1 = lord_bundle(skies, shifted) if shifted else None
|
||||
swapped = swap.get(case["case_id"], [])
|
||||
p2 = lord_bundle(skies, swapped) if swapped else None
|
||||
fit_row: dict[str, Any] = {
|
||||
"case_id": case["case_id"],
|
||||
"day_events": len(events),
|
||||
"rounded_5min": case["case_id"] in rounded,
|
||||
}
|
||||
if real is not None:
|
||||
for rule in rules:
|
||||
if rule.startswith("N5") and rule not in prereg["rules"]["n5_primary"] and rule not in primary:
|
||||
continue
|
||||
counts = rule_counts(real, events, rule)
|
||||
truth = lib.mean_rate(counts, truth_offsets, len(events))
|
||||
window = lib.mean_rate(counts, coarse, len(events))
|
||||
fraction = lib.fraction_explaining_all(counts, coarse, len(events))
|
||||
fit_row[rule] = {
|
||||
"truth": None if truth is None else lib.round6(truth),
|
||||
"window": None if window is None else lib.round6(window),
|
||||
"fraction_all": None if fraction is None else lib.round6(fraction),
|
||||
}
|
||||
if rule in fraction_rows and fraction is not None and len(events) >= n1_min:
|
||||
fraction_rows[rule].append(fraction)
|
||||
if rule in primary and len(events) >= n1_min and p1 is not None and p2 is not None:
|
||||
for label, bundle in (("P1", p1), ("P2", p2)):
|
||||
placebo_rate = lib.mean_rate(rule_counts(bundle, shifted if label == "P1" else swapped, rule), truth_offsets, len(events))
|
||||
n1_rows[rule].append({
|
||||
"case_id": case["case_id"],
|
||||
"placebo": label,
|
||||
"truth": lib.round6(truth or 0.0),
|
||||
"placebo_rate": None if placebo_rate is None else lib.round6(placebo_rate),
|
||||
"diff": None if placebo_rate is None else lib.round6((truth or 0.0) - placebo_rate),
|
||||
"window": None if window is None else lib.round6(window),
|
||||
"fraction_all": None if fraction is None else lib.round6(fraction),
|
||||
})
|
||||
if rule in n5_rows and len(events) >= n1_min and p1 is not None and p2 is not None:
|
||||
for label, bundle in (("P1", p1), ("P2", p2)):
|
||||
placebo_rate = lib.mean_rate(rule_counts(bundle, shifted if label == "P1" else swapped, rule), truth_offsets, len(events))
|
||||
n5_rows[rule].append({
|
||||
"case_id": case["case_id"],
|
||||
"placebo": label,
|
||||
"truth": lib.round6(truth or 0.0),
|
||||
"placebo_rate": None if placebo_rate is None else lib.round6(placebo_rate),
|
||||
"diff": None if placebo_rate is None else lib.round6((truth or 0.0) - placebo_rate),
|
||||
"window": None if window is None else lib.round6(window),
|
||||
"fraction_all": None if fraction is None else lib.round6(fraction),
|
||||
})
|
||||
if len(events) >= n2_min and p1 is not None:
|
||||
real_bits = rule_bits(real, events, prereg["rules"]["n2_primary"])
|
||||
p1_bits = rule_bits(p1, shifted, prereg["rules"]["n2_primary"])
|
||||
# P1 bits are aligned to shifted events, same index as real events.
|
||||
n2_rows.append({
|
||||
"case_id": case["case_id"],
|
||||
"rounded_5min": case["case_id"] in rounded,
|
||||
**n2_case(
|
||||
real_bits, coarse, events, p1_bits,
|
||||
case_id=case["case_id"], seed=str(prereg["tests"]["n2_random_second_seed"]),
|
||||
),
|
||||
})
|
||||
a5_counts = rule_counts(real, events, prereg["rules"]["n3_primary"]) if events else {}
|
||||
leaders = lib.rank_offsets({offset: a5_counts.get(offset, 0) for offset in coarse}) if events else []
|
||||
n3a_rows.append({
|
||||
"case_id": case["case_id"],
|
||||
"rounded_5min": case["case_id"] in rounded,
|
||||
"day_events": len(events),
|
||||
"hit_60s": lib.leaders_within(leaders, 60) if events else False,
|
||||
"hit_120s": lib.leaders_within(leaders, 120) if events else False,
|
||||
"tied_leaders": len(leaders),
|
||||
})
|
||||
else:
|
||||
n3a_rows.append({
|
||||
"case_id": case["case_id"],
|
||||
"rounded_5min": case["case_id"] in rounded,
|
||||
"day_events": 0,
|
||||
"hit_60s": False,
|
||||
"hit_120s": False,
|
||||
"tied_leaders": len(coarse),
|
||||
})
|
||||
case_fit.append(fit_row)
|
||||
|
||||
# N4 at the recorded second only.
|
||||
if events:
|
||||
base_sky = skies[0]
|
||||
base_lords = lib.lords_for_events(base_sky["birth_date"], base_sky["moon_lon"], events)
|
||||
base_part = int(lib.d150_equal(base_sky["asc_lon"])["part_index"])
|
||||
perturbations: dict[str, Any] = {}
|
||||
for name in prereg["n4"]["ayanamsas"]:
|
||||
if name == prereg["ayanamsa"]:
|
||||
continue
|
||||
sky = lib.sky_at(birth, lib.candidate_moment(birth, 0), ayanamsa=name)
|
||||
other = lib.lords_for_events(sky["birth_date"], sky["moon_lon"], events)
|
||||
perturbations[f"ayanamsa:{name}"] = {
|
||||
"level4": level_change(base_lords, other, 3),
|
||||
"level5": level_change(base_lords, other, 4),
|
||||
"d150_changed": int(lib.d150_equal(sky["asc_lon"])["part_index"]) != base_part,
|
||||
}
|
||||
for seconds in prereg["n4"]["time_offsets_seconds"]:
|
||||
sky = skies.get(int(seconds)) or lib.sky_at(birth, lib.candidate_moment(birth, int(seconds)))
|
||||
other = lib.lords_for_events(sky["birth_date"], sky["moon_lon"], events)
|
||||
perturbations[f"time:{int(seconds)}"] = {
|
||||
"level4": level_change(base_lords, other, 3),
|
||||
"level5": level_change(base_lords, other, 4),
|
||||
"d150_changed": int(lib.d150_equal(sky["asc_lon"])["part_index"]) != base_part,
|
||||
}
|
||||
for km in prereg["n4"]["longitude_shifts_km"]:
|
||||
try:
|
||||
moved = lib.shift_longitude_km(float(birth["longitude"]), float(birth["latitude"]), float(km))
|
||||
sky = lib.sky_at(birth, lib.candidate_moment(birth, 0), longitude=moved)
|
||||
other = lib.lords_for_events(sky["birth_date"], sky["moon_lon"], events)
|
||||
perturbations[f"east_km:{km}"] = {
|
||||
"level4": level_change(base_lords, other, 3),
|
||||
"level5": level_change(base_lords, other, 4),
|
||||
"d150_changed": int(lib.d150_equal(sky["asc_lon"])["part_index"]) != base_part,
|
||||
"blocked": False,
|
||||
}
|
||||
except ValueError:
|
||||
perturbations[f"east_km:{km}"] = {"blocked": True}
|
||||
node_sky = lib.sky_at(birth, lib.candidate_moment(birth, 0), node_mode="true")
|
||||
perturbations["node:true"] = {
|
||||
"moon_changed": abs(float(node_sky["moon_lon"]) - float(base_sky["moon_lon"])) > 1e-6,
|
||||
"asc_changed": abs(float(node_sky["asc_lon"]) - float(base_sky["asc_lon"])) > 1e-6,
|
||||
"rahu_changed": node_sky["rahu_lon"] != base_sky["rahu_lon"],
|
||||
"level5": level_change(base_lords, lib.lords_for_events(node_sky["birth_date"], node_sky["moon_lon"], events), 4),
|
||||
}
|
||||
n4_rows.append({"case_id": case["case_id"], "day_events": len(events), "perturbations": perturbations})
|
||||
|
||||
def pack_comparisons(bucket: Mapping[str, list[dict[str, Any]]], seed_base: int) -> dict[str, Any]:
|
||||
packed = {}
|
||||
cursor = 0
|
||||
for rule, rows in bucket.items():
|
||||
packed[rule] = {}
|
||||
for label in n1_placebos:
|
||||
subset = [
|
||||
{
|
||||
"diff": row["diff"],
|
||||
"truth": row["truth"],
|
||||
"placebo": row["placebo_rate"],
|
||||
"window": row["window"],
|
||||
"fraction_all": row["fraction_all"],
|
||||
}
|
||||
for row in rows
|
||||
if row["placebo"] == label and row["diff"] is not None
|
||||
]
|
||||
packed[rule][label] = summarize_rule(subset, seed=seed_base + cursor, permutations=permutations)
|
||||
cursor += 1
|
||||
return packed
|
||||
|
||||
n1 = pack_comparisons(n1_rows, int(prereg["tests"]["n1_permutation_seed_base"]))
|
||||
n5 = pack_comparisons(n5_rows, int(prereg["tests"]["n5_permutation_seed_base"]))
|
||||
n1_pass = all(item["pass"] for rule in n1.values() for item in rule.values())
|
||||
n5_pass = all(item["pass"] for rule in n5.values() for item in rule.values())
|
||||
n2_values = [float(row["delta_random"]) for row in n2_rows if row["delta_random"] is not None]
|
||||
n2_placebo_values = [float(row["delta_placebo"]) for row in n2_rows if row["delta_placebo"] is not None]
|
||||
n2_ci = lib.bootstrap_mean_ci(n2_values, seed=int(prereg["tests"]["n2_bootstrap_seed"]), resamples=resamples)
|
||||
n2_placebo_ci = lib.bootstrap_mean_ci(
|
||||
n2_placebo_values, seed=int(prereg["tests"]["n2_bootstrap_seed"]) + 1, resamples=resamples,
|
||||
)
|
||||
uniform_60 = lib.round6(sum(1 for offset in coarse if abs(offset) <= 60) / len(coarse))
|
||||
uniform_120 = lib.round6(sum(1 for offset in coarse if abs(offset) <= 120) / len(coarse))
|
||||
|
||||
# N3b. Separate from the fit loop so a replay failure does not drop N1.
|
||||
n3b_rows = []
|
||||
gate_radius = int(prereg["n3_protocol"]["radius_gate"])
|
||||
for index, case in enumerate(cases, start=1):
|
||||
print(f"replay {index}/{len(cases)} {case['case_id']}", flush=True)
|
||||
by_radius = {}
|
||||
for radius in radii:
|
||||
try:
|
||||
snapshot = six_probe(case, radius)
|
||||
except Exception as exc: # noqa: BLE001 — recorded as a miss, message dropped
|
||||
snapshot = {
|
||||
"start": None,
|
||||
"end": None,
|
||||
"width": None,
|
||||
"truth_in_range": False,
|
||||
"leader_times": [],
|
||||
"all_within_1min": False,
|
||||
"any_exact": False,
|
||||
"error": type(exc).__name__,
|
||||
}
|
||||
traceback.print_exc(limit=2)
|
||||
events = lib.day_events(case)
|
||||
span = delivery_offsets(snapshot["start"], snapshot["end"], case["birth"]["time"], radius)
|
||||
if events and span:
|
||||
skies = collect_skies(case["birth"], span)
|
||||
bundle = lord_bundle(skies, events)
|
||||
counts = rule_counts(bundle, events, prereg["rules"]["n3_primary"])
|
||||
leaders = lib.rank_offsets(counts)
|
||||
nadi_hit = lib.leaders_within(leaders, int(prereg["n3_protocol"]["nadi_limit_seconds"]))
|
||||
tied = len(leaders)
|
||||
else:
|
||||
nadi_hit = False
|
||||
tied = 0
|
||||
by_radius[str(radius)] = {
|
||||
"truth_in_range": snapshot["truth_in_range"],
|
||||
"width": snapshot["width"],
|
||||
"baseline_all_within_1min": snapshot["all_within_1min"],
|
||||
"baseline_any_exact": snapshot["any_exact"],
|
||||
"nadi_all_within_60s": nadi_hit,
|
||||
"nadi_tied_leaders": tied,
|
||||
"improvement": int(nadi_hit) - int(snapshot["all_within_1min"]),
|
||||
"error": snapshot["error"],
|
||||
}
|
||||
n3b_rows.append({
|
||||
"case_id": case["case_id"],
|
||||
"rounded_5min": case["case_id"] in rounded,
|
||||
"radii": by_radius,
|
||||
})
|
||||
|
||||
def n3b_summary(radius: int) -> dict[str, Any]:
|
||||
key = str(radius)
|
||||
improvements = [int(row["radii"][key]["improvement"]) for row in n3b_rows]
|
||||
inside = sum(1 for row in n3b_rows if row["radii"][key]["truth_in_range"])
|
||||
ci = lib.bootstrap_mean_ci(improvements, seed=int(prereg["tests"]["n3_bootstrap_seed"]) + radius, resamples=resamples)
|
||||
return {
|
||||
"radius": radius,
|
||||
"truth_in_range": f"{inside}/{len(n3b_rows)}",
|
||||
"truth_in_range_count": inside,
|
||||
"truth_in_range_pass": inside >= 76 if len(n3b_rows) == 77 else None,
|
||||
"baseline_all_within_1min": split_rates(
|
||||
[{"case_id": row["case_id"], "hit": row["radii"][key]["baseline_all_within_1min"]} for row in n3b_rows],
|
||||
"hit", rounded,
|
||||
),
|
||||
"baseline_any_exact": rate([row["radii"][key]["baseline_any_exact"] for row in n3b_rows]),
|
||||
"nadi_all_within_60s": split_rates(
|
||||
[{"case_id": row["case_id"], "hit": row["radii"][key]["nadi_all_within_60s"]} for row in n3b_rows],
|
||||
"hit", rounded,
|
||||
),
|
||||
"improvement_ci": ci,
|
||||
"pass": bool(ci["excludes_zero_positive"] and len(n3b_rows) == 77 and inside >= 76),
|
||||
}
|
||||
|
||||
def n4_aggregate(label: str, field: str) -> dict[str, Any]:
|
||||
values = []
|
||||
d150_flags = []
|
||||
weights = []
|
||||
for row in n4_rows:
|
||||
item = row["perturbations"].get(label) or {}
|
||||
if item.get("blocked") or item.get(field) is None:
|
||||
continue
|
||||
values.append(float(item[field]))
|
||||
weights.append(int(row["day_events"]))
|
||||
if "d150_changed" in item:
|
||||
d150_flags.append(bool(item["d150_changed"]))
|
||||
event_rate = None
|
||||
if weights and sum(weights):
|
||||
event_rate = lib.round6(sum(value * weight for value, weight in zip(values, weights, strict=True)) / sum(weights))
|
||||
return {
|
||||
"event_rate": event_rate,
|
||||
"case_rate": rate(d150_flags) if field == "level5" else None,
|
||||
"d150_case_rate": rate(d150_flags),
|
||||
"cases": len(values),
|
||||
}
|
||||
|
||||
wording_hits = []
|
||||
for name in prereg["n4"]["ayanamsas"]:
|
||||
if name == prereg["ayanamsa"]:
|
||||
continue
|
||||
label = f"ayanamsa:{name}"
|
||||
level5 = n4_aggregate(label, "level5")
|
||||
wording_hits.append({
|
||||
"switch": f"{prereg['ayanamsa']}->{name}",
|
||||
"level5_event_rate": level5["event_rate"],
|
||||
"d150_case_rate": level5["d150_case_rate"],
|
||||
"over_20pct": bool(
|
||||
(level5["event_rate"] is not None and level5["event_rate"] > 0.20)
|
||||
or (level5["d150_case_rate"] is not None and level5["d150_case_rate"] > 0.20)
|
||||
),
|
||||
})
|
||||
wording_ban = any(item["over_20pct"] for item in wording_hits)
|
||||
|
||||
n3a = {
|
||||
"grid": "coarse",
|
||||
"uniform_within_60s": uniform_60,
|
||||
"uniform_within_120s": uniform_120,
|
||||
"published_engine_prior_top1_pm10": 0.18,
|
||||
"hit_60s": split_rates(
|
||||
[{"case_id": row["case_id"], "hit": row["hit_60s"]} for row in n3a_rows], "hit", rounded,
|
||||
),
|
||||
"hit_120s": split_rates(
|
||||
[{"case_id": row["case_id"], "hit": row["hit_120s"]} for row in n3a_rows], "hit", rounded,
|
||||
),
|
||||
}
|
||||
n3b = {str(radius): n3b_summary(radius) for radius in radii}
|
||||
n3_pass = bool(n3b.get(str(gate_radius), {}).get("pass"))
|
||||
|
||||
return {
|
||||
"prereg_sha256": lib.file_sha256(lib.PREREG_PATH),
|
||||
"library_sha256": lib.file_sha256(Path(__file__).with_name("nadi_seconds_lib.py")),
|
||||
"runner_sha256": lib.file_sha256(Path(__file__)),
|
||||
"dataset_sha256": lib.file_sha256(lib.HOLDOUT_V5),
|
||||
"swisseph": __import__("swisseph").version,
|
||||
"limit": limit,
|
||||
"n0": {
|
||||
"audit": {key: value for key, value in audit.items() if key != "per_case"},
|
||||
"per_case_day_events": audit["per_case"],
|
||||
"reconciliation_mismatch_cases": [row["case_id"] for row in recon if row["mismatches"]],
|
||||
"reconciliation_events": sum(row["events"] for row in recon),
|
||||
},
|
||||
"n1": {
|
||||
"pass": n1_pass and not limit,
|
||||
"comparisons": n1,
|
||||
"fraction_grid_explains_all_mean": {rule: mean_defined(values) for rule, values in fraction_rows.items()},
|
||||
"cases": case_fit,
|
||||
},
|
||||
"n2": {
|
||||
"pass": bool(n2_ci["excludes_zero_positive"]) and not limit,
|
||||
"versus_random": n2_ci,
|
||||
"versus_placebo_date": n2_placebo_ci,
|
||||
"cases": n2_rows,
|
||||
},
|
||||
"n3": {
|
||||
"pass": n3_pass and not limit,
|
||||
"n3a": n3a,
|
||||
"n3b": n3b,
|
||||
"cases_n3a": n3a_rows,
|
||||
"cases_n3b": n3b_rows,
|
||||
},
|
||||
"n4": {
|
||||
"wording_ban_second_scale": wording_ban,
|
||||
"ayanamsa_switches": wording_hits,
|
||||
"time": {str(seconds): n4_aggregate(f"time:{int(seconds)}", "level5") for seconds in prereg["n4"]["time_offsets_seconds"]},
|
||||
"time_level4": {str(seconds): n4_aggregate(f"time:{int(seconds)}", "level4") for seconds in prereg["n4"]["time_offsets_seconds"]},
|
||||
"longitude_km": {str(km): n4_aggregate(f"east_km:{km}", "level5") for km in prereg["n4"]["longitude_shifts_km"]},
|
||||
"longitude_km_d150": {str(km): n4_aggregate(f"east_km:{km}", "level5")["d150_case_rate"] for km in prereg["n4"]["longitude_shifts_km"]},
|
||||
"node_mean_to_true": {
|
||||
"moon_changed_cases": sum(1 for row in n4_rows if row["perturbations"]["node:true"]["moon_changed"]),
|
||||
"asc_changed_cases": sum(1 for row in n4_rows if row["perturbations"]["node:true"]["asc_changed"]),
|
||||
"rahu_changed_cases": sum(1 for row in n4_rows if row["perturbations"]["node:true"]["rahu_changed"]),
|
||||
"level5_event_rate": n4_aggregate("node:true", "level5")["event_rate"],
|
||||
},
|
||||
"cases": n4_rows,
|
||||
},
|
||||
"n5": {
|
||||
"pass": n5_pass and not limit,
|
||||
"named_text": "blocked",
|
||||
"named_text_reason": "no legal Chandra Kala Nadi source in the repository",
|
||||
"classical_index_status": lib.CLASSICAL_NADI_STATUS,
|
||||
"comparisons": n5,
|
||||
},
|
||||
"gates": {
|
||||
"n1": n1_pass and not limit,
|
||||
"n2": bool(n2_ci["excludes_zero_positive"]) and not limit,
|
||||
"n3": n3_pass and not limit,
|
||||
"n4_blocks_second_scale_wording": wording_ban,
|
||||
"n5": n5_pass and not limit,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--limit", type=int, default=0)
|
||||
parser.add_argument("--radii", default="10,30,60")
|
||||
parser.add_argument("--out", default=str(REPORT))
|
||||
args = parser.parse_args()
|
||||
prereg = load_prereg()
|
||||
radii = tuple(int(item) for item in str(args.radii).split(",") if item.strip())
|
||||
payload = build(prereg, limit=int(args.limit), radii=radii)
|
||||
text = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(text, encoding="utf-8")
|
||||
print(json.dumps(payload["gates"], ensure_ascii=False, sort_keys=True), flush=True)
|
||||
print(f"wrote {out}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Nadi / five-level dasha research helpers. Public v5 cases only; no production edits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.research import nadi_seconds_lib as lib
|
||||
|
||||
|
||||
class _Guard(dict):
|
||||
"""Raises if a fitter touches the truth label."""
|
||||
|
||||
def __getitem__(self, key):
|
||||
if key == "true_minute":
|
||||
raise AssertionError("read true_minute")
|
||||
return dict.__getitem__(self, key)
|
||||
|
||||
def get(self, key, default=None):
|
||||
if key == "true_minute":
|
||||
raise AssertionError("read true_minute")
|
||||
return dict.get(self, key, default)
|
||||
|
||||
|
||||
def _packet() -> dict:
|
||||
moment = datetime(2010, 1, 1)
|
||||
lords = lib.five_lords("1990-06-15", 120.0, moment)
|
||||
return {
|
||||
"lords": lords,
|
||||
"again": lib.five_lords("1990-06-15", 120.0, moment),
|
||||
"d150": lib.d150_equal(10.25),
|
||||
"classical": lib.classical_nadi_index(40.1),
|
||||
"p": lib.paired_sign_flip_p([0.1, -0.2, 0.3, 0.0], seed=20261005, permutations=200),
|
||||
"ci": lib.bootstrap_mean_ci([1.0, 0.0, 1.0, 0.0], seed=20261008, resamples=300),
|
||||
}
|
||||
|
||||
|
||||
def test_two_runs_are_byte_identical() -> None:
|
||||
assert lib.canonical_bytes(_packet()) == lib.canonical_bytes(_packet())
|
||||
|
||||
|
||||
def test_five_levels_abut_nest_and_flip_on_the_boundary() -> None:
|
||||
birth = "1990-06-15"
|
||||
moon = 0.0
|
||||
event = datetime(1990, 6, 25)
|
||||
chain = lib.chain_at(birth, moon, event)
|
||||
assert len(chain) == 5
|
||||
for period in chain:
|
||||
assert period["start"] <= event < period["end"]
|
||||
for period in chain[:4]:
|
||||
rows = lib.subdivide(period)
|
||||
assert rows[0]["start"] == period["start"]
|
||||
for left, right in zip(rows, rows[1:], strict=False):
|
||||
assert left["end"] == right["start"]
|
||||
rows = lib.subdivide(chain[0])
|
||||
boundary = rows[0]["end"]
|
||||
before = lib.five_lords(birth, moon, boundary - timedelta(seconds=1))
|
||||
after = lib.five_lords(birth, moon, boundary)
|
||||
assert before[1] == rows[0]["lord"]
|
||||
assert after[1] == rows[1]["lord"]
|
||||
assert before[1] != after[1]
|
||||
|
||||
|
||||
def test_d150_part_flips_across_slice_and_sign_boundaries() -> None:
|
||||
assert lib.d150_equal(0.199)["part_index"] == 0
|
||||
assert lib.d150_equal(0.201)["part_index"] == 1
|
||||
assert lib.d150_equal(29.999)["part_index"] == 149
|
||||
assert lib.d150_equal(30.001)["part_index"] == 0
|
||||
assert lib.classical_nadi_index(0.1)["status"] == "variant_unverified"
|
||||
assert lib.classical_nadi_index(0.1)["index"] == 0
|
||||
assert lib.classical_nadi_index(30.1)["order"] == "reverse"
|
||||
assert lib.classical_nadi_index(30.1)["index"] == 149
|
||||
assert lib.classical_nadi_index(60.0)["order"] == "from_middle"
|
||||
assert lib.classical_nadi_index(60.0)["index"] == 75
|
||||
|
||||
|
||||
def test_rule_families_use_domain_house_lords_only() -> None:
|
||||
assert lib.domain_target_lords(0, "career") == frozenset({"Saturn"})
|
||||
lords = ("Sun", "Moon", "Mars", "Mercury", "Jupiter")
|
||||
targets = frozenset({"Mercury"})
|
||||
assert lib.explained(lords, targets, "A4") is True
|
||||
assert lib.explained(lords, targets, "A5") is False
|
||||
assert lib.explained(lords, targets, "B1") is True
|
||||
assert lib.explained(lords, targets, "B2") is False
|
||||
assert lib.explained(lords, targets, "N5A", segment_lord="Saturn") is False
|
||||
assert lib.explained(lords, targets, "N5B", segment_lord="Moon") is True
|
||||
assert lib.fit_count([lords, lords], ["career", "career"], 0, "A5") == 0
|
||||
assert lib.rank_offsets({-5: 1, 0: 3, 5: 3, 10: 2}) == [0, 5]
|
||||
|
||||
|
||||
def test_fitter_does_not_read_true_minute() -> None:
|
||||
for function in (
|
||||
lib.fit_count,
|
||||
lib.rank_offsets,
|
||||
lib.explained,
|
||||
lib.counts_by_offset,
|
||||
lib.public_fields,
|
||||
lib.lords_for_events,
|
||||
):
|
||||
assert "true_minute" not in inspect.getsource(function)
|
||||
raw = lib.load_cases()[0]
|
||||
guarded = _Guard(raw)
|
||||
guarded["birth"] = _Guard(raw["birth"])
|
||||
guarded["events"] = [_Guard(event) for event in raw["events"]]
|
||||
public = lib.public_fields(guarded)
|
||||
assert "true_minute" not in public
|
||||
sky = lib.sky_at(public["birth"], lib.candidate_moment(public["birth"], 0))
|
||||
events = lib.day_events(public)[:1]
|
||||
assert events
|
||||
rows = lib.lords_for_events(sky["birth_date"], sky["moon_lon"], events)
|
||||
count = lib.fit_count(rows, [events[0]["domain"]], sky["asc_index"], "A5")
|
||||
assert count in (0, 1)
|
||||
assert lib.rank_offsets({0: 2, 5: 2, -5: 1}) == [0, 5]
|
||||
|
||||
|
||||
def test_midnight_candidate_uses_the_previous_local_date_and_matches_production() -> None:
|
||||
birth = {
|
||||
"date": "1990-06-15",
|
||||
"time": "00:00",
|
||||
"latitude": 31.2,
|
||||
"longitude": 121.5,
|
||||
"timezone_offset": 8.0,
|
||||
}
|
||||
moment = lib.candidate_moment(birth, -30)
|
||||
assert moment == datetime(1990, 6, 14, 23, 59, 30)
|
||||
sky = lib.sky_at(birth, moment)
|
||||
assert sky["birth_date"] == "1990-06-14"
|
||||
event = datetime(2001, 3, 4)
|
||||
assert lib.five_lords(sky["birth_date"], sky["moon_lon"], event)[:3] == lib.production_three(
|
||||
sky["birth_date"], sky["moon_lon"], event,
|
||||
)
|
||||
|
||||
|
||||
def test_v5_truth_audit_matches_the_task_counts() -> None:
|
||||
audit = lib.truth_audit(lib.load_cases())
|
||||
assert audit["cases"] == 77
|
||||
assert audit["rounded_5min_count"] == 52
|
||||
assert audit["unrounded_count"] == 25
|
||||
assert audit["day_event_total"] == 244
|
||||
assert audit["day_events_ge_3"] == 43
|
||||
assert audit["day_events_ge_5"] == 19
|
||||
assert audit["day_events_zero"] == 4
|
||||
assert sum(audit["minute_histogram"].values()) == 77
|
||||
|
||||
|
||||
def test_first_three_levels_match_production_on_every_v5_event() -> None:
|
||||
mismatches = []
|
||||
for case in lib.load_cases():
|
||||
row = lib.reconcile_case(case)
|
||||
if row["mismatches"]:
|
||||
mismatches.append(row["case_id"])
|
||||
assert mismatches == []
|
||||
|
||||
|
||||
def test_placebo_shift_is_seeded_and_stays_after_birth() -> None:
|
||||
case = lib.public_fields(lib.load_cases()[0])
|
||||
events = lib.day_events(case)
|
||||
first = lib.placebo_shift_dates(
|
||||
events, case["birth"]["date"], seed=f"20261005:{case['case_id']}", low_days=30, high_days=180,
|
||||
)
|
||||
second = lib.placebo_shift_dates(
|
||||
events, case["birth"]["date"], seed=f"20261005:{case['case_id']}", low_days=30, high_days=180,
|
||||
)
|
||||
assert first == second
|
||||
birth = case["birth"]["date"]
|
||||
assert all(birth < row["date"] <= "2026-09-14" for row in first)
|
||||
assert [row["domain"] for row in first] == [row["domain"] for row in events]
|
||||
assert [row["date"] for row in first] != [row["date"] for row in events]
|
||||
|
||||
|
||||
def test_longitude_shift_is_about_ten_kilometres() -> None:
|
||||
moved = lib.shift_longitude_km(121.5, 31.2, 10.0)
|
||||
scale = 111.32 * math.cos(math.radians(31.2))
|
||||
assert moved == pytest.approx(121.5 + 10.0 / scale)
|
||||
with pytest.raises(ValueError):
|
||||
lib.shift_longitude_km(0.0, 90.0, 10.0)
|
||||
Reference in New Issue
Block a user