summaryrefslogtreecommitdiff
path: root/toy/toy_transient_chaos.py
blob: 7a6c6bda7e13545fcc9f06aae59a1834461dde52 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""Minimal analytically-grounded toy reproducing 'failure trajectories are more chaotic'.

Framing: recursive reasoning = chaotic SEARCH of latent space until the trajectory lands in the
solution basin, then it converges (the answer is a stable fixed point). At a fixed readout time T:
  - captured by T  => SUCCESS, finite-time Lyapunov exponent (FTLE) is low (chaotic search for a
                      while, then contraction).
  - not captured   => FAILURE, FTLE stays at the chaotic-saddle value.

Map on [0,1] (input s = solution location, drawn per 'puzzle'):
  search phase:  x <- 4 x (1-x)            (fully chaotic, lambda = ln 2 ~ +0.693)
  capture:       if |x - s| < eps  -> converging phase: x <- s + 0.5 (x - s)   (lambda = ln 0.5 < 0)

This is a textbook chaotic-saddle / transient-chaos system: escape (capture) times are ~geometric,
and FTLE over [0,T] = (ln2 * t_search + ln0.5 * (T - t_search)) / T. So FTLE separates outcome
PURELY as a finite-time artifact of capture time -- matching our real-model findings:
  (i) failure more chaotic, (ii) separation is concurrent/finite-time (vanishes as T->inf since
  everyone eventually captures), (iii) it is convergence detection, not a deep correctness signal.

Run: python toy_transient_chaos.py   (CPU, seconds). Produces toy_transient_chaos.png + stats.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

HERE = Path(__file__).resolve().parent
rng = np.random.default_rng(0)


def run(n=20000, T=16, eps=0.04, seed=0):
    rg = np.random.default_rng(seed)
    s = rg.uniform(0.15, 0.85, n)           # 'puzzle': solution location
    x = rg.uniform(0, 1, n)                  # initial latent
    captured = np.zeros(n, bool)
    cap_time = np.full(n, T, int)
    log_deriv_sum = np.zeros(n)
    drift = np.zeros((n, T))
    for t in range(T):
        x_prev = x.copy()
        search = ~captured
        # log|f'| this step
        ld = np.empty(n)
        ld[search] = np.log(np.abs(4 * (1 - 2 * x[search])) + 1e-12)
        ld[~search] = np.log(0.5)
        # update
        x_new = x.copy()
        x_new[search] = 4 * x[search] * (1 - x[search])
        x_new[~search] = s[~search] + 0.5 * (x[~search] - s[~search])
        # capture check (enter basin during search)
        newly = search & (np.abs(x_new - s) < eps)
        captured |= newly
        cap_time[newly] = t + 1
        x = x_new
        log_deriv_sum += ld
        drift[:, t] = np.abs(x - x_prev)
    ftle = log_deriv_sum / T
    success = captured & (np.abs(x - s) < 0.05)   # near solution at readout
    return dict(ftle=ftle, success=success, cap_time=cap_time, drift=drift, T=T, eps=eps)


def auc(score, y):
    p, n = score[y == 1], score[y == 0]
    if len(p) == 0 or len(n) == 0:
        return float("nan")
    a = np.concatenate([p, n]); o = np.argsort(a); r = np.empty(len(a)); r[o] = np.arange(1, len(a) + 1)
    return float((r[:len(p)].sum() - len(p) * (len(p) + 1) / 2) / (len(p) * len(n)))


def main():
    d = run(T=16)
    y = d["success"].astype(int); ftle = d["ftle"]
    print(f"=== Toy transient-chaos (T=16, eps={d['eps']}) ===")
    print(f"success rate = {y.mean():.3f}")
    print(f"FTLE: success median={np.median(ftle[y==1]):+.3f}  failure median={np.median(ftle[y==0]):+.3f}")
    print(f"AUC(-FTLE -> success) = {auc(-ftle, y):.3f}   (>0.9 => reproduces 'failure more chaotic')")
    late_drift = d["drift"][:, -4:].mean(1)
    print(f"late drift: success median={np.median(late_drift[y==1]):.3f} failure median={np.median(late_drift[y==0]):.3f}")
    print(f"AUC(-late_drift -> success) = {auc(-late_drift, y):.3f}")

    # FTLE separation vs readout time T (the finite-time / concurrent-not-antecedent prediction)
    Ts = [4, 8, 16, 32, 64, 128, 256]
    seps, accs = [], []
    for T in Ts:
        dd = run(T=T)
        yy = dd["success"].astype(int)
        seps.append(auc(-dd["ftle"], yy)); accs.append(yy.mean())
    print("\nFTLE-AUC and success-rate vs readout time T (separation is a finite-time effect):")
    for T, a, ac in zip(Ts, seps, accs):
        print(f"  T={T:>3}: AUC(-FTLE->success)={a:.3f}  success_rate={ac:.3f}")

    fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))
    bins = np.linspace(-0.5, 0.75, 50)
    ax[0].hist(ftle[y == 1], bins=bins, alpha=0.6, color="tab:green", density=True, label=f"success (n={int(y.sum())})")
    ax[0].hist(ftle[y == 0], bins=bins, alpha=0.6, color="tab:red", density=True, label=f"failure (n={int((1-y).sum())})")
    ax[0].axvline(0, color="gray", lw=0.6); ax[0].set_xlabel("finite-time Lyapunov exponent (T=16)")
    ax[0].set_title(f"Toy: failure more chaotic\nAUC={auc(-ftle,y):.3f}"); ax[0].legend(fontsize=8)
    ax[1].hist(d["cap_time"][d["cap_time"] < 16], bins=range(0, 17), color="tab:blue", alpha=0.8)
    ax[1].set_xlabel("capture (escape) time"); ax[1].set_ylabel("count")
    ax[1].set_title("escape-time distribution (~geometric:\nchaotic-saddle signature)")
    ax[2].plot(Ts, seps, "o-", label="AUC(-FTLE->success)")
    ax[2].plot(Ts, accs, "s--", color="tab:purple", label="success rate")
    ax[2].set_xscale("log"); ax[2].axhline(0.5, color="gray", lw=0.5)
    ax[2].set_xlabel("readout time T"); ax[2].set_title("separation is FINITE-TIME:\nvanishes as T->inf (all escape)")
    ax[2].legend(fontsize=8)
    fig.tight_layout(); fig.savefig(HERE / "toy_transient_chaos.png", dpi=140)
    print("\nsaved toy_transient_chaos.png")


if __name__ == "__main__":
    main()