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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
|
"""Closed-form batched gate: exact all-triple triangle energy.
The sampled triangle energy cost 5.7 ms per proposal because each
evaluation gathered a variable-length triple set in Python. It has a
closed form. Writing M(sigma) for the elementwise product of the
sigma-permuted text field with the visual field,
pairwise = const - 2 * sum(M)
all-triple = const - 2 * trace(M^3) / 6
because trace of a power is invariant under simultaneous row-column
permutation, so the text-only and vision-only terms do not move. One
batched matrix product evaluates trace(M^3) = sum(M * (M @ M)) for
hundreds of candidate permutations at once, over every C(N,3) triple
rather than a sample.
This buys a genuinely stronger searcher: exact steepest descent over all
N(N-1)/2 transpositions per step, plus batched-proposal tempering.
Hidden pairs score orderings only.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import torch
from .common import read_json, seed_everything, write_json
from .synth_triangle_gate import build_fields, standardized
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--fields", default="", help="Saved .pt with visual_field/text_field.")
parser.add_argument("--data-dir", default="artifacts/synth_v0")
parser.add_argument("--split", choices=["val", "test"], default="test")
parser.add_argument("--samples", type=int, default=512)
parser.add_argument("--merge-distance", type=float, default=30.0)
parser.add_argument("--vision-views", type=int, default=4)
parser.add_argument("--energies", default="pair,triangle,both")
parser.add_argument("--chunk", type=int, default=512)
parser.add_argument("--descent-restarts", type=int, default=5)
parser.add_argument("--descent-max-steps", type=int, default=4000)
parser.add_argument("--replicas", type=int, default=8)
parser.add_argument("--rounds", type=int, default=3000)
parser.add_argument("--proposals", type=int, default=64)
parser.add_argument("--temp-high", type=float, default=3e-2)
parser.add_argument("--temp-low", type=float, default=1e-4)
parser.add_argument("--exchange-every", type=int, default=20)
parser.add_argument("--polish-steps", type=int, default=2000)
parser.add_argument("--device", default="cuda:3")
parser.add_argument("--seed", type=int, default=20260731)
parser.add_argument("--output", default="artifacts/synth_v0/fast_gate.json")
return parser.parse_args()
class ClosedFormEnergy:
"""Energy as a function of M = permuted-text * visual, batched."""
def __init__(
self,
text: torch.Tensor,
visual: torch.Tensor,
pair_weight: float,
triangle_weight: float,
chunk: int,
) -> None:
self.text = text
self.visual = visual
self.pair_weight = pair_weight
self.triangle_weight = triangle_weight
self.chunk = chunk
self.size = len(visual)
self.pair_count = self.size * (self.size - 1)
self.triple_count = self.size * (self.size - 1) * (self.size - 2)
# Permutation-invariant constants, kept so reported values match
# the direct definitions of the two energies.
square_text = text * text
square_visual = visual * visual
self.pair_constant = float(square_text.sum() + square_visual.sum())
self.triangle_constant = float(
self._trace_cube(square_text[None])[0]
+ self._trace_cube(square_visual[None])[0]
)
@staticmethod
def _trace_cube(matrices: torch.Tensor) -> torch.Tensor:
return (matrices * torch.bmm(matrices, matrices)).sum((-2, -1))
def energy(self, permutations: torch.Tensor) -> torch.Tensor:
"""Exact energy for a batch of permutations [B, N]."""
values = []
for start in range(0, len(permutations), self.chunk):
block = permutations[start : start + self.chunk]
permuted = self.text[block[:, :, None], block[:, None, :]]
product = permuted * self.visual
total = torch.zeros(len(block), device=permuted.device)
if self.pair_weight:
pair = (self.pair_constant - 2.0 * product.sum((-2, -1))) / (
self.pair_count
)
total = total + self.pair_weight * pair
if self.triangle_weight:
triangle = (
self.triangle_constant - 2.0 * self._trace_cube(product)
) / self.triple_count
total = total + self.triangle_weight * triangle
values.append(total)
return torch.cat(values)
def all_swaps(size: int, device: torch.device) -> torch.Tensor:
rows, cols = torch.triu_indices(size, size, offset=1, device=device)
return torch.stack([rows, cols], dim=1)
def apply_swaps(permutation: torch.Tensor, swaps: torch.Tensor) -> torch.Tensor:
batch = permutation[None].repeat(len(swaps), 1)
index = torch.arange(len(swaps), device=permutation.device)
p, q = swaps[:, 0], swaps[:, 1]
values_p = batch[index, p].clone()
batch[index, p] = batch[index, q]
batch[index, q] = values_p
return batch
def steepest_descent(
energy: ClosedFormEnergy,
start: torch.Tensor,
swaps: torch.Tensor,
max_steps: int,
) -> tuple[torch.Tensor, float]:
"""Exact steepest descent over every transposition each step."""
current = start.clone()
value = float(energy.energy(current[None])[0])
for _ in range(max_steps):
candidates = apply_swaps(current, swaps)
values = energy.energy(candidates)
best = int(values.argmin())
if float(values[best]) >= value - 1e-12:
break
current = candidates[best]
value = float(values[best])
return current, value
def temper(
energy: ClosedFormEnergy,
starts: torch.Tensor,
args: argparse.Namespace,
generator: torch.Generator,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Batched-proposal parallel tempering; returns states and energies."""
size = energy.size
replicas = len(starts)
temperatures = torch.logspace(
torch.log10(torch.tensor(args.temp_low)),
torch.log10(torch.tensor(args.temp_high)),
replicas,
).to(device)
states = starts.clone()
values = energy.energy(states)
for round_index in range(args.rounds):
p = torch.randint(
0, size, (replicas, args.proposals), generator=generator
).to(device)
q = torch.randint(
0, size, (replicas, args.proposals), generator=generator
).to(device)
valid = p != q
batch = states[:, None, :].repeat(1, args.proposals, 1)
index_r = torch.arange(replicas, device=device)[:, None]
original_p = batch.gather(2, p[..., None]).squeeze(-1)
original_q = batch.gather(2, q[..., None]).squeeze(-1)
batch.scatter_(2, p[..., None], original_q[..., None])
batch.scatter_(2, q[..., None], original_p[..., None])
flat = batch.reshape(replicas * args.proposals, size)
proposal_values = energy.energy(flat).reshape(replicas, args.proposals)
deltas = proposal_values - values[:, None]
noise = torch.rand(
replicas, args.proposals, generator=generator
).to(device)
threshold = -temperatures[:, None] * noise.clamp_min(1e-12).log()
accept = (deltas < threshold) & valid
first = torch.where(
accept.any(-1),
accept.float().argmax(-1),
torch.zeros(replicas, dtype=torch.long, device=device),
)
taken = accept.any(-1)
chosen = batch[index_r.squeeze(-1), first]
states = torch.where(taken[:, None], chosen, states)
values = torch.where(
taken, proposal_values[index_r.squeeze(-1), first], values
)
if round_index % args.exchange_every == 0:
for replica in range(replicas - 1):
gap = (values[replica] - values[replica + 1]) * (
1.0 / temperatures[replica] - 1.0 / temperatures[replica + 1]
)
accept_swap = gap > 0 or float(
torch.rand(1, generator=generator)
) < float(gap.exp().clamp(max=1.0))
if accept_swap:
states[[replica, replica + 1]] = states[
[replica + 1, replica]
]
values[[replica, replica + 1]] = values[
[replica + 1, replica]
]
return states, values
def main() -> None:
args = parse_args()
seed_everything(args.seed)
if args.fields:
state = torch.load(args.fields, map_location="cpu", weights_only=False)
visual_field, text_field = state["visual_field"], state["text_field"]
else:
manifest = read_json(Path(args.data_dir, "manifest.json"))
rows = manifest[args.split][: args.samples]
visual_field, text_field = build_fields(args, rows, manifest)
device = torch.device(args.device)
size = len(visual_field)
generator = torch.Generator().manual_seed(args.seed)
hidden = torch.randperm(size, generator=generator)
truth = torch.argsort(hidden).to(device) # scored, never optimized against
text = standardized(text_field[hidden][:, hidden].double().to(device)).float()
visual = standardized(visual_field.double().to(device)).float()
swaps = all_swaps(size, device)
weights = {"pair": (1.0, 0.0), "triangle": (0.0, 1.0), "both": (1.0, 1.0)}
report = {
"protocol": (
"Closed-form energies over all triples; the decision statistic "
"is the energy of the truth against the deepest state reached "
"by exact steepest descent and batched tempering. Hidden pairs "
"score only."
),
"samples": size,
"energies": {},
}
for name in (item.strip() for item in args.energies.split(",")):
pair_weight, triangle_weight = weights[name]
energy = ClosedFormEnergy(
text, visual, pair_weight, triangle_weight, args.chunk
)
true_energy = float(energy.energy(truth[None])[0])
random_batch = torch.stack(
[
torch.argsort(torch.rand(size, generator=generator))
for _ in range(200)
]
).to(device)
random_values = energy.energy(random_batch)
kept, kept_value = steepest_descent(
energy, truth, swaps, args.descent_max_steps
)
retention = float((kept.cpu() == truth.cpu()).float().mean())
quenches = []
for restart in range(args.descent_restarts):
start = torch.argsort(torch.rand(size, generator=generator)).to(device)
final, value = steepest_descent(
energy, start, swaps, args.descent_max_steps
)
quenches.append(
{
"energy": value,
"accuracy": float((final.cpu() == truth.cpu()).float().mean()),
}
)
starts = torch.stack(
[
torch.argsort(torch.rand(size, generator=generator))
for _ in range(args.replicas)
]
).to(device)
states, values = temper(energy, starts, args, generator, device)
cold = int(values.argmin())
polished, polished_value = steepest_descent(
energy, states[cold], swaps, args.polish_steps
)
tempering = {
"cold_energy": float(values[cold]),
"polished_energy": polished_value,
"polished_accuracy": float(
(polished.cpu() == truth.cpu()).float().mean()
),
"best_replica_accuracy": max(
float((state.cpu() == truth.cpu()).float().mean())
for state in states
),
}
deepest = min(
[polished_value, float(values.min())]
+ [item["energy"] for item in quenches]
)
entry = {
"true_energy": true_energy,
"random_mean": float(random_values.mean()),
"true_z": float(
(random_values.mean() - true_energy)
/ random_values.std().clamp_min(1e-12)
),
"descent_retention_diagnostic": retention,
"descent_from_truth_energy": kept_value,
"quenches": quenches,
"tempering": tempering,
"deepest_seen": deepest,
"margin_over_true": deepest / abs(true_energy) - true_energy / abs(true_energy),
"passes": bool(deepest >= true_energy - 1e-9),
"recovery_accuracy": tempering["polished_accuracy"],
}
report["energies"][name] = entry
print(
json.dumps(
{
name: {
key: entry[key]
for key in (
"true_energy",
"deepest_seen",
"passes",
"recovery_accuracy",
"descent_retention_diagnostic",
"true_z",
)
}
}
)
)
write_json(args.output, report)
print(f"Wrote {args.output}")
if __name__ == "__main__":
main()
def all_pair_swap_deltas(
permuted_text: torch.Tensor, visual: torch.Tensor
) -> torch.Tensor:
"""Energy change of every transposition at once, for the pairwise term.
The brute-force descent forms all N(N-1)/2 candidate permutations and
scores each through the full energy, which at 256 scenes means 32,640
matrices per step and, with the triangle term active, a batched cube
trace over every one of them -- 203 seconds for a single descent. The
pairwise term does not need any of that. Writing S for the alignment sum
and C for `permuted_text @ visual`, swapping positions p and q changes S
by 2(C_pq + C_qp - C_pp - C_qq + 2 A_pq B_pq), so one matrix product
yields the entire table.
Both fields must be symmetric with zero diagonal, which standardisation
already guarantees. Returns a dense [N, N] table of energy deltas; the
sign convention matches `ClosedFormEnergy.energy`, so negative is an
improvement.
"""
size = permuted_text.shape[-1]
product = permuted_text @ visual
diagonal = torch.diagonal(product)
gain = 2.0 * (
product + product.T
- diagonal[:, None] - diagonal[None, :]
+ 2.0 * permuted_text * visual
)
return -2.0 * gain / (size * (size - 1))
def fast_pair_descent(
text: torch.Tensor, visual: torch.Tensor, start: torch.Tensor, max_steps: int
) -> torch.Tensor:
"""Steepest descent on the pairwise energy using the closed-form table."""
size = len(visual)
current = start.clone()
triangle = torch.triu(
torch.ones(size, size, dtype=torch.bool, device=visual.device), diagonal=1
)
for _ in range(max_steps):
permuted = text[current[:, None], current[None, :]]
deltas = all_pair_swap_deltas(permuted, visual)
deltas = torch.where(triangle, deltas, torch.inf)
best = int(deltas.argmin())
if float(deltas.view(-1)[best]) >= -1e-12:
break
p, q = best // size, best % size
current[[p, q]] = current[[q, p]]
return current
|