Compare commits

...
Author SHA1 Message Date
jesse-ux 586f9828db research(rectification): nadi second-level study fails the preregistered gates (BUG-1240)
N1, N2, N3, and N5 do not pass. Switching ayanamsa moves level-5 lords and
equal-division D150 past the 20% wording line. Two full runs are byte-identical.
No production scoring change. closed_by_design.
2026-10-05 02:11:22 +08:00
jesse-ux 57480db324 research(rectification): preregister nadi second-level rules before v5 (BUG-1240)
Lock the N1–N5 rules, grids, placebos, seeds, and gates before the first
scoring run on the public v5 cases. Later changes cannot decide the gate.
2026-10-05 01:00:36 +08:00
jesse-ux 6d51c2de28 research(rectification): put scripts on the path before nadi imports (BUG-1240)
Running the study as a file does not put scripts/ on sys.path, so the
production chart imports failed before any scoring. The library inserts
that directory itself. No rule or threshold changed.
2026-10-05 01:00:36 +08:00
jesse-ux 217cd4a767 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.
2026-10-05 00:58:00 +08:00
10 changed files with 15009 additions and 1 deletions
+16
View File
@@ -16627,3 +16627,19 @@
- 相关记录:BUG-995(前端测试要同时看 cancelled 和退出码)。
- 复发自:无
- 修复版本:待发布
## BUG-1240 | 秒级 / 纳迪校准从未经过检验
- 状态:closed_by_design(2026-10-05 离线研究:预先登记的 N1、N2、N3、N5 都不过门;换岁差时第 5 层和等分 D150 的变化超过 20%,对外不说秒级)
- 首次发现:2026-10-05
- 最近更新:2026-10-05
- 影响面:生时校正对外精度口径。主链 Vimshottari 只评前三层。第 4 层、第 5 层和 D150 不进入计分。
- 用户现象:校正给出时间范围,不能把结果说成某一秒。公开评价集里 52/77 例的记录分钟是 5 的倍数,记录本身没有秒。
- 触发条件:无用户路径。离线研究,任务书 `docs/tasks/TASK-rectification-nadi-seconds-research-20261005.md`。
- 根因:在 77 例公开 AA 上,预先登记的规则没有胜过安慰剂。第 5 层在记录分钟上的拟合率 0.221,日期平移安慰剂 0.216,跨例换日期 0.225。六项主检验只有「第 4 层对日期平移」一项达到 p < 0.05,过门要求六项都达到。留出一件的提升区间含 0。六题后的区间里用第 5 层重排,±10 分钟从 25/77 降到 5/77。换一种岁差,第 5 层约 86%–92% 的日事件换主星,等分 D150 为 73/73 例换段。±15 秒已经让约六成日事件换掉第 5 层。
- 修复:不改生产计分,不立实现单。研究记录见 `docs/research/rectification_nadi_seconds_2026_10_05.md`。
- 验证:`tests/test_nadi_seconds_research.py` 10 项通过(前三层对账 0 差,77 例 964 件事件)。结果 JSON 由 `scripts/research/nadi_seconds_run.py` 写出。预登记提交 `57480db324f1602cfc422ff8c774d0d6e2113af6` 在第一次 v5 计分之前。两次复跑的字节核对写在进度记录里。
- 防复发:再提秒级或纳迪校准时,先复跑这份预登记;不得用看过结果之后新加的规则判过门;不得重调 BUG-1091 已关闭的权重。
- 相关记录:BUG-560、BUG-1090、BUG-1091、BUG-1105、BUG-1141。
- 复发自:无
- 修复版本:研究分支 `codex/rectification-nadi-seconds-research-20261005`(未推送、未部署)
+1
View File
@@ -131,3 +131,4 @@ Do not open new product surfaces before at least one of these four lanes is clos
- 2026-09-29 起评价集换 **v5**(77 例公开 AA,`references/real_case_calibration/minute_rectification_holdout_v5.json`):协议 `holdout_v5_protocol_2026_09_29.md`、构建与基线成绩单 `holdout_v5_build_2026_09_29.md`。后续打分判定以 v5 为准,v4 数字只作历史对照。
- 2026-09-30 盘型口径研究(BUG-1105):`<repo>/docs/research/rectification_varga_resolution_2026_09_30.md`——目标从分钟改为分盘上升段。v5 六题后 ±10 D9 / D10 头段 = 真值 88% / 86%,占比 ≥ 0.6 留一法 92% / 90%;±30 约 1/4–1/3 用户可给可信分盘;±60 分盘 blocked。按段选题 ≤ ±30 有 +3~+7 例;提前停不过红线;「不用校正」只对只需 D1 的问题成立。**建议立实现单**(§7)。
- 2026-10-01 角度类时间技法研究(BUG-1141):`<repo>/docs/research/rectification_angle_timing_2026_10_01.md`——行运压上升 / 天顶、次限推运角、太阳弧(1° ≈ 4 分钟),77 例 v5 真实日期对乱序日期 9 项预登记检验 0 项显著(最高胜过 58%,门槛 99.44%);年龄结构保持对照同样无信号;`closed_by_design`。
- 2026-10-05 纳迪秒级证伪(BUG-1240):`<repo>/docs/research/rectification_nadi_seconds_2026_10_05.md`——第 4 / 第 5 层小运和等分 D150 对安慰剂日期,预先登记的 N1、N2、N3、N5 都不过门;换岁差时第 5 层和 D150 的变化超过 20%。`closed_by_design`。不立实现单,对外不说秒级。
@@ -0,0 +1,116 @@
{
"id": "nadi_seconds_preregistration_2026_10_05",
"dataset": "references/real_case_calibration/minute_rectification_holdout_v5.json",
"event_cutoff": "2026-09-14",
"ayanamsa": "raman",
"node_mode": "mean",
"locked_before_v5_scoring": true,
"grids": {
"coarse_radius_seconds": 600,
"coarse_step_seconds": 5,
"fine_radius_seconds": 120,
"fine_step_seconds": 1
},
"rules": {
"computed": ["A4", "A5", "B1", "B2", "B3", "B4", "B5", "N5A", "N5B"],
"n1_primary": ["A4", "A5", "B2"],
"n2_primary": "A5",
"n3_primary": "A5",
"n5_primary": ["N5A", "N5B"],
"definitions": {
"levels": "五层 Vimshottari 用生产递归:dasha_analyzer.build_dasha_timeline 之后再四次 build_antardasha。第 1 层是大运,第 4 层是 Sookshma,第 5 层是 Prana。前三层必须与 _active_vimshottari 对账。calculate_five_level_dasha 不是计分链。",
"targets": "领域目标宫主只读 DOMAIN_CONFIG 与 _house_lords,不改映射,也不乘辅助分。命中是二值。",
"A4": "第 4 层主星属于该事件领域的目标宫主。",
"A5": "第 5 层主星属于该事件领域的目标宫主。",
"B1": "五层里至少 1 层主星属于目标宫主。探索项,不是过门规则。",
"B2": "五层里至少 2 层主星属于目标宫主。",
"B3": "五层里至少 3 层。探索项。",
"B4": "五层里至少 4 层。探索项。",
"B5": "五层全部属于目标宫主。探索项。",
"N5A": "等分 D150(calc_custom_varga,N=150)上升星座的宫主星属于目标宫主。",
"N5B": "同一颗 D150 宫主星出现在当天五层小运主星里。",
"classical_index": "动宫顺排、固宫逆排、变宫从中段起排,状态 variant_unverified。不进入任何过门,也不进入 N4 的文案触发。不抄录纳迪描述原文。",
"named_text": "blocked。仓库内没有可合法使用的 Chandra Kala Nadi 描述文本。"
}
},
"samples": {
"n1_min_day_events": 3,
"n2_min_day_events": 4,
"n1_expected_n": 43,
"note": "N1 与 N5 只用日精度事件不少于 3 件、且两种安慰剂都非空的例子。开工时数过,43 例两种安慰剂都齐全。比较行的 n 不是 43,就不是本登记的样本,不能判过门。取整组与非取整组分开列,判定用合并样本。"
},
"tests": {
"permutations": 9999,
"bootstrap_resamples": 5000,
"n1_permutation_seed_base": 20261007,
"n2_bootstrap_seed": 20261008,
"n2_random_second_seed": 20261009,
"n3_bootstrap_seed": 20261010,
"n5_permutation_seed_base": 20261011,
"seed_order": "N1 与 N5 都按主规则列表的顺序,每条规则先 P1 再 P2,种子是基数加 0、1、2…。因此 N1 为 A4-P1=20261007、A4-P2=20261008、A5-P1=20261009、A5-P2=20261010、B2-P1=20261011、B2-P2=20261012。N5 为 N5A-P1=20261011、N5A-P2=20261012、N5B-P1=20261013、N5B-P2=20261014。N2 随机对照的种子是字符串 20261009:{case_id}:{event_id}。N2 安慰剂日期的 bootstrap 种子是 20261008+1。N3b 的 bootstrap 种子是 20261010 加半径分钟数,所以 ±10 为 20261020,±30 为 20261040,±60 为 20261070。"
},
"placebo": {
"shift_low_days": 30,
"shift_high_days": 180,
"shift_seed_template": "20261005:{case_id}",
"swap_seed": 20261006,
"p1": "每件日精度事件的日期用 Random(20261005:{case_id}) 平移。幅度 randint(30, 180),符号 ±1。落在 (出生日, 2026-09-14] 之外就反向;仍在外面就按 365 天步长夹回,最后才用出生日之后 400 天并截到截止日期。领域留在原事件上。",
"p2": "把全部日精度事件按 (case_id, event_id) 排序后,用 Random(20261006) 打乱日期。领域留在接收方。日期夹回接收方的 (出生日, 截止日期]。置换以整例为单位。"
},
"statistic": {
"truth_minute_offsets": "0 到 59 秒,含两端。记录没有秒。真值分钟的拟合率是这 60 秒的平均。",
"fit_rate": "某一秒解释的日精度事件数,除以该例日精度事件数。",
"difference": "同一例上,真值分钟平均拟合率减去安慰剂日期在同样 60 秒上的平均拟合率。",
"test": "单侧配对符号翻转。极端值是置换均值大于或等于观察均值的次数。p = (极端值 + 1) / (置换次数 + 1)。过门看精确分数小于 0.05,并且均值大于 0。不看四舍五入到 6 位后的显示值。",
"window_mean": "粗网格上的平均拟合率,等于在粗网格里均匀抽一秒的期望。写入报告。它不是 N1 的过门对照。",
"fraction_all": "粗网格上能解释全部日精度事件的秒所占比例。A4、A5、B1、B2 都报。接近 100% 表示问前事对得上并不能分辨真假。这是诊断,不是过门。"
},
"n3_protocol": {
"radius_gate": 10,
"nadi_limit_seconds": 60,
"hit": "并列第一的偏移必须每一个都在限制内。空的并列是未命中。没有日精度事件时,全部秒并列,记为未命中。",
"n3a": "只按 A5 在粗网格上排序。报告 ≤60 秒与 ≤120 秒,并对照均匀随机(步长 5 秒时 |偏移|≤60 的格点数除以 241)和已发表的引擎先验头名 0.18。N3a 不参与过门。本运行不另做 N3a 的 ±30 / ±60 网格。",
"n3b": "复现六题回放的前半段:六次探测、不注入引导窗口,再取交付区间。区间内按 1 秒重排 A5。命中仍要求全部并列都在记录时间 60 秒内。对照是生产头名全部落在 1 分钟内。提升是纳迪命中减生产命中,整例 bootstrap,下界大于 0 才算提升。另报 any_exact,用来对照已发表的 0.64;那一列不是过门。真值在区间内须不少于 76/77。过门只看半径 10,且例数必须是 77。±30 与 ±60 只报告。",
"published_references": {
"engine_prior_top1_pm10": 0.18,
"six_question_any_exact_pm10": 0.64,
"cluster_head_pm10": 0.68,
"truth_in_range": "76/77",
"source": "docs/research/holdout_v5_build_2026_09_29.md"
}
},
"n4": {
"ayanamsas": ["raman", "lahiri", "kp", "true_citra"],
"time_offsets_seconds": [-60, -30, -15, 15, 30, 60],
"longitude_shifts_km": [10, -10],
"node_mode_compare": "mean_to_true",
"sample": "至少有一件日精度事件的例子。第 4 / 第 5 层按事件加权。D150 用等分段号,按例子计。没有日精度事件的 4 例不进入这张表。",
"wording_trigger": "Raman 分别换成 Lahiri、KP、True Chitra。三次里只要有一次第 5 层事件变化率大于 0.20,或等分 D150 的例子变化率大于 0.20,对外文案就不得出现秒级。时间和地点扰动写入表,本身不触发这条例。交点 mean 与 true 单列,预期只动罗睺计都。古典段号不触发。"
},
"n2": {
"rule": "A5",
"grid": "coarse",
"design": "日精度事件不少于 4 的例子,留一件。其余事件在粗网格上的最高分,并列全部保留。被留出的事件在这些秒上被解释的比例,减去同数量随机秒上的比例。随机种子是 20261009:{case_id}:{event_id}。并列不少于整张网格时,随机对照就是整张网格。例子内先平均,再对例子做 5000 次 bootstrap。过门只看相对随机秒的区间下界大于 0。同一批领先秒上、把留出位置换成 P1 日期的差,只报告,不是额外门槛。"
},
"gates": {
"n1": "A4、A5、B2 对 P1 和 P2 共 6 项全部通过,且每项 n 为 43,且不是 --limit 子集。",
"n2": "相对随机秒的 bootstrap 下界大于 0,且不是子集。",
"n3": "半径 10 上,纳迪相对生产 ≤1 分钟命中的提升,bootstrap 下界大于 0,并且真值在区间内不少于 76/77,例数 77。",
"n4": "不过方法门。只决定对外是否还能说秒级。",
"n5": "N5A、N5B 对 P1 和 P2 共 4 项全部通过,且不是子集。低优先。原文比对保持 blocked。"
},
"not_a_gate": [
"N3a",
"N3 的 ±30 与 ±60",
"取整组与非取整组的拆分",
"古典 D150 段号",
"calculate_five_level_dasha 与生产链的差异",
"B1、B3、B4、B5",
"窗口平均拟合率",
"能解释全部事件的秒所占比例",
"N2 的安慰剂日期对照",
"N3b 的 any_exact 复现列",
"N4 的时间扰动与地点扰动"
],
"after_the_fact": "看过 v5 结果之后改过的规则、阈值或种子,只能写进研究文档的事后列,不能用来判过门。"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
# 生时校正「纳迪秒级」证伪(2026-10-05,BUG-1240)
> 任务书 `docs/tasks/TASK-rectification-nadi-seconds-research-20261005.md`。离线研究,生产代码没有改。
> 数据:`references/real_case_calibration/minute_rectification_holdout_v5.json`(77 例公开 Rodden AA,244 件日精度事件)。默认岁差 Raman、交点 mean。
> 规则在跑数之前单独提交:`57480db324f1602cfc422ff8c774d0d6e2113af6`。文件 `docs/research/nadi_seconds_preregistration_2026_10_05.json`。
> 结果:`docs/research/nadi_seconds_results_2026_10_05.json`。Swiss Ephemeris 2.10.03。`PYTHONHASHSEED=0`。看过结果之后没有改规则、阈值或种子。
## 1. 结论
| 项 | 过门 | 关键数字 | 对照 |
| --- | --- | --- | --- |
| N1 拟合率 | 否 | 6 项里 1 项达到门槛:第 4 层对「日期平移」高 0.090,p = 0.0116。另外 5 项没有 | 日期平移,以及把日期换到别的例子上 |
| N2 留出一件 | 否 | 33 例。留出事件比随机秒高 0.041,95% 区间 −0.021 到 0.101 | 随机秒。安慰剂日期的区间也跨过 0 |
| N3 找回记录分钟 | 否 | ±10:纳迪 5/77,同一次回放的生产头名 25/77,提升 −0.260,区间整个在 0 下面 | 真值仍在交付区间内 76/77 |
| N4 噪声地板 | 触发文案禁令 | 只换岁差,第 5 层有 86%–92% 的日事件换主星,等分 D150 有 73/73 例换段 | 时间和地点写入下表,不单独当方法门 |
| N5 D150 结构 | 否 | 4 项都不达到门槛。按描述文字比对经历:blocked,仓库里没有可合法使用的原文 | 同 N1 的两种安慰剂 |
不立实现单。第 4 层、第 5 层和 D150 不接进校正计分。BUG-1240 记为 `closed_by_design`。
对外不能说秒级。N1–N3 没有过门,而且 N4 已经越过预先登记的 20% 线:换一种岁差算法,秒级记号就大面积变掉。
## 2. N0 底座
五层小运走生产递归(大运时间轴后再切四次),前三层与主链逐事件对账。77 例、964 件事件,不一致 0。等分 D150 用 `calc_custom_varga`(N=150)。古典「动宫顺排、固宫逆排、变宫从中段起排」标成 `variant_unverified`,不参与过门。
记录分钟:52 例是 5 的倍数,25 例不是。日精度事件 244 件;不少于 3 件的 43 例,不少于 4 件的 33 例,不少于 5 件的 19 例,0 件的 4 例。记录里没有秒。真值分钟的拟合率是该分钟 0–59 秒的平均。非 5 倍数那一组仍然只知道分钟,不知道秒。
## 3. N1
样本是预先登记的 43 例,两种安慰剂都在。拟合率是「这一秒解释了多少件日精度事件」除以事件数。差是真值分钟的平均减去安慰剂日期在同样 60 秒上的平均。整例符号翻转 9999 次。过门要均值大于 0,并且精确 p < 0.05。六项都要达到。
| 规则 | 对照 | 真值分钟 | 安慰剂 | 差 | 窗口平均 | p | 过门 |
| --- | --- | --- | ---: | ---: | ---: | ---: | --- |
| 第 4 层属于目标宫主 | 日期平移 | 0.259 | 0.169 | +0.090 | 0.214 | 0.0116 | 达到 |
| 第 4 层属于目标宫主 | 跨例换日期 | 0.259 | 0.221 | +0.038 | 0.214 | 0.1476 | 否 |
| 第 5 层属于目标宫主 | 日期平移 | 0.221 | 0.216 | +0.005 | 0.229 | 0.3794 | 否 |
| 第 5 层属于目标宫主 | 跨例换日期 | 0.221 | 0.225 | −0.003 | 0.229 | 0.5526 | 否 |
| 五层里至少两层命中 | 日期平移 | 0.329 | 0.339 | −0.009 | 0.325 | 0.6338 | 否 |
| 五层里至少两层命中 | 跨例换日期 | 0.329 | 0.318 | +0.012 | 0.325 | 0.3485 | 否 |
第 5 层在真值分钟上的拟合率(0.221)低于 ±10 分钟粗网格的平均(0.229)。记录中的那一分钟,并不比窗口里随便一秒更常被第 5 层解释。
粗网格上能解释全部日精度事件的秒所占比例:第 4 层 0.0017,第 5 层 0.0047,至少一层 0.145,至少两层 0.011。严格规则并不是「几乎每一秒都能解释全部前事」。失败的原因是真值分钟并不比安慰剂更高。
43 例里,分钟为 5 的倍数的有 31 例,其余 12 例。这是描述,没有单独做检验,也不能拿来过门。第 5 层真值分钟拟合率:5 的倍数 0.240,其余 0.171。其余那 12 例的真值分钟(0.171)低于它自己的窗口平均(0.199)。
探索项 B3 / B4 / B5 不参与过门,数字在结果 JSON 的每例记录里。
## 4. N2
33 例、每例至少 4 件日精度事件。留一件,用其余事件在粗网格上取最高分,并列全留。再看被留出的那一件在这些秒上被第 5 层解释的比例,减去同样多的随机秒。
相对随机秒:平均高 0.041,95% 区间 −0.021 到 0.101。相对安慰剂日期:平均高 0.026,区间 −0.037 到 0.087。过门只看随机秒,区间下界要大于 0。没有达到。
5 的倍数 24 例,差的平均 0.047;其余 9 例,差的平均 0.024。两组都没有单独过门。
## 5. N3
命中的定义预先写死:并列第一的每一个偏移都要落在限制里。空并列算未命中。没有日精度事件的 4 例算未命中。
### 单独用第 5 层排序(不参与过门)
±10 分钟、每 5 秒一格。全部并列都落在记录时间 60 秒内:0/77。落在 120 秒内:2/77(0.026),其中 5 的倍数那组 0.038,另一组 0。同一网格上均匀抽一格,60 秒内的期望是 0.104,120 秒内是 0.203。已发表的引擎先验头名是 0.18。并列数的中位是 8;有 23 例超过 25 个并列,这已经塞不进 60 秒那一段。
### 六题之后的交付区间里再排序(过门)
沿用六题回放的前半段:六道选择题,不注入后面的引导窗口。区间内按 1 秒用第 5 层重排。回放 77 例没有报错。
| 半径 | 真值在区间内 | 生产:全部并列都在 1 分钟内 | 纳迪:全部并列都在 60 秒内 | 提升 | 95% 区间 | 宽度中位 |
| --- | --- | ---: | ---: | ---: | --- | ---: |
| ±10(过门) | 76/77 | 25/77(0.325) | 5/77(0.065) | −0.260 | −0.377 到 −0.143 | 13 分钟 |
| ±30(只报告) | 76/77 | 24/77(0.312) | 0/77 | −0.312 | −0.416 到 −0.208 | 33 分钟 |
| ±60(只报告) | 76/77 | 13/77(0.169) | 0/77 | −0.169 | −0.260 到 −0.091 | 69 分钟 |
±10 上,5 的倍数那组:生产 0.288,纳迪 0.058。另一组:生产 0.400,纳迪 0.080。两组都是纳迪更低。
已发表的 ±10 成绩单是:引擎先验头名 0.18,六题回放头名 0.64,簇头名 0.68,真值在区间内 76/77,宽度中位 15 分钟(`docs/research/holdout_v5_build_2026_09_29.md`)。本轮真值覆盖同为 76/77。头名没有复现成 0.64。本轮的生产对照要求每一个并列分钟都在 1 分钟内;线上网格每 2 分钟一格,这等于「唯一头名就是记录的那一分钟」,得到 25/77。已发表的 0.64 是簇代表分钟的头名。宽度中位是 13 分钟,已发表是 15。按预登记,不把 25/77 改写成 0.64。过门比较的是同一次回放里的纳迪和生产,纳迪更低。
## 6. N4 噪声地板
73 例至少有一件日精度事件。第 4、第 5 层按事件加权。等分 D150 按例子计。没有日事件的 4 例不在这张表里。
| 扰动 | 第 4 层事件变化 | 第 5 层事件变化 | 等分 D150 换段的例子 |
| --- | ---: | ---: | ---: |
| Raman → Lahiri | 0.873 | 0.857 | 73/73 |
| Raman → KP | 0.848 | 0.922 | 73/73 |
| Raman → True Chitra | 0.926 | 0.885 | 73/73 |
| 出生时间 −15 秒 / +15 秒 | 0.107 / 0.115 | 0.607 / 0.598 | 12/73 / 29/73 |
| 出生时间 −30 秒 / +30 秒 | 0.213 / 0.213 | 0.820 / 0.828 | 43/73 / 48/73 |
| 出生时间 −60 秒 / +60 秒 | 0.418 / 0.393 | 0.918 / 0.902 | 70/73 / 72/73 |
| 出生地向东 / 向西 10 公里 | 0 / 0 | 0 / 0 | 46/73 / 43/73 |
交点从 mean 改成 true:73 例的月亮和上升都没变,罗睺都变了,第 5 层事件变化率是 0。小运只看月亮,这与预期一致。
文案禁令看三次换岁差。三次的第 5 层事件变化率都大于 0.20,等分 D150 的例子变化率都是 1。禁令成立。±15 秒已经让大约六成日事件换掉第 5 层主星。东西方向移 10 公里不动第 5 层,但大约六成例子的等分 D150 会换段。
## 7. N5
等分 D150 的上升星座宫主。样本仍是 43 例,对照仍是两种安慰剂。
| 规则 | 对照 | 真值分钟 | 安慰剂 | 差 | p | 过门 |
| --- | --- | ---: | ---: | ---: | ---: | --- |
| 宫主属于目标宫主 | 日期平移 | 0.272 | 0.272 | 0 | 1 | 否 |
| 宫主属于目标宫主 | 跨例换日期 | 0.272 | 0.272 | 0 | 1 | 否 |
| 宫主出现在五层小运里 | 日期平移 | 0.476 | 0.451 | +0.025 | 0.2224 | 否 |
| 宫主出现在五层小运里 | 跨例换日期 | 0.476 | 0.490 | −0.014 | 0.6411 | 否 |
第一条规则只用出生那一秒的盘,不用事件日期,所以两种改日期的安慰剂和真值相同。这是这条规则的结果,不是跑数之后改出来的。按描述文字比对经历保持 blocked。
## 8. 对外口径草稿
校正给你一个时间范围。你答完现在这几道题,范围里往往还剩大约一刻钟;你记得的那一分钟大多还在这个范围里,这些分钟分不出先后。我们拿时间记录到分钟的公开例子核对过:用更细的小运去挑秒,并不比把事情的日期挪开更准,也没有把范围收到你记录中的那一分钟。出生时间差半分钟,或者换一种岁差算法,这种细记号就会变。所以校正只说到现在的方法能分开的范围,不把结果说成秒级。
草稿对照 `frontend/docs/VOICE.md`:直接对「你」说,不点名其他产品,不写秒级准确率。没有改 VOICE 文件本身。
## 9. 复现
```bash
export PYTHONHASHSEED=0
python scripts/research/nadi_seconds_run.py
```
预登记、库、跑数脚本、数据集的 sha256 写在结果 JSON 开头和结尾。本轮分别是 `dc012dd2b287b28ac2eefb75d5b9050e7788cb09d0064639878a37753ee8c2d3`、`ba170f3a0476fc41b3be8ac9f035d2f82575006753eb14992ccf124132be0f94`、`3c1af919408c637eead6e43252f6696beec73cb66480d8138a9075d3a8fb8ed6`、`ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a`。同一命令再跑一次,两份结果的 SHA-256 都是 `4f1569c3e8794e0d2c35d244d32b3896e47cf8a1f7d03d16ce01d3f07b51ec69`,逐字节一致。
@@ -0,0 +1,47 @@
# PROGRESS · 生时校正「纳迪秒级」证伪(2026-10-05,BUG-1240)
任务书:`TASK-rectification-nadi-seconds-research-20261005.md`。分支 `codex/rectification-nadi-seconds-research-20261005`,基线 `origin/staging` @ `4c64733f`。只 commit,不 push。未部署。
## 一句话
第 4 层、第 5 层和等分 D150 没有胜过预先登记的安慰剂,也没有在六题后的区间里把记录分钟找得更准。换岁差时细记号大面积变化。BUG-1240 `closed_by_design`。不立实现单。
## 提交
| 提交 | 内容 |
| --- | --- |
| `217cd4a7` | N0 库、跑数脚本、对账测试、BUG-1240 先记为 investigating、状态板改为执行中 |
| `6d51c2de` | 库自己把 `scripts/` 放进导入路径。直接跑脚本时否则找不到星历模块。没有改统计量 |
| `57480db3` | 预登记 JSON,单独提交,在第一次 v5 计分之前 |
| 本提交 | 结果 JSON、研究报告、ACTIVE_FRONTS、BUG 终态、状态板改为待验收、本进度 |
预登记全文 SHA-256:`dc012dd2b287b28ac2eefb75d5b9050e7788cb09d0064639878a37753ee8c2d3`。
## 硬红线
| 要求 | 结果 |
| --- | --- |
| 规则先登记再跑 | 预登记 `57480db3` 早于结果。看过数字之后没有改规则、阈值或种子。事后列为空 |
| 排序程序不读 `true_minute` | 测试扫描拟合函数源码,并用会拒绝这个键的字典做了运行检查 |
| 每个「对上了」都有安慰剂 | N1 六项、N2、N5 四项都写了安慰剂。N3 的对照是同一次六题回放的生产头名 |
| 负结果照写 | 六项里只有第 4 层对日期平移达到 p < 0.05。没有改成只保留这一项 |
| 取整组和非取整组分开 | 写在研究报告 §3–§5。过门用合并样本 |
| 生产代码零改动 | 改动在 `scripts/research/`、`tests/test_nadi_seconds_research.py` 和 `docs/` |
| 不重调 BUG-1091 的权重 | 没有改 `DOMAIN_CONFIG`、计分权重或宫位映射 |
| 不抄纳迪原文 | N5 的文字比对写 blocked |
| 两次复跑逐字节一致 | 见下方「复跑」 |
| 隐私 | 文档和 BUG 不写出生时间。案例编号只留在结果 JSON,与既有公开评价集相同 |
## 复跑
两次全量都用 `PYTHONHASHSEED=0`,半径 ±10 / ±30 / ±60。第一次写出 `docs/research/nadi_seconds_results_2026_10_05.json`(356,824 字节)。第二次写到临时文件。SHA-256 都是 `4f1569c3e8794e0d2c35d244d32b3896e47cf8a1f7d03d16ce01d3f07b51ec69`,逐字节一致。
## 测试
`tests/test_nadi_seconds_research.py` 与开工时那五个文件一起再跑,退出码 0。新测试 10 项通过,前三层对账 0 差,覆盖 77 例 964 件事件。新测试没有加进快速门的固定名单。
Python 快速门用 `--profile quick --skip-frontend-runtime`。这个工作树没有 `frontend/node_modules`,本单也不改前端,所以没有跑 npm,也没有安装依赖。编译、JSON、能力清单和片段审计都通过。pytest 是 1056 passed、1 skipped、1 failed,约 604 秒。失败的是 `tests/test_consultation_native_layers.py::test_the_new_layer_leaves_every_existing_output_unchanged`。同一条单独再跑通过。它不在本单改过的文件里,快速门的固定名单也不包含新的纳迪测试。不把它算成本研究的回归。
## 让步
N0、N1、N2、N3、N4、N5 都做了。N3 的 ±30 / ±60 只报告。N6 没有另做图。
+1 -1
View File
@@ -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 `closed_by_design`;未推送、未部署) |
+574
View File
@@ -0,0 +1,574 @@
#!/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
import sys
from collections.abc import Mapping, Sequence
from datetime import date, datetime, timedelta
from pathlib import Path
from random import Random
from typing import Any
_SCRIPTS = str(Path(__file__).resolve().parents[1])
if _SCRIPTS not in sys.path:
sys.path.insert(0, _SCRIPTS)
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"]
],
}
+676
View File
@@ -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())
+179
View File
@@ -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)