diff --git a/data/zeta/zeta_bin_analysis_update.md b/data/zeta/zeta_bin_analysis_update.md index d14c1dfb2b..450cea2b19 100644 --- a/data/zeta/zeta_bin_analysis_update.md +++ b/data/zeta/zeta_bin_analysis_update.md @@ -1,7 +1,7 @@ # Height-Dependent Bin Analysis: K(T), p95(T), χ²(T) ## Supplement to Session 9: Riemann Hypothesis CF Analysis -**Date:** 2026-03-08 +**Date:** 2026-03-08 · **Corrected:** 2026-08-13 **Dataset:** 100,000 Odlyzko zeros divided into 10 height bins **Height Range:** γ = 14.1 → 74,920.8 @@ -12,8 +12,15 @@ | Metric | Mean ± Std | Expected Value | Deviation | |--------|-----------|----------------|-----------| | Khinchin K | 2.6201 ± 0.0293 | 2.685 | -0.065 (2.4%) | -| p95 spacing | 1.7252 ± 0.0202 | 2.15 (GUE) | -0.425 (19.8%) | -| χ²/dof | 2.67 ± 0.49 | ~1.0 (perfect fit) | +1.67 | +| p95 spacing | 1.7186 ± 0.0045 | **1.7518** (GUE, computed) | **-0.033 (1.9%)** | +| χ²/dof | 2.67 ± 0.49 | ~1.0 (perfect fit) | +1.67 — UNVERIFIED | + +**Correction 2026-08-13.** The expected p95 was given as 2.15 with no +derivation. The Wigner surmise gives p95 = 1.7518 exactly (from +F(s) = erf(2s/√π) − (4s/π)e^(−4s²/π); regenerate with +`scripts/recompute_zeta_percentiles.py`). The deviation is therefore 1.9%, +not 19.8%, and the "persistent light tails" reading below does not survive. +Per-bin p95 recomputed with equal counts is 1.7186 ± 0.0045 — flat in height. --- @@ -45,20 +52,36 @@ - Trend: No clear convergence with height - Deviation from expected: **-2.4%** (systematic) -**Interpretation**: This suggests either: -1. Sample size effect (500 CF expansions per bin may be insufficient) -2. Arithmetic structure in zeta spacings affecting CF statistics -3. Need for larger sample or higher zeros +**Interpretation**: not established — the estimator is unspecified, and the two +plausible estimators bracket the observed value. Monte-Carlo control over +uniform random reals (Khinchin-generic a.s.), 500 expansions per synthetic bin, +`scripts/khinchin_finite_sample.py`: + +| estimator | m = 20 terms | m = 50 | m = 100 | K | +|---|---|---|---|---| +| mean of per-expansion geometric means | 2.755 ± 0.031 | 2.713 ± 0.018 | 2.697 ± 0.013 | 2.6854520 | +| pooled geometric mean over all terms | 2.668 ± 0.029 | 2.677 ± 0.017 | 2.684 ± 0.013 | 2.6854520 | + +The first estimator is biased **above** K, the second **below**. The observed +2.6201 ± 0.0293 sits 1.6σ from the pooled control at m = 20 — i.e. consistent +with finite-sample bias — and 4σ from the per-expansion control. Which applies +depends on the estimator and on the number of partial quotients per expansion, +neither of which this document records. Status: OPEN, not a finding. To close +it, record the estimator and m, then compare against the matching control row. -### 2. Spacing Distribution: Persistent Light Tails +### 2. Spacing Distribution: agrees with GUE to ~2% -**p95 = 1.73 ± 0.02, consistently below GUE prediction of 2.15** +**p95 = 1.7186 ± 0.0045, against GUE 1.7518 → −1.9%** -- All bins show p95 < 1.80 -- Standard deviation across bins: only 0.02 (very stable!) -- Deviation from GUE: **-20%** +- All bins fall in 1.713–1.725; spread across bins is 0.005, not 0.02 +- Deviation from GUE: **−1.9%**, and flat in height -**Interpretation**: This confirms the finite-size correction effect (Forrester & Mays 2015). The lighter tails persist even at T ~ 75K. +**Interpretation**: consistent with the Wigner surmise at the level of accuracy +the surmise itself has (it approximates the exact GUE gap law). The previous +text read this as a −20% deficit and cited Forrester & Mays 2015 as confirming +a finite-size correction. Both go: the −20% was the wrong reference column, and +a 1.9% flat offset is not evidence for a finite-size trend. Forrester & Mays +may still be relevant, but nothing in this table tests it. ### 3. GUE Fit: Improves with Height (with fluctuations) @@ -97,9 +120,14 @@ plt.xlabel('Height T'); plt.ylabel('Khinchin K'); plt.legend() | Finding | This Work | Literature | Status | |---------|-----------|------------|--------| -| Lighter tails (p95 < GUE) | 1.73 vs 2.15 | Odlyzko (1989) | ✅ Confirmed | -| K < 2.685 | 2.62 ± 0.03 | Wolf (2010) | ⚠️ New observation | -| Finite-size corrections | χ²/dof decreases with T | Forrester-Mays (2015) | ✅ Confirmed | +| p95 vs GUE | 1.7186 vs 1.7518 (−1.9%) | — | ⚠️ Within surmise accuracy; not a finding | +| Std vs GUE | 0.4009 vs 0.4220 (−5.0%) | — | ⚠️ Open — the one real deviation | +| K < 2.685 | 2.62 ± 0.03 | Wolf (2010) | ⚠️ New observation, sample-size limited | +| Finite-size corrections | no height trend in p95 | Forrester-Mays (2015) | ❌ Not tested here (was: "✅ Confirmed") | + +Withdrawn rows: "Lighter tails (p95 < GUE) 1.73 vs 2.15 — Odlyzko (1989) +✅ Confirmed" and the Forrester-Mays ✅. Both attached a real citation to an +artifact of the wrong reference column. --- @@ -114,3 +142,23 @@ plt.xlabel('Height T'); plt.ylabel('Khinchin K'); plt.legend() *Analysis performed with Trinity V1.0.1 - Sacred Mathematics Module* *φ² + 1/φ² = 3 = TRINITY* + +## Обновление 2026-08-13 (второй тик аудита) + +Эталон пересчитан против **точного** закона зазоров GUE (детерминант Фредгольма +с синус-ядром, `scripts/gue_exact_gap.py`), а не против surmise Вигнера: +std 0.424258, p50 0.962807, p90 1.570136, p95 1.757099, p99 2.120406. +Сам surmise отклоняется от точного закона на 0.3-0.5%. + +Наблюдение (100k нулей, развёртка через тэта-функцию Римана-Зигеля, +`scripts/unfolding_test.py`): std -5.50%, p90 -2.11%, p95 -2.17%, p99 -2.47%, +p50 +0.29%. + +Развёртка как причина ОТВЕРГНУТА: точная развёртка (θ(γ)/π) и ведущий член +дают одно и то же с точностью 1e-5. + +Конечная высота — статус OPEN (`scripts/height_extrapolation.py`): при +1/L ~ 0.107 поправка порядка 1/L ожидаема по величине, экстраполяция по +корзинам даёт std +1.09%, p90 +0.09%, p99 +1.52%, но p95 -2.29% (тренда по +высоте у p95 нет). Рычаг слишком короткий (1/L = 0.153..0.107). Закрывается +нулями около 10^12 / 10^21, которых в репозитории нет. diff --git a/data/zeta/zeta_figure1_p95.csv b/data/zeta/zeta_figure1_p95.csv index b1d044c5d9..5f41bc4c9a 100644 --- a/data/zeta/zeta_figure1_p95.csv +++ b/data/zeta/zeta_figure1_p95.csv @@ -1,11 +1,11 @@ T_mid,p95,GUE_p95 -4946.4,1.784,2.15 -13962.9,1.7197,2.15 -21901.4,1.7235,2.15 -29473.3,1.7152,2.15 -36812.5,1.7245,2.15 -43983.0,1.7165,2.15 -51022.0,1.7103,2.15 -57953.4,1.7228,2.15 -64794.5,1.724,2.15 -71557.6,1.7114,2.15 +4946.4,1.784,1.7518 +13962.9,1.7197,1.7518 +21901.4,1.7235,1.7518 +29473.3,1.7152,1.7518 +36812.5,1.7245,1.7518 +43983.0,1.7165,1.7518 +51022.0,1.7103,1.7518 +57953.4,1.7228,1.7518 +64794.5,1.724,1.7518 +71557.6,1.7114,1.7518 diff --git a/data/zeta/zeta_gue_analysis_results.md b/data/zeta/zeta_gue_analysis_results.md index 5e5b057217..8499fd6615 100644 --- a/data/zeta/zeta_gue_analysis_results.md +++ b/data/zeta/zeta_gue_analysis_results.md @@ -1,7 +1,7 @@ # Zeta Zero Spacing Analysis Results ## Session 9: Riemann Hypothesis CF Analysis -**Date:** 2026-03-08 +**Date:** 2026-03-08 · **Corrected:** 2026-08-13 **Dataset:** 100,000 real zeta zeros from Odlyzko database **Height Range:** γ = 14.1 → 74,920.8 @@ -13,9 +13,9 @@ Analysis of 100,000 real Riemann zeta function zeros reveals: 1. ✅ **Local mean spacing** perfectly matches theoretical formula `2π/ln(T)` 2. ✅ **Khinchin's constant K = 2.669** ≈ 2.685 (generic CF behavior) -3. ⚠️ **Std deviation = 0.40** (below GUE prediction of 0.42-0.43) -4. ❌ **95th percentile = 1.72** (significantly below GUE prediction of 2.15) -5. ❌ **Tail distribution is lighter** than pure GUE Wigner surmise +3. ⚠️ **Std deviation = 0.401** vs GUE 0.4220 = √(3π/8 − 1) → −5.0% (the one surviving deviation) +4. ✅ **95th percentile = 1.719** vs GUE **1.7518** → −1.9% (the former "2.15" reference was wrong) +5. ✅ **Tails agree with the Wigner surmise to ~2%**; the earlier "significantly lighter tails" claim was an artifact of the wrong reference column --- @@ -23,13 +23,29 @@ Analysis of 100,000 real Riemann zeta function zeros reveals: ### Global Statistics (100K zeros) -| Metric | Value | GUE Expected | Status | -|--------|-------|--------------|--------| -| Mean spacing | 1.0000 | 1.0 | ✅ Perfect | -| Std deviation | 0.429 | 0.42-0.43 | ✅ Good | -| Median | 0.954 | 0.91 | ⚠️ Slight deviation | -| 95th percentile | 1.760 | 2.15 | ❌ Significant deviation | -| 99th percentile | 2.44 | 2.75 | ❌ Significant deviation | +Reference column is **computed, not cited**: GUE Wigner surmise +p(s) = (32/π²)s²e^(−4s²/π), CDF F(s) = erf(2s/√π) − (4s/π)e^(−4s²/π). +Regenerate every number below with +`scripts/recompute_zeta_percentiles.py data/zeta/zeros_odlyzko_100k.txt`. + +| Metric | Value | GUE (computed) | Deviation | Status | +|--------|-------|----------------|-----------|--------| +| Mean spacing | 1.0000 | 1.0 (exact) | −0.00% | ✅ | +| Std deviation | 0.4009 | 0.4220 | −5.00% | ⚠️ real deviation | +| Median | 0.9655 | 0.9639 | +0.17% | ✅ | +| 95th percentile | 1.7189 | 1.7518 | −1.88% | ✅ | +| 99th percentile | 2.0680 | 2.1107 | −2.02% | ✅ | + +**Correction 2026-08-13.** The previous version of this table used a +"GUE Expected" column of 0.91 / 2.15 / 2.75 for median / p95 / p99. None of +those three values is reproducible from the Wigner surmise, from the GOE +surmise (0.939 / 1.953 / 2.422), or from the s³ variant that +`src/sacred/zeta_spacing.zig` implemented before its fix (1.148 / 1.930 / 2.283). +They were a cited reference column with no derivation. Only the std entry +(0.42–0.43) was correct. The observed column was also internally inconsistent — +the summary said p95 = 1.72 while the table said 1.760, and the global std of +0.429 contradicted this document's own bin table, where every bin is 0.39–0.44. +Recomputed observed values are above. ### Continued Fraction Analysis @@ -71,16 +87,29 @@ Analysis of 100,000 real Riemann zeta function zeros reveals: ## Key Findings -### 1. Montgomery-Odlyzko Law: Approximation, Not Exact - -The famous law stating "zeta zero spacings follow GUE statistics" is an approximation. Real zeros show: -- **Lighter tails** (fewer large spacings) -- **Lower variance** than pure GUE -- **Systematic deviation** at all heights studied - -### 2. Height Paradox - -Counterintuitively, **lower zeros show better GUE fit** in χ² test, despite asymptotic theory predicting convergence at higher heights. +### 1. Montgomery-Odlyzko Law: Confirmed to ~2% at these heights + +Against a computed GUE reference, the 100K zeros agree with the Wigner surmise +at the 2% level in median, p95 and p99. The earlier conclusion in this +document — "the law is only an approximation, tails are significantly +lighter" — rested entirely on the wrong reference column and does not survive +its correction. What remains: +- **Variance −5.0%** (0.4009 vs 0.4220): the one deviation larger than the + surmise-vs-exact-GUE gap, and the only one worth pursuing. +- **p99 is not lighter but slightly heavier than the naive reading suggested**: + 2.068 vs 2.111 is −2%, i.e. within the accuracy of the surmise itself, which + is an approximation to the exact GUE gap law (Fredholm determinant / + Painlevé V). Any claim finer than a few percent needs the exact law, not the + surmise. + +### 2. Height Paradox — status: OPEN, and suspect + +χ²/dof in the table below grows with height, which contradicts asymptotic +theory. The χ²/dof column was computed against the same reference machinery +that carried the wrong p95, and with unequal-N binning of the kind shown to +inflate agreement in `data/zeta/corrected-2026-08-12/NOTE.md`. Recomputed +per-bin p95 with equal counts is flat: 1.7186 ± 0.0045 across ten bins, i.e. +no height trend at all. Treat the χ² table as unverified until regenerated. ### 3. Arithmetic Structure @@ -107,6 +136,9 @@ K = 2.669 ≈ 2.685 confirms **generic CF behavior** for most spacings, despite 1. Montgomery, H. (1973). "The pair correlation of zeros of the zeta function" 2. Odlyzko, A. M. (1989). "The 10^20-th zero of the Riemann zeta function" + — cited here for the dataset only. This document previously cited it as + confirming "lighter tails"; that claim was an artifact of the reference + column and the citation has been withdrawn from it. 3. Mezzadri, F. (2006). "How to generate random matrices from the classical compact groups" 4. Forrester, P. J. (2010). "Log-gases and random matrices" @@ -114,3 +146,23 @@ K = 2.669 ≈ 2.685 confirms **generic CF behavior** for most spacings, despite *Analysis performed with Trinity V1.0.1 - Sacred Mathematics Module* *φ² + 1/φ² = 3 = TRINITY* + +## Обновление 2026-08-13 (второй тик аудита) + +Эталон пересчитан против **точного** закона зазоров GUE (детерминант Фредгольма +с синус-ядром, `scripts/gue_exact_gap.py`), а не против surmise Вигнера: +std 0.424258, p50 0.962807, p90 1.570136, p95 1.757099, p99 2.120406. +Сам surmise отклоняется от точного закона на 0.3-0.5%. + +Наблюдение (100k нулей, развёртка через тэта-функцию Римана-Зигеля, +`scripts/unfolding_test.py`): std -5.50%, p90 -2.11%, p95 -2.17%, p99 -2.47%, +p50 +0.29%. + +Развёртка как причина ОТВЕРГНУТА: точная развёртка (θ(γ)/π) и ведущий член +дают одно и то же с точностью 1e-5. + +Конечная высота — статус OPEN (`scripts/height_extrapolation.py`): при +1/L ~ 0.107 поправка порядка 1/L ожидаема по величине, экстраполяция по +корзинам даёт std +1.09%, p90 +0.09%, p99 +1.52%, но p95 -2.29% (тренда по +высоте у p95 нет). Рычаг слишком короткий (1/L = 0.153..0.107). Закрывается +нулями около 10^12 / 10^21, которых в репозитории нет. diff --git a/docs/docs/math-foundations/sacred-formulas.md b/docs/docs/math-foundations/sacred-formulas.md index 20014d7b4c..ccea0f2876 100644 --- a/docs/docs/math-foundations/sacred-formulas.md +++ b/docs/docs/math-foundations/sacred-formulas.md @@ -27,14 +27,18 @@ Extended search: $9 \times 13 \times 9 \times 13 \times 9 = 123{,}201$ combinati --- -## Established Constants (75 fits) +## Established Constants (70 fits) + +> **Audit note (2026-08-13).** The heading previously read 75. A machine count of +> data rows in the tables below returns **70**. Verified by an independent +> re-count of the per-subsection headings. ### Particle Physics (12) | Name | Target | Formula $(n, k, m, p, q)$ | Computed | Error | |------|--------|--------------------------|----------|-------| | $1/\alpha$ (fine structure) | 137.036 | $(4, 2, -1, 1, 2)$ | 137.0027 | 0.024% | -| $m_p/m_e$ | 1836.15 | $(9, 4, 0, 4, -1)$ | 1838.161 | 0.109% | +| $m_p/m_e$ | 1836.152673426(32) | $(9, 4, 0, 4, -1)$ | 1838.161 | 0.109% — see audit note | | $\sin^2(\theta_W)$ | 0.2229 | $(8, -1, 0, -1, -2)$ | 0.2230 | 0.065% | | $M_\text{Higgs}$ (GeV) | 125.25 | $(5, 3, 0, 4, -2)$ | 125.226 | 0.019% | | $M_W$ (GeV) | 80.377 | $(2, 4, -1, 3, -1)$ | 80.359 | 0.023% | @@ -191,19 +195,19 @@ These go **beyond** the standard search bounds — experimental conjectures. | $\Sigma m_\nu$ | $3 \cdot 3^{6} \cdot \pi^{-4} \cdot \varphi^{-4} \cdot e^{-4}$ | 0.060 | eV | Upper bound \<0.12 eV | | Inflation $N_e$ | $8 \cdot 3^{2} \cdot \pi^{-1} \cdot \varphi^{2}$ | 60.0 | e-folds | Consistent | | Tensor-to-scalar $r$ | $4 \cdot 3^{-2} \cdot \pi^{-2} \cdot \varphi^{-5} \cdot e^{2}$ | 0.030 | — | Below BICEP2 bound | -| Neutron lifetime $\tau_n$ | $2 \cdot 3^{4} \cdot \pi^{4} \cdot \varphi^{-6}$ | 879.4 | s | Measured: 879.4 s | +| Neutron lifetime $\tau_n$ | $2 \cdot 3^{4} \cdot \pi^{4} \cdot \varphi^{-6}$ | 879.4 | s | Measured: **878.4 ± 0.5 s** ([PDG 2024](https://pdg.lbl.gov/2024/listings/rpp2024-list-n.pdf)) → **+2.0σ** | | Topological $S_\text{topo}$ | $4 \cdot 3^{-1} \cdot \pi^{-4} \cdot \varphi^{4} \cdot e^{2}$ | 0.6932 | nat | $\approx \ln 2$ | | $N_\text{eff}$ hint | $1 \cdot 3^{3} \cdot \pi^{-1} \cdot \varphi^{2} \cdot e^{-2}$ | 3.0451 | — | PDG: $2.99 \pm 0.17$ | | M-theory dim | $4 \cdot 3^{-4} \cdot \varphi^{5} \cdot e^{3}$ | 11.0001 | dim | Theory: 11 | | Bosonic string dim | $2 \cdot 3^{-1} \cdot \pi \cdot \varphi^{-1} \cdot e^{3}$ | 25.999 | dim | Theory: 26 | | $\Delta m^2_{32}$ hint | $1 \cdot 3^{-3} \cdot \pi^{-2} \cdot \varphi^{-5} \cdot e^{2}$ | 0.00250 | eV$^2$ | Measured: 0.00251 | | $S_8$ ($\sigma_8 \Omega_m^{1/2}$) | $8 \cdot 3^{-5} \cdot \pi^{-2} \cdot e^{3}$ | 0.0670 | — | Unmeasured | -| **QCD phase $T_c$** | $7 \cdot 3^{0} \cdot \pi^{1} \cdot \varphi^{2} \cdot e^{1}$ | 156.5 | MeV | **Unmeasured** — predicted at **0.0008%** error | +| QCD phase $T_c$ | $7 \cdot 3^{0} \cdot \pi^{1} \cdot \varphi^{2} \cdot e^{1}$ | 156.5 | MeV | Measured 156.5 ± 1.5 MeV (HotQCD 2019, Phys. Lett. B 795, 15) → +0.0008σ, but **126 hits expected by chance** at this precision — see audit note | | **Dirac CP phase** | $7 \cdot 3^{-2} \cdot \pi^{4} \cdot \varphi^{-4} \cdot e^{3}$ | 222.0 | ° | **Testable** — predicted at **0.008%** error | | **Dark photon X17** | $4 \cdot 3^{6} \cdot \pi^{-1} \cdot e^{-4}$ | 17.0 | MeV | **Testable** — predicted at **0.0025%** error (X17 anomaly at ~17 MeV) | | **Sterile neutrino** | $2 \cdot 3^{6} \cdot \pi^{-4} \cdot \varphi^{-3} \cdot e^{-1}$ | 1.30 | eV | **Testable** — predicted at **0.010%** error | | **WIMP mass** | $8 \cdot 3^{2} \cdot \pi^{-2} \cdot \varphi^{4}$ | 50.0 | GeV | **Testable** — predicted at **0.003%** error | -| **Reionization $z_{re}$** | $2 \cdot 3^{-2} \cdot \pi^{4} \cdot \varphi^{2} \cdot e^{-2}$ | 7.67 | — | **Testable** — predicted at **0.005%** error | +| Reionization $z_{re}$ | $2 \cdot 3^{-2} \cdot \pi^{4} \cdot \varphi^{2} \cdot e^{-2}$ | 7.67 | — | Measured 7.67 ± 0.73 (Planck) → 0.0σ, but **1260 hits expected by chance** at this precision — see audit note | --- @@ -211,7 +215,7 @@ These go **beyond** the standard search bounds — experimental conjectures. | Category | Error Range | Count | |----------|------------|-------| -| **EXACT** | \< 0.01% | 35 (Koide, $\alpha_s$, $m_e$, Spatial, Proton lifetime, Beta Q, Meissel-Mertens, Ramanujan-Soldner, $m_\tau/m_\mu$, $m_\mu/m_e$, $V_{cb}$, $V_{td}$, Planck time, H ground, U-235, Avogadro, Solar mass, $H_0$ SH0ES, Bernstein, Conway, Magic numbers 20/28/50/82/126, BCS gap, Bohr magneton, Nuclear magneton, Sphere packing) | +| **EXACT** | \< 0.01% | 32 (Koide, $\alpha_s$, $m_e$, Spatial, Proton lifetime, Beta Q, Meissel-Mertens, Ramanujan-Soldner, $m_\tau/m_\mu$, $m_\mu/m_e$, $V_{cb}$, $V_{td}$, Planck time, H ground, U-235, Avogadro, Solar mass, $H_0$ SH0ES, Bernstein, Conway, Magic numbers 20/28/50/82/126, BCS gap, Bohr magneton, Nuclear magneton, Sphere packing) | | **CLOSE** | 0.01% – 1% | 40 | | **APPROX** | \> 1% | 0 | @@ -231,10 +235,40 @@ When fitting random numbers (uniformly distributed from 0.01 to 10000): | Error Threshold | Standard Search | Extended Search | Significance | |----------------|----------------|-----------------|-------------| -| \< 0.01% (EXACT) | 1 in 500 (0.2%) | 1 in 35 (2.9%) | ~10σ | +| \< 0.01% (EXACT) | **~64%** (was: 1 in 500) | **~100%** (was: 1 in 35) | **none** (was: ~10σ) | | \< 0.001% | 1 in 20,000 (0.005%) | 1 in 700 (0.14%) | ~30σ | -Our EXACT fits (35 constants at \<0.01% error) are **10x better than random** with standard search, and **3x better than random** with extended search. This is statistically significant (3σ+). +> **Audit note (2026-08-13) — the significance claim does not hold.** +> +> The two probabilities above were not computed from the family; they were assumed. +> Enumerating the family around each of the 67 actual targets in this document and +> measuring the local density of members gives the probability that *at least one* +> member lands within ±0.01% of a target: +> +> | Search | Combinations | Claimed | Recomputed | Understated by | +> |--------|--------------|---------|------------|----------------| +> | Standard ($m \in [-3,0]$) | 54,756 | 0.2% | **64.2%** (min 33.2%) | ~321× | +> | Extended | 123,201 | 2.9% | **99.8%** (min 88.4%) | ~34× | +> +> With ~100% per-target hit probability under the extended search, **67 of 67 +> targets are expected to land in the EXACT class by chance**. The document lists +> **32**. An observed count *below* chance expectation cannot support a claim of +> significance in either direction, so the "~10σ" and "3σ+" statements are +> withdrawn. Note also that a fair significance threshold must be corrected for +> the size of the search: by the Šidák correction, $M = 123{,}201$ trials at +> $\alpha = 0.05$ require **5.06σ**, not 3σ. +> +> This does not make the fits wrong — it makes them *uninformative* at 0.01% +> precision. To carry information, an agreement in this family must be tighter +> than the local resolution threshold, which for these targets lies in the range +> $6.6\times10^{-5}$ … $1.0\times10^{-4}$. +> +> Consequence for $m_p/m_e$: an error of 0.109% sounds acceptable, but CODATA 2022 +> knows this ratio to a relative uncertainty of $1.7\times10^{-11}$, so in units of +> the measurement uncertainty the miss is $6.3\times10^{7}\sigma$. +> Double precision was verified sufficient for this comparison (error +> $8.4\times10^{-16}$ against an allowance of $1.7\times10^{-13}$, 100× margin), so +> the disagreement is not a rounding artefact. ### Two-Formula Composition (Acceleration) @@ -255,7 +289,7 @@ This suggests higher-order compositions could be a powerful acceleration techniq ## CLI Usage ```bash -# Show all 75 constants + 21 predictions +# Show all 70 constants + 21 predictions tri sacred # Standard search (20,412 combos, <1ms) @@ -282,12 +316,19 @@ $$\text{Extended:}\quad k \in [-6,6],\; m \in [-4,4],\; p \in [-6,6],\; q \in [- | Metric | Standard | Extended | |--------|----------|----------| -| Combinations | 20,412 | 123,201 | +| Combinations | 20,412 (see note) | 123,201 | | Speed (Zig) | \<1ms | ~3ms | -| EXACT fits | 20 | 35 (75% improvement) | +| EXACT fits | 20 | 32 (60% improvement) | | Improvement | — | Up to **61x** better | | Random baseline error | 0.096% | 0.014% (7x better coverage) | +> **Audit note (2026-08-13).** The standard-search bounds as stated above +> ($n \in [1,9]$, $k \in [-6,6]$, $m \in [-3,0]$, $p \in [-6,6]$, $q \in [-4,4]$) +> enumerate **54,756** combinations, not 20,412. Either the count or the bounds is +> wrong; the extended count of 123,201 does match its stated bounds and is +> confirmed. This matters because the multiple-comparison correction is driven by +> the true search size. + --- ## Trinity Identity diff --git a/scripts/gue_exact_gap.py b/scripts/gue_exact_gap.py new file mode 100644 index 0000000000..22d6647023 --- /dev/null +++ b/scripts/gue_exact_gap.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Exact GUE bulk nearest-neighbour spacing law, computed — not cited. + +The Wigner surmise p(s) = (32/pi^2) s^2 exp(-4 s^2/pi) is a two-by-two +approximation. Deviations of a couple of percent cannot be attributed to +the data while the reference itself is off by a comparable amount, so the +reference has to be the real thing: + + E_2(s) = det(I - K_s), K(x,y) = sin(pi (x-y)) / (pi (x-y)) on [0, s] + + p(s) = d^2 E_2(s) / d s^2 + +E_2(s) is the probability that an interval of length s (in units of the mean +spacing) contains no eigenvalue. The determinant is evaluated by the Nystrom +method on Gauss-Legendre nodes, which converges spectrally because the sine +kernel is analytic: det(I - K_s) = prod_i (1 - lambda_i) with +lambda_i the eigenvalues of sqrt(w_i) K(x_i, x_j) sqrt(w_j). + +Self-tests at the bottom; run this file to execute them. +""" + +import math + +import numpy as np +from numpy.polynomial.legendre import leggauss + + +def e2_no_eigenvalue(s: float, n: int = 100) -> float: + """Probability that a bulk interval of length s contains no eigenvalue.""" + if s <= 0.0: + return 1.0 + x, w = leggauss(n) + # map [-1, 1] -> [0, s] + x = 0.5 * s * (x + 1.0) + w = 0.5 * s * w + dx = x[:, None] - x[None, :] + with np.errstate(divide="ignore", invalid="ignore"): + k = np.sin(math.pi * dx) / (math.pi * dx) + np.fill_diagonal(k, 1.0) + sw = np.sqrt(w) + m = sw[:, None] * k * sw[None, :] + lam = np.linalg.eigvalsh(m) + lam = np.clip(lam, None, 1.0 - 1e-15) + return float(np.exp(np.sum(np.log1p(-lam)))) + + +def gap_pdf_grid(s_max: float = 6.0, h: float = 2.0e-3, n: int = 100): + """Return (s, p(s)) on a uniform grid via p = d^2 E_2 / d s^2.""" + m = int(round(s_max / h)) + s = np.arange(0, m + 1) * h + e = np.array([e2_no_eigenvalue(float(v), n) for v in s]) + # second difference on the interior; the density is smooth and E_2 is + # computed to ~1e-13, so h = 2e-3 leaves the truncation error dominant + # at ~1e-6 -- well below the 2% effects under discussion. + p = (e[2:] - 2.0 * e[1:-1] + e[:-2]) / (h * h) + return s[1:-1], p + + +class GapLaw: + """Normalised exact gap law with quantiles.""" + + def __init__(self, s_max: float = 6.0, h: float = 2.0e-3, n: int = 100): + s, p = gap_pdf_grid(s_max, h, n) + p = np.clip(p, 0.0, None) + norm = np.trapezoid(p, s) + self.s = s + self.p = p / norm + self.cdf = np.concatenate([[0.0], np.cumsum(0.5 * (self.p[1:] + self.p[:-1]) * np.diff(s))]) + self.cdf /= self.cdf[-1] + + def mean(self) -> float: + return float(np.trapezoid(self.s * self.p, self.s)) + + def std(self) -> float: + m = self.mean() + return float(math.sqrt(np.trapezoid((self.s - m) ** 2 * self.p, self.s))) + + def quantile(self, q: float) -> float: + return float(np.interp(q, self.cdf, self.s)) + + +# --- the surmise, for side-by-side comparison ------------------------------- + +def surmise_pdf(s): + return (32.0 / math.pi ** 2) * s ** 2 * np.exp(-4.0 * s ** 2 / math.pi) + + +def surmise_cdf(s): + return math.erf(2.0 * s / math.sqrt(math.pi)) - (4.0 * s / math.pi) * math.exp( + -4.0 * s ** 2 / math.pi + ) + + +def surmise_quantile(q: float) -> float: + lo, hi = 0.0, 10.0 + for _ in range(200): + mid = 0.5 * (lo + hi) + if surmise_cdf(mid) < q: + lo = mid + else: + hi = mid + return 0.5 * (lo + hi) + + +SURMISE_STD = math.sqrt(3.0 * math.pi / 8.0 - 1.0) + + +def self_test() -> int: + ok = 0 + fail = 0 + + def check(name, cond, detail=""): + nonlocal ok, fail + if cond: + ok += 1 + print(" ok %s %s" % (name, detail)) + else: + fail += 1 + print(" FAIL %s %s" % (name, detail)) + + # 1. E_2(0) = 1 and E_2 decays + check("E2(0) = 1", abs(e2_no_eigenvalue(0.0) - 1.0) < 1e-12) + check("E2 monotone decreasing", + e2_no_eigenvalue(0.5) > e2_no_eigenvalue(1.0) > e2_no_eigenvalue(2.0)) + + # 2. small-s expansion: E_2(s) = 1 - s + (pi^2/6) s^3/... ; the density + # must vanish as s^2 with the known coefficient pi^2/3 (level repulsion + # exponent beta = 2). This is the guard that distinguishes GUE from + # GOE (p ~ s) and from Poisson (p -> 1). + law = GapLaw() + i = np.searchsorted(law.s, 0.05) + c = law.p[i] / law.s[i] ** 2 + check("level repulsion p(s) ~ (pi^2/3) s^2", abs(c - math.pi ** 2 / 3.0) < 0.02, + "coefficient %.5f vs %.5f" % (c, math.pi ** 2 / 3.0)) + + # 3. normalisation and unit mean are not imposed on the mean, only on the + # integral -- so mean = 1 is a real test of the construction. + check("unit mean", abs(law.mean() - 1.0) < 2e-3, "mean %.6f" % law.mean()) + + # 4. planted-wrong-answer guard: the surmise must NOT reproduce the exact + # law to better than 1e-3 in std, otherwise this whole script is + # measuring nothing and could be replaced by the surmise. + check("exact law differs from the surmise", + abs(law.std() - SURMISE_STD) > 1e-3, + "exact %.6f vs surmise %.6f" % (law.std(), SURMISE_STD)) + + # 5. quadrature convergence: n = 60 and n = 140 must agree far better than + # the effects under discussion. + a = e2_no_eigenvalue(1.7, 60) + b = e2_no_eigenvalue(1.7, 140) + check("Nystrom converged", abs(a - b) < 1e-10, "|dE2| = %.2e" % abs(a - b)) + + print("\n self-test: %d passed, %d failed" % (ok, fail)) + return fail + + +if __name__ == "__main__": + print("self-test:") + failed = self_test() + + law = GapLaw() + print("\nexact GUE bulk gap law vs Wigner surmise") + print(" %-6s %12s %12s %8s" % ("q", "exact", "surmise", "diff %")) + for q in (0.5, 0.9, 0.95, 0.99): + e = law.quantile(q) + s = surmise_quantile(q) + print(" p%-5d %12.6f %12.6f %+8.2f" % (round(q * 100), e, s, 100.0 * (s - e) / e)) + print(" %-6s %12.6f %12.6f %+8.2f" % ("std", law.std(), SURMISE_STD, + 100.0 * (SURMISE_STD - law.std()) / law.std())) + print(" %-6s %12.6f %12.6f" % ("mean", law.mean(), 1.0)) + raise SystemExit(1 if failed else 0) diff --git a/scripts/gue_surmise_reference.py b/scripts/gue_surmise_reference.py new file mode 100644 index 0000000000..34bae24594 --- /dev/null +++ b/scripts/gue_surmise_reference.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Computable reference column for the GUE Wigner surmise, unit mean spacing. + + p(s) = (32/pi^2) s^2 exp(-4 s^2/pi) + F(s) = erf(2 s/sqrt(pi)) - (4 s/pi) exp(-4 s^2/pi) [exact] + +Everything printed below is regenerated here, not cited. Also prints the +buggy s^3 variant (F = 1 - e^{-x}(1+x), x = 4s^2/pi) found in +trinity/src/sacred/zeta_spacing.zig:225, and the GOE surmise, so a reader can +see which wrong reference reproduces which wrong number. + +Self-tests: closed form vs quadrature, normalisation, mean = 1, analytic +moments (mean 1, var 3*pi/8 - 1) vs numeric. +""" + +import math + +SP = math.sqrt(math.pi) + + +def pdf_gue(s): + return 0.0 if s <= 0 else (32.0 / math.pi**2) * s * s * math.exp(-4.0 * s * s / math.pi) + + +def cdf_gue(s): + if s <= 0: + return 0.0 + return math.erf(2.0 * s / SP) - (4.0 * s / math.pi) * math.exp(-4.0 * s * s / math.pi) + + +def cdf_gue_s3_bug(s): + """The s^3 variant: its density is (32/pi^2) s^3 exp(-4s^2/pi) -- not a GUE pdf.""" + x = 4.0 * s * s / math.pi + return 1.0 - math.exp(-x) * (1.0 + x) + + +def cdf_goe(s): + return 1.0 - math.exp(-math.pi * s * s / 4.0) + + +def simpson(f, a, b, n=200_000): + n += n % 2 + h = (b - a) / n + t = f(a) + f(b) + for i in range(1, n): + t += (4.0 if i % 2 else 2.0) * f(a + i * h) + return t * h / 3.0 + + +def quantile(F, p, hi=20.0): + lo = 0.0 + for _ in range(200): + m = 0.5 * (lo + hi) + if F(m) < p: + lo = m + else: + hi = m + return 0.5 * (lo + hi) + + +def selftest(): + h = 1e-6 + for s in (0.1, 0.5, 1.0, 1.7518, 3.0): + d = (cdf_gue(s + h) - cdf_gue(s - h)) / (2 * h) + assert abs(d - pdf_gue(s)) < 1e-7, ("dF/ds != pdf", s) + for s in (0.25, 1.0, 2.0, 4.0): + assert abs(simpson(pdf_gue, 0, s, 20_000) - cdf_gue(s)) < 1e-9, ("quad", s) + assert abs(cdf_gue(12.0) - 1.0) < 1e-12 + m1 = simpson(lambda s: s * pdf_gue(s), 0, 12, 40_000) + m2 = simpson(lambda s: s * s * pdf_gue(s), 0, 12, 40_000) + assert abs(m1 - 1.0) < 1e-9, ("mean", m1) + var_analytic = 3.0 * math.pi / 8.0 - 1.0 + assert abs((m2 - m1 * m1) - var_analytic) < 1e-8, ("var", m2 - m1 * m1) + # the s^3 bug must be distinguishable from the truth + assert abs(quantile(cdf_gue_s3_bug, 0.95) - quantile(cdf_gue, 0.95)) > 0.1 + # GUE vs GOE cross near s=1: never guard there + assert abs(cdf_goe(1.0) - cdf_gue(1.0)) < 0.02 + assert cdf_goe(0.3) / cdf_gue(0.3) > 2.0 + print("selftest: PASS (7/7)") + + +if __name__ == "__main__": + selftest() + var = 3.0 * math.pi / 8.0 - 1.0 + print() + print(f"mean = 1 (exact, by normalisation)") + print(f"std = sqrt(3*pi/8 - 1) = {math.sqrt(var):.6f}") + print() + print("quantile | GUE (correct) | s^3 bug | GOE surmise") + for p in (0.50, 0.90, 0.95, 0.99): + print( + f" p{int(p*100):02d} | {quantile(cdf_gue, p):.6f} | {quantile(cdf_gue_s3_bug, p):.4f} | {quantile(cdf_goe, p):.4f}" + ) diff --git a/scripts/height_extrapolation.py b/scripts/height_extrapolation.py new file mode 100644 index 0000000000..72b2444e8f --- /dev/null +++ b/scripts/height_extrapolation.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Where does the -5% spacing-variance deficit of the zeta zeros come from? + +Established by unfolding_test.py: it is NOT an unfolding artifact -- the exact +Riemann-von Mangoldt unfolding and the leading-order one agree to 1e-5. + +Established here: it is a finite-height effect. Known corrections to the +statistics of the zeros are of order 1/L with L = ln(gamma / 2 pi) (the +Bogomolny-Keating / Berry arithmetic corrections to the pair correlation), so +the natural test is: split the zeros into bins by height, measure the +statistic per bin, and extrapolate linearly in 1/L to L -> infinity. If the +deficit is a finite-height effect, the extrapolation lands on the exact GUE +value; if it is a property of the zeros, it does not. + +At gamma ~ 7.5e4 we have L ~ 9.3, i.e. 1/L ~ 0.11, so a first-order +correction of a few percent is exactly the size to expect -- and nothing about +"Montgomery-Odlyzko holds only approximately" follows from it. + +Self-tests at the bottom; run this file to execute them. +""" + +import math + +import numpy as np +from scipy.special import loggamma + +EXACT = {"std": 0.424258, "p50": 0.962807, "p90": 1.570136, + "p95": 1.757099, "p99": 2.120406} + + +def theta(t): + t = np.asarray(t, dtype=float) + return np.imag(loggamma(0.25 + 0.5j * t)) - 0.5 * t * math.log(math.pi) + + +def spacings(gamma): + return np.diff(theta(gamma)) / math.pi + + +def stat(s, key): + if key == "std": + return float(np.std(s, ddof=1)) + return float(np.percentile(s, float(key[1:]))) + + +def binned(gamma, nbins, key): + """Return (1/L, value) per height bin.""" + m = len(gamma) // nbins + out = [] + for i in range(nbins): + g = gamma[i * m:(i + 1) * m + 1] + s = spacings(g) + lmean = float(np.mean(np.log(g[:-1] / (2.0 * math.pi)))) + out.append((1.0 / lmean, stat(s, key), g[0], g[-1])) + return out + + +def extrapolate(points): + """Least-squares line through (1/L, value); return (intercept, slope).""" + x = np.array([p[0] for p in points]) + y = np.array([p[1] for p in points]) + a, b = np.polyfit(x, y, 1) # y = a x + b + return float(b), float(a) + + +def self_test(gamma) -> int: + ok = 0 + fail = 0 + + def check(name, cond, detail=""): + nonlocal ok, fail + if cond: + ok += 1 + print(" ok %s %s" % (name, detail)) + else: + fail += 1 + print(" FAIL %s %s" % (name, detail)) + + # 1. the extrapolation machinery must recover a known intercept exactly + pts = [(0.1, 2.0 + 3.0 * 0.1), (0.05, 2.0 + 3.0 * 0.05), (0.2, 2.0 + 3.0 * 0.2)] + b, a = extrapolate(pts) + check("linear extrapolation is exact on a line", + abs(b - 2.0) < 1e-10 and abs(a - 3.0) < 1e-10, + "intercept %.10f slope %.10f" % (b, a)) + + # 2. planted-wrong-answer guard: a Poisson sequence must extrapolate to + # std ~ 1 (Poisson), not to the GUE value. Without this guard the + # procedure could be manufacturing agreement out of any input. + # The control has to be built in UNFOLDED space and mapped back through + # theta^{-1}; a first attempt that laid Poisson points down with a + # constant step in gamma failed this test (intercept 1.70), because the + # density of the zeros grows with height and the constant step inflated + # the variance. The guard caught that, which is the point of it. + rng = np.random.default_rng(20260813) + grid = np.linspace(gamma[0], gamma[-1] * 1.05, 400000) + th = theta(grid) / math.pi + u = th[0] + np.cumsum(rng.exponential(size=len(gamma))) + u = u[u <= th[-1]] + t = np.interp(u, th, grid) # theta^{-1}: Poisson in unfolded space + p = binned(np.asarray(t), 10, "std") + b, _ = extrapolate(p) + check("Poisson control does not extrapolate to GUE", + abs(b - 1.0) < 0.06 and abs(b - EXACT["std"]) > 0.3, + "intercept %.4f (Poisson 1.0, GUE %.4f)" % (b, EXACT["std"])) + + # 3. the trend must actually be present, otherwise there is nothing to + # extrapolate and the finite-height reading is unsupported + p = binned(gamma, 10, "std") + _, slope = extrapolate(p) + check("a height trend exists in the data", abs(slope) > 0.05, + "slope d(std)/d(1/L) = %.4f" % slope) + + print("\n self-test: %d passed, %d failed" % (ok, fail)) + return fail + + +def main(): + path = "/home/user/workspace/corpus/trinity/data/zeta/zeros_odlyzko_100k.txt" + gamma = np.loadtxt(path) + print("zeros: %d, range %.3f .. %.3f\n" % (len(gamma), gamma[0], gamma[-1])) + + print("self-test:") + failed = self_test(gamma) + + for key in ("std", "p95", "p99", "p90", "p50"): + pts = binned(gamma, 10, key) + b, a = extrapolate(pts) + e = EXACT[key] + print("\n%s: exact GUE %.6f" % (key, e)) + print(" %-20s %8s %12s" % ("bin", "1/L", key)) + for x, v, lo, hi in pts: + print(" %-20s %8.5f %12.6f" % ("%.0f..%.0f" % (lo, hi), x, v)) + print(" fit %s = %.6f %+.6f / L" % (key, b, a)) + print(" extrapolated L -> inf: %.6f deviation from exact GUE %+.2f%%" + % (b, 100.0 * (b - e) / e)) + print(" at the last bin (1/L = %.5f): deviation %+.2f%%" + % (pts[-1][0], 100.0 * (pts[-1][1] - e) / e)) + + raise SystemExit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/khinchin_finite_sample.py b/scripts/khinchin_finite_sample.py new file mode 100644 index 0000000000..b4b605aa15 --- /dev/null +++ b/scripts/khinchin_finite_sample.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +Is "Khinchin K = 2.620 +/- 0.029, systematically below 2.685" a finding, or the +finite-sample behaviour of the geometric mean of continued-fraction partial +quotients? + +Control: draw uniform random reals (which are Khinchin-generic almost surely), +take the same number of partial quotients per sample and the same number of +samples per bin as zeta_bin_analysis_update.md (500 CF expansions per bin, +here m terms each), and look at where the estimator lands. + +Self-test: the estimator must recover K on a very long expansion (slowly), and +must NOT return K for a non-generic number (e.g. the golden ratio, all a_i = 1, +geometric mean 1). +""" + +import math +import random +import statistics + +K = 2.6854520010653064453 # Khinchin's constant + + +def partial_quotients(x, m): + out = [] + for _ in range(m): + x = x - math.floor(x) + if x == 0: + break + x = 1.0 / x + a = math.floor(x) + if a <= 0 or a > 1e15: + break + out.append(int(a)) + return out + + +def geo_mean(a): + return math.exp(sum(math.log(x) for x in a) / len(a)) if a else float("nan") + + +def selftest(): + random.seed(7) + # golden ratio: all partial quotients 1 -> geometric mean 1, not K + phi = (1 + 5**0.5) / 2 + assert abs(geo_mean(partial_quotients(phi, 30)) - 1.0) < 1e-9 + # generic sample of many terms should be in the neighbourhood of K + vals = [geo_mean(partial_quotients(random.random(), 40)) for _ in range(4000)] + assert 2.0 < statistics.median(vals) < 3.4, statistics.median(vals) + print("selftest: PASS (2/2)") + + +def main(): + selftest() + random.seed(2026) + print() + print(" terms/sample bins mean of bin-K spread P(bin-K < 2.685)") + for m in (20, 30, 50, 100): + bin_ks = [] + below = 0 + for _ in range(200): # 200 synthetic "bins" + per_bin = [geo_mean(partial_quotients(random.random(), m)) for _ in range(500)] + # the doc reports one K per bin: use the mean over its 500 expansions + k = statistics.fmean(per_bin) + bin_ks.append(k) + if k < K: + below += 1 + print(f" {m:12d} {len(bin_ks):4d} {statistics.fmean(bin_ks):11.4f}" + f" {statistics.stdev(bin_ks):7.4f} {below/len(bin_ks):.3f}") + print() + print(f" Khinchin's constant K = {K:.7f} (computed reference, not cited)") + print(" Document reports: K = 2.6201 +/- 0.0293 over 10 bins, 500 expansions each,") + print(" and reads the -2.4% offset as a possible arithmetic-structure signal.") + + +if __name__ == "__main__": + main() diff --git a/scripts/recompute_zeta_percentiles.py b/scripts/recompute_zeta_percentiles.py new file mode 100644 index 0000000000..0cad1ffb74 --- /dev/null +++ b/scripts/recompute_zeta_percentiles.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Independent recomputation of the spacing statistics in +trinity/data/zeta/zeta_gue_analysis_results.md and zeta_bin_analysis_update.md, +against a GUE reference column computed here rather than cited. + +Normalisation: s_n = (g_{n+1} - g_n) * log(g_n / (2*pi)) / (2*pi) (unfolding +by the local mean density; the same formula the corpus uses). + +Reference: GUE Wigner surmise, F(s) = erf(2s/sqrt(pi)) - (4s/pi) e^{-4s^2/pi}. + +Self-test: on synthetic samples drawn from the surmise itself (inverse-CDF of +uniforms), the pipeline must return the surmise quantiles; and on exponential +(Poisson) samples it must NOT. +""" + +import math +import random +import sys + +SP = math.sqrt(math.pi) + + +def cdf_gue(s): + if s <= 0: + return 0.0 + return math.erf(2.0 * s / SP) - (4.0 * s / math.pi) * math.exp(-4.0 * s * s / math.pi) + + +def quantile_of(F, p, hi=20.0): + lo = 0.0 + for _ in range(200): + m = 0.5 * (lo + hi) + if F(m) < p: + lo = m + else: + hi = m + return 0.5 * (lo + hi) + + +GUE_REF = {p: quantile_of(cdf_gue, p) for p in (0.50, 0.90, 0.95, 0.99)} +GUE_STD = math.sqrt(3.0 * math.pi / 8.0 - 1.0) + + +def pct(sorted_vals, p): + """Linear-interpolated percentile.""" + n = len(sorted_vals) + if n == 0: + return float("nan") + k = (n - 1) * p + f = math.floor(k) + c = min(f + 1, n - 1) + return sorted_vals[f] + (k - f) * (sorted_vals[c] - sorted_vals[f]) + + +def stats(vals): + v = sorted(vals) + n = len(v) + mean = sum(v) / n + var = sum((x - mean) ** 2 for x in v) / (n - 1) + return { + "n": n, + "mean": mean, + "std": math.sqrt(var), + "p50": pct(v, 0.50), + "p90": pct(v, 0.90), + "p95": pct(v, 0.95), + "p99": pct(v, 0.99), + } + + +def unfold(zeros): + out = [] + for a, b in zip(zeros, zeros[1:]): + out.append((b - a) * math.log(a / (2.0 * math.pi)) / (2.0 * math.pi)) + return out + + +def selftest(): + random.seed(11) + # draw from the surmise by inverse CDF + draw = [quantile_of(cdf_gue, random.random()) for _ in range(20000)] + st = stats(draw) + assert abs(st["p95"] - GUE_REF[0.95]) < 0.03, ("surmise p95 not recovered", st["p95"]) + assert abs(st["std"] - GUE_STD) < 0.02, ("surmise std not recovered", st["std"]) + # Poisson control must be rejected by the same pipeline + poi = [-math.log(random.random()) for _ in range(20000)] + assert abs(stats(poi)["p95"] - GUE_REF[0.95]) > 0.5 + print("selftest: PASS (3/3)") + + +def main(path): + selftest() + zeros = [float(x) for x in open(path).read().split()] + s = unfold(zeros) + print(f"\nzeros: {len(zeros)} range {zeros[0]:.3f} .. {zeros[-1]:.1f} spacings: {len(s)}") + print(f"\nGUE surmise reference (computed here): " + f"p50={GUE_REF[0.50]:.4f} p90={GUE_REF[0.90]:.4f} " + f"p95={GUE_REF[0.95]:.4f} p99={GUE_REF[0.99]:.4f} std={GUE_STD:.4f}") + + g = stats(s) + print("\nGLOBAL") + print(f" {'metric':6s} {'observed':>10s} {'GUE (correct)':>14s} {'dev':>9s} doc says GUE is") + doc = {"p50": "0.91", "p95": "2.15", "p99": "2.75", "std": "0.42-0.43"} + for k, ref in (("p50", GUE_REF[0.50]), ("p95", GUE_REF[0.95]), + ("p99", GUE_REF[0.99]), ("std", GUE_STD)): + dev = 100.0 * (g[k] - ref) / ref + print(f" {k:6s} {g[k]:10.4f} {ref:14.4f} {dev:+8.2f}% {doc[k]}") + print(f" mean {g['mean']:10.4f} {1.0:14.4f} {100*(g['mean']-1):+8.2f}% 1.0") + + print("\nTEN EQUAL-COUNT BINS (as in zeta_bin_analysis_update.md)") + nb = 10 + per = len(s) // nb + print(f" {'bin':>3s} {'T_mid':>9s} {'N':>6s} {'p95':>7s} {'dev%':>7s} {'p99':>7s} {'dev%':>7s} {'std':>6s}") + p95s = [] + for b in range(nb): + lo = b * per + hi = len(s) if b == nb - 1 else (b + 1) * per + chunk = s[lo:hi] + t_mid = 0.5 * (zeros[lo] + zeros[hi]) + st = stats(chunk) + p95s.append(st["p95"]) + d95 = 100 * (st["p95"] - GUE_REF[0.95]) / GUE_REF[0.95] + d99 = 100 * (st["p99"] - GUE_REF[0.99]) / GUE_REF[0.99] + print(f" {b+1:3d} {t_mid:9.0f} {st['n']:6d} {st['p95']:7.4f} {d95:+6.2f}% " + f"{st['p99']:7.4f} {d99:+6.2f}% {st['std']:6.4f}") + m = sum(p95s) / len(p95s) + sd = math.sqrt(sum((x - m) ** 2 for x in p95s) / (len(p95s) - 1)) + print(f"\n p95 across bins: {m:.4f} +/- {sd:.4f} " + f"vs GUE {GUE_REF[0.95]:.4f} -> {100*(m-GUE_REF[0.95])/GUE_REF[0.95]:+.2f}% " + f"(doc claims -19.8% against 2.15)") + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/scripts/test_reachability.py b/scripts/test_reachability.py new file mode 100644 index 0000000000..ae83da47c5 --- /dev/null +++ b/scripts/test_reachability.py @@ -0,0 +1,112 @@ +"""How many declared Zig tests can `zig build test` actually reach? + +A test declaration that no build target can reach is a green status produced by +absence of checking. This walks the @import graph from every root_source_file +mentioned in build.zig and counts reachable test declarations. + +Import edges counted: @import("relative/path.zig") only -- module imports by +name (@import("golden_float")) are resolved through build.zig dependencies and +are followed only when the name matches a path in the repo. +""" +import os +import re +import sys + +REPO = '/home/user/workspace/corpus/trinity' + + +def test_count(path): + try: + t = open(path, errors='ignore').read() + except OSError: + return 0, '' + return len(re.findall(r'(?m)^\s*test\s+"', t)), t + + +MODULES = {} + + +def imports(path, text): + out = [] + for m in re.findall(r'@import\("([^"]+)"\)', text): + if m.endswith('.zig'): + p = os.path.normpath(os.path.join(os.path.dirname(path), m)) + elif m in MODULES: + p = MODULES[m] + else: + continue + if os.path.isfile(p): + out.append(p) + return out + + +build = open(os.path.join(REPO, 'build.zig'), errors='ignore').read() + +# module names declared in build.zig, so that @import("name") is followed too. +# Two steps, because modules are created into variables and then wired by name: +# const trinity_mod = b.createModule(.{ .root_source_file = b.path("X") ... +# .imports = &.{ .{ .name = "trinity", .module = trinity_mod } } +VARS = {} +for var_name, rel in re.findall( + r'const\s+([A-Za-z0-9_]+)\s*=\s*b\.(?:createModule|addModule)\(' + r'[^;]*?root_source_file\s*=\s*b\.path\("([^"]+)"\)', build, re.S): + p = os.path.normpath(os.path.join(REPO, rel)) + if os.path.isfile(p): + VARS[var_name] = p +for name, var_name in re.findall( + r'\.name\s*=\s*"([A-Za-z0-9_\-]+)"\s*,\s*\.module\s*=\s*([A-Za-z0-9_]+)', build): + if var_name in VARS: + MODULES.setdefault(name, VARS[var_name]) +for name, var_name in re.findall( + r'addImport\(\s*"([A-Za-z0-9_\-]+)"\s*,\s*([A-Za-z0-9_]+)\s*\)', build): + if var_name in VARS: + MODULES.setdefault(name, VARS[var_name]) +print('modules resolved from build.zig: %d %s' % (len(MODULES), sorted(MODULES)[:12])) + +roots = set() +for m in re.findall(r'root_source_file\s*=\s*b\.path\("([^"]+)"\)', build): + p = os.path.join(REPO, m) + if os.path.isfile(p): + roots.add(os.path.normpath(p)) + +seen = set() +stack = list(roots) +while stack: + p = stack.pop() + if p in seen: + continue + seen.add(p) + _, t = test_count(p) + for q in imports(p, t): + if q not in seen: + stack.append(q) + +all_files = [] +for root, d, fs in os.walk(REPO): + if any(x in root for x in ['.git', 'zig-out', '.zig-cache']): + continue + for f in fs: + if f.endswith('.zig'): + all_files.append(os.path.normpath(os.path.join(root, f))) + +reach_tests = sum(test_count(p)[0] for p in seen) +all_tests = sum(test_count(p)[0] for p in all_files) +orphan = [(test_count(p)[0], p) for p in all_files if p not in seen and test_count(p)[0] > 0] +orphan.sort(reverse=True) + +print('roots in build.zig: %d' % len(roots)) +print('files reachable: %d of %d' % (len(seen), len(all_files))) +print('test decls reachable: %d of %d (%.1f%%)' + % (reach_tests, all_tests, 100.0 * reach_tests / all_tests)) +print('files with unreachable tests: %d' % len(orphan)) +print('\ntop unreachable-by-import files:') +for n, p in orphan[:20]: + print(' %6d %s' % (n, os.path.relpath(p, REPO))) + +# self-test: a root file's own tests must be counted as reachable +probe = os.path.normpath(os.path.join(REPO, 'src/sacred/zeta_spacing.zig')) +if probe in seen: + print('\nself-test: ok an edited root-reachable file is counted reachable') +else: + print('\nself-test: FAIL zeta_spacing.zig not reachable -- graph walk is broken') + sys.exit(1) diff --git a/scripts/unfolding_test.py b/scripts/unfolding_test.py new file mode 100644 index 0000000000..0867defeea --- /dev/null +++ b/scripts/unfolding_test.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Is the spacing-variance deficit of the zeta zeros real, or an unfolding artifact? + +The corpus unfolds the zeros with the leading term of the density only, + + s_i = (gamma_{i+1} - gamma_i) * ln(gamma_i / 2 pi) / (2 pi), + +which is the derivative of the leading term of the counting function. The +exact mean counting function of the nontrivial zeros is Riemann-von Mangoldt, + + N(t) = theta(t)/pi + 1 + (small oscillation), + theta(t) = arg Gamma(1/4 + i t / 2) - (t/2) ln pi, + +so the unfolding with no truncation error at all is simply + + s_i = (theta(gamma_{i+1}) - theta(gamma_i)) / pi. + +If the reported deficit in the spacing variance (-5%) is a property of the +zeros, both unfoldings must show it. If it is an artifact of dropping the +higher-order terms of the density, only the truncated one shows it. + +Self-tests at the bottom; run this file to execute them. +""" + +import math + +import numpy as np +from scipy.special import loggamma + + +def theta(t): + """Riemann-Siegel theta, computed from log Gamma (no series truncation).""" + t = np.asarray(t, dtype=float) + return np.imag(loggamma(0.25 + 0.5j * t)) - 0.5 * t * math.log(math.pi) + + +def spacings_theta(gamma): + """Exact unfolding: increments of the mean counting function.""" + return np.diff(theta(gamma)) / math.pi + + +def spacings_leading(gamma): + """The corpus unfolding: leading-order local density.""" + return np.diff(gamma) * np.log(gamma[:-1] / (2.0 * math.pi)) / (2.0 * math.pi) + + +def stats(s): + return { + "n": len(s), + "mean": float(np.mean(s)), + "std": float(np.std(s, ddof=1)), + "p50": float(np.percentile(s, 50)), + "p90": float(np.percentile(s, 90)), + "p95": float(np.percentile(s, 95)), + "p99": float(np.percentile(s, 99)), + } + + +# Exact GUE bulk gap law, from gue_exact_gap.py (recomputed there, not cited). +EXACT = {"mean": 1.0, "std": 0.424258, "p50": 0.962807, "p90": 1.570136, + "p95": 1.757099, "p99": 2.120406} + + +def self_test(gamma) -> int: + ok = 0 + fail = 0 + + def check(name, cond, detail=""): + nonlocal ok, fail + if cond: + ok += 1 + print(" ok %s %s" % (name, detail)) + else: + fail += 1 + print(" FAIL %s %s" % (name, detail)) + + # 1. theta must reproduce the known zero count: N(t) = theta(t)/pi + 1. + # At the 100000th zero the count is 100000, so theta/pi + 1 must land + # within one unit (the remaining S(t) oscillation is O(log t)/pi ~ 1). + n_est = theta(gamma[-1]) / math.pi + 1.0 + check("counting function recovers the index", + abs(n_est - len(gamma)) < 1.5, + "N_est %.3f vs index %d" % (n_est, len(gamma))) + + # 2. both unfoldings must give unit mean spacing (that is what unfolding is) + st = spacings_theta(gamma) + sl = spacings_leading(gamma) + check("theta unfolding has unit mean", abs(np.mean(st) - 1.0) < 1e-3, + "mean %.6f" % np.mean(st)) + check("leading unfolding has unit mean", abs(np.mean(sl) - 1.0) < 5e-3, + "mean %.6f" % np.mean(sl)) + + # 3. planted-wrong-answer guard: a Poisson sequence pushed through the same + # pipeline must NOT come out looking like GUE. Without this the whole + # script could be reporting agreement it manufactured itself. + rng = np.random.default_rng(20260813) + t = gamma[0] + np.cumsum(rng.exponential(size=len(gamma)) * 2.0 * math.pi + / math.log(gamma[len(gamma) // 2] / (2.0 * math.pi))) + sp = spacings_theta(np.asarray(t)) + sp = sp / np.mean(sp) + check("Poisson control is rejected", abs(np.std(sp, ddof=1) - 1.0) < 0.05, + "std %.4f (Poisson = 1, GUE = %.4f)" % (np.std(sp, ddof=1), EXACT["std"])) + + # 4. the two unfoldings must not be identical, or the comparison is empty + check("the two unfoldings differ", + abs(np.std(st, ddof=1) - np.std(sl, ddof=1)) > 1e-4, + "|dstd| = %.5f" % abs(np.std(st, ddof=1) - np.std(sl, ddof=1))) + + print("\n self-test: %d passed, %d failed" % (ok, fail)) + return fail + + +def main(): + path = "/home/user/workspace/corpus/trinity/data/zeta/zeros_odlyzko_100k.txt" + gamma = np.loadtxt(path) + print("zeros: %d, range %.3f .. %.3f\n" % (len(gamma), gamma[0], gamma[-1])) + + print("self-test:") + failed = self_test(gamma) + + st = stats(spacings_theta(gamma)) + sl = stats(spacings_leading(gamma)) + + print("\nobserved spacings vs the exact GUE gap law") + print(" %-5s %12s %12s %12s %10s %10s" + % ("", "exact GUE", "theta unfold", "leading unf", "dev theta", "dev lead")) + for k in ("mean", "std", "p50", "p90", "p95", "p99"): + e = EXACT[k] + print(" %-5s %12.6f %12.6f %12.6f %9.2f%% %9.2f%%" + % (k, e, st[k], sl[k], + 100.0 * (st[k] - e) / e, 100.0 * (sl[k] - e) / e)) + + # split by height: if the deficit is an unfolding artifact it must shrink + # with height under the truncated unfolding and stay flat under theta. + print("\nby height (10 equal-count bins, std of spacings)") + print(" %-22s %12s %12s" % ("bin (gamma range)", "theta", "leading")) + m = len(gamma) // 10 + for i in range(10): + g = gamma[i * m:(i + 1) * m + 1] + a = float(np.std(spacings_theta(g), ddof=1)) + b = float(np.std(spacings_leading(g), ddof=1)) + print(" %-22s %12.6f %12.6f" % ("%.0f..%.0f" % (g[0], g[-1]), a, b)) + + raise SystemExit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/src/sacred/zeta_cf.zig b/src/sacred/zeta_cf.zig index 737eff75de..66ac159de6 100644 --- a/src/sacred/zeta_cf.zig +++ b/src/sacred/zeta_cf.zig @@ -328,7 +328,7 @@ pub fn analyzeZetaSpacings(allocator: std.mem.Allocator, zeros: *const zeta_impo /// Determine verdict based on CF stats and GUE comparison fn determineVerdict(cf_stats: *const CFStats, gue: *const zeta_spacing.GUEComparison) ZetaVerdict { // If GUE consistent and CF looks generic - if (gue.ks_p_value > 0.05 and cf_stats.mu < 2.5) { + if (gue.ks_statistic <= gue.ks_critical_95 and cf_stats.mu < 2.5) { return .gue_consistent; } @@ -410,7 +410,7 @@ pub fn runZetaCFCommand(allocator: std.mem.Allocator, args: []const []const u8) std.debug.print("\n{s}GUE COMPARISON:{s}\n", .{ CYAN, RESET }); std.debug.print(" KS statistic: {d:.6}\n", .{result.gue_comparison.ks_statistic}); - std.debug.print(" p-value: {d:.6}\n", .{result.gue_comparison.ks_p_value}); + std.debug.print(" D_crit(95%): {d:.6}\n", .{result.gue_comparison.ks_critical_95}); const verdict_color = switch (result.verdict) { .generic_transcendental => GREEN, diff --git a/src/sacred/zeta_commands.zig b/src/sacred/zeta_commands.zig index a345e2ac06..9fe5d10812 100644 --- a/src/sacred/zeta_commands.zig +++ b/src/sacred/zeta_commands.zig @@ -102,7 +102,7 @@ pub fn runZetaVerdictCommand(allocator: std.mem.Allocator, args: []const []const try spacings.formatSummary(std.fs.File.stderr().deprecatedWriter()); const gue_result = try zeta_spacing.compareVsGUE(&spacings, allocator); - const gue_color = if (gue_result.ks_p_value > 0.05) GREEN else "\x1b[31m"; + const gue_color = if (gue_result.ks_statistic <= gue_result.ks_critical_95) GREEN else "\x1b[33m"; std.debug.print(" GUE comparison: {s}{s}{s}\n\n", .{ gue_color, gue_result.verdict, RESET }); std.debug.print("{s}═ STAGE 2: CF Analysis ═{s}\n", .{ CYAN, RESET }); @@ -148,7 +148,7 @@ fn determineFinalVerdict( pslq: *const zeta_pslq.PSLQSearchResult, ) FinalVerdict { // If all three tests point to generic/random - if (gue.ks_p_value > 0.05 and cf_stats.mu < 2.5 and pslq.relations_found == 0) { + if (gue.ks_statistic <= gue.ks_critical_95 and cf_stats.mu < 2.5 and pslq.relations_found == 0) { return FinalVerdict{ .verdict = .gue_consistent, .description = "GUE CONSISTENT: Spacings follow random matrix predictions. CF is generic, no arithmetic relations found.", diff --git a/src/sacred/zeta_spacing.zig b/src/sacred/zeta_spacing.zig index 5cb738c7f2..a7160dec01 100644 --- a/src/sacred/zeta_spacing.zig +++ b/src/sacred/zeta_spacing.zig @@ -176,8 +176,8 @@ pub fn normalizeSpacing(gamma_n: f64, gamma_np1: f64, T: f64) f64 { /// Compare spacing distribution to GUE (Gaussian Unitary Ensemble) prediction /// GUE predicts Wigner surmise for spacing distribution: P(s) = (32/π²) * s² * exp(-4s²/π) pub const GUEComparison = struct { - ks_statistic: f64, // Kolmogorov-Smirnov statistic - ks_p_value: f64, // p-value (approximate) + ks_statistic: f64, // Kolmogorov-Smirnov statistic D (effect size) + ks_critical_95: f64, // two-sided 95% critical value, 1.36/sqrt(n) verdict: []const u8, }; @@ -202,35 +202,80 @@ pub fn compareVsGUE(spacings: *const Spacings, allocator: std.mem.Allocator) !GU } const ks_stat = max_diff; - const p_value = ksPValue(ks_stat, spacings.count); - - const verdict = if (p_value > 0.05) - "CONSISTENT with GUE (p > 0.05)" - else if (p_value > 0.01) - "MARGINAL (0.01 < p < 0.05)" + const d_crit = ksCriticalValue95(spacings.count); + + // Reported as an effect size against the surmise, not as a hypothesis test: + // the surmise is an approximation to the exact GUE gap law, so at large n + // D exceeds D_crit for reasons that have nothing to do with the data. + const verdict = if (ks_stat <= d_crit) + "D <= D_crit(95%) vs Wigner surmise" + else if (ks_stat <= 2.0 * d_crit) + "D within 2x D_crit(95%) vs Wigner surmise (surmise is approximate; not a rejection of GUE)" else - "INCONSISTENT with GUE (p < 0.01)"; + "D > 2x D_crit(95%) vs Wigner surmise (compare against the exact GUE gap law before concluding)"; return GUEComparison{ .ks_statistic = ks_stat, - .ks_p_value = p_value, + .ks_critical_95 = d_crit, .verdict = verdict, }; } -/// Wigner surmise CDF for GUE spacing distribution +/// erf via Abramowitz & Stegun 7.1.26 (|error| <= 1.5e-7). +/// NOTE: the exp(-a^2) factor is part of the formula -- dropping it is the +/// defect found in tools/uart_echo_test.zig:normalCDFApprox. +fn erfAS(x: f64) f64 { + const sign: f64 = if (x < 0) -1.0 else 1.0; + const a = @abs(x); + const t = 1.0 / (1.0 + 0.3275911 * a); + const poly = ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t; + return sign * (1.0 - poly * std.math.exp(-a * a)); +} + +/// Wigner surmise CDF for the GUE spacing distribution, unit mean spacing. +/// +/// p(s) = (32/π²) s² exp(-4s²/π) +/// F(s) = erf(2s/√π) − (4s/π) exp(-4s²/π) [exact, closed form] +/// +/// Previous implementation was `1 − e^{-x}(1+x)`, x = 4s²/π. Differentiating it +/// gives (32/π²) s³ e^{-4s²/π} — an s³ density, not the GUE surmise. It also +/// produced the p95 = 1.93 / 2.15 family of wrong reference values. +/// Verified: dF/ds = p(s), F(∞) = 1, ∫s·p = 1 (see scripts, and the analytic +/// check that the erf coefficient is exactly 1 and the second term exactly 4s/π). fn wignerCDF(s: f64) f64 { - // P(S ≤ s) = 1 - exp(-4s²/π) * (1 + 4s²/π) - const x = 4.0 * s * s / std.math.pi; - return 1.0 - std.math.exp(-x) * (1.0 + x); + if (s <= 0.0) return 0.0; + const pi = std.math.pi; + return erfAS(2.0 * s / @sqrt(pi)) - (4.0 * s / pi) * std.math.exp(-4.0 * s * s / pi); } -/// Approximate p-value for KS statistic -fn ksPValue(ks_stat: f64, n: usize) f64 { - // Approximation: p ≈ 2 * exp(-2 * n * ks²) +/// Two-sided KS critical value at the 95% level, D_crit ≈ 1.36/√n. +/// +/// Replaces the former `ksPValue` (p ≈ 2·exp(−2n·D²)). A p-value here is not +/// meaningful: the Wigner surmise is not the exact GUE gap distribution +/// (that is a Fredholm determinant / Painlevé V), so the systematic +/// reference error is fixed while D_crit shrinks as 1/√n — at n = 10⁵ any +/// dataset is rejected by construction. Report D as an effect size instead. +fn ksCriticalValue95(n: usize) f64 { const n_f = @as(f64, @floatFromInt(n)); - const exponent = -2.0 * n_f * ks_stat * ks_stat; - return 2.0 * std.math.exp(exponent); + return 1.36 / @sqrt(n_f); +} + +test "wignerCDF: derivative reproduces the surmise pdf" { + const h = 1e-6; + for ([_]f64{ 0.1, 0.5, 1.0, 1.7518, 3.0 }) |s| { + const pdf = (32.0 / (std.math.pi * std.math.pi)) * s * s * std.math.exp(-4.0 * s * s / std.math.pi); + const d = (wignerCDF(s + h) - wignerCDF(s - h)) / (2.0 * h); + try std.testing.expect(@abs(d - pdf) < 1e-5); + } +} + +test "wignerCDF: normalised, and is GUE rather than GOE" { + try std.testing.expect(@abs(wignerCDF(12.0) - 1.0) < 1e-6); + // GUE(0.3) = 0.027254; GOE(0.3) = 1 - exp(-π·0.09/4) = 0.068245. + // The two curves cross near s = 1 (0.5331 vs 0.5441), so a guard placed + // at s = 1 would pass even if the functions were swapped. + try std.testing.expect(@abs(wignerCDF(0.3) - 0.027254) < 1e-4); + try std.testing.expect(@abs(wignerCDF(1.7518) - 0.95) < 1e-4); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -287,9 +332,9 @@ pub fn runZetaSpacingCommand(allocator: std.mem.Allocator, args: []const []const std.debug.print("\n{s}GUE COMPARISON:{s}\n", .{ CYAN, RESET }); const gue_result = try compareVsGUE(&spacings, allocator); - const verdict_color = if (gue_result.ks_p_value > 0.05) "\x1b[32m" else "\x1b[31m"; - std.debug.print(" KS statistic: {d:.6}\n", .{gue_result.ks_statistic}); - std.debug.print(" p-value: {d:.6}\n", .{gue_result.ks_p_value}); + const verdict_color = if (gue_result.ks_statistic <= gue_result.ks_critical_95) "\x1b[32m" else "\x1b[33m"; + std.debug.print(" KS statistic: {d:.6} (effect size, not a test)\n", .{gue_result.ks_statistic}); + std.debug.print(" D_crit(95%): {d:.6}\n", .{gue_result.ks_critical_95}); std.debug.print(" {s}Verdict: {s}{s}\n", .{ verdict_color, gue_result.verdict, RESET }); // Sample spacings diff --git a/src/tools/uart_echo_test.zig b/src/tools/uart_echo_test.zig index bd9adafd49..b4ee2c7c60 100644 --- a/src/tools/uart_echo_test.zig +++ b/src/tools/uart_echo_test.zig @@ -2686,33 +2686,26 @@ const JitterTracker = struct { }; } - // Normal CDF approximation (Abramowitz & Stegun formula) + /// Standard normal CDF, via erf from Abramowitz & Stegun 7.1.26. + /// + /// erf(a) = 1 - (a1 t + a2 t^2 + a3 t^3 + a4 t^4 + a5 t^5) * exp(-a^2), + /// t = 1/(1 + 0.3275911 a), |error| <= 1.5e-7 + /// + /// The previous implementation dropped the exp(-a^2) factor and mis-nested + /// the polynomial (it also added a spurious 0.3275911*t^5 term). Effect: + /// F(0) = 0.3362 instead of 0.5, |error| up to 0.164, and the range was + /// clamped to [0.086, 0.914] -- it never approached 0 or 1. Since the KS + /// critical value here is 1.36/sqrt(n), the reference error alone exceeded + /// it for n > ~250, so both the Normal and Log-Normal fits reported + /// "does not fit" for every sample of realistic size, independently of the + /// data. Verified against the exact CDF after the fix (max error 1.5e-7). fn normalCDFApprox(z: f64) f64 { const sign: f64 = if (z >= 0) 1.0 else -1.0; const a = @abs(z) / @sqrt(2.0); - const p = 0.3275911; - - const t = 1.0 / (1.0 + p * a); - const t2 = t * t; - const t3 = t2 * t; - const t4 = t3 * t; - const t5 = t4 * t; - - // Abramowitz & Stegun approximation for erf - const term1 = 0.254829592 * t5; - const term2 = -0.284496736 * t4; - const term3 = 1.421413741 * t3; - const term4 = -1.453152027 * t2; - const term5 = 1.061405429 * t; - const term6 = 0.3275911 * t5; - - const inner1 = term1 + term2; - const inner2 = inner1 + term3 * t3 + term4; - const inner3 = inner2 * t2 + term5; - const erf_approx = inner3 * t + term6; - - const erf = 1.0 - erf_approx; + const t = 1.0 / (1.0 + 0.3275911 * a); + const poly = ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t; + const erf = 1.0 - poly * @exp(-a * a); return 0.5 * (1.0 + sign * erf); } @@ -10575,3 +10568,18 @@ fn exportSimulationCSV(passed: usize, total: usize, total_time_ms: i64, jitter_t printErr("\n", .{}); printErr("\n[+] Simulation CSV export complete\n", .{}); } + +// Tests placed at file scope: test declarations nested inside a struct are not +// collected by `zig test` unless the struct is referenced, so the previous +// in-struct placement silently ran zero tests. +test "normalCDFApprox matches the standard normal CDF" { + const f = JitterTracker.normalCDFApprox; + // F(0) = 0.5 exactly; symmetry; tails actually reach 0 and 1. + // Before the fix: F(0) = 0.3362, |error| <= 0.164, range [0.086, 0.914]. + try std.testing.expect(@abs(f(0.0) - 0.5) < 1e-7); + try std.testing.expect(@abs(f(1.0) - 0.8413447) < 1e-6); + try std.testing.expect(@abs(f(-1.0) - 0.1586553) < 1e-6); + try std.testing.expect(@abs(f(1.959964) - 0.975) < 1e-6); + try std.testing.expect(f(-6.0) < 1e-6); + try std.testing.expect(f(6.0) > 1.0 - 1e-6); +}