summaryrefslogtreecommitdiff
path: root/worldalign/rank_gate.py
blob: d55f12e8ffdf4f91997494e60eff1700c23f1eea (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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
"""Is low-rank failure an information limit or a solver limit?

The rank ladder is the result that retires the correlation gate, so it has to
survive the objection the project has fallen for three times before: a limit
that looks intrinsic and turns out to belong to the search operator. At rank
eight the composed solver reaches 13%, and that alone does not say whether the
truth is unreachable or merely unreached.

The gate answers it. If the deepest state a strong searcher finds is deeper
than the truth, the truth is not the optimum and no solver of any cost
recovers it -- the width really is an information limit. If the truth is
deepest and the solver still misses it, the ladder measures search difficulty
instead and the conclusion has to be rewritten.
"""

from __future__ import annotations

import argparse
import json

import numpy as np
import torch

from .common import write_json
from .spectral_match import grampa
from .synth_fast_gate import ClosedFormEnergy, all_swaps, steepest_descent
from .synth_triangle_gate import standardized


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--fields", default="artifacts/synth_v1/fields_tier0_ws_256.pt")
    parser.add_argument("--ranks", type=int, nargs="+", default=[4, 8, 16, 256])
    parser.add_argument("--restarts", type=int, default=60)
    parser.add_argument("--trials", type=int, default=3)
    parser.add_argument("--device", default="cuda:3")
    parser.add_argument("--output", default="artifacts/synth_v1/rank_gate.json")
    return parser.parse_args()


def offdiagonal(matrix: np.ndarray) -> np.ndarray:
    return matrix[~np.eye(len(matrix), dtype=bool)]


def normalise(matrix: np.ndarray) -> np.ndarray:
    values = offdiagonal(matrix)
    out = (matrix - values.mean()) / values.std()
    np.fill_diagonal(out, 0.0)
    return out


def truncate(matrix: np.ndarray, rank: int) -> np.ndarray:
    symmetric = (matrix + matrix.T) / 2.0
    values, vectors = np.linalg.eigh(symmetric)
    order = np.argsort(np.abs(values))[::-1][:rank]
    return (vectors[:, order] * values[order]) @ vectors[:, order].T


def main() -> None:
    args = parse_args()
    state = torch.load(args.fields, map_location="cpu", weights_only=False)
    visual_full = state["visual_field"].double().numpy()
    text_full = state["text_field"].double().numpy()
    size = len(visual_full)
    device = torch.device(args.device)
    swaps = all_swaps(size, device)

    rows = []
    for rank in args.ranks:
        visual = normalise(truncate(visual_full, rank) if rank < size else visual_full)
        text = normalise(truncate(text_full, rank) if rank < size else text_full)
        verdicts, gaps, accuracies = [], [], []
        for trial in range(args.trials):
            generator = np.random.default_rng(trial)
            hidden = generator.permutation(size)
            shuffled = text[np.ix_(hidden, hidden)]
            energy = ClosedFormEnergy(
                standardized(torch.from_numpy(shuffled).to(device)).float(),
                standardized(torch.from_numpy(visual).to(device)).float(),
                1.0, 1.0, 256,
            )
            truth = torch.from_numpy(np.argsort(hidden).copy()).to(device)
            truth_energy = float(energy.energy(truth[None])[0])

            starts = [torch.from_numpy(grampa(visual, shuffled, 1.0).copy()).to(device)]
            starts += [
                torch.from_numpy(generator.permutation(size).copy()).to(device)
                for _ in range(args.restarts)
            ]
            best_energy, best_accuracy = np.inf, 0.0
            for start in starts:
                final, _ = steepest_descent(energy, start, swaps, 4000)
                value = float(energy.energy(final[None])[0])
                if value < best_energy:
                    best_energy = value
                    best_accuracy = float(
                        (hidden[final.cpu().numpy()] == np.arange(size)).mean()
                    )
            verdicts.append(truth_energy <= best_energy + 1e-6)
            gaps.append(best_energy - truth_energy)
            accuracies.append(best_accuracy)

        row = {
            "rank": rank,
            "truth_is_deepest": f"{sum(verdicts)}/{args.trials}",
            "mean_energy_gap_best_minus_truth": float(np.mean(gaps)),
            "best_accuracy": float(np.mean(accuracies)),
            "reading": (
                "information limit" if sum(verdicts) == 0 else
                "truth is optimal; failure is search" if sum(verdicts) == args.trials
                else "mixed"
            ),
        }
        rows.append(row)
        print(
            f"rank={rank:<5} truth deepest {row['truth_is_deepest']}  "
            f"gap(best-truth)={row['mean_energy_gap_best_minus_truth']:+.4f}  "
            f"best acc={row['best_accuracy']:.3f}  -> {row['reading']}",
            flush=True,
        )

    summary = {
        "protocol": (
            "Spectral start plus random restarts, each run to a local optimum "
            "under exact steepest descent. A negative gap means the searcher "
            "found a state deeper than the truth, so the truth is not the "
            "optimum and the limit is information rather than search."
        ),
        "size": size,
        "restarts": args.restarts,
        "rows": rows,
    }
    print(json.dumps({"done": True}))
    write_json(args.output, summary)


if __name__ == "__main__":
    main()