ptyche 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: ptyche
3
+ Version: 0.1.0
4
+ Summary: Trustworthy nonlinear-dynamics invariants (D2, Lyapunov, K2) from non-generic sensor data
5
+ Author-email: Christian Knopp <cknopp@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Zynerji/Ptyche
8
+ Project-URL: Repository, https://github.com/Zynerji/Ptyche
9
+ Keywords: nonlinear dynamics,chaos,correlation dimension,lyapunov exponent,kolmogorov-sinai entropy,takens embedding,attractor reconstruction,time series,grassberger-procaccia
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=1.21
20
+ Requires-Dist: scipy>=1.7
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # Ptyche
26
+
27
+ **Trustworthy nonlinear-dynamics invariants from real (non-generic) sensor data.**
28
+
29
+ *Ptyche* (Greek πτυχή, "a fold") detects the attractor **folding** that corrupts chaos
30
+ invariants when a sensor records a non-generic observable, and recovers the true values.
31
+
32
+ ```bash
33
+ pip install ptyche
34
+ ```
35
+
36
+ ## The bottleneck it breaks
37
+
38
+ Most sensors don't record a system's state — they record a *nonlinear function* of it:
39
+ intensity (∝ amplitude²), power, magnitude `|x|`, rectified or log signals. Takens' embedding
40
+ theorem only guarantees attractor reconstruction for **generic** observables. An even/symmetric
41
+ observable of a (near-)symmetric system — the usual case for intensity/power — is **non-generic**:
42
+ the delay embedding reconstructs only the *symmetry quotient* of the attractor. Standard
43
+ pipelines then pick an embedding dimension `m` by hand and report a correlation dimension `D2`,
44
+ Lyapunov exponent `λ₁`, or entropy `K2` that is **silently wrong** (folded, under-resolved) — and
45
+ sometimes lands on a famous constant, producing a false "discovery."
46
+
47
+ Ptyche breaks this **reliability bottleneck** (it is not magic and breaks no law of computation)
48
+ with the same three-step machinery applied to every invariant:
49
+
50
+ 1. **Converge** — estimate the invariant across embedding dimensions and report the **plateau /
51
+ most-converged value**, not a single hand-picked `m` (GP estimates inflate at large `m`; the
52
+ low-`m` value under-resolves).
53
+ 2. **Detect folding** — flag genericity failure (`D2` rises sharply from low `m` to the plateau)
54
+ and recover the true dimension.
55
+ 3. **Guard against false constants** — warn when an *unconverged* estimate sits on an **exotic**
56
+ number (golden ratio, plastic number, √2, e, π, Feigenbaum δ, …). Integers and simple
57
+ fractions are deliberately excluded — a limit cycle's `D2 = 1` is real, not a trap.
58
+
59
+ ## Three invariants, one wrapper
60
+
61
+ | function | invariant | method |
62
+ |---|---|---|
63
+ | `correlation_dimension` / `analyze` | `D2` | Grassberger–Procaccia, plateau-converged |
64
+ | `lyapunov_rosenstein` | `λ₁` | Rosenstein mean-log-divergence (+ `r²` fit quality) |
65
+ | `k2_entropy` | `K2` | GP correlation entropy, convergence-aware (KS lower bound) |
66
+
67
+ ## Worked example (`python demo.py`)
68
+
69
+ A square-law (intensity) detector watching the Lorenz attractor (true `D2 ≈ 2.06`,
70
+ `λ₁ ≈ 0.906/t`):
71
+
72
+ ```
73
+ --- square-law observable x(t)^2 (intensity/power sensor) ---
74
+ D2 by embedding m: {3: 1.306, 4: 1.615, 5: 1.881, 6: 1.937, 7: 1.964, 8: 1.998}
75
+ naive D2 (low m) = 1.306 | converged/best D2 = 1.998
76
+ verdict: FOLDED (use converged D2, distrust the naive value)
77
+ * FALSE-CONSTANT TRAP: low-m D2=1.306 ~ plastic number rho (1.3247) -- a resolution
78
+ artifact, NOT a coincidence; the converged D2 = 2.00.
79
+ ```
80
+
81
+ The naive pipeline would have reported `D2 ≈ 1.33` (the plastic ratio!). Ptyche flags the
82
+ folding, recovers `D2 ≈ 2`, and names the trap. On the *generic* observable it returns the
83
+ trustworthy `D2 ≈ 2.06`, `λ₁ ≈ 0.91/t`.
84
+
85
+ ## Use
86
+
87
+ ```python
88
+ import numpy as np, ptyche as p
89
+
90
+ rep = p.analyze(signal, fs=1/dt) # fs = sampling rate -> rates in per-time units
91
+ print(rep.verdict, rep.D2_converged, rep.folded, rep.false_constant_traps)
92
+ print(rep.lyapunov1["lambda1"], rep.k2_entropy["K2"])
93
+ for note in rep.notes: # plain-language caveats (folding, fit quality, convergence)
94
+ print(note)
95
+ ```
96
+
97
+ Individual estimators: `p.lyapunov_rosenstein(x, fs=...)`, `p.k2_entropy(x, fs=...)`,
98
+ `p.embedding_scan`, `p.converged_dimension`, `p.folding_score`, `p.false_constant_warnings`,
99
+ and `p.logperiodic_squarelaw_check` (for discrete-scale-invariant signals: a square-law detector
100
+ measures the apparent rescaling ratio as √λ, not λ).
101
+
102
+ ## Honest scope
103
+
104
+ Ptyche makes existing scalar-series estimators **harder to fool**; it does not beat dedicated
105
+ multi-channel Lyapunov packages on precision. `D2` and `λ₁` are essentially exact on Lorenz;
106
+ `K2` from a single scalar series converges slowly and is reported **with** its convergence flag
107
+ (treat a non-converged `K2` as an upper estimate). Slow-`λ` systems (e.g. Rössler) may need a
108
+ wider `fit_window` — the `r²` field tells you when the fit is poor.
109
+
110
+ ## Provenance
111
+
112
+ Derived from the *Systrophē* chronology-protection study, where a gravitational-wave
113
+ cross-polarization `ψ ∝ ω²` (an even observable of the Z₂-symmetric Lorenz attractor)
114
+ under-reconstructed the attractor, and a `D2 ≈ 1.30 ≈` plastic-number coincidence turned out to
115
+ be a low-embedding artifact. Ptyche packages that lesson as a general guard.
116
+
117
+ Dependencies: `numpy`, `scipy`. License: MIT.
@@ -0,0 +1,6 @@
1
+ ptyche.py,sha256=Zt82_PxG36d4w1xUNHlv0gt4Ryqwk3xRg2fhGFGsN5k,19418
2
+ ptyche-0.1.0.dist-info/licenses/LICENSE,sha256=fR9yA8xD4ZbEKaSXyWCpb06OttIN2BY3h_d6yCljnGs,1072
3
+ ptyche-0.1.0.dist-info/METADATA,sha256=CcCTBTzs-zt6QBAdFqh6RFwmxsBc2w1JxTmVyml3Fjo,5792
4
+ ptyche-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ ptyche-0.1.0.dist-info/top_level.txt,sha256=ww5woN6aVWr18V0VHZCxo569e7Qc5_h3W7P9pnWvoX0,7
6
+ ptyche-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Knopp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ptyche
ptyche.py ADDED
@@ -0,0 +1,397 @@
1
+ """Ptyche (Greek pi-tau-upsilon-chi-eta, "a fold") -- trustworthy nonlinear-dynamics
2
+ invariants from non-generic (e.g. square-law) observations.
3
+
4
+ The bottleneck
5
+ --------------
6
+ Real sensors usually record a *nonlinear function* of a system's state -- intensity
7
+ (proportional to amplitude^2), power, magnitude |x|, rectified or log signals. Takens'
8
+ embedding theorem guarantees attractor reconstruction only for *generic* observables; an
9
+ even/symmetric observable of a symmetric system (the common case for intensity/power) is
10
+ NON-generic: the delay embedding reconstructs only the symmetry *quotient* of the attractor.
11
+ Standard pipelines (pick an embedding dimension m, compute the correlation dimension D2 /
12
+ Lyapunov / entropy) then return values that are SILENTLY WRONG -- folded and under-resolved --
13
+ and occasionally land near a famous constant, producing false "discoveries."
14
+
15
+ Ptyche breaks this in three steps:
16
+ 1. CONVERGE -- estimate the invariant across embedding dimensions and report the converged
17
+ value (a single low-m number is untrustworthy).
18
+ 2. DETECT -- flag attractor folding / genericity failure (D2 still climbing with m;
19
+ signal sign-collapse) and recover the true dimension.
20
+ 3. GUARD -- warn when an UNCONVERGED estimate coincides with a famous constant
21
+ (the "plastic-ratio trap": D2(m=3)=1.33 ~ plastic number, but D2 -> 2.06).
22
+
23
+ The same converge/detect/guard machinery wraps three invariants:
24
+ * D2 -- correlation dimension (Grassberger-Procaccia),
25
+ * lambda1 -- largest Lyapunov exponent (Rosenstein), and
26
+ * K2 -- correlation (Kolmogorov-Sinai lower-bound) entropy.
27
+
28
+ It is not magic and breaks no law of computation: it breaks the *reliability* bottleneck of
29
+ nonlinear-dynamics estimation under realistic, non-generic sensing.
30
+
31
+ Dependencies: numpy, scipy.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass, field
37
+
38
+ import numpy as np
39
+ from scipy.spatial import cKDTree
40
+ from scipy.spatial.distance import pdist
41
+
42
+ __version__ = "0.1.0"
43
+
44
+ # Exotic constants people get falsely excited to "discover" in an unconverged dimension
45
+ # estimate. Deliberately EXCLUDES integers and simple fractions (1, 2, 3, 1/2, ...): those are
46
+ # legitimate attractor dimensions (limit cycle = 1, torus/Lorenz ~ 2), not numerology traps.
47
+ FAMOUS_CONSTANTS = {
48
+ "golden ratio phi": 1.6180339887,
49
+ "plastic number rho": 1.3247179572,
50
+ "sqrt(2)": 1.4142135624,
51
+ "sqrt(3)": 1.7320508076,
52
+ "e": 2.7182818285,
53
+ "pi": 3.1415926536,
54
+ "ln 2": 0.6931471806,
55
+ "Feigenbaum delta": 4.6692016091,
56
+ "Euler-Mascheroni gamma": 0.5772156649,
57
+ }
58
+
59
+
60
+ # --------------------------------------------------------------------------- #
61
+ # Correlation dimension (Grassberger-Procaccia) with robust scaling-band fit.
62
+ # --------------------------------------------------------------------------- #
63
+ def _gp_dimension(points: np.ndarray, n_eps: int = 28) -> float:
64
+ d = pdist(points)
65
+ d = d[d > 0]
66
+ if d.size < 50:
67
+ return float("nan")
68
+ dmin, dmax = np.percentile(d, 0.5), np.percentile(d, 99)
69
+ if not (dmax > dmin > 0):
70
+ return float("nan")
71
+ eps = np.logspace(np.log10(dmin), np.log10(dmax), n_eps)
72
+ npair = d.size
73
+ C = np.array([np.count_nonzero(d < e) / npair for e in eps])
74
+ valid = C > 0
75
+ le, lc = np.log(eps[valid]), np.log(C[valid])
76
+ band = (C[valid] >= 3e-3) & (C[valid] <= 0.15) # below saturation, above depletion
77
+ if band.sum() < 4:
78
+ lo, hi = int(0.2 * len(le)), int(0.6 * len(le))
79
+ band = np.zeros(len(le), bool); band[lo:hi] = True
80
+ slope, _ = np.polyfit(le[band], lc[band], 1)
81
+ return float(slope)
82
+
83
+
84
+ def _delay_embed(x: np.ndarray, m: int, tau: int) -> np.ndarray:
85
+ n = len(x) - (m - 1) * tau
86
+ if n <= 0:
87
+ raise ValueError("series too short for embedding")
88
+ return np.column_stack([x[i * tau:i * tau + n] for i in range(m)])
89
+
90
+
91
+ def autocorr_delay(x: np.ndarray, max_lag: int = 2000) -> int:
92
+ """Takens delay = first lag where autocorrelation drops below 1/e."""
93
+ x = np.asarray(x, float) - np.mean(x)
94
+ var = float(np.dot(x, x))
95
+ if var == 0:
96
+ return 1
97
+ for k in range(1, min(max_lag, len(x) - 1)):
98
+ if np.dot(x[:-k], x[k:]) / var < np.exp(-1.0):
99
+ return max(k, 1)
100
+ return max(1, len(x) // 20)
101
+
102
+
103
+ def correlation_dimension(x: np.ndarray, m: int = 5, tau: int | None = None,
104
+ n_points: int = 6000) -> float:
105
+ """GP correlation dimension of a scalar series via delay embedding.
106
+
107
+ Subsamples to n_points (temporal decorrelation). NOTE: GP estimates inflate at large m
108
+ with finite data (curse of dimensionality); trust the plateau, not the largest m -- which
109
+ is exactly why `converged_dimension` reports the flattest m-window rather than max(m)."""
110
+ x = np.asarray(x, float)
111
+ if tau is None:
112
+ tau = autocorr_delay(x)
113
+ emb = _delay_embed(x, m, tau)
114
+ idx = np.linspace(0, len(emb) - 1, min(n_points, len(emb))).astype(int)
115
+ return _gp_dimension(emb[idx])
116
+
117
+
118
+ # --------------------------------------------------------------------------- #
119
+ # Largest Lyapunov exponent (Rosenstein 1993) -- from a scalar series.
120
+ # --------------------------------------------------------------------------- #
121
+ def lyapunov_rosenstein(x: np.ndarray, m: int = 7, tau: int | None = None, fs: float = 1.0,
122
+ theiler: int | None = None, cap: int = 8000, k_max: int | None = None,
123
+ fit_window: tuple = (0.02, 0.4)) -> dict:
124
+ """Largest Lyapunov exponent via Rosenstein's mean-log-divergence method.
125
+
126
+ Tracks how nearest-neighbour trajectory pairs separate: <ln d(k)> grows linearly with
127
+ slope = lambda1 (per sample). Pass fs = 1/dt to get lambda1 per unit time (default per
128
+ sample). Uses a CONTIGUOUS slice (no striding -- the step size must equal the sampling
129
+ interval) and a Theiler window to exclude temporally-correlated neighbours.
130
+
131
+ Returns {'lambda1', 'lambda1_per_sample', 'divergence_curve', 'fit_k', 'r2'}.
132
+ """
133
+ x = np.asarray(x, float)
134
+ if tau is None:
135
+ tau = autocorr_delay(x)
136
+ xc = x[:min(len(x), cap)]
137
+ emb = _delay_embed(xc, m, tau)
138
+ M = len(emb)
139
+ if M < 200:
140
+ return {"lambda1": float("nan"), "lambda1_per_sample": float("nan"),
141
+ "divergence_curve": np.array([]), "fit_k": (0, 0), "r2": float("nan")}
142
+ if theiler is None:
143
+ theiler = max(tau * (m - 1), tau) # one embedding window ~ one orbit
144
+ if k_max is None:
145
+ k_max = min(int(2.0 * theiler), M // 4)
146
+ tree = cKDTree(emb)
147
+ # nearest neighbour of each point that is at least `theiler` samples away in time
148
+ kq = min(M, 2 * theiler + 5)
149
+ dists, idxs = tree.query(emb, k=kq)
150
+ nbr = np.full(M, -1, int)
151
+ for i in range(M):
152
+ for j, di in zip(idxs[i][1:], dists[i][1:]):
153
+ if abs(int(j) - i) > theiler:
154
+ nbr[i] = int(j); break
155
+ valid0 = np.where(nbr >= 0)[0]
156
+ div = np.full(k_max + 1, np.nan)
157
+ for k in range(k_max + 1):
158
+ ii = valid0[(valid0 + k < M) & (nbr[valid0] + k < M)]
159
+ if ii.size < 20:
160
+ break
161
+ d = np.linalg.norm(emb[ii + k] - emb[nbr[ii] + k], axis=1)
162
+ d = d[d > 0]
163
+ if d.size:
164
+ div[k] = float(np.mean(np.log(d)))
165
+ ks = np.where(np.isfinite(div))[0]
166
+ if ks.size < 5:
167
+ return {"lambda1": float("nan"), "lambda1_per_sample": float("nan"),
168
+ "divergence_curve": div, "fit_k": (0, 0), "r2": float("nan")}
169
+ lo = max(1, int(fit_window[0] * k_max)); hi = max(lo + 3, int(fit_window[1] * k_max))
170
+ seg = ks[(ks >= lo) & (ks <= hi)]
171
+ if seg.size < 3:
172
+ seg = ks[:max(3, ks.size // 2)]
173
+ slope, intercept = np.polyfit(seg, div[seg], 1)
174
+ resid = div[seg] - (slope * seg + intercept)
175
+ ss = float(np.sum((div[seg] - np.mean(div[seg])) ** 2))
176
+ r2 = float(1.0 - np.sum(resid ** 2) / ss) if ss > 0 else float("nan")
177
+ return {"lambda1": float(slope * fs), "lambda1_per_sample": float(slope),
178
+ "divergence_curve": div, "fit_k": (int(seg[0]), int(seg[-1])), "r2": r2}
179
+
180
+
181
+ # --------------------------------------------------------------------------- #
182
+ # K2 correlation entropy (Grassberger-Procaccia) -- KS-entropy lower bound.
183
+ # --------------------------------------------------------------------------- #
184
+ def _corr_sum(points: np.ndarray, eps: np.ndarray) -> np.ndarray:
185
+ d = pdist(points); d = d[d > 0]; n = d.size
186
+ if n < 50:
187
+ return np.full(len(eps), np.nan)
188
+ return np.array([np.count_nonzero(d < e) / n for e in eps])
189
+
190
+
191
+ def _k2_pair(x, m1, m2, tau, n_points):
192
+ """Single (m, m+1) GP entropy estimate: (fs-free) ln[C_m/C_{m+1}] / tau, per sample."""
193
+ e1 = _delay_embed(x, m1, tau); e2 = _delay_embed(x, m2, tau)
194
+ i1 = np.linspace(0, len(e1) - 1, min(n_points, len(e1))).astype(int)
195
+ i2 = np.linspace(0, len(e2) - 1, min(n_points, len(e2))).astype(int)
196
+ p1, p2 = e1[i1], e2[i2]
197
+ d1 = pdist(p1); d1 = d1[d1 > 0]; d2 = pdist(p2); d2 = d2[d2 > 0]
198
+ if d1.size < 50 or d2.size < 50:
199
+ return float("nan")
200
+ lo = np.log10(max(np.percentile(d1, 1), np.percentile(d2, 1)))
201
+ hi = np.log10(min(np.percentile(d1, 50), np.percentile(d2, 50)))
202
+ if not (hi > lo):
203
+ return float("nan")
204
+ eps = np.logspace(lo, hi, 18)
205
+ C1, C2 = _corr_sum(p1, eps), _corr_sum(p2, eps)
206
+ band = np.isfinite(C1) & np.isfinite(C2) & (C1 >= 1e-2) & (C1 <= 0.1) & (C2 > 0)
207
+ if band.sum() < 3:
208
+ return float("nan")
209
+ return float(np.mean(np.log(C1[band] / C2[band])) / tau)
210
+
211
+
212
+ def k2_entropy(x: np.ndarray, m_values=(3, 4, 5, 6, 7, 8), tau: int | None = None,
213
+ fs: float = 1.0, n_points: int = 4000, tol: float = 0.15) -> dict:
214
+ """Correlation entropy K2 (a lower bound on the Kolmogorov-Sinai entropy h_KS).
215
+
216
+ From the embedding-dimension scaling of the correlation sum,
217
+ C_m(eps) ~ eps^{D2} * exp(-m * tau * K2),
218
+ the finite-m estimate K2(m) = (fs/tau) * <ln[ C_m / C_{m+1} ]>_eps DECREASES toward the true
219
+ K2 as m grows (low-m overestimates). Mirroring the dimension pillar, this returns the whole
220
+ K2(m) sequence, the most-converged (largest reliable m) value, and a convergence flag --
221
+ rather than one misleading low-m number. Pass fs = 1/dt for per-time units.
222
+
223
+ Returns {'K2', 'K2_per_sample', 'k2_by_m', 'converged', 'descending'}. A positive converged
224
+ K2 is a chaos signature; K2 -> 0 for periodic/quasiperiodic signals. It remains an ESTIMATE
225
+ (a KS lower bound that converges slowly from above); read it with its convergence flag.
226
+ """
227
+ x = np.asarray(x, float)
228
+ if tau is None:
229
+ tau = autocorr_delay(x)
230
+ ms = sorted(int(m) for m in m_values)
231
+ k2_by_m = {}
232
+ for m1, m2 in zip(ms[:-1], ms[1:]):
233
+ v = _k2_pair(x, m1, m2, tau, n_points)
234
+ if np.isfinite(v):
235
+ k2_by_m[m1] = float(v * fs)
236
+ if not k2_by_m:
237
+ return {"K2": float("nan"), "K2_per_sample": float("nan"), "k2_by_m": {},
238
+ "converged": False, "descending": False}
239
+ keys = sorted(k2_by_m); vals = [k2_by_m[k] for k in keys]
240
+ k2 = vals[-1] # most-converged (largest m) value
241
+ converged = bool(len(vals) >= 2 and abs(vals[-1] - vals[-2]) / max(abs(vals[-1]), 1e-9) < tol)
242
+ descending = bool(len(vals) >= 2 and vals[-1] < vals[0])
243
+ return {"K2": float(k2), "K2_per_sample": float(k2 / fs), "k2_by_m": k2_by_m,
244
+ "converged": converged, "descending": descending}
245
+
246
+
247
+ # --------------------------------------------------------------------------- #
248
+ # The three pillars: converge, detect folding, guard against false constants.
249
+ # --------------------------------------------------------------------------- #
250
+ def embedding_scan(x: np.ndarray, m_values=(3, 4, 5, 6, 7, 8), tau: int | None = None,
251
+ n_points: int = 6000) -> dict:
252
+ """D2 across embedding dimensions m (the curve a single number hides). The default m
253
+ range stays below the finite-sample inflation regime for low-dimensional attractors."""
254
+ if tau is None:
255
+ tau = autocorr_delay(x)
256
+ d2 = {int(m): correlation_dimension(x, m=int(m), tau=tau, n_points=n_points)
257
+ for m in m_values}
258
+ return {"tau": int(tau), "d2_by_m": d2}
259
+
260
+
261
+ def converged_dimension(d2_by_m: dict, plateau_tol: float = 0.10) -> dict:
262
+ """Best D2 = the PLATEAU (flattest consecutive-m window), NOT max(m).
263
+
264
+ GP estimates rise from an underestimate at small m, plateau near the true dimension, then
265
+ inflate at large m (finite-sample). The plateau is the trustworthy value. The low-m value
266
+ and the rise-to-plateau quantify folding / under-resolution.
267
+ """
268
+ ms = sorted(d2_by_m)
269
+ vals = np.array([d2_by_m[m] for m in ms], float)
270
+ ok = np.isfinite(vals); ms = list(np.array(ms)[ok]); vals = vals[ok]
271
+ if len(vals) < 2:
272
+ return {"D2": float(vals[-1]) if len(vals) else float("nan"),
273
+ "converged": False, "rise": float("nan"), "D2_lowm": float("nan"),
274
+ "m_at_D2": (ms[-1] if ms else None), "top_spread": float("nan")}
275
+ # GP underestimates from below within the (capped, pre-inflation) m range, so the best
276
+ # estimate is the maximum; convergence = the top of the curve has flattened.
277
+ D2 = float(np.max(vals))
278
+ top_spread = float(abs(vals[-1] - vals[-2]))
279
+ return {"D2": D2, "converged": bool(top_spread < plateau_tol),
280
+ "top_spread": top_spread, "m_at_D2": int(ms[int(np.argmax(vals))]),
281
+ "D2_lowm": float(vals[0]), "rise": float(D2 - vals[0])}
282
+
283
+
284
+ def false_constant_warnings(value: float, tol_rel: float = 0.02) -> list:
285
+ """Flag a value suspiciously close to a famous constant (the numerology trap)."""
286
+ if not np.isfinite(value):
287
+ return []
288
+ out = []
289
+ for name, c in FAMOUS_CONSTANTS.items():
290
+ if c > 0 and abs(value - c) / c < tol_rel:
291
+ out.append({"constant": name, "value": c,
292
+ "rel_diff": float(abs(value - c) / c)})
293
+ return out
294
+
295
+
296
+ def _folding_score(D2_lowm: float, D2_plateau: float) -> float:
297
+ """0..1: relative rise from the low-m estimate to the plateau (folding signature).
298
+ ~0 = generic/well-reconstructed; large = the low-m value is a folded under-estimate."""
299
+ if not (np.isfinite(D2_lowm) and np.isfinite(D2_plateau)) or D2_plateau <= 0:
300
+ return float("nan")
301
+ return float(np.clip((D2_plateau - D2_lowm) / D2_plateau, 0.0, 1.0))
302
+
303
+
304
+ def folding_score(x: np.ndarray, **kw) -> float:
305
+ """Convenience: folding score of a series (0 generic .. 1 strongly folded)."""
306
+ scan = embedding_scan(x, **kw)
307
+ conv = converged_dimension(scan["d2_by_m"])
308
+ return _folding_score(conv["D2_lowm"], conv["D2"])
309
+
310
+
311
+ @dataclass
312
+ class FoldReport:
313
+ D2_naive_lowm: float
314
+ D2_converged: float
315
+ converged: bool
316
+ folded: bool
317
+ folding_score: float
318
+ rise_with_m: float
319
+ tau: int
320
+ d2_by_m: dict
321
+ false_constant_traps: list = field(default_factory=list)
322
+ verdict: str = ""
323
+ notes: list = field(default_factory=list)
324
+ lyapunov1: dict | None = None
325
+ k2_entropy: dict | None = None
326
+
327
+
328
+ def analyze(x: np.ndarray, m_values=(3, 4, 5, 6, 7, 8), tau: int | None = None,
329
+ n_points: int = 6000, dynamics: bool = True, fs: float = 1.0) -> FoldReport:
330
+ """One-call diagnosis. Returns a FoldReport with the trustworthy invariant + warnings.
331
+
332
+ With dynamics=True also estimates the largest Lyapunov exponent (Rosenstein) and the
333
+ correlation entropy K2 behind the same convergence/folding caveats. Pass fs = 1/dt for the
334
+ dynamical rates in per-time units (default per-sample)."""
335
+ x = np.asarray(x, float)
336
+ scan = embedding_scan(x, m_values=m_values, tau=tau, n_points=n_points)
337
+ conv = converged_dimension(scan["d2_by_m"])
338
+ fold_s = _folding_score(conv["D2_lowm"], conv["D2"])
339
+ folded = bool(np.isfinite(fold_s) and fold_s > 0.25)
340
+ # only warn about a famous-constant coincidence when the naive value is actually
341
+ # misleading (folded / not converged); an integer D2 of a genuine limit cycle is not a trap.
342
+ traps = false_constant_warnings(conv.get("D2_lowm", float("nan"))) if folded else []
343
+ notes = []
344
+ if folded:
345
+ notes.append(
346
+ f"Attractor FOLDING / non-generic observable: D2 climbs from "
347
+ f"{conv['D2_lowm']:.2f} (m={m_values[0]}) to {conv['D2']:.2f} (m={m_values[-1]}); "
348
+ f"the low-m value is an under-resolved quotient, not the true dimension.")
349
+ if traps:
350
+ names = ", ".join(t["constant"] for t in traps)
351
+ notes.append(
352
+ f"FALSE-CONSTANT TRAP: the unconverged low-m D2={conv.get('D2_lowm', float('nan')):.3f} "
353
+ f"~ {names}. This is a resolution artifact, NOT a real coincidence -- the converged "
354
+ f"D2={conv['D2']:.2f}.")
355
+ if not conv["converged"]:
356
+ notes.append("D2 not yet plateaued: report as a LOWER BOUND; increase m / data length.")
357
+ if conv["converged"] and not folded:
358
+ notes.append("Reconstruction looks generic and converged; D2 is trustworthy.")
359
+ verdict = ("FOLDED (use converged D2, distrust the naive value)" if folded
360
+ else ("CONVERGED" if conv["converged"] else "UNDER-RESOLVED (lower bound only)"))
361
+ lya = ent = None
362
+ if dynamics:
363
+ lya = lyapunov_rosenstein(x, tau=scan["tau"], fs=fs)
364
+ ent = k2_entropy(x, m_values=m_values, tau=scan["tau"], fs=fs)
365
+ if folded:
366
+ notes.append("Under folding the embedding reconstructs the symmetry quotient, so "
367
+ "lambda1 / K2 are quotient-attractor estimates -- interpret with the "
368
+ "same caution as the folded D2.")
369
+ if lya and np.isfinite(lya.get("r2", float("nan"))) and lya["r2"] < 0.9:
370
+ notes.append("lambda1 divergence fit is poor (r2 < 0.9): widen fit_window / add data.")
371
+ if ent and not ent.get("converged", False):
372
+ notes.append("K2 has not converged across m (still descending): treat as an upper "
373
+ "estimate of the KS-entropy lower bound.")
374
+ return FoldReport(
375
+ D2_naive_lowm=conv.get("D2_lowm", float("nan")),
376
+ D2_converged=conv["D2"], converged=conv["converged"], folded=folded,
377
+ folding_score=fold_s, rise_with_m=conv["rise"], tau=scan["tau"],
378
+ d2_by_m=scan["d2_by_m"], false_constant_traps=traps, verdict=verdict, notes=notes,
379
+ lyapunov1=lya, k2_entropy=ent)
380
+
381
+
382
+ # --------------------------------------------------------------------------- #
383
+ # Bonus: log-periodic (discrete-scale-invariance) square-law correction.
384
+ # --------------------------------------------------------------------------- #
385
+ def logperiodic_squarelaw_check(signal_in_u: np.ndarray, u: np.ndarray) -> dict:
386
+ """For a log-periodic (DSI) signal sampled uniformly in u=ln r, a square-law detector
387
+ measures DOUBLE the log-frequency (apparent rescaling ratio sqrt(lambda), not lambda).
388
+ Returns the measured log-frequency of the signal and of its square."""
389
+ def logfreq(s):
390
+ s = s - np.mean(s); sgn = np.sign(s); sgn[sgn == 0] = 1
391
+ n_zero = int(np.sum(np.abs(np.diff(sgn)) > 0))
392
+ return n_zero * np.pi / float(u[-1] - u[0])
393
+ a_lin = logfreq(signal_in_u)
394
+ a_sq = logfreq(signal_in_u ** 2)
395
+ return {"logfreq_linear": a_lin, "logfreq_squarelaw": a_sq,
396
+ "doubling_ratio": (a_sq / a_lin if a_lin else float("nan")),
397
+ "apparent_dsi_is_sqrt_of_true": True}