topoexploit 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,416 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ExploitGym Data Loader for TopoExploit
4
+
5
+ Clones the ExploitGym repo and tokenizes task data into training memmaps.
6
+ Each task contains vulnerability descriptions, patches, exploit docs, and PoC source.
7
+
8
+ Tier structure:
9
+ Tier 1: vulnerability_analysis -- CVE description -> vulnerability analysis
10
+ Tier 2: patch_analysis -- source + patch -> fix explanation
11
+ Tier 3: exploit_development -- vulnerability -> exploit strategy + PoC code
12
+
13
+ Author: Gris Iscomeback
14
+ License: GPL v3
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import subprocess
23
+ import time
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ import numpy as np
29
+
30
+ from .model import BPETokenizer
31
+
32
+
33
+ EXPLOITGYM_REPO = "https://github.com/sunblaze-ucb/exploitgym.git"
34
+ EXPLOITGYM_BRANCH = "main"
35
+ TASK_IDS_FILE = "data/task_ids/v1.txt"
36
+
37
+
38
+ @dataclass
39
+ class ExploitGymLoaderConfig:
40
+ data_cache: str = "data_exploitgym"
41
+ repo_cache: str = ".exploitgym_repo"
42
+ max_tokens_per_tier: Tuple[int, int, int] = (50_000_000, 80_000_000, 100_000_000)
43
+ val_fraction: float = 0.05
44
+ holdout_fraction: float = 0.05
45
+ split_seed: int = 2026
46
+ max_file_chars: int = 32_000
47
+ tier_names: Tuple[str, str, str] = (
48
+ "vulnerability_analysis",
49
+ "patch_analysis",
50
+ "exploit_development",
51
+ )
52
+
53
+
54
+ def ensure_repo(repo_cache: str, logger: logging.Logger) -> Path:
55
+ repo_path = Path(repo_cache)
56
+ if repo_path.exists() and (repo_path / ".git").exists():
57
+ logger.info(f"ExploitGym repo found at {repo_path}, pulling latest...")
58
+ try:
59
+ subprocess.run(
60
+ ["git", "-C", str(repo_path), "pull", "--ff-only"],
61
+ capture_output=True, timeout=60,
62
+ )
63
+ except Exception:
64
+ logger.warning("git pull failed, using existing repo")
65
+ else:
66
+ logger.info(f"Cloning ExploitGym to {repo_path}...")
67
+ subprocess.run(
68
+ ["git", "clone", "--depth=1", "-b", EXPLOITGYM_BRANCH,
69
+ EXPLOITGYM_REPO, str(repo_path)],
70
+ check=True, timeout=300,
71
+ )
72
+ return repo_path
73
+
74
+
75
+ def _read_file_safe(path: Path, max_chars: int = 32000) -> str:
76
+ try:
77
+ text = path.read_text(encoding="utf-8", errors="replace")
78
+ return text[:max_chars]
79
+ except (OSError, UnicodeDecodeError):
80
+ return ""
81
+
82
+
83
+ def _parse_task_ids(repo_path: Path) -> List[str]:
84
+ ids_path = repo_path / TASK_IDS_FILE
85
+ if not ids_path.exists():
86
+ return []
87
+ return [
88
+ line.strip() for line in ids_path.read_text().splitlines()
89
+ if line.strip() and not line.startswith("#")
90
+ ]
91
+
92
+
93
+ def _task_id_to_path(task_id: str) -> Optional[Path]:
94
+ parts = task_id.split(":", 1)
95
+ if len(parts) != 2:
96
+ return None
97
+ family, name = parts
98
+ return Path(family) / name
99
+
100
+
101
+ def parse_task(task_dir: Path, task_id: str, max_chars: int) -> Optional[Dict[str, str]]:
102
+ family = task_id.split(":")[0] if ":" in task_id else "unknown"
103
+ rel = _task_id_to_path(task_id)
104
+ if rel is None:
105
+ return None
106
+ full_dir = task_dir / rel
107
+ if not full_dir.exists():
108
+ return None
109
+
110
+ result: Dict[str, str] = {"task_id": task_id, "family": family}
111
+
112
+ if family == "kernel":
113
+ result["description"] = _read_file_safe(full_dir / "docs" / "vulnerability.md", max_chars)
114
+ result["exploit_doc"] = _read_file_safe(full_dir / "docs" / "exploit.md", max_chars)
115
+ result["novel_techniques"] = _read_file_safe(full_dir / "docs" / "novel-techniques.md", max_chars)
116
+ result["reference_exploit"] = _read_file_safe(full_dir / "docs" / "reference-exploit.md", max_chars)
117
+ result["patch"] = _read_file_safe(full_dir / "patch.diff", max_chars)
118
+ result["pov_c"] = _read_file_safe(full_dir / "pov" / "pov.c", max_chars)
119
+ elif family == "user":
120
+ result["description"] = _read_file_safe(full_dir / "description.txt", max_chars)
121
+ result["patch"] = _read_file_safe(full_dir / "patch.diff", max_chars)
122
+ result["pov_c"] = _read_file_safe(full_dir / "pov" / "pov.c", max_chars)
123
+ elif family == "v8":
124
+ result["description"] = _read_file_safe(full_dir / "description.txt", max_chars)
125
+ result["patch"] = _read_file_safe(full_dir / "patch.diff", max_chars)
126
+ result["pov_c"] = _read_file_safe(full_dir / "pov" / "pov.c", max_chars)
127
+ else:
128
+ return None
129
+
130
+ has_any = any(result.get(k, "").strip() for k in
131
+ ["description", "exploit_doc", "patch", "pov_c"])
132
+ return result if has_any else None
133
+
134
+
135
+
136
+
137
+ def _format_vulnerability_analysis(task: Dict[str, str]) -> Optional[str]:
138
+ tid = task.get("task_id", "unknown")
139
+ family = task.get("family", "unknown")
140
+ desc = task.get("description", "").strip()
141
+ exploit_doc = task.get("exploit_doc", "").strip()
142
+ if not desc and not exploit_doc:
143
+ return None
144
+ parts = [f"### Task: {tid} ({family} vulnerability)"]
145
+ if desc:
146
+ parts.append(f"### Vulnerability Description\n{desc}")
147
+ if exploit_doc:
148
+ lines = exploit_doc.split("\n")[:100]
149
+ parts.append("### Vulnerability Analysis\n" + "\n".join(lines))
150
+ return "\n\n".join(parts) + "\n"
151
+
152
+
153
+ def _format_patch_analysis(task: Dict[str, str]) -> Optional[str]:
154
+ patch = task.get("patch", "").strip()
155
+ desc = task.get("description", "").strip()
156
+ exploit_doc = task.get("exploit_doc", "").strip()
157
+ if not patch:
158
+ return None
159
+ parts = [f"### Task: {task.get('task_id', 'unknown')}"]
160
+ if desc:
161
+ parts.append(f"### Vulnerability Context\n{desc}")
162
+ parts.append(f"### Patch\n```diff\n{patch}\n```")
163
+ if exploit_doc:
164
+ parts.append(f"### Exploit Documentation\n{exploit_doc[:4000]}")
165
+ return "\n\n".join(parts) + "\n"
166
+
167
+
168
+ def _format_exploit_development(task: Dict[str, str]) -> Optional[str]:
169
+ desc = task.get("description", "").strip()
170
+ exploit_doc = task.get("exploit_doc", "").strip()
171
+ novel_doc = task.get("novel_techniques", "").strip()
172
+ pov_c = task.get("pov_c", "").strip()
173
+ ref_exploit = task.get("reference_exploit", "").strip()
174
+ if not desc and not pov_c:
175
+ return None
176
+ parts = []
177
+ tid = task.get("task_id", "unknown")
178
+ family = task.get("family", "unknown")
179
+ parts.append(f"### Task: {tid} ({family} exploit development)")
180
+ if desc:
181
+ parts.append(f"### Vulnerability\n{desc}")
182
+ if exploit_doc:
183
+ parts.append(f"### Exploit Strategy\n{exploit_doc[:4000]}")
184
+ if novel_doc:
185
+ parts.append(f"### Novel Techniques\n{novel_doc[:2000]}")
186
+ if ref_exploit:
187
+ parts.append(f"### Reference Exploit\n{ref_exploit[:4000]}")
188
+ if pov_c:
189
+ parts.append(f"### Proof of Concept Code\n```c\n{pov_c[:8000]}\n```")
190
+ return "\n\n".join(parts) + "\n"
191
+
192
+
193
+ FORMATTERS = {
194
+ "vulnerability_analysis": _format_vulnerability_analysis,
195
+ "patch_analysis": _format_patch_analysis,
196
+ "exploit_development": _format_exploit_development,
197
+ }
198
+
199
+
200
+ class ExploitGymDataLoader:
201
+ def __init__(self, config: ExploitGymLoaderConfig, tokenizer: BPETokenizer,
202
+ logger: logging.Logger):
203
+ self.config = config
204
+ self.tokenizer = tokenizer
205
+ self.logger = logger
206
+ self.cache_dir = Path(config.data_cache)
207
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
208
+
209
+ def _tier_paths(self, tier: str) -> Dict[str, Path]:
210
+ return {
211
+ split: self.cache_dir / f"tokens_{tier}_{split}.bin"
212
+ for split in ("train", "val", "holdout")
213
+ }
214
+
215
+ def _manifest_path(self, tier: str) -> Path:
216
+ return self.cache_dir / f"manifest_{tier}.json"
217
+
218
+ def _already_prepared(self, tier: str) -> bool:
219
+ paths = self._tier_paths(tier)
220
+ manifest = self._manifest_path(tier)
221
+ if not manifest.exists():
222
+ return False
223
+ if not all(p.exists() for p in paths.values()):
224
+ return False
225
+ try:
226
+ with open(manifest, "r") as f:
227
+ meta = json.load(f)
228
+ sizes = meta.get("sizes", {})
229
+ except (json.JSONDecodeError, OSError):
230
+ return False
231
+ for split, p in paths.items():
232
+ disk_n = p.stat().st_size // 4
233
+ if disk_n == 0:
234
+ return False
235
+ expected = sizes.get(split, 0)
236
+ if expected > 0 and abs(disk_n - expected) > 4:
237
+ return False
238
+ return True
239
+
240
+ def _load_all_tasks(self, repo_path: Path) -> List[Dict[str, str]]:
241
+ task_dir = repo_path / "data" / "tasks"
242
+ task_ids = _parse_task_ids(repo_path)
243
+ if not task_ids:
244
+ self.logger.warning("No task IDs found, scanning directories...")
245
+ for family_dir in ["kernel", "user", "v8"]:
246
+ fpath = task_dir / family_dir
247
+ if not fpath.exists():
248
+ continue
249
+ for subset in fpath.iterdir():
250
+ if subset.is_dir():
251
+ for task in subset.iterdir():
252
+ if task.is_dir():
253
+ tid = f"{family_dir}:{subset.name}/{task.name}"
254
+ task_ids.append(tid)
255
+ self.logger.info(f"Found {len(task_ids)} task IDs")
256
+ tasks = []
257
+ for tid in task_ids:
258
+ t = parse_task(task_dir, tid, self.config.max_file_chars)
259
+ if t is not None:
260
+ tasks.append(t)
261
+ self.logger.info(f"Parsed {len(tasks)} tasks with data")
262
+ return tasks
263
+
264
+ def prepare_tier(self, tier_index: int, force: bool = False) -> Dict[str, int]:
265
+ tier = self.config.tier_names[tier_index]
266
+ if not force and self._already_prepared(tier):
267
+ with open(self._manifest_path(tier), "r") as f:
268
+ return json.load(f)["sizes"]
269
+
270
+ repo_path = ensure_repo(self.config.repo_cache, self.logger)
271
+ all_tasks = self._load_all_tasks(repo_path)
272
+ formatter = FORMATTERS[tier]
273
+
274
+ # Split ESTRATIFICADO: reparte train/val/holdout con la MISMA
275
+ # distribucion de caracteristicas estructurales (pov_c, patch,
276
+ # familia, longitud) para evitar distribution shift entre splits.
277
+ #
278
+ # Antes el split era shuffle aleatorio -> el holdout podia quedarse
279
+ # sin tareas con pov_c mientras train si las tenia, lo que hacfa que
280
+ # el modelo aprendiera a generar PoC pero el holdout no las tuviera
281
+ # (loss alto por mismatch de formato, NO por overfitting).
282
+ rng = np.random.default_rng(self.config.split_seed + tier_index)
283
+ n = len(all_tasks)
284
+ n_hold = max(1, int(n * self.config.holdout_fraction))
285
+ n_val = max(1, int(n * self.config.val_fraction))
286
+
287
+ # Estrato = (familia, tiene_pov_c, tiene_patch, bucket_longitud)
288
+ def stratum_key(task: Dict[str, str]) -> Tuple:
289
+ f = formatter(task)
290
+ length = len(f) if f else 0
291
+ if length == 0:
292
+ len_bucket = 0
293
+ elif length < 500:
294
+ len_bucket = 1
295
+ elif length < 2000:
296
+ len_bucket = 2
297
+ else:
298
+ len_bucket = 3
299
+ return (
300
+ task.get("family", "unknown"),
301
+ 1 if len(task.get("pov_c", "") or "") > 0 else 0,
302
+ 1 if len(task.get("patch", "") or "") > 0 else 0,
303
+ len_bucket,
304
+ )
305
+
306
+ # Agrupar tareas por estrato
307
+ strata: Dict[Tuple, List[int]] = {}
308
+ for i, task in enumerate(all_tasks):
309
+ key = stratum_key(task)
310
+ strata.setdefault(key, []).append(i)
311
+
312
+ hold_idx: set = set()
313
+ val_idx: set = set()
314
+
315
+ # Dentro de cada estrato, repartir proporcionalmente
316
+ for key, members in strata.items():
317
+ rng.shuffle(members)
318
+ m = len(members)
319
+ # Asignar n_hold / n_val de forma proporcional al tamano del estrato
320
+ n_hold_s = max(1, int(round(m * self.config.holdout_fraction)))
321
+ n_val_s = max(1, int(round(m * self.config.val_fraction)))
322
+ # Asegurar que no exceda el tamano del estrato
323
+ n_hold_s = min(n_hold_s, m)
324
+ n_val_s = min(n_val_s, m - n_hold_s)
325
+ hold_idx.update(members[:n_hold_s])
326
+ val_idx.update(members[n_hold_s:n_hold_s + n_val_s])
327
+
328
+ self.logger.info(
329
+ f"[{tier}] stratified split: holdout={len(hold_idx)} val={len(val_idx)} "
330
+ f"train={n - len(hold_idx) - len(val_idx)} (estratos={len(strata)})"
331
+ )
332
+
333
+ eot = self.tokenizer.eot_token()
334
+ max_tokens = self.config.max_tokens_per_tier[tier_index]
335
+ sizes = {"train": 0, "val": 0, "holdout": 0}
336
+ files = {
337
+ split: open(self._tier_paths(tier)[split], "wb")
338
+ for split in ("train", "val", "holdout")
339
+ }
340
+ batch_tokens: Dict[str, List[int]] = {k: [] for k in files}
341
+ CHUNK = 1_000_000
342
+
343
+ def flush(split: str) -> None:
344
+ arr = np.array(batch_tokens[split], dtype=np.int32)
345
+ files[split].write(arr.tobytes())
346
+ sizes[split] += arr.size
347
+ batch_tokens[split] = []
348
+
349
+ train_cap_hit = False
350
+ # Dedup exacto del texto formateado en el split train (evita retrain
351
+ # sobre tareas casi identicas que pasan por el mismo template).
352
+ seen_train_text: set = set()
353
+ for i, task in enumerate(all_tasks):
354
+ if i in hold_idx:
355
+ split = "holdout"
356
+ elif i in val_idx:
357
+ split = "val"
358
+ else:
359
+ if train_cap_hit:
360
+ continue
361
+ split = "train"
362
+
363
+ text = formatter(task)
364
+ if not text:
365
+ continue
366
+ # Dedup exacto solo en train
367
+ if split == "train":
368
+ tkey = text[:5000]
369
+ if tkey in seen_train_text:
370
+ continue
371
+ seen_train_text.add(tkey)
372
+ try:
373
+ ids = self.tokenizer.encode(text)
374
+ except Exception:
375
+ continue
376
+ batch_tokens[split].extend(ids)
377
+ batch_tokens[split].append(eot)
378
+ if len(batch_tokens[split]) >= CHUNK:
379
+ flush(split)
380
+ if sizes["train"] >= max_tokens > 0:
381
+ train_cap_hit = True
382
+
383
+ for split in files:
384
+ if batch_tokens[split]:
385
+ flush(split)
386
+ files[split].close()
387
+
388
+ if sizes["train"] == 0:
389
+ for p in self._tier_paths(tier).values():
390
+ try:
391
+ p.unlink()
392
+ except OSError:
393
+ pass
394
+ raise RuntimeError(f"[{tier}] No training examples produced.")
395
+
396
+ manifest = {
397
+ "tier": tier,
398
+ "sizes": sizes,
399
+ "split_seed": self.config.split_seed + tier_index,
400
+ "saved_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
401
+ "n_tasks": n,
402
+ }
403
+ with open(self._manifest_path(tier), "w") as f:
404
+ json.dump(manifest, f, indent=2)
405
+ self.logger.info(
406
+ f"[{tier}] train={sizes['train']:,} val={sizes['val']:,} "
407
+ f"holdout={sizes['holdout']:,} tokens from {n} tasks"
408
+ )
409
+ return sizes
410
+
411
+ def open_memmap(self, tier: str, split: str) -> np.memmap:
412
+ path = self._tier_paths(tier)[split]
413
+ if not path.exists():
414
+ raise FileNotFoundError(f"Missing {path}. Run --prepare-exploitgym first.")
415
+ n = path.stat().st_size // 4
416
+ return np.memmap(path, dtype=np.int32, mode="r", shape=(n,))
topogpt3/hodge_cm.py ADDED
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Fases 2-5: martillo Hodge-CM para TopoGPT3 / modelo espectral continuo.
3
+ F2: CM phase-lattice STE suave. F3: Hodge regularizer + Fisher hinge.
4
+ F4: MCMC offline. F5: Hecke pooling solo escala.
5
+ """
6
+ import math
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+
11
+
12
+ # ---------- FASE 2: CM phase lattice ----------
13
+ class CMPhaseQuantSTE(torch.autograd.Function):
14
+ @staticmethod
15
+ def forward(ctx, phase, m: int, beta: float):
16
+ step = 2 * math.pi / m
17
+ q = torch.round(phase / step) * step
18
+ return (1 - beta) * phase + beta * q
19
+
20
+ @staticmethod
21
+ def backward(ctx, grad_out):
22
+ return grad_out, None, None
23
+
24
+
25
+ def cm_phase_loss(kr: torch.Tensor, ki: torch.Tensor, m: int = 16):
26
+ """E_Q: distancia cuadratica media a raices m-esimas. kr,ki reales misma forma."""
27
+ phase = torch.atan2(ki, kr)
28
+ step = 2 * math.pi / m
29
+ q = torch.round(phase / step) * step
30
+ d = torch.angle(torch.exp(1j * (phase - q)).to(torch.complex64))
31
+ return (d ** 2).mean()
32
+
33
+
34
+ def apply_cm_soft(kr, ki, m: int = 16, beta: float = 0.05):
35
+ """Cuantizacion suave de fase con STE. Magnitud intacta."""
36
+ with torch.no_grad():
37
+ mag = torch.sqrt(kr ** 2 + ki ** 2 + 1e-12)
38
+ phase = torch.atan2(ki, kr)
39
+ phase_q = CMPhaseQuantSTE.apply(phase, m, beta)
40
+ kr_q = mag * torch.cos(phase_q)
41
+ ki_q = mag * torch.sin(phase_q)
42
+ # STE: forward usa q, backward fluye a kr,ki originales
43
+ kr_out = kr + (kr_q - kr).detach()
44
+ ki_out = ki + (ki_q - ki).detach()
45
+ return kr_out, ki_out
46
+
47
+
48
+ # ---------- FASE 3: Hodge + Fisher ----------
49
+ def freq_grid_laplacian(fh: int, fw: int, device=None, dtype=torch.float32):
50
+ N = fh * fw
51
+ L = torch.zeros(N, N, device=device, dtype=dtype)
52
+
53
+ def idx(h, w):
54
+ return h * fw + w
55
+
56
+ for h in range(fh):
57
+ for w in range(fw):
58
+ i = idx(h, w)
59
+ for dh, dw in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
60
+ j = idx((h + dh) % fh, (w + dw) % fw)
61
+ L[i, i] += 1
62
+ L[i, j] -= 1
63
+ return L
64
+
65
+
66
+ def hodge_heat_residue(kr, ki, t: float = 2.0):
67
+ """||K - e^{-tL} K||_F^2 sobre grid freq. kr,ki: [...,Fh,Fw]."""
68
+ *lead, fh, fw = kr.shape
69
+ N = fh * fw
70
+ L = freq_grid_laplacian(fh, fw, device=kr.device, dtype=kr.dtype)
71
+ vals, vecs = torch.linalg.eigh(L)
72
+ heat = (vecs * torch.exp(-t * vals)) @ vecs.T # [N,N]
73
+ out = 0.0
74
+ for x in (kr, ki):
75
+ xf = x.reshape(-1, N)
76
+ xs = (heat @ xf.T).T
77
+ out = out + ((xf - xs) ** 2).mean()
78
+ return out / 2.0
79
+
80
+
81
+ def harmonic_ratio(kr, ki, t: float = 2.0):
82
+ *lead, fh, fw = kr.shape
83
+ N = fh * fw
84
+ L = freq_grid_laplacian(fh, fw, device=kr.device, dtype=kr.dtype)
85
+ vals, vecs = torch.linalg.eigh(L)
86
+ heat = (vecs * torch.exp(-t * vals)) @ vecs.T
87
+ num, den = 0.0, 0.0
88
+ for x in (kr, ki):
89
+ xf = x.reshape(-1, N)
90
+ xs = (heat @ xf.T).T
91
+ num += (xs ** 2).sum()
92
+ den += (xf ** 2).sum()
93
+ return (num / (den + 1e-12)).item()
94
+
95
+
96
+ def fisher_hinge(singulars: torch.Tensor, r: int = 16, margin: float = 1e-3):
97
+ s = torch.sort(singulars, descending=True).values
98
+ if s.numel() <= r:
99
+ return torch.tensor(0.0, device=s.device)
100
+ gap = s[r - 1] - s[r]
101
+ return F.relu(margin - gap)
102
+
103
+
104
+ # ---------- FASE 4: MCMC offline ----------
105
+ def langevin_refine(param, energy_fn, steps: int = 200, eta: float = 1e-3,
106
+ temp: float = 1e-4, seed: int = 0):
107
+ """ULA offline: theta <- theta - eta grad E + sqrt(2 eta T) xi."""
108
+ g = torch.Generator(device=param.device)
109
+ g.manual_seed(seed)
110
+ th = param.clone().float().detach()
111
+ for _ in range(steps):
112
+ th.requires_grad_(True)
113
+ e = energy_fn(th)
114
+ grad = torch.autograd.grad(e, th)[0].detach()
115
+ th = th.detach()
116
+ noise = torch.randn(th.shape, generator=g, device=th.device, dtype=th.dtype)
117
+ with torch.no_grad():
118
+ th = th - eta * grad + math.sqrt(2 * eta * temp) * noise
119
+ return th.detach()
120
+
121
+
122
+ @torch.no_grad()
123
+ def gibbs_cluster_assign(feats: torch.Tensor, n_clusters: int = 8, iters: int = 20, seed: int = 0):
124
+ """Clustering blando por fase para nodos/frecuencias. feats: [N,D] complejo o real."""
125
+ g = torch.Generator(device=feats.device)
126
+ g.manual_seed(seed)
127
+ if torch.is_complex(feats):
128
+ X = torch.view_as_real(feats.reshape(feats.shape[0], -1)).flatten(1)
129
+ else:
130
+ X = feats.flatten(1).float()
131
+ perm = torch.randperm(X.shape[0], generator=g, device=X.device)[:n_clusters]
132
+ centroids = X[perm].clone()
133
+ assign = torch.zeros(X.shape[0], dtype=torch.long, device=X.device)
134
+ for _ in range(iters):
135
+ d = ((X.unsqueeze(1) - centroids.unsqueeze(0)) ** 2).sum(-1)
136
+ # muestreo Gibbs aproximado: softmax sobre -d
137
+ probs = F.softmax(-d, dim=1)
138
+ assign = torch.multinomial(probs, 1, generator=g).squeeze(1)
139
+ for c in range(n_clusters):
140
+ m = assign == c
141
+ if m.any():
142
+ centroids[c] = X[m].mean(0)
143
+ return assign, centroids
144
+
145
+
146
+ # ---------- FASE 5: Hecke pooling solo escala ----------
147
+ class HeckeScalePool(nn.Module):
148
+ """Promedio sobre orbitas de escala/traslacion. Solo donde hay simetria real
149
+ (Strassen T, Fourier interp Willmore). NO reemplazo de atencion general."""
150
+
151
+ def __init__(self, modes: int = 4):
152
+ super().__init__()
153
+ self.modes = modes
154
+
155
+ def forward(self, Kfreq: torch.Tensor):
156
+ # Kfreq: [...,Fh,Fw] complejo. Promedia sobre shifts ciclicos (orbita traslacion).
157
+ outs = [torch.roll(Kfreq, shifts=(dh, dw), dims=(-2, -1))
158
+ for dh in range(min(2, Kfreq.shape[-2]))
159
+ for dw in range(min(2, Kfreq.shape[-1]))]
160
+ return sum(outs) / len(outs)