summaryrefslogtreecommitdiff
path: root/worldalign/synth_transfer_energy.py
diff options
context:
space:
mode:
Diffstat (limited to 'worldalign/synth_transfer_energy.py')
-rw-r--r--worldalign/synth_transfer_energy.py276
1 files changed, 276 insertions, 0 deletions
diff --git a/worldalign/synth_transfer_energy.py b/worldalign/synth_transfer_energy.py
new file mode 100644
index 0000000..67a081f
--- /dev/null
+++ b/worldalign/synth_transfer_energy.py
@@ -0,0 +1,276 @@
+"""Prediction-transfer alignment energy for the synthetic world.
+
+The third rung of the energy ladder: no hand-designed geometry and no
+pair-trained EBM. Each modality's own predictor induces a substitution
+kernel over scenes -- which other scenes the world model finds compatible
+-- and the alignment energy is the conjugacy defect of the two kernels
+under a candidate coupling.
+
+- Vision kernel: the masked-reconstruction tower inpaints a half-masked
+ render; pixel distance between the inpainting and other scenes' renders
+ gives K_V[i, i'].
+- Text kernel: the causal LM scores scene j's caption sentences as a
+ continuation of scene i's caption prefix; referential consistency makes
+ same-content continuations likely, giving K_T.
+
+Both kernels are unimodal-predictor functionals. Hidden pairs enter only
+through the gate that scores orderings of the energy.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import torch
+import torch.nn.functional as F
+from tqdm import tqdm
+
+from .common import batch_indices, read_json, seed_everything, write_json
+from .manifold_gate import standardize_relation
+from .ricci_control import run_gates
+from .synth_towers import TextTower, VisionTower, load_image, tokenize
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--data-dir", default="artifacts/synth_v0")
+ parser.add_argument(
+ "--vision-tower", default="artifacts/synth_v0/vision_tower_simmim.pt"
+ )
+ parser.add_argument("--text-tower", default="artifacts/synth_v0/text_tower.pt")
+ parser.add_argument("--split", choices=["val", "test"], default="test")
+ parser.add_argument("--samples", type=int, default=512)
+ parser.add_argument("--mask-ratio", type=float, default=0.5)
+ parser.add_argument("--kernel-temperature", type=float, default=0.03)
+ parser.add_argument("--batch-size", type=int, default=256)
+ parser.add_argument("--random-perms", type=int, default=300)
+ parser.add_argument("--descent-restarts", type=int, default=5)
+ parser.add_argument("--descent-max-steps", type=int, default=200000)
+ parser.add_argument(
+ "--descent-objective", default="mse", choices=["mse", "m30_total"]
+ )
+ parser.add_argument("--descent-verify-top", type=int, default=64)
+ parser.add_argument("--device", default="cuda:3")
+ parser.add_argument("--seed", type=int, default=20260731)
+ parser.add_argument(
+ "--output", default="artifacts/synth_v0/transfer_energy_gate.json"
+ )
+ parser.add_argument(
+ "--kernels-output", default="artifacts/synth_v0/transfer_kernels.pt"
+ )
+ return parser.parse_args()
+
+
+@torch.inference_mode()
+def vision_kernel(
+ rows: list[int], manifest: dict, args: argparse.Namespace
+) -> torch.Tensor:
+ state = torch.load(args.vision_tower, map_location="cpu", weights_only=False)
+ saved = state["args"]
+ model = VisionTower(
+ manifest["image_size"], saved["patch"], saved["dim"], saved["depth"], saved["heads"]
+ ).to(args.device)
+ model.load_state_dict(state["model"])
+ model.eval()
+ image_dir = Path(manifest["image_dir"])
+ tokens = (manifest["image_size"] // saved["patch"]) ** 2
+ generator = torch.Generator(device=args.device).manual_seed(args.seed)
+
+ references = []
+ for indices in batch_indices(len(rows), args.batch_size):
+ pixels = torch.stack(
+ [load_image(image_dir / f"scene{rows[i]:06d}_v0.png") for i in indices]
+ )
+ references.append(pixels)
+ references = torch.cat(references) # [N, 3, H, W] view-0 renders on cpu
+
+ inpainted = []
+ for indices in tqdm(
+ list(batch_indices(len(rows), args.batch_size)), desc="vision kernel"
+ ):
+ # Inpaint view 1 under a random half mask; compare to view-0 renders.
+ pixels = torch.stack(
+ [load_image(image_dir / f"scene{rows[i]:06d}_v1.png") for i in indices]
+ ).to(args.device)
+ mask = (
+ torch.rand(len(pixels), tokens, generator=generator, device=args.device)
+ < args.mask_ratio
+ )
+ encoded = model.encode(pixels, mask=mask)
+ patches = model.reconstruction(encoded[:, 1:]) # [B, T, p*p*3]
+ grid = manifest["image_size"] // saved["patch"]
+ patch = saved["patch"]
+ image = patches.reshape(len(pixels), grid, grid, patch, patch, 3)
+ image = image.permute(0, 5, 1, 3, 2, 4).reshape(
+ len(pixels), 3, manifest["image_size"], manifest["image_size"]
+ )
+ blended = torch.where(
+ mask.reshape(len(pixels), 1, grid, 1, grid, 1)
+ .expand(-1, 3, -1, patch, -1, patch)
+ .reshape_as(image),
+ image,
+ pixels,
+ )
+ inpainted.append(blended.float().cpu())
+ inpainted = torch.cat(inpainted)
+
+ # Layout-invariant content distance: Chamfer between foreground patch
+ # sets in pixel space. Renders of the same scene share patch CONTENT
+ # (colors, local shape fragments) while positions are resampled, so
+ # per-pixel comparison would measure layout instead.
+ patch = saved["patch"]
+ grid = manifest["image_size"] // patch
+
+ def patch_sets(images: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
+ pieces = images.reshape(len(images), 3, grid, patch, grid, patch)
+ pieces = pieces.permute(0, 2, 4, 1, 3, 5).reshape(len(images), grid * grid, -1)
+ background = pieces.mean(-1)
+ foreground = background > background.min(1, keepdim=True).values + 0.02
+ return pieces, foreground
+
+ inpaint_pieces, inpaint_fg = patch_sets(inpainted)
+ reference_pieces, reference_fg = patch_sets(references)
+ distances = torch.zeros(len(rows), len(rows))
+ device = args.device
+ reference_pieces = reference_pieces.to(device)
+ reference_fg = reference_fg.to(device)
+ for start in range(0, len(rows), 32):
+ stop = min(start + 32, len(rows))
+ block = inpaint_pieces[start:stop].to(device)
+ block_fg = inpaint_fg[start:stop].to(device)
+ cross = torch.cdist(block, reference_pieces.reshape(-1, block.shape[-1]))
+ cross = cross.reshape(stop - start, block.shape[1], len(rows), -1)
+ masked_ab = cross.masked_fill(
+ ~reference_fg[None, None, :, :], float("inf")
+ ).amin(-1)
+ forward = (
+ (masked_ab * block_fg[:, :, None]).sum(1)
+ / block_fg.sum(1).clamp_min(1)[:, None]
+ )
+ masked_ba = cross.masked_fill(
+ ~block_fg[:, :, None, None].expand_as(cross), float("inf")
+ ).amin(1)
+ backward = (
+ (masked_ba * reference_fg[None, :, :]).sum(-1)
+ / reference_fg.sum(-1).clamp_min(1)[None, :]
+ )
+ distances[start:stop] = (0.5 * (forward + backward)).float().cpu()
+ distances[torch.isinf(distances) | torch.isnan(distances)] = distances[
+ torch.isfinite(distances)
+ ].max()
+ return distances
+
+
+@torch.inference_mode()
+def text_kernel(
+ rows: list[int], captions: list[list[str]], args: argparse.Namespace
+) -> torch.Tensor:
+ state = torch.load(args.text_tower, map_location="cpu", weights_only=False)
+ saved = state["args"]
+ vocab = state["vocab"]
+ model = TextTower(
+ len(vocab), saved["text_dim"], saved["depth"], saved.get("text_heads", 4), saved["context"]
+ ).to(args.device)
+ model.load_state_dict(state["model"])
+ model.eval()
+
+ prefixes = [tokenize(captions[row][0], vocab) for row in rows]
+ # Continuations are the RELATION sentences only: they refer back to
+ # groups the prefix must have introduced, so their likelihood grades
+ # shared content instead of rewarding verbatim repetition.
+ continuations = []
+ for row in rows:
+ sentences = captions[row][1].split(". ")
+ relational = ". ".join(sentences[1:]) if len(sentences) > 1 else sentences[0]
+ continuations.append(tokenize(relational, vocab)[1:]) # drop bos
+ scores = torch.zeros(len(rows), len(rows))
+ jobs = [(i, j) for i in range(len(rows)) for j in range(len(rows))]
+ for indices in tqdm(
+ list(batch_indices(len(jobs), args.batch_size)), desc="text kernel"
+ ):
+ batch = [jobs[k] for k in indices]
+ sequences = [
+ (prefixes[i] + continuations[j])[: saved["context"]] for i, j in batch
+ ]
+ longest = max(len(s) for s in sequences)
+ tokens = torch.zeros(len(batch), longest, dtype=torch.long)
+ for row, sequence in enumerate(sequences):
+ tokens[row, : len(sequence)] = torch.tensor(sequence)
+ tokens = tokens.to(args.device)
+ hidden = model(tokens)
+ logits = model.logits(hidden[:, :-1])
+ log_probs = F.log_softmax(logits, dim=-1)
+ for row, (i, j) in enumerate(batch):
+ begin = len(prefixes[i]) - 1
+ end = min(len(prefixes[i]) + len(continuations[j]), longest) - 1
+ if end <= begin:
+ scores[i, j] = 0.0
+ continue
+ targets = tokens[row, begin + 1 : end + 1]
+ picked = log_probs[row, begin:end].gather(1, targets[:, None])
+ scores[i, j] = float(picked.mean())
+ return -scores # negative mean log-likelihood as a distance
+
+
+def main() -> None:
+ args = parse_args()
+ seed_everything(args.seed)
+ manifest = read_json(Path(args.data_dir, "manifest.json"))
+ captions = read_json(Path(args.data_dir, "captions.json"))["captions"]
+ rows = manifest[args.split][: args.samples]
+
+ visual_distance = vision_kernel(rows, manifest, args)
+ text_distance = text_kernel(rows, captions, args)
+ torch.save(
+ {
+ "rows": rows,
+ "visual_distance": visual_distance,
+ "text_distance": text_distance,
+ },
+ args.kernels_output,
+ )
+
+ # Substitution kernels as standardized relation channels: negative
+ # distances, symmetrized, standardized off-diagonal. The gate machinery
+ # then scores orderings exactly as for any relation field.
+ visual_similarity = -visual_distance
+ text_similarity = -0.5 * (text_distance + text_distance.T)
+ visual_channels = standardize_relation(
+ 0.5 * (visual_similarity + visual_similarity.T).double()
+ )[0][None]
+ text_channels = standardize_relation(text_similarity.double())[0][None]
+ generator = torch.Generator().manual_seed(args.seed)
+ report = {
+ "protocol": (
+ "Substitution kernels are unimodal-predictor functionals "
+ "(masked inpainting distance; caption continuation "
+ "likelihood). Hidden pairs score orderings only."
+ ),
+ "split": args.split,
+ "samples": len(rows),
+ **run_gates(text_channels, visual_channels, args, generator),
+ }
+ verdict = {
+ "true_z_mse": report["gate_a"]["random"]["mse"]["true_z"],
+ "improving_fraction": report["gate_b"]["improving_fraction"],
+ "descent_keeps": report["descent_from_true"]["final_accuracy"],
+ "true_mse": report["gate_a"]["true"]["mse"],
+ "best_random_descent": min(
+ (r["final_objective"] for r in report["descent_from_random"]),
+ default=None,
+ ),
+ }
+ verdict["counterfeit_found"] = bool(
+ verdict["best_random_descent"] is not None
+ and verdict["best_random_descent"] < verdict["true_mse"]
+ )
+ report["verdict"] = verdict
+ print(json.dumps({"verdict": verdict}))
+ write_json(args.output, report)
+ print(f"Wrote {args.output}")
+
+
+if __name__ == "__main__":
+ main()