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.
- topoexploit-0.1.0.dist-info/METADATA +523 -0
- topoexploit-0.1.0.dist-info/RECORD +23 -0
- topoexploit-0.1.0.dist-info/WHEEL +5 -0
- topoexploit-0.1.0.dist-info/entry_points.txt +6 -0
- topoexploit-0.1.0.dist-info/licenses/LICENSE +661 -0
- topoexploit-0.1.0.dist-info/top_level.txt +1 -0
- topogpt3/__init__.py +125 -0
- topogpt3/__main__.py +49 -0
- topogpt3/api_server.py +1178 -0
- topogpt3/continuation.py +114 -0
- topogpt3/ewc.py +369 -0
- topogpt3/exploitgym_config.py +130 -0
- topogpt3/exploitgym_loader.py +416 -0
- topogpt3/hodge_cm.py +160 -0
- topogpt3/inference.py +731 -0
- topogpt3/inference_hrm.py +1502 -0
- topogpt3/jlens.py +958 -0
- topogpt3/lens_model.py +389 -0
- topogpt3/merged_config.py +187 -0
- topogpt3/model.py +3824 -0
- topogpt3/structured_output.py +251 -0
- topogpt3/train.py +1939 -0
- topogpt3/vanilla_control.py +240 -0
|
@@ -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)
|