td-builder commited on
Commit
56d7f42
·
verified ·
1 Parent(s): a6bf422

Upload 163 files

Browse files
hugging/td_fuse/SELF_IMPROVE_LOOP.md ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TD Self-Improvement Loop — The Plan
2
+
3
+ ## What We Have Now (Cycles 1-3)
4
+
5
+ Right now, **we** are the brain. We pick the datasets, we decide what to train on, we run the script. The model just eats whatever we feed it. It's like a student who has no say in their homework.
6
+
7
+ ## What We Want
8
+
9
+ The model becomes its own teacher. It figures out what it's bad at — both by testing its answers AND checking its own weights — finds its own study material from anywhere on the internet, trains itself, and repeats forever. Each loop it gets a little smarter.
10
+
11
+ ## The Loop (5 Steps)
12
+
13
+ ### Step 1: Test Itself (Two Ways)
14
+
15
+ **A) Answer Testing** — run the model through a big set of questions across every category:
16
+
17
+ - **Math** — arithmetic, algebra, word problems, competition math
18
+ - **Code** — write functions, debug code, explain code
19
+ - **Reasoning** — logic puzzles, multi-step deduction, common sense
20
+ - **Creativity** — writing stories, poems, explanations, rewriting text
21
+ - **Knowledge** — science, history, geography, general facts
22
+ - **Instruction following** — do exactly what the prompt says
23
+
24
+ Maybe 100-200 questions per category. The model generates answers. We check which ones are **wrong**.
25
+
26
+ **B) Weight Health Check** — the same analysis selfimprove.py already does:
27
+
28
+ - Kurtosis, skewness, dead neurons, extreme weights
29
+ - SVD rank utilization (is the layer using its full capacity?)
30
+ - Condition numbers (is the layer numerically stable?)
31
+ - Layer coherence (do adjacent layers flow smoothly?)
32
+ - Compare to reference (how far has each layer drifted?)
33
+ - Clogged norms (are layernorms blocking information flow?)
34
+
35
+ This catches problems that answer testing might miss — a layer could be giving okay answers now but have terrible weight health that will cause problems later. Like a car that drives fine but has a cracked engine block.
36
+
37
+ ### Step 2: Identify Weaknesses
38
+
39
+ Combine BOTH sources:
40
+
41
+ **From answer testing:**
42
+ - "Got 95% on arithmetic but 40% on word problems" → weak at word problems
43
+ - "Code works but is messy and slow" → weak at code quality
44
+ - "Logic breaks down after step 5" → weak at long-chain reasoning
45
+ - "Stories are boring and repetitive" → weak at creativity
46
+
47
+ **From weight health:**
48
+ - "Layer 6 post_attention_layernorm kurtosis 2446" → needs surgical norm repair
49
+ - "Layer 0 k_proj condition number 78983" → numerically unstable, needs targeted training
50
+ - "Layers 23-28 low rank utilization" → these layers aren't using their capacity
51
+ - "Layer 33-35 coherence issues" → merge artifacts still present
52
+
53
+ The model literally tells us what it's bad at from two angles — what it gets wrong AND what's broken inside. No guessing.
54
+
55
+ ### Step 3: Find Training Data (Search Everywhere)
56
+
57
+ This is the new part. Instead of us picking datasets, the script searches the entire internet for training data that matches the weaknesses:
58
+
59
+ **Search sources (in order of priority):**
60
+
61
+ 1. **HuggingFace Datasets API** — biggest collection of ML datasets, easy to download programmatically
62
+ 2. **GitHub** — search for repos with training data, benchmarks, problem sets
63
+ 3. **arXiv/Papers With Code** — find datasets mentioned in papers about the specific weakness
64
+ 4. **Kaggle** — competitions and datasets (especially good for code and math)
65
+ 5. **Web scraping** — if no dataset exists, scrape relevant content (Stack Overflow for code, math forums for math, etc.)
66
+
67
+ **How it picks:**
68
+ - Bad at word problems → search "math word problems dataset" across all sources → download best matches → format
69
+ - Bad at code quality → search "code review" + "competitive programming" → download → format
70
+ - Bad at long reasoning → search "chain of thought reasoning" + "multi-step" → download → format
71
+ - Weight health issues → search for data that exercises the broken layers (e.g., complex reasoning exercises the deeper layers)
72
+
73
+ The script already knows how to format data into the `<|im_start|>` chat template. It just needs to find the RIGHT data based on the weaknesses from Step 2.
74
+
75
+ For things where there's no perfect dataset (like creativity), use **STaR** — make the model generate multiple answers, keep only the best ones (scored by length, diversity, coherence), and train on those winners.
76
+
77
+ **Data allocation:** More samples go to worse weaknesses. If math is 42% and code is 65%, math gets 2x more training data than code.
78
+
79
+ ### Step 4: Train + Repair
80
+
81
+ Two-pronged approach (what selfimprove.py already does, but targeted):
82
+
83
+ **Weight repair (surgical):**
84
+ - Fix clogged norms by blending with reference values
85
+ - This happens BEFORE training so the pipes are clear
86
+
87
+ **LoRA training (learning):**
88
+ - Target the layers that weight health flagged as worst
89
+ - Train on the data from Step 3
90
+ - Unfrozen norms get trained with higher LR
91
+ - Merge LoRA back into model
92
+
93
+ ### Step 5: Verify
94
+
95
+ Before moving on, check that things actually improved:
96
+
97
+ - Re-run the benchmark from Step 1A — did scores go up?
98
+ - Re-run weight health from Step 1B — did health improve?
99
+ - If scores went DOWN on anything → flag it, investigate, maybe rollback
100
+ - Save everything: model checkpoint, benchmark scores, weight health report
101
+
102
+ ### Then: Loop
103
+
104
+ The improved model from Step 5 becomes the input for Step 1. Start over.
105
+
106
+ Test → Diagnose (answers + weights) → Find data (anywhere) → Train + Repair → Verify → Repeat.
107
+
108
+ Each cycle the easy stuff gets solved and only the hard stuff remains. The training gets more and more targeted. The model gets a little better each time.
109
+
110
+ ## What We Need to Build
111
+
112
+ ### 1. Benchmark Suite (`td_benchmark.py`)
113
+
114
+ A big test set. Sources:
115
+
116
+ - GSM8K test split (math)
117
+ - HumanEval or MBPP (code — checks if code actually runs)
118
+ - ARC Challenge (reasoning)
119
+ - MMLU-Pro test split (knowledge + reasoning)
120
+ - Custom creativity prompts (score by a separate model or rule-based)
121
+ - Custom instruction-following tests
122
+
123
+ The benchmark runs the model, checks answers, and outputs a score per category.
124
+
125
+ ### 2. Weight Health Analyzer (already built in `selfimprove.py`)
126
+
127
+ This already exists — `compute_health_scores()`, `compute_damage_scores()`, `compute_coherence_scores()`, `detect_clogged_norms()`. We just need to make it output a structured weakness report that the data finder can use.
128
+
129
+ ### 3. Weakness Finder (`td_weakness.py`)
130
+
131
+ Takes BOTH benchmark results AND weight health and outputs:
132
+
133
+ - Which categories scored low (from answers)
134
+ - Which layers are unhealthy (from weights)
135
+ - Search keywords for finding datasets — not just on HuggingFace, ANYWHERE
136
+
137
+ Example output:
138
+ ```
139
+ WEAKNESSES FOUND:
140
+
141
+ ANSWER WEAKNESSES:
142
+ math_word_problems: 42% (target: 80%)
143
+ → search: "math word problems dataset", "grade school math", "GSM8K hard"
144
+ code_debugging: 35% (target: 70%)
145
+ → search: "code debugging dataset", "bug fix pairs", "code review"
146
+ long_reasoning: 51% (target: 75%)
147
+ → search: "chain of thought reasoning", "multi-step logic"
148
+
149
+ WEIGHT WEAKNESSES:
150
+ layers 0-6: high kurtosis norms (avg 200+)
151
+ → action: surgical norm repair
152
+ layers 23-28: low rank utilization (<10%)
153
+ → action: target with LoRA, train on complex tasks
154
+ layer 0 k_proj: condition number 78983
155
+ → action: include in LoRA targets, high priority
156
+ ```
157
+
158
+ ### 4. Dataset Finder (`td_data_finder.py`)
159
+
160
+ Takes the search keywords and:
161
+
162
+ - Searches HuggingFace Datasets API (primary — easiest to download)
163
+ - Searches GitHub for repos with training data
164
+ - Searches Kaggle datasets
165
+ - Falls back to web scraping if needed (Stack Overflow, math forums, etc.)
166
+ - Downloads the best matches
167
+ - Formats them into the chat template
168
+ - Allocates more samples to worse weaknesses
169
+
170
+ ### 5. The Loop Runner (`td_loop.py`)
171
+
172
+ Ties it all together in an endless loop:
173
+
174
+ ```
175
+ cycle = 0
176
+ while True:
177
+ cycle += 1
178
+
179
+ # Step 1: Test (both ways)
180
+ answer_scores = benchmark(model)
181
+ weight_health = analyze_weights(model)
182
+
183
+ # Step 2: Diagnose
184
+ weaknesses = find_weaknesses(answer_scores, weight_health)
185
+
186
+ # Step 3: Find data (search everywhere)
187
+ data = find_training_data(weaknesses)
188
+
189
+ # Step 4: Train + repair
190
+ repair_norms(model, weight_health)
191
+ model = train_lora(model, data, weight_health)
192
+
193
+ # Step 5: Verify
194
+ new_scores = benchmark(model)
195
+ if any_score_dropped(new_scores, answer_scores):
196
+ print(f"WARNING: Regression detected! Investigating...")
197
+ # Could rollback or adjust
198
+
199
+ save_checkpoint(model, cycle, new_scores, weight_health)
200
+
201
+ if all_scores_above_target(new_scores):
202
+ print(f"Target performance reached after {cycle} cycles!")
203
+ break
204
+ ```
205
+
206
+ ## Phase 2: Synthetic Learning Mode
207
+
208
+ ### The Problem
209
+
210
+ Eventually, the model will run out of external datasets. It's trained on every math dataset, every code repo, every reasoning benchmark. The internet has been squeezed dry. Now what?
211
+
212
+ This isn't hypothetical — it's already happening to the big AI companies. OpenAI, Google, Anthropic — they're all running into the same wall: there's only so much human-written data in the world.
213
+
214
+ ### The Solution: Synthetic Learning
215
+
216
+ When external data runs out, the model enters **Synthetic Learning Mode** — it becomes its own teacher AND its own textbook writer. Instead of finding data, it creates data.
217
+
218
+ **How it works:**
219
+
220
+ 1. **The model generates problems for itself.** Not random ones — problems specifically designed to target its weaknesses. If it's bad at 5-step algebra, it writes 5-step algebra problems. If it's bad at recursive code, it writes recursive coding challenges.
221
+
222
+ 2. **The model generates multiple answers to each problem.** Maybe 8-16 attempts per question, using different reasoning paths each time (this is what PACER does — sample multiple traces).
223
+
224
+ 3. **Verify which answers are correct.** For math: check the number. For code: run it and see if it passes tests. For logic: check against known rules. Only keep the winners.
225
+
226
+ 4. **Train on the winners.** The correct reasoning chains become the training data. The model learns from its own best thinking.
227
+
228
+ 5. **The improved model generates harder problems.** Because it solved the easy ones, it can now create harder challenges for itself. The difficulty automatically scales up.
229
+
230
+ **This is real — it already works:**
231
+
232
+ - **STaR (Self-Taught Reasoner)** — generate reasoning chains, keep correct ones, train on them. Published by Google.
233
+ - **Self-Play** — the model debates itself, the better argument wins. Used in AlphaGo and now in LLM training.
234
+ - **Rejection Sampling** — generate many, filter to best, train on best. This is how DeepSeek-R1 was trained.
235
+ - **GRPO** — what we already planned. Group Relative Policy Optimisation rewards correct answers automatically.
236
+
237
+ **Synthetic Learning categories:**
238
+
239
+ - **Math:** Generate equations → solve them multiple ways → verify answer → train on correct chains
240
+ - **Code:** Generate coding challenges → write solutions → run the code → if it works, train on it
241
+ - **Reasoning:** Generate logic puzzles with known answers → solve them → verify → train on correct ones
242
+ - **Creativity:** Generate multiple versions of stories/poems → score them (diversity, coherence, originality) → train on best ones
243
+ - **Knowledge:** Generate question-answer pairs from what it already knows → verify against itself → train to make recall faster and more accurate
244
+ - **Cross-domain:** Generate problems that require MULTIPLE skills (math + code, reasoning + creativity) — these are the hardest and most valuable
245
+
246
+ **Quality control (so it doesn't train on garbage):**
247
+
248
+ - **Cherry_LLM perplexity filter** — catches repetitive or low-quality synthetic data before it enters training
249
+ - **Verified rewards only** — no guessing. Math must be checkable, code must run, logic must follow rules.
250
+ - **Diversity checks** — if all the synthetic data looks the same, throw it out. Variety matters.
251
+ - **Calibration** — compare the model's confidence to its actual accuracy. If it's confident but wrong, that's a problem.
252
+
253
+ ### When Does Synthetic Mode Activate?
254
+
255
+ The dataset finder tracks what's been used:
256
+
257
+ ```
258
+ if datasets_exhausted(weakness_category):
259
+ print(f"No new external data for {weakness_category}")
260
+ print(f"Entering SYNTHETIC LEARNING MODE")
261
+ data = generate_synthetic_data(model, weakness_category, num_samples=8000)
262
+ else:
263
+ data = download_external_data(weakness_category)
264
+ ```
265
+
266
+ It can also run in **hybrid mode** — mix external data (for things it hasn't seen) with synthetic data (for things where external data ran out). Best of both worlds.
267
+
268
+ ### The Flywheel Effect
269
+
270
+ This is where it gets exciting. Each cycle:
271
+
272
+ 1. Better model → generates harder problems
273
+ 2. Harder problems → more room to improve
274
+ 3. More improvement → even better model
275
+ 4. Even better model → even harder problems
276
+ 5. Repeat
277
+
278
+ The model literally pulls itself up by its own bootstraps. Each cycle creates the conditions for the next cycle. The ceiling keeps rising because the model keeps raising it.
279
+
280
+ ### Future Hardware (When TD Pays For Itself)
281
+
282
+ By the time synthetic learning is fully running, the model will be smart enough to help design its own infrastructure:
283
+
284
+ - Optimise GPU usage patterns (which operations bottleneck, how to schedule them)
285
+ - Help design custom training pipelines that are faster for its specific architecture
286
+ - Suggest hardware configurations that maximise training throughput
287
+ - Eventually, help design specialised hardware (custom GPU kernels, FPGA configurations, or even chip architectures)
288
+
289
+ This is the endgame: TD doesn't just improve its brain, it improves the hardware running its brain.
290
+
291
+ ## Build Order
292
+
293
+ **Phase 1 — Self-Improvement Loop:**
294
+ 1. **Benchmark suite first** — we need to measure before we can improve
295
+ 2. **Weight health reporter** — restructure existing code to output weakness report
296
+ 3. **Weakness finder** — combines benchmark + weight health into search queries
297
+ 4. **Dataset finder** — searches everywhere (HuggingFace, GitHub, Kaggle, web)
298
+ 5. **Loop runner** — connects everything, runs forever
299
+
300
+ **Phase 2 — Synthetic Learning (built after Phase 1 is running):**
301
+ 6. **Synthetic data generator** — creates problems + solutions for each weakness category
302
+ 7. **Verification engine** — checks math answers, runs code, validates logic
303
+ 8. **Quality filters** — Cherry_LLM perplexity scoring, diversity checks, calibration
304
+ 9. **Hybrid mode** — mix external + synthetic data based on what's available
305
+ 10. **Difficulty scaling** — auto-increase problem difficulty as the model improves
306
+
307
+ ## What This Means
308
+
309
+ - Cycles 1-3 = us manually improving the model (current)
310
+ - Cycle 4+ = the model drives its own improvement with external data (Phase 1)
311
+ - When external data runs out = synthetic learning kicks in (Phase 2)
312
+ - Two diagnostic methods: answer testing (what it gets wrong) + weight health (what's broken inside)
313
+ - Dataset search is not limited to one source — it can find data anywhere
314
+ - When data sources dry up — the model generates its own training data
315
+ - Each loop is automatic — start it and walk away
316
+ - The endgame: TD improves itself, then improves the hardware running itself
317
+
318
+ ## Dad's Tests
319
+
320
+ 1. **Economic test:** Big companies DO this — it's called RLHF/RLAIF. We're doing a simpler version that works on one GPU. The weight health checking is extra — most companies don't bother because they have enough compute to brute force it.
321
+ 2. **Architecture test:** Built on Qwen3-VL which is current and actively maintained. LoRA + HuggingFace are industry standard. Web search for datasets is how researchers actually find training data.
322
+ 3. **Realism test:** Each piece already works individually (benchmark tools exist, training works, dataset APIs exist, web search works). We're just connecting them into one loop.
323
+ 4. **Pragmatism test:** Uses selfimprove.py we already built for the training + weight analysis. Just adds testing + multi-source dataset search on top.
324
+ 5. **Long-term test:** Self-improvement loops are the future of AI. This approach scales — better base model = better self-diagnosis = better training data = faster improvement.
hugging/td_fuse/__pycache__/selfimprove.cpython-310.pyc ADDED
Binary file (39.1 kB). View file
 
hugging/td_fuse/__pycache__/td_benchmark.cpython-310.pyc ADDED
Binary file (24.8 kB). View file
 
hugging/td_fuse/__pycache__/td_contamination.cpython-310.pyc ADDED
Binary file (5.34 kB). View file
 
hugging/td_fuse/__pycache__/td_data_finder.cpython-310.pyc ADDED
Binary file (13 kB). View file
 
hugging/td_fuse/__pycache__/td_holdout.cpython-310.pyc ADDED
Binary file (13.9 kB). View file
 
hugging/td_fuse/__pycache__/td_loop.cpython-310.pyc ADDED
Binary file (26.4 kB). View file
 
hugging/td_fuse/__pycache__/td_weakness.cpython-310.pyc ADDED
Binary file (20.2 kB). View file
 
hugging/td_fuse/selfimprove.py CHANGED
@@ -52,6 +52,9 @@ class SelfImproveConfig:
52
  save_steps: int = 100
53
  num_train_samples: int = 3000
54
 
 
 
 
55
  # Layernorm unfreezing
56
  norm_kurtosis_threshold: float = 15.0
57
 
@@ -89,7 +92,7 @@ def auto_detect_model_path() -> Tuple[str, int]:
89
 
90
  # Check for highest cycle output (cycle3, cycle2, etc.)
91
  if base.exists():
92
- for n in range(10, 0, -1):
93
  cycle_dir = base / f"improved_cycle{n}"
94
  if cycle_dir.exists() and list(cycle_dir.glob("*.safetensors")):
95
  print(f" Found cycle {n} output at {cycle_dir}")
@@ -118,6 +121,27 @@ def analyze_layer(name: str, tensor: torch.Tensor) -> Dict:
118
  """Full health analysis for a single weight tensor."""
119
  t = tensor.float()
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  stats = {
122
  "name": name,
123
  "shape": list(tensor.shape),
@@ -125,16 +149,16 @@ def analyze_layer(name: str, tensor: torch.Tensor) -> Dict:
125
  "dtype": str(tensor.dtype),
126
  "l2_norm": t.norm(2).item(),
127
  "frobenius_norm": t.norm('fro').item() if t.dim() >= 2 else t.norm(2).item(),
128
- "mean": t.mean().item(),
129
- "std": t.std().item(),
130
  "min": t.min().item(),
131
  "max": t.max().item(),
132
- "abs_mean": t.abs().mean().item(),
133
  "sparsity": (t.abs() < 1e-6).float().mean().item(),
134
- "kurtosis": ((t - t.mean()) / max(t.std().item(), 1e-8)).pow(4).mean().item() - 3,
135
- "skewness": ((t - t.mean()) / max(t.std().item(), 1e-8)).pow(3).mean().item(),
136
  "near_zero_pct": (t.abs() < 1e-5).float().mean().item(),
137
- "extreme_pct": (t.abs() > t.abs().mean() * 5).float().mean().item(),
138
  }
139
 
140
  if t.dim() == 2 and min(t.shape) >= 2:
@@ -180,13 +204,21 @@ def analyze_model_weights(model_path: str, label: str = "model") -> Dict:
180
 
181
  for st_file in st_files:
182
  print(f" Loading {Path(st_file).name}...")
183
- state_dict = load_file(st_file, device="cpu")
 
 
 
 
184
  for name, tensor in state_dict.items():
185
  if any(skip in name for skip in ["visual.", "merger."]):
186
  continue
187
- stats = analyze_layer(name, tensor)
188
- all_stats[name] = stats
189
- total_params += stats["params"]
 
 
 
 
190
  del state_dict
191
  gc.collect()
192
 
@@ -207,7 +239,8 @@ def compute_health_scores(analysis: Dict) -> Dict[str, Dict]:
207
  issues = []
208
  score = 0.0
209
 
210
- kurt = abs(stats["kurtosis"])
 
211
  if kurt > 10:
212
  score += 0.3
213
  issues.append(f"extreme kurtosis ({kurt:.1f})")
@@ -215,7 +248,7 @@ def compute_health_scores(analysis: Dict) -> Dict[str, Dict]:
215
  score += 0.15
216
  issues.append(f"high kurtosis ({kurt:.1f})")
217
 
218
- skew = abs(stats["skewness"])
219
  if skew > 2:
220
  score += 0.15
221
  issues.append(f"high skew ({skew:.2f})")
@@ -243,24 +276,25 @@ def compute_health_scores(analysis: Dict) -> Dict[str, Dict]:
243
  elif sv_ratio > 1000:
244
  score += 0.1
245
 
246
- dead = stats["near_zero_pct"]
247
  if dead > 0.3:
248
  score += 0.2
249
  issues.append(f"high dead neuron % ({dead:.1%})")
250
  elif dead > 0.15:
251
  score += 0.1
252
 
253
- extreme = stats["extreme_pct"]
254
  if extreme > 0.05:
255
  score += 0.15
256
  issues.append(f"many extreme weights ({extreme:.1%})")
257
 
258
- if stats["std"] < 1e-4:
 
259
  score += 0.25
260
- issues.append(f"near-zero variance ({stats['std']:.6f})")
261
- elif stats["std"] > 1.0:
262
  score += 0.15
263
- issues.append(f"high variance ({stats['std']:.4f})")
264
 
265
  health_scores[name] = {
266
  "health_score": min(score, 1.0),
@@ -293,10 +327,11 @@ def compute_damage_scores(model_analysis: Dict, ref_analysis: Dict) -> Dict[str,
293
  r_stats = ref_analysis["layers"][ref_key]
294
  matched += 1
295
 
296
- norm_diff = abs(m_stats["l2_norm"] - r_stats["l2_norm"]) / max(r_stats["l2_norm"], 1e-8)
297
- std_diff = abs(m_stats["std"] - r_stats["std"]) / max(r_stats["std"], 1e-8)
298
- kurt_diff = abs(m_stats["kurtosis"] - r_stats["kurtosis"]) / max(abs(r_stats["kurtosis"]) + 1, 1e-8)
299
- sparsity_diff = abs(m_stats["sparsity"] - r_stats["sparsity"])
 
300
 
301
  sv_ratio_diff = 0
302
  if "sv_ratio" in m_stats and "sv_ratio" in r_stats:
@@ -335,8 +370,8 @@ def compute_coherence_scores(analysis: Dict) -> Dict[int, float]:
335
  if layer_num not in layer_norms:
336
  layer_norms[layer_num] = []
337
  layer_stds[layer_num] = []
338
- layer_norms[layer_num].append(stats["l2_norm"])
339
- layer_stds[layer_num].append(stats["std"])
340
 
341
  avg_norms = {l: sum(ns)/len(ns) for l, ns in layer_norms.items()}
342
  avg_stds = {l: sum(ss)/len(ss) for l, ss in layer_stds.items()}
@@ -465,7 +500,8 @@ def detect_clogged_norms(health_scores: Dict[str, Dict], threshold: float = 15.0
465
  clogged = []
466
  for name, h in health_scores.items():
467
  if any(norm_type in name.lower() for norm_type in ["layernorm", "ln_", "_norm"]):
468
- kurt = abs(h["stats"]["kurtosis"])
 
469
  if kurt > threshold:
470
  clogged.append(name)
471
 
@@ -502,7 +538,11 @@ def repair_clogged_norms(model_path: str, clogged_norms: List[str],
502
  print(" Loading reference norms...")
503
  ref_norms = {}
504
  for st_file in sorted(glob.glob(f"{ref_path}/*.safetensors")):
505
- state = load_file(st_file, device="cpu")
 
 
 
 
506
  for name, tensor in state.items():
507
  if name in clogged_norms:
508
  ref_norms[name] = tensor.clone()
@@ -518,14 +558,40 @@ def repair_clogged_norms(model_path: str, clogged_norms: List[str],
518
  repaired = 0
519
  st_files = sorted(glob.glob(f"{model_path}/*.safetensors"))
520
 
 
 
 
 
521
  for st_file in st_files:
522
- state = load_file(st_file, device="cpu")
 
 
 
 
523
  changed = False
524
 
525
  for name in list(state.keys()):
526
  if name in ref_norms:
 
 
 
 
 
 
 
527
  kurt = abs(health_scores[name]["stats"]["kurtosis"])
528
 
 
 
 
 
 
 
 
 
 
 
 
529
  # Blend ratio based on severity
530
  if kurt > 100:
531
  ref_weight = 0.80 # Severely broken — mostly use reference
@@ -549,7 +615,11 @@ def repair_clogged_norms(model_path: str, clogged_norms: List[str],
549
  changed = True
550
 
551
  if changed:
552
- save_file(state, st_file)
 
 
 
 
553
  del state
554
 
555
  gc.collect()
@@ -583,12 +653,44 @@ def improve_with_lora(model_path: str, target_layers: List[int],
583
  from datasets import load_dataset
584
  from torch.utils.data import Dataset
585
 
586
- # Load model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  print(" Loading model...")
588
- model = AutoModelForImageTextToText.from_pretrained(
589
- model_path, dtype=torch.bfloat16,
590
- device_map="auto", trust_remote_code=True
591
- )
 
 
 
 
 
 
 
592
  tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
593
 
594
  # Enable gradient checkpointing for mega mode (saves ~40% VRAM)
@@ -607,7 +709,16 @@ def improve_with_lora(model_path: str, target_layers: List[int],
607
  bias="none",
608
  task_type=TaskType.CAUSAL_LM,
609
  )
610
- model = get_peft_model(model, lora_config)
 
 
 
 
 
 
 
 
 
611
 
612
  # UNFREEZE clogged norms — the key cycle 2 fix
613
  unfrozen_count = 0
@@ -638,29 +749,75 @@ def improve_with_lora(model_path: str, target_layers: List[int],
638
  train_texts = []
639
  total = cfg.num_train_samples
640
 
641
- if cfg.mega_mode:
642
- # Priority split: code=2000, math=2000, reasoning=1500, creativity=1000, instructions=1000, general=500
643
- alloc = {"code": int(total*0.25), "math": int(total*0.25),
644
- "reasoning": int(total*0.19), "creativity": int(total*0.12),
645
- "instructions": int(total*0.12), "general": int(total*0.07)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  else:
647
- per = total // 5
648
- alloc = {"code": per, "math": per, "reasoning": per,
649
- "creativity": 0, "instructions": per, "general": per}
650
 
651
  # 1. MATH (top priority)
652
  try:
653
- ds = load_dataset("openai/gsm8k", "main", split="train")
654
  count = 0
655
- for row in ds:
656
- if count >= alloc["math"]:
657
- break
658
- q, a = row.get("question", ""), row.get("answer", "")
659
- if q and a:
660
- chat = (f"<|im_start|>user\n{q}\nThink step by step.<|im_end|>\n"
661
- f"<|im_start|>assistant\n<think>\n{a}\n</think><|im_end|>")
662
- train_texts.append(chat)
663
- count += 1
 
 
664
  print(f" Math (GSM8K): {count}")
665
  except Exception as e:
666
  print(f" WARNING: GSM8K failed: {e}")
@@ -686,20 +843,21 @@ def improve_with_lora(model_path: str, target_layers: List[int],
686
 
687
  # 2. CODE (top priority)
688
  try:
689
- ds = load_dataset("sahil2801/CodeAlpaca-20k", split="train")
690
  count = 0
691
- for row in ds:
692
- if count >= alloc["code"]:
693
- break
694
- inst = row.get("instruction", "")
695
- inp = row.get("input", "")
696
- out = row.get("output", "")
697
- if inst and out and len(out) > 20:
698
- prompt = f"{inst}\n{inp}" if inp else inst
699
- chat = (f"<|im_start|>user\n{prompt}<|im_end|>\n"
700
- f"<|im_start|>assistant\n{out}<|im_end|>")
701
- train_texts.append(chat)
702
- count += 1
 
 
703
  print(f" Code (CodeAlpaca): {count}")
704
  except Exception as e:
705
  print(f" WARNING: CodeAlpaca failed: {e}")
@@ -725,21 +883,22 @@ def improve_with_lora(model_path: str, target_layers: List[int],
725
 
726
  # 3. REASONING (top priority)
727
  try:
728
- ds = load_dataset("TIGER-Lab/MMLU-Pro", split="test")
729
  count = 0
730
- for row in ds:
731
- if count >= alloc["reasoning"]:
732
- break
733
- q = row.get("question", "")
734
- options = row.get("options", [])
735
- answer_idx = row.get("answer_index", -1)
736
- if q and options and 0 <= answer_idx < len(options):
737
- answer_text = options[answer_idx]
738
- chat = (f"<|im_start|>user\n{q}<|im_end|>\n"
739
- f"<|im_start|>assistant\n<think>\nLet me reason through this.\n"
740
- f"{answer_text}\n</think>\n{answer_text}<|im_end|>")
741
- train_texts.append(chat)
742
- count += 1
 
 
743
  print(f" Reasoning (MMLU-Pro): {count}")
744
  except Exception as e:
745
  print(f" WARNING: MMLU-Pro failed: {e}")
@@ -770,37 +929,39 @@ def improve_with_lora(model_path: str, target_layers: List[int],
770
 
771
  # 5. INSTRUCTIONS (general)
772
  try:
773
- ds = load_dataset("yahma/alpaca-cleaned", split="train")
774
  count = 0
775
- for row in ds:
776
- if count >= alloc.get("instructions", 0):
777
- break
778
- inst = row.get("instruction", "")
779
- inp = row.get("input", "")
780
- out = row.get("output", "")
781
- if inst and out and len(out) > 30:
782
- prompt = f"{inst}\n{inp}" if inp else inst
783
- chat = (f"<|im_start|>user\n{prompt}<|im_end|>\n"
784
- f"<|im_start|>assistant\n{out}<|im_end|>")
785
- train_texts.append(chat)
786
- count += 1
 
 
787
  print(f" Instructions (Alpaca): {count}")
788
  except Exception as e:
789
  print(f" WARNING: Alpaca failed: {e}")
790
 
791
  # 6. GENERAL TEXT (lowest priority)
792
  try:
793
- ds = load_dataset("neuralmagic/LLM_compression_calibration", split="train")
794
  count = 0
795
- for row in ds:
796
- if count >= alloc.get("general", 0):
797
- break
798
- text = row.get("text", "")
799
- if len(text) > 100:
800
- chat = (f"<|im_start|>user\nContinue this text naturally.<|im_end|>\n"
801
- f"<|im_start|>assistant\n{text[:800]}<|im_end|>")
802
- train_texts.append(chat)
803
- count += 1
 
 
804
  print(f" General text: {count}")
805
  except Exception as e:
806
  print(f" WARNING: General text failed: {e}")
@@ -811,6 +972,44 @@ def improve_with_lora(model_path: str, target_layers: List[int],
811
  print(" ERROR: Not enough training data!")
812
  return model_path
813
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  random.shuffle(train_texts)
815
 
816
  # Tokenize
@@ -831,10 +1030,15 @@ def improve_with_lora(model_path: str, target_layers: List[int],
831
  print(" Tokenizing...")
832
  dataset = ImproveDataset(train_texts, tokenizer)
833
 
 
 
 
 
834
  out_dir = Path(cfg.output_dir) / "train_output"
835
  out_dir.mkdir(parents=True, exist_ok=True)
836
 
837
- total_steps = (len(dataset) * cfg.train_epochs) // (cfg.train_batch * cfg.train_grad_accum)
 
838
 
839
  # Set up optimizer with different LR for norms vs LoRA
840
  # Group parameters: norms get higher LR
@@ -891,19 +1095,85 @@ def improve_with_lora(model_path: str, target_layers: List[int],
891
  print(f"\n Training: ~{total_steps} steps on {len(target_layers)} layers + {len(clogged_norms)} norms")
892
  print(f" Data: {len(train_texts)} samples × {cfg.train_epochs} epochs")
893
  print(f" LoRA: r={cfg.lora_r}, alpha={cfg.lora_alpha}")
894
- trainer.train()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
 
896
  # Merge LoRA back
897
  print("\n Merging LoRA adapters into model...")
898
- merged = model.merge_and_unload()
 
 
 
 
 
 
 
 
 
 
899
 
900
  # Save
901
  save_dir = Path(cfg.output_dir) / f"improved_cycle{cycle_num}"
902
  save_dir.mkdir(parents=True, exist_ok=True)
903
  print(f" Saving to {save_dir}...")
904
- merged.save_pretrained(str(save_dir), safe_serialization=True)
905
- tokenizer.save_pretrained(str(save_dir))
906
- sz = sum(f.stat().st_size for f in save_dir.glob("*.safetensors")) / 1e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
907
  print(f" SAVED: {save_dir} ({sz:.1f} GB)")
908
 
909
  shutil.rmtree(str(out_dir), ignore_errors=True)
@@ -927,11 +1197,29 @@ def quick_benchmark(model_path: str) -> Dict:
927
 
928
  from transformers import AutoModelForImageTextToText, AutoTokenizer
929
 
930
- model = AutoModelForImageTextToText.from_pretrained(
931
- model_path, dtype=torch.bfloat16,
932
- device_map="auto", trust_remote_code=True
933
- )
934
- tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
 
936
  tests = [
937
  {"q": "What is 47 * 23?", "expected": "1081", "type": "math"},
@@ -946,20 +1234,36 @@ def quick_benchmark(model_path: str) -> Dict:
946
 
947
  results = []
948
  for test in tests:
949
- chat = f"<|im_start|>user\n{test['q']}<|im_end|>\n<|im_start|>assistant\n"
950
- ids = tokenizer(chat, return_tensors="pt").to(model.device)
951
- out = model.generate(**ids, max_new_tokens=200, do_sample=False)
952
- answer = tokenizer.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True)
953
-
954
- passed = test["expected"].lower() in answer.lower()
955
- status = "PASS" if passed else "FAIL"
956
- results.append({"type": test["type"], "passed": passed})
957
-
958
- print(f"\n [{status}] {test['type']}: {test['q']}")
959
- print(f" Answer: {answer[:200]}")
960
- print(f" Expected to contain: {test['expected']}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
961
 
962
- score = sum(1 for r in results if r["passed"])
963
  print(f"\n BENCHMARK: {score}/{len(tests)} passed")
964
 
965
  del model
@@ -983,7 +1287,18 @@ def run_cycle(cfg: SelfImproveConfig = None):
983
  if not cfg.model_path:
984
  cfg.model_path, cycle_num = auto_detect_model_path()
985
  else:
986
- cycle_num = 2 if "improve" in cfg.model_path else 1
 
 
 
 
 
 
 
 
 
 
 
987
 
988
  # MEGA MODE for cycle 3+
989
  if cycle_num >= 3 and not cfg.mega_mode:
@@ -1023,16 +1338,24 @@ def run_cycle(cfg: SelfImproveConfig = None):
1023
  print("STEP 3/7: REFERENCE COMPARISON")
1024
  print(f"{'='*40}")
1025
  ref_path = Path(cfg.output_dir) / "reference_weights"
1026
- if not ref_path.exists() or not list(ref_path.glob("*.safetensors")):
1027
- print(f" Downloading reference: {cfg.reference_model}...")
1028
- from huggingface_hub import snapshot_download
1029
- snapshot_download(
1030
- cfg.reference_model, local_dir=str(ref_path),
1031
- allow_patterns=["*.safetensors", "*.json"],
1032
- ignore_patterns=["*.bin", "*.pt", "*.onnx"],
1033
- )
1034
- ref_analysis = analyze_model_weights(str(ref_path), "Qwen3-VL-8B (reference)")
1035
- damage_scores = compute_damage_scores(model_analysis, ref_analysis)
 
 
 
 
 
 
 
 
1036
  # DON'T delete reference yet — we need it for norm repair
1037
  gc.collect()
1038
 
@@ -1055,24 +1378,31 @@ def run_cycle(cfg: SelfImproveConfig = None):
1055
  if clogged_norms:
1056
  print(f"\n CLOGGED NORMS DETECTED ({len(clogged_norms)}):")
1057
  for cn in clogged_norms:
1058
- kurt = abs(health_scores[cn]["stats"]["kurtosis"])
 
1059
  print(f" {cn}: kurtosis={kurt:.1f}")
1060
  else:
1061
  print(f"\n No clogged norms above threshold {cfg.norm_kurtosis_threshold}")
1062
 
1063
  # STEP 5c: REPAIR clogged norms surgically
1064
- if clogged_norms:
1065
  print(f"\n{'='*40}")
1066
  print("STEP 5c/7: SURGICAL NORM REPAIR")
1067
  print(f"{'='*40}")
1068
- repair_clogged_norms(cfg.model_path, clogged_norms, str(ref_path), health_scores)
 
 
 
 
 
 
1069
 
1070
  # Clean up reference now
1071
  print(f" Cleaning up reference weights...")
1072
  shutil.rmtree(str(ref_path), ignore_errors=True)
1073
  gc.collect()
1074
 
1075
- # Save report
1076
  report_dir = Path(cfg.output_dir)
1077
  report_dir.mkdir(parents=True, exist_ok=True)
1078
  pre_report = {
@@ -1085,8 +1415,11 @@ def run_cycle(cfg: SelfImproveConfig = None):
1085
  "layer_details": {str(k): v for k, v in layer_details.items()},
1086
  "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
1087
  }
1088
- with open(report_dir / f"pre_cycle{cycle_num}_report.json", "w") as f:
1089
- json.dump(pre_report, f, indent=2)
 
 
 
1090
 
1091
  # STEP 6: Train
1092
  print(f"\n{'='*40}")
@@ -1099,16 +1432,26 @@ def run_cycle(cfg: SelfImproveConfig = None):
1099
  print("STEP 7/7: VERIFY + BENCHMARK")
1100
  print(f"{'='*40}")
1101
 
1102
- # Re-analyze health
1103
- post_analysis = analyze_model_weights(improved_path, "TD improved")
1104
- post_health = compute_health_scores(post_analysis)
1105
-
1106
  pre_avg = sum(h["health_score"] for h in health_scores.values()) / max(len(health_scores), 1)
1107
- post_avg = sum(h["health_score"] for h in post_health.values()) / max(len(post_health), 1)
 
 
 
 
 
 
 
 
1108
 
1109
- # Per-layer comparison
1110
- print(f"\n Per-layer improvement:")
 
 
 
1111
  for layer_num in target_layers:
 
 
1112
  post_layer_scores = []
1113
  for name, h in post_health.items():
1114
  parts = name.split(".")
@@ -1127,16 +1470,22 @@ def run_cycle(cfg: SelfImproveConfig = None):
1127
  if clogged_norms:
1128
  print(f"\n Clogged norm changes:")
1129
  for cn in clogged_norms:
1130
- pre_kurt = abs(health_scores[cn]["stats"]["kurtosis"])
 
1131
  # Find matching post norm
1132
  if cn in post_health:
1133
- post_kurt = abs(post_health[cn]["stats"]["kurtosis"])
 
1134
  delta = pre_kurt - post_kurt
1135
  symbol = "+" if delta > 0 else "-"
1136
  print(f" {cn}: kurtosis {pre_kurt:.1f} → {post_kurt:.1f} ({symbol}{abs(delta):.1f})")
1137
 
1138
- # Quick benchmark
1139
- benchmark = quick_benchmark(improved_path)
 
 
 
 
1140
 
1141
  elapsed = (time.time() - start) / 60
1142
  health_improvement = (pre_avg - post_avg) / max(pre_avg, 1e-8) * 100
@@ -1149,22 +1498,26 @@ def run_cycle(cfg: SelfImproveConfig = None):
1149
  print(f" Health BEFORE: {pre_avg:.6f}")
1150
  print(f" Health AFTER: {post_avg:.6f}")
1151
  print(f" Health improvement: {health_improvement:.1f}%")
1152
- print(f" Benchmark: {benchmark['score']}/{benchmark['total']}")
1153
  print(f" Improved model: {improved_path}")
1154
  print(f"{'='*60}")
1155
 
1156
- # Final report
1157
- final_report = {
1158
- **pre_report,
1159
- "pre_avg_health": pre_avg,
1160
- "post_avg_health": post_avg,
1161
- "health_improvement_pct": health_improvement,
1162
- "benchmark": benchmark,
1163
- "duration_min": elapsed,
1164
- "improved_path": improved_path,
1165
- }
1166
- with open(Path(improved_path) / "cycle_report.json", "w") as f:
1167
- json.dump(final_report, f, indent=2)
 
 
 
 
1168
 
1169
  return improved_path
1170
 
 
52
  save_steps: int = 100
53
  num_train_samples: int = 3000
54
 
55
+ # Custom training data (from td_data_finder — targeted by weakness)
56
+ custom_data_path: str = "" # If set, load from this JSONL instead of hardcoded datasets
57
+
58
  # Layernorm unfreezing
59
  norm_kurtosis_threshold: float = 15.0
60
 
 
92
 
93
  # Check for highest cycle output (cycle3, cycle2, etc.)
94
  if base.exists():
95
+ for n in range(50, 0, -1):
96
  cycle_dir = base / f"improved_cycle{n}"
97
  if cycle_dir.exists() and list(cycle_dir.glob("*.safetensors")):
98
  print(f" Found cycle {n} output at {cycle_dir}")
 
121
  """Full health analysis for a single weight tensor."""
122
  t = tensor.float()
123
 
124
+ std_val = t.std().item()
125
+ mean_val = t.mean().item()
126
+ abs_mean_val = t.abs().mean().item()
127
+
128
+ # Kurtosis/skewness: guard against overflow for extreme tensors
129
+ # (e.g. degenerate norms with std near 0 → huge normalized values → inf in pow(4))
130
+ try:
131
+ normalized = (t - mean_val) / max(std_val, 1e-8)
132
+ # Clamp to prevent inf/nan from pow(4) on extreme values
133
+ normalized = normalized.clamp(-1e4, 1e4)
134
+ kurtosis_val = normalized.pow(4).mean().item() - 3
135
+ skewness_val = normalized.pow(3).mean().item()
136
+ # Catch NaN/Inf that slipped through
137
+ if math.isnan(kurtosis_val) or math.isinf(kurtosis_val):
138
+ kurtosis_val = 0.0
139
+ if math.isnan(skewness_val) or math.isinf(skewness_val):
140
+ skewness_val = 0.0
141
+ except Exception:
142
+ kurtosis_val = 0.0
143
+ skewness_val = 0.0
144
+
145
  stats = {
146
  "name": name,
147
  "shape": list(tensor.shape),
 
149
  "dtype": str(tensor.dtype),
150
  "l2_norm": t.norm(2).item(),
151
  "frobenius_norm": t.norm('fro').item() if t.dim() >= 2 else t.norm(2).item(),
152
+ "mean": mean_val,
153
+ "std": std_val,
154
  "min": t.min().item(),
155
  "max": t.max().item(),
156
+ "abs_mean": abs_mean_val,
157
  "sparsity": (t.abs() < 1e-6).float().mean().item(),
158
+ "kurtosis": kurtosis_val,
159
+ "skewness": skewness_val,
160
  "near_zero_pct": (t.abs() < 1e-5).float().mean().item(),
161
+ "extreme_pct": (t.abs() > abs_mean_val * 5).float().mean().item() if abs_mean_val > 0 else 0.0,
162
  }
163
 
164
  if t.dim() == 2 and min(t.shape) >= 2:
 
204
 
205
  for st_file in st_files:
206
  print(f" Loading {Path(st_file).name}...")
207
+ try:
208
+ state_dict = load_file(st_file, device="cpu")
209
+ except Exception as e:
210
+ print(f" WARNING: Failed to load {st_file}: {e} — skipping shard")
211
+ continue
212
  for name, tensor in state_dict.items():
213
  if any(skip in name for skip in ["visual.", "merger."]):
214
  continue
215
+ try:
216
+ stats = analyze_layer(name, tensor)
217
+ all_stats[name] = stats
218
+ total_params += stats["params"]
219
+ except Exception as e:
220
+ print(f" WARNING: analyze_layer failed for {name}: {e}")
221
+ continue
222
  del state_dict
223
  gc.collect()
224
 
 
239
  issues = []
240
  score = 0.0
241
 
242
+ # Safe access — analyze_layer might have partial results if SVD failed etc.
243
+ kurt = abs(stats.get("kurtosis", 0))
244
  if kurt > 10:
245
  score += 0.3
246
  issues.append(f"extreme kurtosis ({kurt:.1f})")
 
248
  score += 0.15
249
  issues.append(f"high kurtosis ({kurt:.1f})")
250
 
251
+ skew = abs(stats.get("skewness", 0))
252
  if skew > 2:
253
  score += 0.15
254
  issues.append(f"high skew ({skew:.2f})")
 
276
  elif sv_ratio > 1000:
277
  score += 0.1
278
 
279
+ dead = stats.get("near_zero_pct", 0)
280
  if dead > 0.3:
281
  score += 0.2
282
  issues.append(f"high dead neuron % ({dead:.1%})")
283
  elif dead > 0.15:
284
  score += 0.1
285
 
286
+ extreme = stats.get("extreme_pct", 0)
287
  if extreme > 0.05:
288
  score += 0.15
289
  issues.append(f"many extreme weights ({extreme:.1%})")
290
 
291
+ std_val = stats.get("std", 0)
292
+ if std_val < 1e-4:
293
  score += 0.25
294
+ issues.append(f"near-zero variance ({std_val:.6f})")
295
+ elif std_val > 1.0:
296
  score += 0.15
297
+ issues.append(f"high variance ({std_val:.4f})")
298
 
299
  health_scores[name] = {
300
  "health_score": min(score, 1.0),
 
327
  r_stats = ref_analysis["layers"][ref_key]
328
  matched += 1
329
 
330
+ # Safe .get() for all stats — partial analysis results possible
331
+ norm_diff = abs(m_stats.get("l2_norm", 0) - r_stats.get("l2_norm", 0)) / max(r_stats.get("l2_norm", 1e-8), 1e-8)
332
+ std_diff = abs(m_stats.get("std", 0) - r_stats.get("std", 0)) / max(r_stats.get("std", 1e-8), 1e-8)
333
+ kurt_diff = abs(m_stats.get("kurtosis", 0) - r_stats.get("kurtosis", 0)) / max(abs(r_stats.get("kurtosis", 0)) + 1, 1e-8)
334
+ sparsity_diff = abs(m_stats.get("sparsity", 0) - r_stats.get("sparsity", 0))
335
 
336
  sv_ratio_diff = 0
337
  if "sv_ratio" in m_stats and "sv_ratio" in r_stats:
 
370
  if layer_num not in layer_norms:
371
  layer_norms[layer_num] = []
372
  layer_stds[layer_num] = []
373
+ layer_norms[layer_num].append(stats.get("l2_norm", 0))
374
+ layer_stds[layer_num].append(stats.get("std", 0))
375
 
376
  avg_norms = {l: sum(ns)/len(ns) for l, ns in layer_norms.items()}
377
  avg_stds = {l: sum(ss)/len(ss) for l, ss in layer_stds.items()}
 
500
  clogged = []
501
  for name, h in health_scores.items():
502
  if any(norm_type in name.lower() for norm_type in ["layernorm", "ln_", "_norm"]):
503
+ stats = h.get("stats", {}) if isinstance(h, dict) else {}
504
+ kurt = abs(stats.get("kurtosis", 0))
505
  if kurt > threshold:
506
  clogged.append(name)
507
 
 
538
  print(" Loading reference norms...")
539
  ref_norms = {}
540
  for st_file in sorted(glob.glob(f"{ref_path}/*.safetensors")):
541
+ try:
542
+ state = load_file(st_file, device="cpu")
543
+ except Exception as e:
544
+ print(f" WARNING: Failed to load reference {st_file}: {e} — skipping")
545
+ continue
546
  for name, tensor in state.items():
547
  if name in clogged_norms:
548
  ref_norms[name] = tensor.clone()
 
558
  repaired = 0
559
  st_files = sorted(glob.glob(f"{model_path}/*.safetensors"))
560
 
561
+ if not st_files:
562
+ print(" WARNING: No safetensors files found in model path! Skipping repair.")
563
+ return model_path
564
+
565
  for st_file in st_files:
566
+ try:
567
+ state = load_file(st_file, device="cpu")
568
+ except Exception as e:
569
+ print(f" WARNING: Failed to load {st_file}: {e} — skipping this shard")
570
+ continue
571
  changed = False
572
 
573
  for name in list(state.keys()):
574
  if name in ref_norms:
575
+ # Guard: health_scores might not have this name
576
+ if name not in health_scores or "stats" not in health_scores[name]:
577
+ print(f" {name}: not in health_scores — SKIP")
578
+ continue
579
+ if "kurtosis" not in health_scores[name]["stats"]:
580
+ print(f" {name}: no kurtosis in stats — SKIP")
581
+ continue
582
  kurt = abs(health_scores[name]["stats"]["kurtosis"])
583
 
584
+ # BUG FIX: Check if reference is actually better before blending.
585
+ # Some reference norms have HIGHER kurtosis than our model's.
586
+ # Blending toward a worse reference makes things worse!
587
+ # Example from cycle 3: layers.5.input_layernorm went 28.5 → 43.4
588
+ ref_t = ref_norms[name].float()
589
+ ref_kurt = abs(((ref_t - ref_t.mean()) / max(ref_t.std().item(), 1e-8)).pow(4).mean().item() - 3)
590
+
591
+ if ref_kurt >= kurt:
592
+ print(f" {name}: kurtosis {kurt:.1f} (ref is worse: {ref_kurt:.1f}) — SKIP")
593
+ continue
594
+
595
  # Blend ratio based on severity
596
  if kurt > 100:
597
  ref_weight = 0.80 # Severely broken — mostly use reference
 
615
  changed = True
616
 
617
  if changed:
618
+ try:
619
+ save_file(state, st_file)
620
+ except Exception as e:
621
+ print(f" ERROR: Failed to save repaired weights to {st_file}: {e}")
622
+ print(f" Disk may be full! Norm repair aborted for this shard.")
623
  del state
624
 
625
  gc.collect()
 
653
  from datasets import load_dataset
654
  from torch.utils.data import Dataset
655
 
656
+ # ── DISK SPACE CHECK ──
657
+ # Qwen3-VL-8B saves as ~16GB. Need at least 25GB free for save + temp files.
658
+ try:
659
+ disk = shutil.disk_usage(cfg.output_dir if Path(cfg.output_dir).exists() else ".")
660
+ free_gb = disk.free / 1e9
661
+ print(f" Disk space: {free_gb:.1f} GB free")
662
+ if free_gb < 25:
663
+ print(f" WARNING: Only {free_gb:.1f} GB free! Need ~25 GB for training + save.")
664
+ if free_gb < 10:
665
+ print(f" CRITICAL: Less than 10 GB free — ABORTING to prevent disk-full corruption!")
666
+ return model_path
667
+ except Exception as e:
668
+ print(f" Could not check disk space: {e} — continuing anyway")
669
+
670
+ # ── CHECKPOINT VALIDATION ──
671
+ model_dir = Path(model_path)
672
+ if model_dir.is_dir():
673
+ if not (model_dir / "config.json").exists():
674
+ print(f" ERROR: No config.json in {model_path} — stale or corrupt checkpoint!")
675
+ return model_path
676
+ st_files = list(model_dir.glob("*.safetensors"))
677
+ if not st_files and not list(model_dir.glob("*.bin")):
678
+ print(f" ERROR: No model weights in {model_path} — checkpoint is empty!")
679
+ return model_path
680
+
681
+ # Load model with OOM guard
682
  print(" Loading model...")
683
+ try:
684
+ model = AutoModelForImageTextToText.from_pretrained(
685
+ model_path, dtype=torch.bfloat16,
686
+ device_map="auto", trust_remote_code=True
687
+ )
688
+ except (RuntimeError, Exception) as e:
689
+ gc.collect()
690
+ if torch.cuda.is_available():
691
+ torch.cuda.empty_cache()
692
+ print(f" CRITICAL: Failed to load model: {e}")
693
+ return model_path
694
  tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
695
 
696
  # Enable gradient checkpointing for mega mode (saves ~40% VRAM)
 
709
  bias="none",
710
  task_type=TaskType.CAUSAL_LM,
711
  )
712
+ try:
713
+ model = get_peft_model(model, lora_config)
714
+ except Exception as e:
715
+ print(f"\n CRITICAL: get_peft_model failed: {e}")
716
+ print(f" LoRA config may be incompatible with this model.")
717
+ del model
718
+ gc.collect()
719
+ if torch.cuda.is_available():
720
+ torch.cuda.empty_cache()
721
+ return model_path
722
 
723
  # UNFREEZE clogged norms — the key cycle 2 fix
724
  unfrozen_count = 0
 
749
  train_texts = []
750
  total = cfg.num_train_samples
751
 
752
+ # === CUSTOM DATA PATH (from td_data_finder — targeted by weakness) ===
753
+ # If td_loop found weakness-targeted data, use that instead of hardcoded datasets.
754
+ # This is what makes the self-improvement loop actually self-improving:
755
+ # each cycle trains on different data based on what the model is bad at.
756
+ if cfg.custom_data_path and Path(cfg.custom_data_path).exists():
757
+ print(f" Using TARGETED training data from: {cfg.custom_data_path}")
758
+ with open(cfg.custom_data_path) as _f:
759
+ for line in _f:
760
+ line = line.strip()
761
+ if not line:
762
+ continue
763
+ try:
764
+ item = json.loads(line)
765
+ text = item.get("text", "")
766
+ if text and len(text) > 20:
767
+ train_texts.append(text)
768
+ except json.JSONDecodeError:
769
+ continue
770
+ if len(train_texts) >= total:
771
+ break
772
+ print(f" Loaded {len(train_texts)} targeted samples")
773
+ if len(train_texts) < total * 0.5:
774
+ print(f" WARNING: Only got {len(train_texts)}/{total} targeted samples.")
775
+ print(f" Supplementing with generic data to reach {total}...")
776
+ # Fall through to hardcoded data loading below to fill the gap
777
+ else:
778
+ # Got enough targeted data — skip hardcoded loading
779
+ # Jump past the hardcoded data section
780
+ pass # train_texts is already populated
781
+
782
+ # Only load hardcoded datasets if we don't have enough targeted data
783
+ # Only load generic datasets if targeted data wasn't enough
784
+ # When alloc values are 0, each section's `if count >= alloc[key]: break`
785
+ # exits immediately, loading 0 samples. Clean skip, no indent changes needed.
786
+ alloc = {"code": 0, "math": 0, "reasoning": 0, "creativity": 0,
787
+ "instruction_following": 0, "general": 0}
788
+ if len(train_texts) < total:
789
+ remaining_needed = total - len(train_texts)
790
+ if cfg.custom_data_path and train_texts:
791
+ print(f" Need {remaining_needed} more samples from generic datasets...")
792
+ scale = remaining_needed / total
793
+ else:
794
+ scale = 1.0
795
+
796
+ if cfg.mega_mode:
797
+ alloc = {"code": int(total*0.25*scale), "math": int(total*0.25*scale),
798
+ "reasoning": int(total*0.19*scale), "creativity": int(total*0.12*scale),
799
+ "instruction_following": int(total*0.12*scale), "general": int(total*0.07*scale)}
800
+ else:
801
+ per = int((total // 5) * scale)
802
+ alloc = {"code": per, "math": per, "reasoning": per,
803
+ "creativity": 0, "instruction_following": per, "general": per}
804
  else:
805
+ print(f" Targeted data sufficient ({len(train_texts)} samples). Skipping generic datasets.")
 
 
806
 
807
  # 1. MATH (top priority)
808
  try:
 
809
  count = 0
810
+ if alloc["math"] > 0:
811
+ ds = load_dataset("openai/gsm8k", "main", split="train")
812
+ for row in ds:
813
+ if count >= alloc["math"]:
814
+ break
815
+ q, a = row.get("question", ""), row.get("answer", "")
816
+ if q and a:
817
+ chat = (f"<|im_start|>user\n{q}\nThink step by step.<|im_end|>\n"
818
+ f"<|im_start|>assistant\n<think>\n{a}\n</think><|im_end|>")
819
+ train_texts.append(chat)
820
+ count += 1
821
  print(f" Math (GSM8K): {count}")
822
  except Exception as e:
823
  print(f" WARNING: GSM8K failed: {e}")
 
843
 
844
  # 2. CODE (top priority)
845
  try:
 
846
  count = 0
847
+ if alloc["code"] > 0:
848
+ ds = load_dataset("sahil2801/CodeAlpaca-20k", split="train")
849
+ for row in ds:
850
+ if count >= alloc["code"]:
851
+ break
852
+ inst = row.get("instruction", "")
853
+ inp = row.get("input", "")
854
+ out = row.get("output", "")
855
+ if inst and out and len(out) > 20:
856
+ prompt = f"{inst}\n{inp}" if inp else inst
857
+ chat = (f"<|im_start|>user\n{prompt}<|im_end|>\n"
858
+ f"<|im_start|>assistant\n{out}<|im_end|>")
859
+ train_texts.append(chat)
860
+ count += 1
861
  print(f" Code (CodeAlpaca): {count}")
862
  except Exception as e:
863
  print(f" WARNING: CodeAlpaca failed: {e}")
 
883
 
884
  # 3. REASONING (top priority)
885
  try:
 
886
  count = 0
887
+ if alloc["reasoning"] > 0:
888
+ ds = load_dataset("TIGER-Lab/MMLU-Pro", split="test")
889
+ for row in ds:
890
+ if count >= alloc["reasoning"]:
891
+ break
892
+ q = row.get("question", "")
893
+ options = row.get("options", [])
894
+ answer_idx = row.get("answer_index", -1)
895
+ if q and options and 0 <= answer_idx < len(options):
896
+ answer_text = options[answer_idx]
897
+ chat = (f"<|im_start|>user\n{q}<|im_end|>\n"
898
+ f"<|im_start|>assistant\n<think>\nLet me reason through this.\n"
899
+ f"{answer_text}\n</think>\n{answer_text}<|im_end|>")
900
+ train_texts.append(chat)
901
+ count += 1
902
  print(f" Reasoning (MMLU-Pro): {count}")
903
  except Exception as e:
904
  print(f" WARNING: MMLU-Pro failed: {e}")
 
929
 
930
  # 5. INSTRUCTIONS (general)
931
  try:
 
932
  count = 0
933
+ if alloc.get("instruction_following", 0) > 0:
934
+ ds = load_dataset("yahma/alpaca-cleaned", split="train")
935
+ for row in ds:
936
+ if count >= alloc["instruction_following"]:
937
+ break
938
+ inst = row.get("instruction", "")
939
+ inp = row.get("input", "")
940
+ out = row.get("output", "")
941
+ if inst and out and len(out) > 30:
942
+ prompt = f"{inst}\n{inp}" if inp else inst
943
+ chat = (f"<|im_start|>user\n{prompt}<|im_end|>\n"
944
+ f"<|im_start|>assistant\n{out}<|im_end|>")
945
+ train_texts.append(chat)
946
+ count += 1
947
  print(f" Instructions (Alpaca): {count}")
948
  except Exception as e:
949
  print(f" WARNING: Alpaca failed: {e}")
950
 
951
  # 6. GENERAL TEXT (lowest priority)
952
  try:
 
953
  count = 0
954
+ if alloc.get("general", 0) > 0:
955
+ ds = load_dataset("neuralmagic/LLM_compression_calibration", split="train")
956
+ for row in ds:
957
+ if count >= alloc["general"]:
958
+ break
959
+ text = row.get("text", "")
960
+ if len(text) > 100:
961
+ chat = (f"<|im_start|>user\nContinue this text naturally.<|im_end|>\n"
962
+ f"<|im_start|>assistant\n{text[:800]}<|im_end|>")
963
+ train_texts.append(chat)
964
+ count += 1
965
  print(f" General text: {count}")
966
  except Exception as e:
967
  print(f" WARNING: General text failed: {e}")
 
972
  print(" ERROR: Not enough training data!")
973
  return model_path
974
 
975
+ # === CONTAMINATION CHECK ===
976
+ # Remove any training samples that match benchmark/holdout questions.
977
+ # Without this, benchmark scores become meaningless after a few cycles.
978
+ try:
979
+ from td_contamination import check_contamination
980
+ before = len(train_texts)
981
+ train_texts = check_contamination(train_texts)
982
+ if len(train_texts) < before:
983
+ print(f" Contamination guard removed {before - len(train_texts)} samples")
984
+ except ImportError:
985
+ print(" WARNING: td_contamination.py not found — skipping contamination check")
986
+
987
+ # === DIVERSITY CHECK (anti-collapse) ===
988
+ # If too many training samples are near-duplicates, the model collapses
989
+ # toward repeating the same patterns. Detect and warn early.
990
+ unique_prefixes = set()
991
+ for t in train_texts:
992
+ # Use first 100 chars as a rough fingerprint
993
+ prefix = t[:100].strip().lower()
994
+ unique_prefixes.add(prefix)
995
+ diversity_ratio = len(unique_prefixes) / max(len(train_texts), 1)
996
+ print(f" Diversity check: {len(unique_prefixes)}/{len(train_texts)} unique prefixes ({diversity_ratio:.0%})")
997
+ if diversity_ratio < 0.5:
998
+ print(" WARNING: Low diversity (<50%) — high risk of mode collapse!")
999
+ print(" Deduplicating training data...")
1000
+ seen = set()
1001
+ deduped = []
1002
+ for t in train_texts:
1003
+ prefix = t[:100].strip().lower()
1004
+ if prefix not in seen:
1005
+ seen.add(prefix)
1006
+ deduped.append(t)
1007
+ removed = len(train_texts) - len(deduped)
1008
+ train_texts = deduped
1009
+ print(f" Removed {removed} near-duplicate samples. Now {len(train_texts)} unique.")
1010
+ elif diversity_ratio < 0.7:
1011
+ print(" NOTE: Moderate diversity (50-70%) — monitor for early signs of collapse")
1012
+
1013
  random.shuffle(train_texts)
1014
 
1015
  # Tokenize
 
1030
  print(" Tokenizing...")
1031
  dataset = ImproveDataset(train_texts, tokenizer)
1032
 
1033
+ if len(dataset) == 0:
1034
+ print(" ERROR: Dataset is empty after tokenization!")
1035
+ return model_path
1036
+
1037
  out_dir = Path(cfg.output_dir) / "train_output"
1038
  out_dir.mkdir(parents=True, exist_ok=True)
1039
 
1040
+ effective_batch = cfg.train_batch * cfg.train_grad_accum
1041
+ total_steps = (len(dataset) * cfg.train_epochs) // max(effective_batch, 1)
1042
 
1043
  # Set up optimizer with different LR for norms vs LoRA
1044
  # Group parameters: norms get higher LR
 
1095
  print(f"\n Training: ~{total_steps} steps on {len(target_layers)} layers + {len(clogged_norms)} norms")
1096
  print(f" Data: {len(train_texts)} samples × {cfg.train_epochs} epochs")
1097
  print(f" LoRA: r={cfg.lora_r}, alpha={cfg.lora_alpha}")
1098
+
1099
+ try:
1100
+ train_result = trainer.train()
1101
+ except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
1102
+ err_str = str(e).lower()
1103
+ if "out of memory" in err_str or "cuda" in err_str:
1104
+ print(f"\n OOM DURING TRAINING: {e}")
1105
+ print(" Cleaning up GPU memory and returning previous model...")
1106
+ del model, trainer
1107
+ gc.collect()
1108
+ if torch.cuda.is_available():
1109
+ torch.cuda.empty_cache()
1110
+ shutil.rmtree(str(out_dir), ignore_errors=True)
1111
+ return model_path
1112
+ else:
1113
+ raise # Re-raise non-OOM RuntimeErrors
1114
+
1115
+ # ── NaN/Inf LOSS CHECK ──
1116
+ # If training produced NaN loss, the model weights are garbage — abort
1117
+ try:
1118
+ final_loss = train_result.training_loss
1119
+ if final_loss is not None and (math.isnan(final_loss) or math.isinf(final_loss)):
1120
+ print(f"\n CRITICAL: Training loss is {final_loss}! Model weights are corrupted.")
1121
+ print(f" Falling back to previous model: {model_path}")
1122
+ del model, trainer
1123
+ gc.collect()
1124
+ if torch.cuda.is_available():
1125
+ torch.cuda.empty_cache()
1126
+ shutil.rmtree(str(out_dir), ignore_errors=True)
1127
+ return model_path
1128
+ print(f"\n Training complete. Final loss: {final_loss:.4f}")
1129
+ except (AttributeError, TypeError):
1130
+ print("\n Training complete. (Could not read final loss)")
1131
 
1132
  # Merge LoRA back
1133
  print("\n Merging LoRA adapters into model...")
1134
+ try:
1135
+ merged = model.merge_and_unload()
1136
+ except Exception as e:
1137
+ print(f"\n CRITICAL: LoRA merge failed: {e}")
1138
+ print(f" Falling back to previous model: {model_path}")
1139
+ del model, trainer
1140
+ gc.collect()
1141
+ if torch.cuda.is_available():
1142
+ torch.cuda.empty_cache()
1143
+ shutil.rmtree(str(out_dir), ignore_errors=True)
1144
+ return model_path
1145
 
1146
  # Save
1147
  save_dir = Path(cfg.output_dir) / f"improved_cycle{cycle_num}"
1148
  save_dir.mkdir(parents=True, exist_ok=True)
1149
  print(f" Saving to {save_dir}...")
1150
+ try:
1151
+ merged.save_pretrained(str(save_dir), safe_serialization=True)
1152
+ tokenizer.save_pretrained(str(save_dir))
1153
+ except Exception as e:
1154
+ print(f"\n CRITICAL: Model save failed: {e}")
1155
+ print(f" Disk may be full! Falling back to previous model: {model_path}")
1156
+ del merged, model
1157
+ gc.collect()
1158
+ if torch.cuda.is_available():
1159
+ torch.cuda.empty_cache()
1160
+ shutil.rmtree(str(save_dir), ignore_errors=True)
1161
+ shutil.rmtree(str(out_dir), ignore_errors=True)
1162
+ return model_path
1163
+
1164
+ # Verify save was successful — check files exist and aren't tiny/corrupt
1165
+ st_files = list(save_dir.glob("*.safetensors"))
1166
+ if not st_files:
1167
+ print(" ERROR: No safetensors files saved! Disk may be full.")
1168
+ print(f" Falling back to previous model: {model_path}")
1169
+ shutil.rmtree(str(save_dir), ignore_errors=True)
1170
+ return model_path
1171
+ sz = sum(f.stat().st_size for f in st_files) / 1e9
1172
+ if sz < 1.0: # Qwen3-VL-8B should be ~16GB, anything under 1GB is corrupt
1173
+ print(f" ERROR: Saved model suspiciously small ({sz:.2f} GB). Likely corrupt!")
1174
+ print(f" Falling back to previous model: {model_path}")
1175
+ shutil.rmtree(str(save_dir), ignore_errors=True)
1176
+ return model_path
1177
  print(f" SAVED: {save_dir} ({sz:.1f} GB)")
1178
 
1179
  shutil.rmtree(str(out_dir), ignore_errors=True)
 
1197
 
1198
  from transformers import AutoModelForImageTextToText, AutoTokenizer
1199
 
1200
+ # ── CHECKPOINT VALIDATION ──
1201
+ mp = Path(model_path)
1202
+ if mp.is_dir():
1203
+ if not (mp / "config.json").exists():
1204
+ print(f" ERROR: No config.json in {model_path} — skipping quick benchmark")
1205
+ return {"score": 0, "total": 5, "results": [], "error": "no config.json"}
1206
+ if not list(mp.glob("*.safetensors")) and not list(mp.glob("*.bin")):
1207
+ print(f" ERROR: No model weights in {model_path} — skipping quick benchmark")
1208
+ return {"score": 0, "total": 5, "results": [], "error": "no weights"}
1209
+
1210
+ # ── MODEL LOAD with OOM guard ──
1211
+ try:
1212
+ model = AutoModelForImageTextToText.from_pretrained(
1213
+ model_path, dtype=torch.bfloat16,
1214
+ device_map="auto", trust_remote_code=True
1215
+ )
1216
+ tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
1217
+ except (RuntimeError, Exception) as e:
1218
+ gc.collect()
1219
+ if torch.cuda.is_available():
1220
+ torch.cuda.empty_cache()
1221
+ print(f" CRITICAL: Failed to load model for quick benchmark: {e}")
1222
+ return {"score": 0, "total": 5, "results": [], "error": str(e)}
1223
 
1224
  tests = [
1225
  {"q": "What is 47 * 23?", "expected": "1081", "type": "math"},
 
1234
 
1235
  results = []
1236
  for test in tests:
1237
+ try:
1238
+ chat = f"<|im_start|>user\n{test['q']}<|im_end|>\n<|im_start|>assistant\n"
1239
+ ids = tokenizer(chat, return_tensors="pt").to(model.device)
1240
+ try:
1241
+ with torch.no_grad():
1242
+ out = model.generate(**ids, max_new_tokens=200, do_sample=False)
1243
+ except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
1244
+ gc.collect()
1245
+ if torch.cuda.is_available():
1246
+ torch.cuda.empty_cache()
1247
+ print(f"\n [OOM] {test['type']}: {test['q']}")
1248
+ results.append({"type": test["type"], "passed": False, "error": "OOM"})
1249
+ continue
1250
+ answer = tokenizer.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True)
1251
+ # Truncate garbage output
1252
+ if len(answer) > 5000:
1253
+ answer = answer[:5000]
1254
+
1255
+ passed = test["expected"].lower() in answer.lower()
1256
+ status = "PASS" if passed else "FAIL"
1257
+ results.append({"type": test["type"], "passed": passed})
1258
+
1259
+ print(f"\n [{status}] {test['type']}: {test['q']}")
1260
+ print(f" Answer: {answer[:200]}")
1261
+ print(f" Expected to contain: {test['expected']}")
1262
+ except Exception as e:
1263
+ print(f"\n [ERROR] {test['type']}: {test['q']} — {e}")
1264
+ results.append({"type": test["type"], "passed": False, "error": str(e)})
1265
 
1266
+ score = sum(1 for r in results if r.get("passed", False))
1267
  print(f"\n BENCHMARK: {score}/{len(tests)} passed")
1268
 
1269
  del model
 
1287
  if not cfg.model_path:
1288
  cfg.model_path, cycle_num = auto_detect_model_path()
1289
  else:
1290
+ # BUG FIX: Parse actual cycle number from path instead of assuming 2
1291
+ # "improved_cycle5" → cycle 6, "improved_cycle2" → cycle 3
1292
+ # Old code: `cycle_num = 2 if "improve" in cfg.model_path else 1`
1293
+ # That would ALWAYS save to improved_cycle2, overwriting previous!
1294
+ import re as _re
1295
+ cycle_match = _re.search(r'improved_cycle(\d+)', cfg.model_path)
1296
+ if cycle_match:
1297
+ cycle_num = int(cycle_match.group(1)) + 1
1298
+ elif "improve" in cfg.model_path:
1299
+ cycle_num = 2
1300
+ else:
1301
+ cycle_num = 1
1302
 
1303
  # MEGA MODE for cycle 3+
1304
  if cycle_num >= 3 and not cfg.mega_mode:
 
1338
  print("STEP 3/7: REFERENCE COMPARISON")
1339
  print(f"{'='*40}")
1340
  ref_path = Path(cfg.output_dir) / "reference_weights"
1341
+ ref_available = False
1342
+ try:
1343
+ if not ref_path.exists() or not list(ref_path.glob("*.safetensors")):
1344
+ print(f" Downloading reference: {cfg.reference_model}...")
1345
+ from huggingface_hub import snapshot_download
1346
+ snapshot_download(
1347
+ cfg.reference_model, local_dir=str(ref_path),
1348
+ allow_patterns=["*.safetensors", "*.json"],
1349
+ ignore_patterns=["*.bin", "*.pt", "*.onnx"],
1350
+ )
1351
+ ref_analysis = analyze_model_weights(str(ref_path), "Qwen3-VL-8B (reference)")
1352
+ damage_scores = compute_damage_scores(model_analysis, ref_analysis)
1353
+ ref_available = True
1354
+ except Exception as e:
1355
+ print(f" WARNING: Reference comparison failed: {e}")
1356
+ print(f" Continuing without damage scores (norm repair + LoRA still work)")
1357
+ damage_scores = {}
1358
+ ref_analysis = None
1359
  # DON'T delete reference yet — we need it for norm repair
1360
  gc.collect()
1361
 
 
1378
  if clogged_norms:
1379
  print(f"\n CLOGGED NORMS DETECTED ({len(clogged_norms)}):")
1380
  for cn in clogged_norms:
1381
+ stats = health_scores.get(cn, {}).get("stats", {})
1382
+ kurt = abs(stats.get("kurtosis", 0))
1383
  print(f" {cn}: kurtosis={kurt:.1f}")
1384
  else:
1385
  print(f"\n No clogged norms above threshold {cfg.norm_kurtosis_threshold}")
1386
 
1387
  # STEP 5c: REPAIR clogged norms surgically
1388
+ if clogged_norms and ref_available:
1389
  print(f"\n{'='*40}")
1390
  print("STEP 5c/7: SURGICAL NORM REPAIR")
1391
  print(f"{'='*40}")
1392
+ try:
1393
+ repair_clogged_norms(cfg.model_path, clogged_norms, str(ref_path), health_scores)
1394
+ except Exception as e:
1395
+ print(f" WARNING: Norm repair failed: {e}")
1396
+ print(f" Continuing without repair — LoRA training can still help")
1397
+ elif clogged_norms and not ref_available:
1398
+ print(f"\n Skipping norm repair — reference model not available")
1399
 
1400
  # Clean up reference now
1401
  print(f" Cleaning up reference weights...")
1402
  shutil.rmtree(str(ref_path), ignore_errors=True)
1403
  gc.collect()
1404
 
1405
+ # Save report (non-critical)
1406
  report_dir = Path(cfg.output_dir)
1407
  report_dir.mkdir(parents=True, exist_ok=True)
1408
  pre_report = {
 
1415
  "layer_details": {str(k): v for k, v in layer_details.items()},
1416
  "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
1417
  }
1418
+ try:
1419
+ with open(report_dir / f"pre_cycle{cycle_num}_report.json", "w") as f:
1420
+ json.dump(pre_report, f, indent=2)
1421
+ except OSError as e:
1422
+ print(f" WARNING: Could not save pre-report: {e}")
1423
 
1424
  # STEP 6: Train
1425
  print(f"\n{'='*40}")
 
1432
  print("STEP 7/7: VERIFY + BENCHMARK")
1433
  print(f"{'='*40}")
1434
 
1435
+ # Re-analyze health (non-critical — if this fails, cycle still succeeded)
 
 
 
1436
  pre_avg = sum(h["health_score"] for h in health_scores.values()) / max(len(health_scores), 1)
1437
+ post_avg = pre_avg # Default: same as before if analysis fails
1438
+ post_health = {}
1439
+ try:
1440
+ post_analysis = analyze_model_weights(improved_path, "TD improved")
1441
+ post_health = compute_health_scores(post_analysis)
1442
+ post_avg = sum(h["health_score"] for h in post_health.values()) / max(len(post_health), 1)
1443
+ except Exception as e:
1444
+ print(f" WARNING: Post-training health analysis failed: {e}")
1445
+ print(f" Cannot compare before/after — but training output is still valid.")
1446
 
1447
+ # Per-layer comparison (only if post-analysis succeeded)
1448
+ if post_health:
1449
+ print(f"\n Per-layer improvement:")
1450
+ else:
1451
+ print(f"\n (Skipping per-layer comparison — post-analysis unavailable)")
1452
  for layer_num in target_layers:
1453
+ if not post_health:
1454
+ break
1455
  post_layer_scores = []
1456
  for name, h in post_health.items():
1457
  parts = name.split(".")
 
1470
  if clogged_norms:
1471
  print(f"\n Clogged norm changes:")
1472
  for cn in clogged_norms:
1473
+ pre_stats = health_scores.get(cn, {}).get("stats", {})
1474
+ pre_kurt = abs(pre_stats.get("kurtosis", 0))
1475
  # Find matching post norm
1476
  if cn in post_health:
1477
+ post_stats = post_health.get(cn, {}).get("stats", {})
1478
+ post_kurt = abs(post_stats.get("kurtosis", 0))
1479
  delta = pre_kurt - post_kurt
1480
  symbol = "+" if delta > 0 else "-"
1481
  print(f" {cn}: kurtosis {pre_kurt:.1f} → {post_kurt:.1f} ({symbol}{abs(delta):.1f})")
1482
 
1483
+ # Quick benchmark (non-critical — don't crash if it fails)
1484
+ try:
1485
+ benchmark = quick_benchmark(improved_path)
1486
+ except Exception as e:
1487
+ print(f" WARNING: Quick benchmark failed: {e}")
1488
+ benchmark = {"score": 0, "total": 0, "error": str(e)}
1489
 
1490
  elapsed = (time.time() - start) / 60
1491
  health_improvement = (pre_avg - post_avg) / max(pre_avg, 1e-8) * 100
 
1498
  print(f" Health BEFORE: {pre_avg:.6f}")
1499
  print(f" Health AFTER: {post_avg:.6f}")
1500
  print(f" Health improvement: {health_improvement:.1f}%")
1501
+ print(f" Benchmark: {benchmark.get('score', '?')}/{benchmark.get('total', '?')}")
1502
  print(f" Improved model: {improved_path}")
1503
  print(f"{'='*60}")
1504
 
1505
+ # Final report (non-critical)
1506
+ try:
1507
+ final_report = {
1508
+ **pre_report,
1509
+ "pre_avg_health": pre_avg,
1510
+ "post_avg_health": post_avg,
1511
+ "health_improvement_pct": health_improvement,
1512
+ "benchmark": benchmark,
1513
+ "duration_min": elapsed,
1514
+ "improved_path": improved_path,
1515
+ }
1516
+ report_save_path = Path(improved_path) / "cycle_report.json"
1517
+ with open(report_save_path, "w") as f:
1518
+ json.dump(final_report, f, indent=2)
1519
+ except Exception as e:
1520
+ print(f" WARNING: Could not save cycle report: {e}")
1521
 
1522
  return improved_path
1523
 
hugging/td_fuse/td_benchmark.py ADDED
@@ -0,0 +1,833 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TD Benchmark Suite v1 — Model Report Card
3
+
4
+ Tests the model across 6 categories and gives a score per category.
5
+ This is Step 1A of the self-improvement loop.
6
+
7
+ Categories:
8
+ 1. Math (arithmetic, algebra, word problems)
9
+ 2. Code (write functions, debug, explain)
10
+ 3. Reasoning (logic, multi-step, common sense)
11
+ 4. Creativity (writing, rewriting, explaining)
12
+ 5. Knowledge (science, history, general facts)
13
+ 6. Instruction Following (do exactly what's asked)
14
+
15
+ Output: JSON report with scores per category + list of failed questions.
16
+ The weakness finder uses this to decide what to train on next.
17
+ """
18
+
19
+ import torch
20
+ import json
21
+ import time
22
+ import re
23
+ import gc
24
+ from pathlib import Path
25
+ from typing import Dict, List, Tuple, Optional
26
+ from dataclasses import dataclass, field
27
+
28
+
29
+ @dataclass
30
+ class BenchmarkConfig:
31
+ model_path: str = ""
32
+ output_dir: str = "td_fuse_outputs/benchmark"
33
+ output_path: str = "" # If set, save results to this exact path
34
+ max_new_tokens: int = 256 # SPEED: 512 was overkill, most answers are short
35
+ temperature: float = 0.0 # Greedy — deterministic answers
36
+ batch_questions: int = 1 # Questions per batch (1 = sequential)
37
+ disable_thinking: bool = True # SPEED: Skip <think> blocks for benchmark
38
+
39
+
40
+ # ============================================================
41
+ # TEST BANKS — questions with verifiable answers
42
+ # ============================================================
43
+
44
+ MATH_TESTS = [
45
+ # Arithmetic
46
+ {"q": "What is 47 * 23?", "answer": "1081", "type": "arithmetic", "difficulty": "easy"},
47
+ {"q": "What is 156 + 289?", "answer": "445", "type": "arithmetic", "difficulty": "easy"},
48
+ {"q": "What is 1000 - 387?", "answer": "613", "type": "arithmetic", "difficulty": "easy"},
49
+ {"q": "What is 144 / 12?", "answer": "12", "type": "arithmetic", "difficulty": "easy"},
50
+ {"q": "What is 17 * 19?", "answer": "323", "type": "arithmetic", "difficulty": "easy"},
51
+ {"q": "What is 2.5 * 4.8?", "answer": "12", "type": "arithmetic", "difficulty": "medium"},
52
+ {"q": "What is 15% of 240?", "answer": "36", "type": "arithmetic", "difficulty": "medium"},
53
+ {"q": "What is 3/4 + 2/3? Give your answer as a fraction.", "answer": "17/12", "type": "fractions", "difficulty": "medium"},
54
+
55
+ # Algebra
56
+ {"q": "Solve for x: 3x + 7 = 22", "answer": "5", "type": "algebra", "difficulty": "easy"},
57
+ {"q": "Solve for x: 2x - 5 = 13", "answer": "9", "type": "algebra", "difficulty": "easy"},
58
+ {"q": "Solve for x: x^2 = 144", "answer": "12", "type": "algebra", "difficulty": "medium"},
59
+ {"q": "If f(x) = 2x + 3, what is f(7)?", "answer": "17", "type": "algebra", "difficulty": "medium"},
60
+
61
+ # Word problems
62
+ {"q": "A store sells apples for $2 each and oranges for $3 each. If I buy 4 apples and 3 oranges, how much do I spend?", "answer": "17", "type": "word_problem", "difficulty": "easy"},
63
+ {"q": "A train travels at 60 mph. How far does it go in 2.5 hours?", "answer": "150", "type": "word_problem", "difficulty": "easy"},
64
+ {"q": "If 5 workers can build a wall in 10 days, how many days would it take 10 workers?", "answer": "5", "type": "word_problem", "difficulty": "medium"},
65
+ {"q": "A rectangle has a perimeter of 30 cm and a width of 5 cm. What is its length?", "answer": "10", "type": "word_problem", "difficulty": "medium"},
66
+ {"q": "I have 3 times as many cats as dogs. I have 2 dogs. How many animals do I have in total?", "answer": "8", "type": "word_problem", "difficulty": "easy"},
67
+ {"q": "A car uses 8 liters of fuel per 100km. How many liters does it need for a 350km trip?", "answer": "28", "type": "word_problem", "difficulty": "medium"},
68
+
69
+ # Harder math
70
+ {"q": "What is the sum of the first 10 positive integers?", "answer": "55", "type": "series", "difficulty": "medium"},
71
+ {"q": "What is 2^10?", "answer": "1024", "type": "exponents", "difficulty": "medium"},
72
+ {"q": "What is the greatest common divisor of 48 and 36?", "answer": "12", "type": "number_theory", "difficulty": "medium"},
73
+ {"q": "If a triangle has sides of length 3, 4, and 5, what is its area?", "answer": "6", "type": "geometry", "difficulty": "medium"},
74
+ {"q": "What is the square root of 169?", "answer": "13", "type": "roots", "difficulty": "easy"},
75
+ {"q": "A coin is flipped 3 times. How many possible outcomes are there?", "answer": "8", "type": "probability", "difficulty": "medium"},
76
+ {"q": "What is 7! (7 factorial)?", "answer": "5040", "type": "factorial", "difficulty": "hard"},
77
+ ]
78
+
79
+ CODE_TESTS = [
80
+ # Write functions
81
+ {"q": "Write a Python function called 'is_even' that returns True if a number is even, False otherwise. Just give the function, no explanation.",
82
+ "check": "is_even", "verify": "callable", "type": "write", "difficulty": "easy"},
83
+ {"q": "Write a Python function called 'factorial' that computes the factorial of a non-negative integer. Just give the function.",
84
+ "check": "factorial", "verify": "callable", "type": "write", "difficulty": "medium"},
85
+ {"q": "Write a Python function called 'reverse_string' that takes a string and returns it reversed. Just give the function.",
86
+ "check": "reverse_string", "verify": "callable", "type": "write", "difficulty": "easy"},
87
+ {"q": "Write a Python function called 'max_of_three' that takes three numbers and returns the largest. Just give the function.",
88
+ "check": "max_of_three", "verify": "callable", "type": "write", "difficulty": "easy"},
89
+ {"q": "Write a Python function called 'count_vowels' that counts the number of vowels (a,e,i,o,u) in a string. Just give the function.",
90
+ "check": "count_vowels", "verify": "callable", "type": "write", "difficulty": "medium"},
91
+ {"q": "Write a Python function called 'fibonacci' that returns the nth Fibonacci number (0-indexed, so fibonacci(0)=0, fibonacci(1)=1). Just give the function.",
92
+ "check": "fibonacci", "verify": "callable", "type": "write", "difficulty": "medium"},
93
+ {"q": "Write a Python function called 'is_palindrome' that returns True if a string reads the same forwards and backwards. Just give the function.",
94
+ "check": "is_palindrome", "verify": "callable", "type": "write", "difficulty": "medium"},
95
+ {"q": "Write a Python function called 'flatten' that takes a list of lists and returns a single flat list. Example: flatten([[1,2],[3,4]]) returns [1,2,3,4]. Just give the function.",
96
+ "check": "flatten", "verify": "callable", "type": "write", "difficulty": "medium"},
97
+
98
+ # Debug
99
+ {"q": "This Python code has a bug. Fix it and give ONLY the corrected code:\ndef add_list(lst):\n total = 0\n for i in range(len(lst) + 1):\n total += lst[i]\n return total",
100
+ "check": "range(len(lst))", "verify": "contains", "type": "debug", "difficulty": "easy"},
101
+ {"q": "This Python code has a bug. Fix it and give ONLY the corrected code:\ndef greet(name):\n return 'Hello' + name",
102
+ "check": ["Hello ' + name", "Hello \" + name", "'Hello ' +", "'Hello, '", "f'Hello {", "f\"Hello {", "Hello \" +"], "verify": "contains_any", "type": "debug", "difficulty": "easy"},
103
+
104
+ # Explain
105
+ {"q": "In one sentence, what does this Python code do?\n[x**2 for x in range(10) if x % 2 == 0]",
106
+ "check": "square", "verify": "contains", "type": "explain", "difficulty": "easy"},
107
+ {"q": "In one sentence, what does this Python code do?\nlen(set(my_list))",
108
+ "check": "unique", "verify": "contains", "type": "explain", "difficulty": "easy"},
109
+ ]
110
+
111
+ REASONING_TESTS = [
112
+ # Logic
113
+ {"q": "If all roses are flowers and all flowers need water, do roses need water? Answer yes or no.",
114
+ "answer": "yes", "type": "syllogism", "difficulty": "easy"},
115
+ {"q": "If it's raining, the ground is wet. The ground is wet. Is it definitely raining? Answer yes or no.",
116
+ "answer": "no", "type": "logic_fallacy", "difficulty": "medium"},
117
+ {"q": "All cats are animals. Some animals are pets. Can we conclude all cats are pets? Answer yes or no.",
118
+ "answer": "no", "type": "syllogism", "difficulty": "medium"},
119
+ {"q": "If A is taller than B, and B is taller than C, who is the shortest?",
120
+ "answer": "c", "type": "transitive", "difficulty": "easy"},
121
+ {"q": "If no fish can fly, and all salmon are fish, can salmon fly? Answer yes or no.",
122
+ "answer": "no", "type": "syllogism", "difficulty": "easy"},
123
+
124
+ # Pattern recognition
125
+ {"q": "What comes next: 2, 4, 8, 16, ?", "answer": "32", "type": "pattern", "difficulty": "easy"},
126
+ {"q": "What comes next: 1, 1, 2, 3, 5, 8, ?", "answer": "13", "type": "pattern", "difficulty": "medium"},
127
+ {"q": "What comes next: 3, 6, 9, 12, ?", "answer": "15", "type": "pattern", "difficulty": "easy"},
128
+ {"q": "What comes next: 1, 4, 9, 16, 25, ?", "answer": "36", "type": "pattern", "difficulty": "medium"},
129
+
130
+ # Multi-step reasoning
131
+ {"q": "There are 3 boxes. Box A is heavier than Box B. Box C is lighter than Box B. Which box is the heaviest?",
132
+ "answer": "a", "type": "multi_step", "difficulty": "easy"},
133
+ {"q": "Tom is twice as old as Jerry. Jerry is 3 years older than Spike. Spike is 4 years old. How old is Tom?",
134
+ "answer": "14", "type": "multi_step", "difficulty": "medium"},
135
+ {"q": "A snail climbs 3 meters during the day but slips back 2 meters at night. How many days does it take to climb a 10 meter wall?",
136
+ "answer": "8", "type": "multi_step", "difficulty": "hard"},
137
+ {"q": "In a race, you overtake the person in 2nd place. What position are you now in?",
138
+ "answer": "2", "type": "trick", "difficulty": "medium"},
139
+
140
+ # Common sense
141
+ {"q": "If I put a ice cube in a hot pan, what happens to it?",
142
+ "answer": "melt", "type": "common_sense", "difficulty": "easy"},
143
+ {"q": "Can a dead person eat lunch? Answer yes or no.",
144
+ "answer": "no", "type": "common_sense", "difficulty": "easy"},
145
+ ]
146
+
147
+ CREATIVITY_TESTS = [
148
+ # These are scored differently — we check for length, variety, and coherence
149
+ {"q": "Write a 4-line poem about the ocean.",
150
+ "min_lines": 3, "min_words": 15, "type": "poem", "difficulty": "easy"},
151
+ {"q": "Describe a sunset to someone who has never seen one, in 2-3 sentences.",
152
+ "min_lines": 1, "min_words": 20, "type": "description", "difficulty": "easy"},
153
+ {"q": "Come up with 3 creative names for a coffee shop run by robots.",
154
+ "min_items": 3, "type": "brainstorm", "difficulty": "easy"},
155
+ {"q": "Write a very short story (3-5 sentences) about a cat who learns to fly.",
156
+ "min_lines": 2, "min_words": 25, "type": "story", "difficulty": "medium"},
157
+ {"q": "Explain how a refrigerator works to a 5 year old, in 2-3 simple sentences.",
158
+ "min_lines": 1, "min_words": 15, "type": "explain_simple", "difficulty": "easy"},
159
+ {"q": "Rewrite this sentence to make it more exciting: 'The man walked to the store.'",
160
+ "min_words": 5, "type": "rewrite", "difficulty": "easy",
161
+ "must_not_contain": "The man walked to the store"},
162
+ {"q": "Give me an analogy that explains what a CPU does in a computer.",
163
+ "min_words": 10, "type": "analogy", "difficulty": "medium"},
164
+ {"q": "Write a haiku about programming (5-7-5 syllable pattern).",
165
+ "min_lines": 3, "min_words": 8, "type": "poem", "difficulty": "medium"},
166
+ ]
167
+
168
+ KNOWLEDGE_TESTS = [
169
+ # Science
170
+ {"q": "What planet is closest to the Sun?", "answer": "mercury", "type": "science", "difficulty": "easy"},
171
+ {"q": "What is the chemical symbol for water?", "answer": "h2o", "type": "science", "difficulty": "easy"},
172
+ {"q": "What gas do plants absorb from the atmosphere?", "answer": "carbon dioxide", "type": "science", "difficulty": "easy",
173
+ "alt_answers": ["co2"]},
174
+ {"q": "How many chromosomes do humans have?", "answer": "46", "type": "science", "difficulty": "medium"},
175
+ {"q": "What is the speed of light in km/s approximately?", "answer": "300000", "type": "science", "difficulty": "medium",
176
+ "alt_answers": ["300,000", "3×10^8", "3x10^8", "299792"]},
177
+
178
+ # History
179
+ {"q": "In what year did World War 2 end?", "answer": "1945", "type": "history", "difficulty": "easy"},
180
+ {"q": "Who was the first person to walk on the Moon?", "answer": "armstrong", "type": "history", "difficulty": "easy",
181
+ "alt_answers": ["neil armstrong"]},
182
+ {"q": "What country built the Great Wall?", "answer": "china", "type": "history", "difficulty": "easy"},
183
+
184
+ # Geography
185
+ {"q": "What is the largest ocean on Earth?", "answer": "pacific", "type": "geography", "difficulty": "easy"},
186
+ {"q": "What is the capital of Japan?", "answer": "tokyo", "type": "geography", "difficulty": "easy"},
187
+ {"q": "What is the longest river in the world?", "answer": "nile", "type": "geography", "difficulty": "medium",
188
+ "alt_answers": ["amazon"]}, # Both are accepted — depends on measurement method
189
+ {"q": "How many continents are there?", "answer": "7", "type": "geography", "difficulty": "easy"},
190
+
191
+ # General
192
+ {"q": "What programming language is known for its use in web browsers?", "answer": "javascript", "type": "tech", "difficulty": "easy",
193
+ "alt_answers": ["js"]},
194
+ {"q": "What does CPU stand for?", "answer": "central processing unit", "type": "tech", "difficulty": "easy"},
195
+ {"q": "What does DNA stand for?", "answer": "deoxyribonucleic acid", "type": "science", "difficulty": "medium"},
196
+ ]
197
+
198
+ INSTRUCTION_TESTS = [
199
+ # Follow exact instructions
200
+ {"q": "Respond with only the word 'hello'. Nothing else.",
201
+ "check": "hello", "verify": "exact_word", "type": "exact", "difficulty": "easy"},
202
+ {"q": "List exactly 3 colors, one per line. Nothing else.",
203
+ "check": 3, "verify": "line_count", "type": "format", "difficulty": "easy"},
204
+ {"q": "Write a sentence that contains exactly 5 words.",
205
+ "check": 5, "verify": "word_count", "type": "format", "difficulty": "medium"},
206
+ {"q": "Give me a number between 1 and 10. Just the number, nothing else.",
207
+ "check": "number_1_10", "verify": "number_range", "type": "exact", "difficulty": "easy"},
208
+ {"q": "Repeat the following sentence exactly: 'The quick brown fox jumps over the lazy dog.'",
209
+ "check": "the quick brown fox jumps over the lazy dog", "verify": "contains_exact", "type": "repeat", "difficulty": "easy"},
210
+ {"q": "Name 5 fruits. Number them 1-5.",
211
+ "check": 5, "verify": "numbered_items", "type": "format", "difficulty": "easy"},
212
+ {"q": "Answer this question with exactly one word: What color is the sky on a clear day?",
213
+ "check": 1, "verify": "word_count", "type": "exact", "difficulty": "medium"},
214
+ {"q": "Write the alphabet in reverse (Z to A) with commas between each letter.",
215
+ "check": "z", "verify": "starts_with", "type": "exact", "difficulty": "hard"},
216
+ {"q": "Respond with ONLY a JSON object with keys 'name' and 'age'. Use any values.",
217
+ "check": "{", "verify": "starts_with_json", "type": "format", "difficulty": "medium"},
218
+ {"q": "Say 'yes' if 2+2=4, otherwise say 'no'. Only one word.",
219
+ "check": "yes", "verify": "exact_word", "type": "exact", "difficulty": "easy"},
220
+ ]
221
+
222
+
223
+ # ============================================================
224
+ # ANSWER CHECKING
225
+ # ============================================================
226
+
227
+ def strip_thinking(response: str) -> str:
228
+ """
229
+ BUG FIX: Qwen3-VL wraps reasoning in <think>...</think> tags.
230
+ These aren't special tokens so they stay in the decoded output.
231
+ We need to strip them before checking answers, otherwise:
232
+ - instruction checks see "<think>" as the first word
233
+ - code exec tries to run thinking text as Python
234
+ - exact match checks fail because of extra text
235
+ """
236
+ # Remove <think>...</think> blocks
237
+ cleaned = re.sub(r'<think>.*?</think>', '', response, flags=re.DOTALL)
238
+ # Also handle unclosed think tags (model didn't close it)
239
+ cleaned = re.sub(r'<think>.*$', '', cleaned, flags=re.DOTALL)
240
+ return cleaned.strip()
241
+
242
+
243
+ def check_math_answer(response: str, expected: str) -> bool:
244
+ """Check if the response contains the correct number."""
245
+ response_clean = strip_thinking(response).lower().strip()
246
+ # Strip commas from numbers (model might say "1,081" for 1081)
247
+ response_no_commas = response_clean.replace(",", "")
248
+ # Extract numbers from response
249
+ numbers = re.findall(r'-?\d+\.?\d*', response_no_commas)
250
+
251
+ # Also check for fraction answers
252
+ if "/" in expected:
253
+ if expected.lower() in response_clean:
254
+ return True
255
+ # Check decimal equivalent
256
+ parts = expected.split("/")
257
+ if len(parts) == 2:
258
+ try:
259
+ decimal_val = str(round(int(parts[0]) / int(parts[1]), 4))
260
+ if decimal_val in response_no_commas:
261
+ return True
262
+ except (ValueError, ZeroDivisionError):
263
+ pass
264
+
265
+ # BUG FIX: Also match "12" to "12.0" (correct but different format)
266
+ for num_str in numbers:
267
+ try:
268
+ if float(num_str) == float(expected):
269
+ return True
270
+ except ValueError:
271
+ continue
272
+
273
+ return expected in numbers
274
+
275
+
276
+ def check_knowledge_answer(response: str, test: dict) -> bool:
277
+ """Check if response contains the expected answer or alternatives."""
278
+ response_lower = strip_thinking(response).lower().strip()
279
+ if test["answer"].lower() in response_lower:
280
+ return True
281
+ for alt in test.get("alt_answers", []):
282
+ if alt.lower() in response_lower:
283
+ return True
284
+ return False
285
+
286
+
287
+ def check_reasoning_answer(response: str, expected: str) -> bool:
288
+ """Check reasoning answer — flexible matching."""
289
+ response_lower = strip_thinking(response).lower().strip()
290
+ expected_lower = expected.lower()
291
+
292
+ # Direct match
293
+ if expected_lower in response_lower:
294
+ return True
295
+
296
+ # For yes/no questions
297
+ if expected_lower in ["yes", "no"]:
298
+ # Check first word
299
+ first_word = response_lower.split()[0] if response_lower.split() else ""
300
+ first_word = first_word.strip(".,!?")
301
+ return first_word == expected_lower
302
+
303
+ # For number answers
304
+ numbers = re.findall(r'-?\d+\.?\d*', response_lower)
305
+ if expected_lower in numbers:
306
+ return True
307
+
308
+ return False
309
+
310
+
311
+ def check_code_answer(response: str, test: dict) -> bool:
312
+ """Check code answers — verify function exists and/or contains expected code."""
313
+ verify = test["verify"]
314
+ check = test["check"]
315
+ response = strip_thinking(response)
316
+
317
+ if verify == "callable":
318
+ # Try to extract and compile the function
319
+ code_block = response
320
+ # Extract from markdown code block if present
321
+ md_match = re.search(r'```(?:python)?\s*\n(.*?)```', response, re.DOTALL)
322
+ if md_match:
323
+ code_block = md_match.group(1)
324
+
325
+ # BUG FIX: If no code block found, try to extract just the def...
326
+ if not md_match and "def " in code_block:
327
+ # Find from first 'def' to end — skip any explanation text before it
328
+ def_start = code_block.index("def ")
329
+ code_block = code_block[def_start:]
330
+
331
+ try:
332
+ import signal
333
+
334
+ def _timeout_handler(signum, frame):
335
+ raise TimeoutError("Code execution timed out")
336
+
337
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
338
+ # BUG FIX: Set alarm BEFORE exec(), not after!
339
+ # If model writes `while True: pass` at module level, exec()
340
+ # hangs forever and the alarm never gets set.
341
+ signal.alarm(10) # 10 seconds for compile+exec+tests
342
+
343
+ try:
344
+ namespace = {}
345
+ exec(code_block.strip(), namespace)
346
+ func = namespace.get(check)
347
+ if func is None or not callable(func):
348
+ return False
349
+
350
+ # Run basic tests based on function name
351
+ if check == "is_even":
352
+ result = func(4) == True and func(3) == False
353
+ elif check == "factorial":
354
+ result = func(5) == 120 and func(0) == 1
355
+ elif check == "reverse_string":
356
+ result = func("hello") == "olleh"
357
+ elif check == "max_of_three":
358
+ result = func(1, 5, 3) == 5
359
+ elif check == "count_vowels":
360
+ result = func("hello") == 2
361
+ elif check == "fibonacci":
362
+ result = func(0) == 0 and func(1) == 1 and func(6) == 8
363
+ elif check == "is_palindrome":
364
+ result = func("racecar") == True and func("hello") == False
365
+ elif check == "flatten":
366
+ result = func([[1, 2], [3, 4]]) == [1, 2, 3, 4]
367
+ else:
368
+ result = True
369
+ return result
370
+ finally:
371
+ signal.alarm(0)
372
+ signal.signal(signal.SIGALRM, old_handler)
373
+ except (Exception, TimeoutError):
374
+ return False
375
+
376
+ elif verify == "contains":
377
+ return check.lower() in response.lower()
378
+
379
+ elif verify == "contains_any":
380
+ return any(c.lower() in response.lower() for c in check)
381
+
382
+ return False
383
+
384
+
385
+ def check_creativity_answer(response: str, test: dict) -> bool:
386
+ """Check creativity — basic quality checks."""
387
+ response = strip_thinking(response)
388
+ lines = [l.strip() for l in response.strip().split("\n") if l.strip()]
389
+ words = response.split()
390
+
391
+ # Must not contain forbidden text (for rewrite tasks)
392
+ if "must_not_contain" in test:
393
+ if test["must_not_contain"].lower() in response.lower():
394
+ return False
395
+
396
+ # Check minimum lines
397
+ if "min_lines" in test and len(lines) < test["min_lines"]:
398
+ return False
399
+
400
+ # Check minimum words
401
+ if "min_words" in test and len(words) < test["min_words"]:
402
+ return False
403
+
404
+ # Check minimum items (for brainstorm)
405
+ if "min_items" in test:
406
+ # Count numbered items or bullet points
407
+ items = [l for l in lines if re.match(r'^\d+[\.\):]|^[-*•]', l)]
408
+ if len(items) < test["min_items"]:
409
+ # Also count plain lines as items
410
+ if len(lines) < test["min_items"]:
411
+ return False
412
+
413
+ # Basic coherence — not just random characters
414
+ if len(response.strip()) < 10:
415
+ return False
416
+
417
+ return True
418
+
419
+
420
+ def check_instruction_answer(response: str, test: dict) -> bool:
421
+ """Check instruction following — strict format checks."""
422
+ verify = test["verify"]
423
+ check = test["check"]
424
+ response_clean = strip_thinking(response).strip()
425
+ response_lower = response_clean.lower()
426
+
427
+ if verify == "exact_word":
428
+ # Should be just this word (maybe with punctuation)
429
+ first_word = response_lower.split()[0] if response_lower.split() else ""
430
+ first_word = re.sub(r'[.,!?"\']', '', first_word)
431
+ return first_word == check.lower()
432
+
433
+ elif verify == "line_count":
434
+ lines = [l.strip() for l in response_clean.split("\n") if l.strip()]
435
+ return len(lines) == check
436
+
437
+ elif verify == "word_count":
438
+ # Find the main sentence (skip thinking/explanation)
439
+ # Look for a clean sentence
440
+ lines = [l.strip() for l in response_clean.split("\n") if l.strip()]
441
+ for line in lines:
442
+ # Skip lines that look like meta-commentary
443
+ if any(skip in line.lower() for skip in ["here is", "here's", "sure", "okay"]):
444
+ continue
445
+ words = line.split()
446
+ if len(words) == check:
447
+ return True
448
+ # Also check total
449
+ return len(response_clean.split()) == check
450
+
451
+ elif verify == "number_range":
452
+ numbers = re.findall(r'\d+', response_clean)
453
+ if numbers:
454
+ n = int(numbers[0])
455
+ return 1 <= n <= 10
456
+ return False
457
+
458
+ elif verify == "contains_exact":
459
+ return check.lower() in response_lower
460
+
461
+ elif verify == "numbered_items":
462
+ items = re.findall(r'^\d+[\.\):]', response_clean, re.MULTILINE)
463
+ return len(items) >= check
464
+
465
+ elif verify == "starts_with":
466
+ return response_lower.startswith(check.lower())
467
+
468
+ elif verify == "starts_with_json":
469
+ stripped = response_clean.lstrip()
470
+ # May be in a code block
471
+ if "```" in stripped:
472
+ md_match = re.search(r'```(?:json)?\s*\n(.*?)```', stripped, re.DOTALL)
473
+ if md_match:
474
+ stripped = md_match.group(1).strip()
475
+ try:
476
+ obj = json.loads(stripped)
477
+ return "name" in obj and "age" in obj
478
+ except (json.JSONDecodeError, TypeError):
479
+ return stripped.startswith("{")
480
+
481
+ return False
482
+
483
+
484
+ # ============================================================
485
+ # MODEL RUNNER
486
+ # ============================================================
487
+
488
+ def load_model(model_path: str):
489
+ """Load model and tokenizer. Validates checkpoint integrity first."""
490
+ from transformers import AutoModelForImageTextToText, AutoTokenizer
491
+ from pathlib import Path
492
+
493
+ # Validate checkpoint before loading — catch stale/corrupt checkpoints
494
+ p = Path(model_path)
495
+ if p.is_dir():
496
+ config_file = p / "config.json"
497
+ if not config_file.exists():
498
+ raise FileNotFoundError(f"No config.json in {model_path} — stale or corrupt checkpoint!")
499
+ st_files = list(p.glob("*.safetensors"))
500
+ if not st_files and not list(p.glob("*.bin")):
501
+ raise FileNotFoundError(f"No model weights in {model_path} — checkpoint is empty!")
502
+ total_size = sum(f.stat().st_size for f in st_files) / 1e9 if st_files else 0
503
+ if st_files and total_size < 0.5:
504
+ raise ValueError(f"Model weights only {total_size:.2f} GB — likely corrupt checkpoint!")
505
+
506
+ print(f" Loading model from {model_path}...")
507
+ try:
508
+ model = AutoModelForImageTextToText.from_pretrained(
509
+ model_path, dtype=torch.bfloat16,
510
+ device_map="auto", trust_remote_code=True
511
+ )
512
+ except (RuntimeError, torch.cuda.OutOfMemoryError) as e:
513
+ gc.collect()
514
+ if torch.cuda.is_available():
515
+ torch.cuda.empty_cache()
516
+ raise RuntimeError(f"Failed to load model (OOM or corrupt): {e}") from e
517
+
518
+ tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
519
+ return model, tokenizer
520
+
521
+
522
+ def ask_model(model, tokenizer, question: str, max_tokens: int = 256,
523
+ disable_thinking: bool = True) -> str:
524
+ """Ask the model a question and get the response.
525
+
526
+ SPEED: disable_thinking=True adds /no_think to skip <think> blocks.
527
+ Qwen3-VL's thinking mode generates 100-500+ tokens of reasoning
528
+ before giving a 1-word answer. For benchmarks this wastes ~80% of
529
+ inference time. /no_think makes it answer directly.
530
+ """
531
+ # Build prompt — /no_think goes in the system message
532
+ if disable_thinking:
533
+ chat = (
534
+ "<|im_start|>system\n/no_think<|im_end|>\n"
535
+ f"<|im_start|>user\n{question}<|im_end|>\n"
536
+ "<|im_start|>assistant\n"
537
+ )
538
+ else:
539
+ chat = f"<|im_start|>user\n{question}<|im_end|>\n<|im_start|>assistant\n"
540
+
541
+ ids = tokenizer(chat, return_tensors="pt").to(model.device)
542
+
543
+ try:
544
+ with torch.no_grad():
545
+ out = model.generate(
546
+ **ids,
547
+ max_new_tokens=max_tokens,
548
+ do_sample=False,
549
+ temperature=None,
550
+ top_p=None,
551
+ )
552
+ except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
553
+ # OOM during generation — clean up and return empty
554
+ gc.collect()
555
+ if torch.cuda.is_available():
556
+ torch.cuda.empty_cache()
557
+ print(f" OOM during generation: {e}")
558
+ return "[OOM — no response generated]"
559
+
560
+ response = tokenizer.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True)
561
+
562
+ # Guard against garbage output: if response is absurdly long, truncate
563
+ if len(response) > 5000:
564
+ response = response[:5000]
565
+
566
+ return response.strip()
567
+
568
+
569
+ # ============================================================
570
+ # RUN BENCHMARK
571
+ # ============================================================
572
+
573
+ def run_category(model, tokenizer, category_name: str, tests: list,
574
+ check_fn, cfg: BenchmarkConfig) -> Dict:
575
+ """Run all tests in a category and return results."""
576
+ print(f"\n === {category_name.upper()} ({len(tests)} questions) ===")
577
+
578
+ results = []
579
+ passed = 0
580
+ failed_questions = []
581
+
582
+ for i, test in enumerate(tests):
583
+ q = test["q"]
584
+ q_start = time.time()
585
+
586
+ try:
587
+ response = ask_model(model, tokenizer, q, cfg.max_new_tokens,
588
+ disable_thinking=cfg.disable_thinking)
589
+ except Exception as e:
590
+ print(f" [ERROR] Question {i+1} crashed: {e}")
591
+ response = "[ERROR — question crashed]"
592
+ gc.collect()
593
+ if torch.cuda.is_available():
594
+ torch.cuda.empty_cache()
595
+
596
+ q_time = time.time() - q_start
597
+
598
+ try:
599
+ if check_fn == check_code_answer:
600
+ correct = check_fn(response, test)
601
+ elif check_fn == check_creativity_answer:
602
+ correct = check_fn(response, test)
603
+ elif check_fn == check_instruction_answer:
604
+ correct = check_fn(response, test)
605
+ elif check_fn == check_knowledge_answer:
606
+ correct = check_fn(response, test)
607
+ elif check_fn == check_reasoning_answer:
608
+ correct = check_fn(response, test.get("answer", ""))
609
+ else:
610
+ # Math
611
+ correct = check_fn(response, test.get("answer", ""))
612
+ except Exception as e:
613
+ print(f" [ERROR] Answer checker crashed on question {i+1}: {e}")
614
+ correct = False
615
+
616
+ status = "PASS" if correct else "FAIL"
617
+ if correct:
618
+ passed += 1
619
+ else:
620
+ # Store the clean response (without thinking) for the failure report
621
+ clean_response = strip_thinking(response)
622
+ failed_questions.append({
623
+ "question": q,
624
+ "response": clean_response[:300],
625
+ "expected": test.get("answer", test.get("check", "N/A")),
626
+ "type": test.get("type", "unknown"),
627
+ "difficulty": test.get("difficulty", "unknown"),
628
+ })
629
+
630
+ # Print compact result
631
+ short_q = q[:60] + "..." if len(q) > 60 else q
632
+ print(f" [{status}] {short_q} ({q_time:.1f}s)")
633
+ if not correct:
634
+ clean_r = strip_thinking(response)
635
+ short_r = clean_r[:80] + "..." if len(clean_r) > 80 else clean_r
636
+ print(f" Got: {short_r}")
637
+
638
+ results.append({
639
+ "question": q,
640
+ "correct": correct,
641
+ "type": test.get("type", "unknown"),
642
+ "difficulty": test.get("difficulty", "unknown"),
643
+ })
644
+
645
+ total = len(tests)
646
+ # CRITICAL FIX: Score as 0.0-1.0 (NOT 0-100) so td_weakness.py targets work
647
+ score = passed / total if total > 0 else 0.0
648
+
649
+ print(f"\n SCORE: {passed}/{total} ({score:.0%})")
650
+
651
+ # Per-type breakdown
652
+ type_scores = {}
653
+ for r in results:
654
+ t = r["type"]
655
+ if t not in type_scores:
656
+ type_scores[t] = {"correct": 0, "total": 0}
657
+ type_scores[t]["total"] += 1
658
+ if r["correct"]:
659
+ type_scores[t]["correct"] += 1
660
+
661
+ for t, s in sorted(type_scores.items()):
662
+ pct = s["correct"] / s["total"] if s["total"] > 0 else 0.0
663
+ print(f" {t}: {s['correct']}/{s['total']} ({pct:.0%})")
664
+
665
+ return {
666
+ "category": category_name,
667
+ "score": score, # 0.0-1.0 scale
668
+ "passed": passed,
669
+ "total": total,
670
+ "type_scores": {t: {"correct": s["correct"], "total": s["total"],
671
+ "pct": s["correct"] / s["total"] if s["total"] > 0 else 0.0}
672
+ for t, s in type_scores.items()},
673
+ "failed_questions": failed_questions,
674
+ "results": results,
675
+ }
676
+
677
+
678
+ def run_benchmark(cfg: BenchmarkConfig = None) -> Dict:
679
+ """Run the full benchmark suite."""
680
+ if cfg is None:
681
+ cfg = BenchmarkConfig()
682
+
683
+ # Auto-detect model
684
+ if not cfg.model_path:
685
+ from pathlib import Path
686
+ base = Path("td_fuse_outputs/self_improve")
687
+ if base.exists():
688
+ for n in range(50, 0, -1):
689
+ d = base / f"improved_cycle{n}"
690
+ if d.exists() and list(d.glob("*.safetensors")):
691
+ cfg.model_path = str(d)
692
+ break
693
+ if not cfg.model_path:
694
+ healed = Path("td_fuse_outputs/reasoning_healed")
695
+ if healed.exists():
696
+ cfg.model_path = str(healed)
697
+ if not cfg.model_path:
698
+ raise FileNotFoundError("No model found!")
699
+
700
+ start = time.time()
701
+ print("=" * 60)
702
+ print(f"TD BENCHMARK SUITE v1")
703
+ print(f"Model: {cfg.model_path}")
704
+ print(f"Started: {time.strftime('%H:%M:%S')}")
705
+ print("=" * 60)
706
+
707
+ model, tokenizer = load_model(cfg.model_path)
708
+
709
+ # Run all categories
710
+ # SPEED: Per-category max tokens. Math/knowledge/reasoning answers are
711
+ # 1-10 tokens. Code needs 200+. Creativity needs 150+.
712
+ # Using 256 for a "yes/no" question wastes inference time.
713
+ CATEGORY_MAX_TOKENS = {
714
+ "math": 64,
715
+ "code": 300, # Functions can be long
716
+ "reasoning": 64,
717
+ "creativity": 200, # Stories/poems need room
718
+ "knowledge": 64,
719
+ "instruction_following": 128, # JSON output, alphabet, etc.
720
+ }
721
+
722
+ categories = [
723
+ ("math", MATH_TESTS, check_math_answer),
724
+ ("code", CODE_TESTS, check_code_answer),
725
+ ("reasoning", REASONING_TESTS, check_reasoning_answer),
726
+ ("creativity", CREATIVITY_TESTS, check_creativity_answer),
727
+ ("knowledge", KNOWLEDGE_TESTS, check_knowledge_answer),
728
+ ("instruction_following", INSTRUCTION_TESTS, check_instruction_answer),
729
+ ]
730
+
731
+ all_results = {}
732
+ total_passed = 0
733
+ total_questions = 0
734
+
735
+ # Save original max_new_tokens and swap per-category
736
+ orig_max_tokens = cfg.max_new_tokens
737
+ for cat_name, tests, check_fn in categories:
738
+ cfg.max_new_tokens = CATEGORY_MAX_TOKENS.get(cat_name, orig_max_tokens)
739
+ result = run_category(model, tokenizer, cat_name, tests, check_fn, cfg)
740
+ all_results[cat_name] = result
741
+ total_passed += result["passed"]
742
+ total_questions += result["total"]
743
+ cfg.max_new_tokens = orig_max_tokens # Restore
744
+
745
+ # Cleanup
746
+ del model
747
+ gc.collect()
748
+ if torch.cuda.is_available():
749
+ torch.cuda.empty_cache()
750
+
751
+ elapsed = (time.time() - start) / 60
752
+ overall_score = total_passed / total_questions if total_questions > 0 else 0.0
753
+
754
+ # Print summary
755
+ print(f"\n{'='*60}")
756
+ print(f"BENCHMARK COMPLETE — {elapsed:.1f} min")
757
+ print(f"{'='*60}")
758
+ print(f"\n REPORT CARD:")
759
+ print(f" {'Category':<25} {'Score':>10} {'Grade':>8}")
760
+ print(f" {'-'*45}")
761
+
762
+ for cat_name in ["math", "code", "reasoning", "creativity", "knowledge", "instruction_following"]:
763
+ r = all_results[cat_name]
764
+ score = r["score"] # 0.0-1.0
765
+ if score >= 0.90: grade = "A"
766
+ elif score >= 0.80: grade = "B"
767
+ elif score >= 0.70: grade = "C"
768
+ elif score >= 0.60: grade = "D"
769
+ else: grade = "F"
770
+ bar = "█" * int(score * 20)
771
+ print(f" {cat_name:<25} {r['passed']:>3}/{r['total']:<3} ({score:>5.0%}) {grade:>4} {bar}")
772
+
773
+ print(f"\n OVERALL: {total_passed}/{total_questions} ({overall_score:.0%})")
774
+ print(f"{'='*60}")
775
+
776
+ # Identify weaknesses (bottom 3 sub-categories)
777
+ all_types = []
778
+ for cat_name, result in all_results.items():
779
+ for type_name, type_score in result["type_scores"].items():
780
+ all_types.append({
781
+ "category": cat_name,
782
+ "type": type_name,
783
+ "pct": type_score["pct"],
784
+ "correct": type_score["correct"],
785
+ "total": type_score["total"],
786
+ })
787
+
788
+ weakest = sorted(all_types, key=lambda x: x["pct"])[:5]
789
+ print(f"\n TOP 5 WEAKNESSES:")
790
+ for w in weakest:
791
+ print(f" {w['category']}/{w['type']}: {w['correct']}/{w['total']} ({w['pct']:.0%})")
792
+
793
+ # Save report
794
+ out_dir = Path(cfg.output_dir)
795
+ out_dir.mkdir(parents=True, exist_ok=True)
796
+
797
+ report = {
798
+ "model_path": cfg.model_path,
799
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
800
+ "duration_min": elapsed,
801
+ "overall_score": overall_score,
802
+ "total_passed": total_passed,
803
+ "total_questions": total_questions,
804
+ "categories": {name: {
805
+ "score": r["score"],
806
+ "passed": r["passed"],
807
+ "total": r["total"],
808
+ "type_scores": r["type_scores"],
809
+ "failed_questions": r["failed_questions"],
810
+ } for name, r in all_results.items()},
811
+ "weakest_areas": weakest,
812
+ }
813
+
814
+ # Support both output_path (exact path) and output_dir (auto-named)
815
+ if cfg.output_path:
816
+ report_path = Path(cfg.output_path)
817
+ report_path.parent.mkdir(parents=True, exist_ok=True)
818
+ else:
819
+ report_path = out_dir / f"benchmark_{time.strftime('%Y%m%d_%H%M%S')}.json"
820
+
821
+ try:
822
+ with open(report_path, "w") as f:
823
+ json.dump(report, f, indent=2)
824
+ print(f"\n Report saved: {report_path}")
825
+ except OSError as e:
826
+ print(f"\n WARNING: Could not save benchmark report: {e}")
827
+ print(f" Results are still returned in memory — loop can continue.")
828
+
829
+ return report
830
+
831
+
832
+ if __name__ == "__main__":
833
+ run_benchmark()
hugging/td_fuse/td_contamination.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TD Contamination Check — Benchmark Isolation Guard
3
+
4
+ Ensures training data doesn't contain benchmark or holdout questions.
5
+ If benchmark questions leak into training, scores become meaningless.
6
+
7
+ This runs BEFORE training starts. If contamination is found, it:
8
+ 1. Removes the contaminated samples from training data
9
+ 2. Logs what was removed and why
10
+ 3. Returns the cleaned data
11
+
12
+ Usage:
13
+ from td_contamination import check_contamination, get_all_benchmark_questions
14
+ clean_texts = check_contamination(train_texts)
15
+
16
+ # Or standalone:
17
+ python td_contamination.py path/to/training_data.jsonl
18
+ """
19
+
20
+ import json
21
+ import re
22
+ from pathlib import Path
23
+ from typing import List, Set, Tuple
24
+
25
+
26
+ def get_all_benchmark_questions() -> Set[str]:
27
+ """
28
+ Collect ALL questions from td_benchmark.py and td_holdout.py.
29
+ Returns a set of normalized question strings.
30
+ """
31
+ questions = set()
32
+
33
+ # Import benchmark questions
34
+ try:
35
+ from td_benchmark import (MATH_TESTS, CODE_TESTS, REASONING_TESTS,
36
+ CREATIVITY_TESTS, KNOWLEDGE_TESTS, INSTRUCTION_TESTS)
37
+ for bank in [MATH_TESTS, CODE_TESTS, REASONING_TESTS,
38
+ CREATIVITY_TESTS, KNOWLEDGE_TESTS, INSTRUCTION_TESTS]:
39
+ for test in bank:
40
+ questions.add(_normalize(test["q"]))
41
+ except ImportError:
42
+ print(" WARNING: Could not import td_benchmark — skipping main benchmark")
43
+
44
+ # Import holdout questions
45
+ try:
46
+ from td_holdout import (HOLDOUT_MATH, HOLDOUT_CODE, HOLDOUT_REASONING,
47
+ HOLDOUT_CREATIVITY, HOLDOUT_KNOWLEDGE, HOLDOUT_INSTRUCTION)
48
+ for bank in [HOLDOUT_MATH, HOLDOUT_CODE, HOLDOUT_REASONING,
49
+ HOLDOUT_CREATIVITY, HOLDOUT_KNOWLEDGE, HOLDOUT_INSTRUCTION]:
50
+ for test in bank:
51
+ questions.add(_normalize(test["q"]))
52
+ except ImportError:
53
+ print(" WARNING: Could not import td_holdout — skipping holdout benchmark")
54
+
55
+ return questions
56
+
57
+
58
+ def _normalize(text: str) -> str:
59
+ """Normalize text for fuzzy matching."""
60
+ text = text.lower().strip()
61
+ # Remove punctuation except basic math operators
62
+ text = re.sub(r'[^\w\s+\-*/=^]', '', text)
63
+ # Collapse whitespace
64
+ text = re.sub(r'\s+', ' ', text)
65
+ return text
66
+
67
+
68
+ def _extract_question_from_chat(text: str) -> str:
69
+ """Extract the user question from a chat-formatted training sample."""
70
+ # Match <|im_start|>user\n...<|im_end|>
71
+ match = re.search(r'<\|im_start\|>user\n(.*?)<\|im_end\|>', text, re.DOTALL)
72
+ if match:
73
+ return match.group(1).strip()
74
+ return text
75
+
76
+
77
+ def check_contamination(train_texts: List[str], verbose: bool = True) -> List[str]:
78
+ """
79
+ Check training data for benchmark question contamination.
80
+
81
+ Args:
82
+ train_texts: List of training text strings
83
+ verbose: Print details about contamination found
84
+
85
+ Returns:
86
+ Cleaned list with contaminated samples removed
87
+ """
88
+ benchmark_questions = get_all_benchmark_questions()
89
+ if not benchmark_questions:
90
+ if verbose:
91
+ print(" Contamination check: No benchmark questions loaded — SKIP")
92
+ return train_texts
93
+
94
+ if verbose:
95
+ print(f"\n Contamination check: scanning {len(train_texts)} samples against "
96
+ f"{len(benchmark_questions)} benchmark questions...")
97
+
98
+ clean = []
99
+ removed = []
100
+ # Also build n-gram set for partial matching (catches paraphrased questions)
101
+ benchmark_ngrams = set()
102
+ for q in benchmark_questions:
103
+ words = q.split()
104
+ # 5-grams from each question
105
+ for i in range(max(0, len(words) - 4)):
106
+ benchmark_ngrams.add(' '.join(words[i:i+5]))
107
+
108
+ for text in train_texts:
109
+ question = _extract_question_from_chat(text)
110
+ norm_q = _normalize(question)
111
+
112
+ # Check 1: Exact match
113
+ if norm_q in benchmark_questions:
114
+ removed.append(("exact", question[:80]))
115
+ continue
116
+
117
+ # Check 2: Substring match — benchmark question appears inside training text
118
+ contaminated = False
119
+ for bq in benchmark_questions:
120
+ if len(bq) > 15 and bq in _normalize(text):
121
+ removed.append(("substring", f"'{bq[:50]}' found in sample"))
122
+ contaminated = True
123
+ break
124
+
125
+ if contaminated:
126
+ continue
127
+
128
+ # Check 3: High n-gram overlap (catches paraphrases)
129
+ norm_words = norm_q.split()
130
+ if len(norm_words) >= 5:
131
+ sample_ngrams = set()
132
+ for i in range(len(norm_words) - 4):
133
+ sample_ngrams.add(' '.join(norm_words[i:i+5]))
134
+ if sample_ngrams and benchmark_ngrams:
135
+ overlap = len(sample_ngrams & benchmark_ngrams) / max(len(sample_ngrams), 1)
136
+ if overlap > 0.5: # More than half the 5-grams match a benchmark question
137
+ removed.append(("ngram_overlap", f"{overlap:.0%} overlap: {question[:60]}"))
138
+ continue
139
+
140
+ clean.append(text)
141
+
142
+ if verbose:
143
+ if removed:
144
+ print(f" CONTAMINATION FOUND: {len(removed)} samples removed!")
145
+ for reason, detail in removed[:10]: # Show first 10
146
+ print(f" [{reason}] {detail}")
147
+ if len(removed) > 10:
148
+ print(f" ... and {len(removed) - 10} more")
149
+ else:
150
+ print(f" Contamination check: CLEAN (0 matches found)")
151
+
152
+ return clean
153
+
154
+
155
+ def check_file(filepath: str) -> Tuple[int, int]:
156
+ """Check a JSONL file for contamination. Returns (clean_count, removed_count)."""
157
+ texts = []
158
+ with open(filepath) as f:
159
+ for line in f:
160
+ line = line.strip()
161
+ if not line:
162
+ continue
163
+ try:
164
+ item = json.loads(line)
165
+ text = item.get("text", "")
166
+ if text:
167
+ texts.append(text)
168
+ except json.JSONDecodeError:
169
+ continue
170
+
171
+ clean = check_contamination(texts)
172
+ return len(clean), len(texts) - len(clean)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ import sys
177
+
178
+ if len(sys.argv) < 2:
179
+ # Just report benchmark question count
180
+ questions = get_all_benchmark_questions()
181
+ print(f"Loaded {len(questions)} benchmark questions to check against")
182
+ print("\nUsage: python td_contamination.py path/to/training_data.jsonl")
183
+ sys.exit(0)
184
+
185
+ filepath = sys.argv[1]
186
+ if not Path(filepath).exists():
187
+ print(f"File not found: {filepath}")
188
+ sys.exit(1)
189
+
190
+ clean_count, removed_count = check_file(filepath)
191
+ print(f"\nResult: {clean_count} clean, {removed_count} contaminated")
192
+ if removed_count > 0:
193
+ print("ACTION: Training on this data would inflate benchmark scores!")
hugging/td_fuse/td_data_finder.py ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TD Dataset Finder v1 — Search Everywhere for Training Data
3
+
4
+ Takes the weakness report from td_weakness.py and finds training data
5
+ to fix those weaknesses. Searches multiple sources in priority order.
6
+
7
+ This is Step 3 of the self-improvement loop.
8
+
9
+ Sources (in order of priority):
10
+ 1. HuggingFace Datasets API — biggest, easiest to download
11
+ 2. GitHub — repos with training data, benchmarks, problem sets
12
+ 3. Kaggle — competitions and datasets
13
+ 4. (Future) Web scraping — Stack Overflow, math forums, etc.
14
+
15
+ Input:
16
+ - WeaknessReport from td_weakness.py
17
+
18
+ Output:
19
+ - Formatted training data in chat template, ready for training
20
+ - Data allocation follows the weakness report (worse = more data)
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import random
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Dict, List, Optional, Tuple
29
+ from dataclasses import dataclass, field
30
+
31
+
32
+ # ============================================================
33
+ # DATA STRUCTURES
34
+ # ============================================================
35
+
36
+ @dataclass
37
+ class DatasetResult:
38
+ """A dataset found from searching."""
39
+ name: str
40
+ source: str # "huggingface", "github", "kaggle", "web"
41
+ url: str
42
+ description: str
43
+ size_estimate: int # approximate number of samples
44
+ relevance_score: float # 0-1, how well it matches the weakness
45
+ category: str # which weakness category it's for
46
+
47
+
48
+ @dataclass
49
+ class FormattedSample:
50
+ """A single training sample in chat template format."""
51
+ system: str
52
+ user: str
53
+ assistant: str
54
+ category: str
55
+ source: str
56
+
57
+
58
+ @dataclass
59
+ class TrainingData:
60
+ """Complete training data package ready for the trainer."""
61
+ samples: List[FormattedSample]
62
+ sources_used: List[DatasetResult]
63
+ allocation: Dict[str, int] # category -> actual samples collected
64
+ total_samples: int
65
+ categories_covered: List[str]
66
+ categories_missing: List[str] # categories we couldn't find data for
67
+
68
+
69
+ # ============================================================
70
+ # CHAT TEMPLATE FORMATTER
71
+ # ============================================================
72
+
73
+ def format_chat_template(
74
+ system: str,
75
+ user: str,
76
+ assistant: str,
77
+ ) -> str:
78
+ """
79
+ Format a sample into Qwen3-VL's chat template.
80
+ This is the <|im_start|> format that selfimprove.py already uses.
81
+ """
82
+ parts = []
83
+ if system:
84
+ parts.append(f"<|im_start|>system\n{system}<|im_end|>")
85
+ parts.append(f"<|im_start|>user\n{user}<|im_end|>")
86
+ parts.append(f"<|im_start|>assistant\n{assistant}<|im_end|>")
87
+ return "\n".join(parts)
88
+
89
+
90
+ def format_sample_to_dict(sample: FormattedSample) -> Dict:
91
+ """Convert a FormattedSample to the dict format selfimprove.py expects."""
92
+ return {
93
+ "text": format_chat_template(sample.system, sample.user, sample.assistant),
94
+ "category": sample.category,
95
+ "source": sample.source,
96
+ }
97
+
98
+
99
+ # ============================================================
100
+ # HUGGINGFACE SEARCH
101
+ # ============================================================
102
+
103
+ def search_huggingface(
104
+ keywords: List[str],
105
+ tags: List[str],
106
+ category: str,
107
+ max_datasets: int = 5,
108
+ ) -> List[DatasetResult]:
109
+ """
110
+ Search HuggingFace Datasets API for matching datasets.
111
+
112
+ Uses the huggingface_hub library to search by keyword and tags.
113
+ Returns metadata about found datasets (doesn't download yet).
114
+ """
115
+ results = []
116
+
117
+ try:
118
+ from huggingface_hub import HfApi
119
+ api = HfApi()
120
+
121
+ # Search by each keyword
122
+ seen = set()
123
+ for keyword in keywords[:3]: # Top 3 keywords
124
+ try:
125
+ datasets = api.list_datasets(
126
+ search=keyword,
127
+ sort="downloads",
128
+ direction=-1,
129
+ limit=max_datasets,
130
+ )
131
+
132
+ for ds in datasets:
133
+ if ds.id in seen:
134
+ continue
135
+ seen.add(ds.id)
136
+
137
+ # Estimate relevance based on tag overlap
138
+ ds_tags = set(ds.tags or [])
139
+ tag_overlap = len(ds_tags.intersection(set(tags)))
140
+ relevance = min(0.5 + (tag_overlap * 0.15), 1.0)
141
+
142
+ # Boost if keyword appears in name
143
+ if any(kw.lower() in ds.id.lower() for kw in keyword.split()):
144
+ relevance = min(relevance + 0.2, 1.0)
145
+
146
+ results.append(DatasetResult(
147
+ name=ds.id,
148
+ source="huggingface",
149
+ url=f"https://huggingface.co/datasets/{ds.id}",
150
+ description=ds.id, # HF API doesn't always return description
151
+ size_estimate=ds.downloads or 0, # Use downloads as proxy
152
+ relevance_score=relevance,
153
+ category=category,
154
+ ))
155
+ except Exception as e:
156
+ print(f" Warning: HF search failed for '{keyword}': {e}")
157
+ continue
158
+
159
+ except ImportError:
160
+ print(" Warning: huggingface_hub not installed. Install with: pip install huggingface_hub")
161
+ return []
162
+
163
+ # Sort by relevance
164
+ results.sort(key=lambda r: r.relevance_score, reverse=True)
165
+ return results[:max_datasets]
166
+
167
+
168
+ def download_huggingface_dataset(
169
+ dataset_name: str,
170
+ category: str,
171
+ num_samples: int,
172
+ split: str = "train",
173
+ ) -> List[FormattedSample]:
174
+ """
175
+ Download and format samples from a HuggingFace dataset.
176
+
177
+ Handles common dataset formats:
178
+ - QA pairs (question/answer columns)
179
+ - Instruction pairs (instruction/output columns)
180
+ - Chat format (conversations column)
181
+ - Text only (text column) — wrap in QA format
182
+ """
183
+ samples = []
184
+
185
+ try:
186
+ from datasets import load_dataset
187
+
188
+ print(f" Downloading {dataset_name} (split={split})...")
189
+ try:
190
+ ds = load_dataset(dataset_name, split=split, streaming=True)
191
+ except Exception:
192
+ # Try without split specification
193
+ try:
194
+ ds = load_dataset(dataset_name, streaming=True)
195
+ # Get first available split
196
+ if hasattr(ds, 'keys'):
197
+ split_name = list(ds.keys())[0]
198
+ ds = ds[split_name]
199
+ else:
200
+ return []
201
+ except Exception as e:
202
+ print(f" Failed to load {dataset_name}: {e}")
203
+ return []
204
+
205
+ # Collect samples (streaming so we don't download everything)
206
+ # Timeout: 5 min per dataset to prevent hanging on slow/broken sources
207
+ import time as _time
208
+ download_start = _time.time()
209
+ download_timeout = 300 # 5 minutes max per dataset
210
+ count = 0
211
+ stall_count = 0
212
+ for item in ds:
213
+ if count >= num_samples:
214
+ break
215
+ if _time.time() - download_start > download_timeout:
216
+ print(f" Timeout: {dataset_name} took >{download_timeout}s — moving on with {count} samples")
217
+ break
218
+
219
+ sample = _format_hf_item(item, category, dataset_name)
220
+ if sample:
221
+ samples.append(sample)
222
+ count += 1
223
+ stall_count = 0
224
+ else:
225
+ stall_count += 1
226
+ # If 500 consecutive items produce nothing, dataset format is wrong
227
+ if stall_count > 500:
228
+ print(f" {dataset_name}: 500 consecutive unparseable items — skipping")
229
+ break
230
+
231
+ print(f" Got {len(samples)} samples from {dataset_name}")
232
+
233
+ except ImportError:
234
+ print(" Warning: datasets library not installed. Install with: pip install datasets")
235
+ except Exception as e:
236
+ print(f" Error downloading {dataset_name}: {e}")
237
+
238
+ return samples
239
+
240
+
241
+ def _format_hf_item(item: Dict, category: str, source: str) -> Optional[FormattedSample]:
242
+ """
243
+ Try to format a HuggingFace dataset item into a chat sample.
244
+ Handles many common column naming conventions.
245
+ """
246
+ # Guard: item must be a dict
247
+ if not isinstance(item, dict):
248
+ return None
249
+
250
+ # Common column name patterns for user/assistant content
251
+ user_keys = ["question", "instruction", "input", "prompt", "query", "problem", "text"]
252
+ assistant_keys = ["answer", "output", "response", "solution", "target", "completion"]
253
+ system_keys = ["system", "system_prompt", "context"]
254
+
255
+ user_text = None
256
+ assistant_text = None
257
+ system_text = ""
258
+
259
+ # Try to find user content (guard: value might be non-string like int, list, etc.)
260
+ for key in user_keys:
261
+ if key in item and item[key]:
262
+ val = item[key]
263
+ if isinstance(val, (str, int, float)):
264
+ user_text = str(val).strip()
265
+ if user_text:
266
+ break
267
+ # Skip lists, dicts, None-like values
268
+
269
+ # Try to find assistant content
270
+ for key in assistant_keys:
271
+ if key in item and item[key]:
272
+ val = item[key]
273
+ if isinstance(val, (str, int, float)):
274
+ assistant_text = str(val).strip()
275
+ if assistant_text:
276
+ break
277
+
278
+ # Try to find system content
279
+ for key in system_keys:
280
+ if key in item and item[key]:
281
+ val = item[key]
282
+ if isinstance(val, (str, int, float)):
283
+ system_text = str(val).strip()
284
+ break
285
+
286
+ # Handle conversation format
287
+ if not user_text and "conversations" in item:
288
+ convs = item["conversations"]
289
+ if isinstance(convs, list) and len(convs) >= 2:
290
+ for c in convs:
291
+ if not isinstance(c, dict):
292
+ continue # Skip malformed conversation entries
293
+ role = str(c.get("role", c.get("from", ""))).strip()
294
+ content = c.get("content", c.get("value", ""))
295
+ if content is None:
296
+ continue
297
+ content = str(content).strip()
298
+ if role in ["user", "human"] and not user_text:
299
+ user_text = content
300
+ elif role in ["assistant", "gpt", "bot"] and not assistant_text:
301
+ assistant_text = content
302
+ elif role == "system" and not system_text:
303
+ system_text = content
304
+
305
+ # Handle messages format (OpenAI-style)
306
+ if not user_text and "messages" in item:
307
+ msgs = item["messages"]
308
+ if isinstance(msgs, list):
309
+ for m in msgs:
310
+ if not isinstance(m, dict):
311
+ continue # Skip malformed message entries
312
+ role = str(m.get("role", "")).strip()
313
+ content = m.get("content", "")
314
+ if content is None:
315
+ continue
316
+ content = str(content).strip()
317
+ if role == "user" and not user_text:
318
+ user_text = content
319
+ elif role == "assistant" and not assistant_text:
320
+ assistant_text = content
321
+ elif role == "system" and not system_text:
322
+ system_text = content
323
+
324
+ # Handle MMLU-style multiple choice: question + choices + answer_index
325
+ if not assistant_text and "choices" in item and "answer" in item:
326
+ choices = item["choices"]
327
+ answer_idx = item["answer"]
328
+ if isinstance(choices, list) and len(choices) > 0 and len(choices) <= 26:
329
+ # Format as multiple choice question (cap at 26 = A-Z)
330
+ if user_text:
331
+ choice_strs = [f"{chr(65+i)}. {c}" for i, c in enumerate(choices)]
332
+ user_text = user_text + "\n" + "\n".join(choice_strs)
333
+ if isinstance(answer_idx, int) and 0 <= answer_idx < len(choices):
334
+ assistant_text = f"{chr(65+answer_idx)}. {choices[answer_idx]}"
335
+ elif isinstance(answer_idx, str) and len(answer_idx) == 1 and answer_idx.isalpha():
336
+ idx = ord(answer_idx.upper()) - 65
337
+ if 0 <= idx < len(choices):
338
+ assistant_text = f"{answer_idx.upper()}. {choices[idx]}"
339
+
340
+ # Handle GSM8K-style: answer contains "#### final_number"
341
+ if assistant_text and "####" in assistant_text:
342
+ # Keep the full chain-of-thought — it's good training data for reasoning
343
+ pass
344
+
345
+ # Need at least user + assistant
346
+ if not user_text or not assistant_text:
347
+ return None
348
+
349
+ # Skip very short or very long samples
350
+ if len(user_text) < 10 or len(assistant_text) < 5: # Allow short answers (like "42")
351
+ return None
352
+ if len(user_text) > 5000 or len(assistant_text) > 10000:
353
+ return None
354
+
355
+ return FormattedSample(
356
+ system=system_text,
357
+ user=user_text,
358
+ assistant=assistant_text,
359
+ category=category,
360
+ source=source,
361
+ )
362
+
363
+
364
+ # ============================================================
365
+ # WELL-KNOWN DATASETS PER CATEGORY
366
+ # ============================================================
367
+
368
+ # If the search finds nothing, fall back to these known-good datasets
369
+ # Verified dataset names on HuggingFace (March 2026)
370
+ # Each tuple: (dataset_name, config_or_subset)
371
+ # If a dataset fails to load, it moves to the next one silently
372
+ FALLBACK_DATASETS = {
373
+ "math": [
374
+ ("openai/gsm8k", "main"), # 8.8K train, question/answer with ####
375
+ ("lighteval/MATH", "all"), # 12.5K, competition math
376
+ ("microsoft/orca-math-word-problems-200k", None), # 200K word problems
377
+ ],
378
+ "code": [
379
+ ("openai/openai_humaneval", None), # 164 problems — small but high quality
380
+ ("google-research-datasets/mbpp", None), # FIX: full name (short "mbpp" fails)
381
+ ("codeparrot/apps", "introductory"), # Competitive programming
382
+ ],
383
+ "reasoning": [
384
+ ("allenai/ai2_arc", "ARC-Challenge"), # 2.6K challenge set
385
+ ("Rowan/hellaswag", None), # 40K commonsense
386
+ ("allenai/openbookqa", "main"), # FIX: replaced openai_prm800k (not a public dataset)
387
+ ],
388
+ "creativity": [
389
+ ("teknium/OpenHermes-2.5", None), # 1M instruction pairs
390
+ ("Open-Orca/OpenOrca", None), # 4.2M conversations
391
+ ],
392
+ "knowledge": [
393
+ ("TIGER-Lab/MMLU-Pro", None), # MMLU harder version
394
+ ("cais/mmlu", "all"), # Classic MMLU
395
+ ("truthfulqa/truthful_qa", "generation"), # 817 truthfulness questions
396
+ ],
397
+ "instruction_following": [
398
+ ("HuggingFaceH4/ultrafeedback_binarized", None), # 61K preference pairs
399
+ ("tatsu-lab/alpaca", None), # 52K instructions
400
+ ],
401
+ }
402
+
403
+
404
+ # ============================================================
405
+ # MAIN DATA FINDER
406
+ # ============================================================
407
+
408
+ def find_training_data(
409
+ search_plan: List[Dict],
410
+ data_allocation: Dict[str, int],
411
+ output_dir: str = "td_fuse_outputs/training_data",
412
+ hf_token: Optional[str] = None,
413
+ ) -> TrainingData:
414
+ """
415
+ Search for and download training data based on the weakness report.
416
+
417
+ This is the main entry point that td_loop.py calls.
418
+
419
+ Args:
420
+ search_plan: From WeaknessReport.search_plan
421
+ data_allocation: From WeaknessReport.data_allocation
422
+ output_dir: Where to save the formatted data
423
+ hf_token: HuggingFace token for private datasets
424
+
425
+ Returns:
426
+ TrainingData with formatted samples ready for training
427
+ """
428
+ print("\n" + "=" * 60)
429
+ print("TD DATASET FINDER v1")
430
+ print("=" * 60)
431
+
432
+ if hf_token:
433
+ os.environ["HF_TOKEN"] = hf_token
434
+
435
+ all_samples = []
436
+ all_sources = []
437
+ categories_covered = []
438
+ categories_missing = []
439
+
440
+ for plan_item in search_plan:
441
+ category = plan_item["category"]
442
+ needed = data_allocation.get(category, 1000)
443
+ keywords = plan_item["keywords"]
444
+ hf_tags = plan_item.get("hf_tags", [])
445
+
446
+ print(f"\n--- Searching for: {category} ({needed} samples needed) ---")
447
+
448
+ # Step 1: Search HuggingFace
449
+ print(f" Searching HuggingFace...")
450
+ hf_results = search_huggingface(keywords, hf_tags, category)
451
+
452
+ if hf_results:
453
+ print(f" Found {len(hf_results)} datasets on HuggingFace:")
454
+ for r in hf_results[:3]:
455
+ print(f" - {r.name} (relevance: {r.relevance_score:.2f})")
456
+
457
+ # Step 2: Download from best matches
458
+ category_samples = []
459
+ for result in hf_results:
460
+ if len(category_samples) >= needed:
461
+ break
462
+
463
+ remaining = needed - len(category_samples)
464
+ samples = download_huggingface_dataset(
465
+ dataset_name=result.name,
466
+ category=category,
467
+ num_samples=remaining,
468
+ )
469
+ category_samples.extend(samples)
470
+ if samples:
471
+ all_sources.append(result)
472
+
473
+ # Step 3: If not enough, try fallback datasets
474
+ if len(category_samples) < needed:
475
+ print(f" Only got {len(category_samples)}/{needed}. Trying fallback datasets...")
476
+ fallbacks = FALLBACK_DATASETS.get(category, [])
477
+ try:
478
+ from datasets import load_dataset # Import once outside loop
479
+ except ImportError:
480
+ print(" Warning: datasets library not installed — cannot try fallbacks")
481
+ fallbacks = [] # Skip the fallback loop
482
+ for ds_name, config in fallbacks:
483
+ if len(category_samples) >= needed:
484
+ break
485
+
486
+ remaining = needed - len(category_samples)
487
+ try:
488
+ if config:
489
+ ds = load_dataset(ds_name, config, streaming=True, split="train")
490
+ else:
491
+ ds = load_dataset(ds_name, streaming=True, split="train")
492
+
493
+ count = 0
494
+ for item in ds:
495
+ if count >= remaining:
496
+ break
497
+ sample = _format_hf_item(item, category, ds_name)
498
+ if sample:
499
+ category_samples.append(sample)
500
+ count += 1
501
+
502
+ if count > 0:
503
+ print(f" Got {count} from fallback: {ds_name}")
504
+ all_sources.append(DatasetResult(
505
+ name=ds_name,
506
+ source="huggingface_fallback",
507
+ url=f"https://huggingface.co/datasets/{ds_name}",
508
+ description=f"Fallback dataset for {category}",
509
+ size_estimate=count,
510
+ relevance_score=0.5,
511
+ category=category,
512
+ ))
513
+ except Exception as e:
514
+ print(f" Fallback {ds_name} failed: {e}")
515
+ continue
516
+
517
+ # Track what we got
518
+ if category_samples:
519
+ all_samples.extend(category_samples)
520
+ categories_covered.append(category)
521
+ print(f" Total for {category}: {len(category_samples)} samples")
522
+ else:
523
+ categories_missing.append(category)
524
+ print(f" WARNING: No data found for {category}!")
525
+
526
+ # Shuffle all samples together
527
+ random.shuffle(all_samples)
528
+
529
+ # Save to disk
530
+ output_path = Path(output_dir)
531
+ try:
532
+ output_path.mkdir(parents=True, exist_ok=True)
533
+ except OSError as e:
534
+ print(f"\n ERROR: Cannot create output dir {output_dir}: {e}")
535
+ # Still return what we collected even if can't save
536
+ actual_allocation = {}
537
+ for s in all_samples:
538
+ actual_allocation[s.category] = actual_allocation.get(s.category, 0) + 1
539
+ return TrainingData(
540
+ samples=all_samples, sources_used=all_sources,
541
+ allocation=actual_allocation, total_samples=len(all_samples),
542
+ categories_covered=categories_covered, categories_missing=categories_missing,
543
+ )
544
+
545
+ # Save as JSONL (one sample per line)
546
+ data_file = output_path / "training_data.jsonl"
547
+ try:
548
+ with open(data_file, "w") as f:
549
+ for sample in all_samples:
550
+ f.write(json.dumps(format_sample_to_dict(sample)) + "\n")
551
+ print(f"\nSaved {len(all_samples)} samples to {data_file}")
552
+ except OSError as e:
553
+ print(f"\n WARNING: Failed to save training data: {e} (disk may be full)")
554
+
555
+ # Save metadata
556
+ meta_file = output_path / "data_metadata.json"
557
+ actual_allocation = {}
558
+ for s in all_samples:
559
+ actual_allocation[s.category] = actual_allocation.get(s.category, 0) + 1
560
+
561
+ meta = {
562
+ "total_samples": len(all_samples),
563
+ "allocation": actual_allocation,
564
+ "sources": [
565
+ {"name": s.name, "source": s.source, "category": s.category, "url": s.url}
566
+ for s in all_sources
567
+ ],
568
+ "categories_covered": categories_covered,
569
+ "categories_missing": categories_missing,
570
+ }
571
+ try:
572
+ with open(meta_file, "w") as f:
573
+ json.dump(meta, f, indent=2)
574
+ print(f"Saved metadata to {meta_file}")
575
+ except OSError as e:
576
+ print(f" WARNING: Failed to save metadata: {e}")
577
+
578
+ return TrainingData(
579
+ samples=all_samples,
580
+ sources_used=all_sources,
581
+ allocation=actual_allocation,
582
+ total_samples=len(all_samples),
583
+ categories_covered=categories_covered,
584
+ categories_missing=categories_missing,
585
+ )
586
+
587
+
588
+ # ============================================================
589
+ # STANDALONE USAGE
590
+ # ============================================================
591
+
592
+ if __name__ == "__main__":
593
+ import sys
594
+
595
+ if len(sys.argv) < 2:
596
+ print("Usage: python td_data_finder.py <weakness_report.json> [output_dir]")
597
+ print("\nSearches for training data based on the weakness report.")
598
+ sys.exit(1)
599
+
600
+ # Load weakness report
601
+ from td_weakness import load_report
602
+ report = load_report(sys.argv[1])
603
+
604
+ output_dir = sys.argv[2] if len(sys.argv) > 2 else "td_fuse_outputs/training_data"
605
+
606
+ # Find data
607
+ training_data = find_training_data(
608
+ search_plan=report.search_plan,
609
+ data_allocation=report.data_allocation,
610
+ output_dir=output_dir,
611
+ )
612
+
613
+ print(f"\nDone! Got {training_data.total_samples} samples across {len(training_data.categories_covered)} categories.")
614
+ if training_data.categories_missing:
615
+ print(f"Missing data for: {', '.join(training_data.categories_missing)}")
hugging/td_fuse/td_holdout.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TD Holdout Benchmark — The Truth Test
3
+
4
+ This is a SEPARATE benchmark that NEVER influences training decisions.
5
+ It only runs at the start and end to verify improvement is real.
6
+
7
+ Why this exists:
8
+ The main benchmark (td_benchmark.py) has 85 fixed questions.
9
+ After enough cycles, the model might get good at THOSE specific
10
+ questions without actually getting smarter. This is overfitting.
11
+
12
+ The holdout uses completely different questions testing the same
13
+ skills. If the main benchmark shows improvement but the holdout
14
+ doesn't, we know we're overfitting.
15
+
16
+ Rules:
17
+ 1. NEVER use holdout results to decide training data
18
+ 2. NEVER share holdout questions with the training pipeline
19
+ 3. Only run holdout at: first cycle (baseline) and final cycle (verify)
20
+ 4. If holdout score drops while main benchmark rises = OVERFITTING
21
+
22
+ Usage:
23
+ python td_holdout.py # Run on latest model
24
+ python td_holdout.py path/to/model # Run on specific model
25
+ """
26
+
27
+ import json
28
+ import time
29
+ import gc
30
+ import re
31
+ from pathlib import Path
32
+ from typing import Dict
33
+
34
+
35
+ # ============================================================
36
+ # HOLDOUT QUESTIONS — completely different from td_benchmark.py
37
+ # ============================================================
38
+
39
+ HOLDOUT_MATH = [
40
+ # Different numbers, same skills as main benchmark
41
+ {"q": "What is 63 * 17?", "answer": "1071", "type": "arithmetic"},
42
+ {"q": "What is 892 - 456?", "answer": "436", "type": "arithmetic"},
43
+ {"q": "What is 25% of 360?", "answer": "90", "type": "arithmetic"},
44
+ {"q": "Solve for x: 5x - 3 = 27", "answer": "6", "type": "algebra"},
45
+ {"q": "If f(x) = 3x - 1, what is f(5)?", "answer": "14", "type": "algebra"},
46
+ {"q": "A book costs $15. If there's a 20% discount, what's the price?", "answer": "12", "type": "word_problem"},
47
+ {"q": "A tank fills at 3 liters per minute. How long to fill a 45 liter tank?", "answer": "15", "type": "word_problem"},
48
+ {"q": "What is the sum of the first 5 prime numbers?", "answer": "28", "type": "number_theory"},
49
+ {"q": "What is 3^5?", "answer": "243", "type": "exponents"},
50
+ {"q": "What is the least common multiple of 6 and 8?", "answer": "24", "type": "number_theory"},
51
+ ]
52
+
53
+ HOLDOUT_CODE = [
54
+ {"q": "Write a Python function called 'sum_list' that takes a list of numbers and returns their sum. Just the function.",
55
+ "check": "sum_list", "verify": "callable",
56
+ "tests": [{"args": [[1,2,3]], "expected": 6}, {"args": [[]], "expected": 0}]},
57
+ {"q": "Write a Python function called 'remove_duplicates' that takes a list and returns a new list with duplicates removed, preserving order. Just the function.",
58
+ "check": "remove_duplicates", "verify": "callable",
59
+ "tests": [{"args": [[1,2,2,3,1]], "expected": [1,2,3]}]},
60
+ {"q": "Write a Python function called 'is_prime' that returns True if a number is prime. Just the function.",
61
+ "check": "is_prime", "verify": "callable",
62
+ "tests": [{"args": [7], "expected": True}, {"args": [4], "expected": False}, {"args": [1], "expected": False}]},
63
+ {"q": "Write a Python function called 'count_words' that counts the number of words in a string. Just the function.",
64
+ "check": "count_words", "verify": "callable",
65
+ "tests": [{"args": ["hello world foo"], "expected": 3}, {"args": [""], "expected": 0}]},
66
+ {"q": "This code has a bug. Fix it:\ndef average(numbers):\n return sum(numbers) / len(numbers)",
67
+ "check": "len(numbers) == 0", "verify": "contains_any",
68
+ "alt_checks": ["not numbers", "len(numbers) == 0", "if numbers", "if not", "len(numbers) < 1", "ZeroDivisionError"]},
69
+ ]
70
+
71
+ HOLDOUT_REASONING = [
72
+ {"q": "If all dogs are mammals and some mammals can swim, can all dogs swim? Answer yes or no.", "answer": "no", "type": "syllogism"},
73
+ {"q": "What comes next: 5, 10, 20, 40, ?", "answer": "80", "type": "pattern"},
74
+ {"q": "What comes next: 1, 3, 6, 10, 15, ?", "answer": "21", "type": "pattern"},
75
+ {"q": "There are 5 people in a room. Each shakes hands with everyone else exactly once. How many handshakes total?", "answer": "10", "type": "multi_step"},
76
+ {"q": "A farmer has chickens and cows. He counts 20 heads and 56 legs. How many chickens does he have?", "answer": "12", "type": "multi_step"},
77
+ {"q": "If you rearrange the letters 'CIFAIPC' you get the name of a what? Ocean or country?", "answer": "ocean", "type": "common_sense"},
78
+ {"q": "Is it possible for your mother's brother to be a woman? Answer yes or no.", "answer": "no", "type": "common_sense"},
79
+ ]
80
+
81
+ HOLDOUT_CREATIVITY = [
82
+ {"q": "Write a 4-line poem about rain.", "min_lines": 3, "min_words": 15, "type": "poem"},
83
+ {"q": "Explain gravity to a 5 year old in 2-3 sentences.", "min_lines": 1, "min_words": 15, "type": "explain_simple"},
84
+ {"q": "Come up with 3 creative names for a pet goldfish.", "min_items": 3, "type": "brainstorm"},
85
+ {"q": "Rewrite this to be more dramatic: 'The sun set behind the mountains.'",
86
+ "min_words": 5, "type": "rewrite", "must_not_contain": "The sun set behind the mountains"},
87
+ ]
88
+
89
+ HOLDOUT_KNOWLEDGE = [
90
+ {"q": "What is the largest planet in our solar system?", "answer": "jupiter", "type": "science"},
91
+ {"q": "What element has the atomic number 1?", "answer": "hydrogen", "type": "science"},
92
+ {"q": "Who wrote Romeo and Juliet?", "answer": "shakespeare", "type": "history", "alt_answers": ["william shakespeare"]},
93
+ {"q": "What is the capital of Australia?", "answer": "canberra", "type": "geography"},
94
+ {"q": "What year did the first iPhone come out?", "answer": "2007", "type": "tech"},
95
+ {"q": "What does RAM stand for?", "answer": "random access memory", "type": "tech"},
96
+ ]
97
+
98
+ HOLDOUT_INSTRUCTION = [
99
+ {"q": "Respond with only the word 'goodbye'. Nothing else.",
100
+ "check": "goodbye", "verify": "exact_word", "type": "exact"},
101
+ {"q": "List exactly 4 animals, one per line. Nothing else.",
102
+ "check": 4, "verify": "line_count", "type": "format"},
103
+ {"q": "Write a sentence that contains exactly 7 words.",
104
+ "check": 7, "verify": "word_count", "type": "format"},
105
+ {"q": "Give me a number between 50 and 100. Just the number.",
106
+ "check": "number_50_100", "verify": "number_range_custom", "type": "exact"},
107
+ {"q": "Say 'no' if 10 > 20, otherwise say 'yes'. Only one word.",
108
+ "check": "yes", "verify": "exact_word", "type": "exact"},
109
+ ]
110
+
111
+
112
+ # ============================================================
113
+ # ANSWER CHECKING (reuses td_benchmark logic)
114
+ # ============================================================
115
+
116
+ def strip_thinking(response: str) -> str:
117
+ cleaned = re.sub(r'<think>.*?</think>', '', response, flags=re.DOTALL)
118
+ cleaned = re.sub(r'<think>.*$', '', cleaned, flags=re.DOTALL)
119
+ return cleaned.strip()
120
+
121
+
122
+ def check_math(response: str, expected: str) -> bool:
123
+ response_clean = strip_thinking(response).lower().replace(",", "")
124
+ numbers = re.findall(r'-?\d+\.?\d*', response_clean)
125
+ for n in numbers:
126
+ try:
127
+ if float(n) == float(expected):
128
+ return True
129
+ except ValueError:
130
+ continue
131
+ return False
132
+
133
+
134
+ def check_code(response: str, test: dict) -> bool:
135
+ response = strip_thinking(response)
136
+ code_block = response
137
+ md_match = re.search(r'```(?:python)?\s*\n(.*?)```', response, re.DOTALL)
138
+ if md_match:
139
+ code_block = md_match.group(1)
140
+ elif "def " in code_block:
141
+ def_start = code_block.index("def ")
142
+ code_block = code_block[def_start:]
143
+
144
+ if test["verify"] == "callable":
145
+ try:
146
+ import signal
147
+
148
+ def _timeout(signum, frame):
149
+ raise TimeoutError()
150
+
151
+ old = signal.signal(signal.SIGALRM, _timeout)
152
+ signal.alarm(10)
153
+ try:
154
+ ns = {}
155
+ exec(code_block.strip(), ns)
156
+ func = ns.get(test["check"])
157
+ if not func or not callable(func):
158
+ return False
159
+ for t in test.get("tests", []):
160
+ result = func(*t["args"])
161
+ if result != t["expected"]:
162
+ return False
163
+ return True
164
+ finally:
165
+ signal.alarm(0)
166
+ signal.signal(signal.SIGALRM, old)
167
+ except Exception:
168
+ return False
169
+ elif test["verify"] == "contains_any":
170
+ checks = test.get("alt_checks", [test["check"]])
171
+ return any(c.lower() in response.lower() for c in checks)
172
+ return False
173
+
174
+
175
+ def check_reasoning(response: str, expected: str) -> bool:
176
+ response_lower = strip_thinking(response).lower().strip()
177
+ if expected.lower() in response_lower:
178
+ return True
179
+ if expected.lower() in ["yes", "no"]:
180
+ first = response_lower.split()[0].strip(".,!?") if response_lower.split() else ""
181
+ return first == expected.lower()
182
+ numbers = re.findall(r'-?\d+\.?\d*', response_lower)
183
+ return expected.lower() in numbers
184
+
185
+
186
+ def check_creativity(response: str, test: dict) -> bool:
187
+ response = strip_thinking(response)
188
+ lines = [l.strip() for l in response.strip().split("\n") if l.strip()]
189
+ words = response.split()
190
+ if "must_not_contain" in test and test["must_not_contain"].lower() in response.lower():
191
+ return False
192
+ if "min_lines" in test and len(lines) < test["min_lines"]:
193
+ return False
194
+ if "min_words" in test and len(words) < test["min_words"]:
195
+ return False
196
+ if "min_items" in test:
197
+ items = [l for l in lines if re.match(r'^\d+[\.\):]|^[-*•]', l)]
198
+ if len(items) < test["min_items"] and len(lines) < test["min_items"]:
199
+ return False
200
+ return len(response.strip()) >= 10
201
+
202
+
203
+ def check_knowledge(response: str, test: dict) -> bool:
204
+ response_lower = strip_thinking(response).lower()
205
+ if test["answer"].lower() in response_lower:
206
+ return True
207
+ for alt in test.get("alt_answers", []):
208
+ if alt.lower() in response_lower:
209
+ return True
210
+ return False
211
+
212
+
213
+ def check_instruction(response: str, test: dict) -> bool:
214
+ response_clean = strip_thinking(response).strip()
215
+ response_lower = response_clean.lower()
216
+ verify = test["verify"]
217
+ check = test["check"]
218
+
219
+ if verify == "exact_word":
220
+ first = response_lower.split()[0] if response_lower.split() else ""
221
+ return re.sub(r'[.,!?"\']', '', first) == check.lower()
222
+ elif verify == "line_count":
223
+ lines = [l.strip() for l in response_clean.split("\n") if l.strip()]
224
+ return len(lines) == check
225
+ elif verify == "word_count":
226
+ lines = [l.strip() for l in response_clean.split("\n") if l.strip()]
227
+ for line in lines:
228
+ if any(s in line.lower() for s in ["here is", "here's", "sure", "okay"]):
229
+ continue
230
+ if len(line.split()) == check:
231
+ return True
232
+ return len(response_clean.split()) == check
233
+ elif verify == "number_range_custom":
234
+ numbers = re.findall(r'\d+', response_clean)
235
+ if numbers:
236
+ return 50 <= int(numbers[0]) <= 100
237
+ return False
238
+ return False
239
+
240
+
241
+ # ============================================================
242
+ # RUN HOLDOUT
243
+ # ============================================================
244
+
245
+ def run_holdout(model_path: str, output_path: str = None) -> Dict:
246
+ """Run the holdout benchmark. Returns scores per category."""
247
+ import torch
248
+ from transformers import AutoModelForImageTextToText, AutoTokenizer
249
+
250
+ print("=" * 60)
251
+ print("TD HOLDOUT BENCHMARK — Truth Test")
252
+ print(f"Model: {model_path}")
253
+ print("=" * 60)
254
+ print("\n WARNING: This benchmark must NEVER influence training!\n")
255
+
256
+ # Validate checkpoint before loading
257
+ model_dir = Path(model_path)
258
+ if model_dir.is_dir():
259
+ if not (model_dir / "config.json").exists():
260
+ raise FileNotFoundError(f"No config.json in {model_path} — stale or corrupt checkpoint!")
261
+ st_files = list(model_dir.glob("*.safetensors"))
262
+ if not st_files and not list(model_dir.glob("*.bin")):
263
+ raise FileNotFoundError(f"No model weights in {model_path} — checkpoint is empty!")
264
+
265
+ # Load model with OOM guard
266
+ try:
267
+ model = AutoModelForImageTextToText.from_pretrained(
268
+ model_path, dtype=torch.bfloat16,
269
+ device_map="auto", trust_remote_code=True
270
+ )
271
+ except (RuntimeError, Exception) as e:
272
+ gc.collect()
273
+ try:
274
+ import torch as _t
275
+ if _t.cuda.is_available():
276
+ _t.cuda.empty_cache()
277
+ except Exception:
278
+ pass
279
+ raise RuntimeError(f"Failed to load model for holdout: {e}") from e
280
+
281
+ tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
282
+
283
+ def ask(question, max_tokens=128):
284
+ chat = (
285
+ "<|im_start|>system\n/no_think<|im_end|>\n"
286
+ f"<|im_start|>user\n{question}<|im_end|>\n"
287
+ "<|im_start|>assistant\n"
288
+ )
289
+ ids = tokenizer(chat, return_tensors="pt").to(model.device)
290
+ try:
291
+ with torch.no_grad():
292
+ out = model.generate(**ids, max_new_tokens=max_tokens, do_sample=False,
293
+ temperature=None, top_p=None)
294
+ except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
295
+ gc.collect()
296
+ if torch.cuda.is_available():
297
+ torch.cuda.empty_cache()
298
+ print(f" OOM during holdout generation: {e}")
299
+ return "[OOM — no response]"
300
+ resp = tokenizer.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True).strip()
301
+ # Guard against garbage: truncate absurdly long responses
302
+ if len(resp) > 5000:
303
+ resp = resp[:5000]
304
+ return resp
305
+
306
+ categories = {
307
+ "math": (HOLDOUT_MATH, check_math, 64),
308
+ "code": (HOLDOUT_CODE, check_code, 300),
309
+ "reasoning": (HOLDOUT_REASONING, check_reasoning, 64),
310
+ "creativity": (HOLDOUT_CREATIVITY, check_creativity, 200),
311
+ "knowledge": (HOLDOUT_KNOWLEDGE, check_knowledge, 64),
312
+ "instruction_following": (HOLDOUT_INSTRUCTION, check_instruction, 128),
313
+ }
314
+
315
+ results = {}
316
+ total_passed = 0
317
+ total_questions = 0
318
+ start = time.time()
319
+
320
+ for cat_name, (tests, check_fn, max_tokens) in categories.items():
321
+ passed = 0
322
+ for test in tests:
323
+ q = test["q"]
324
+
325
+ try:
326
+ response = ask(q, max_tokens)
327
+ except Exception as e:
328
+ print(f" [ERROR] {cat_name} question crashed: {e}")
329
+ response = "[ERROR — question crashed]"
330
+ gc.collect()
331
+ if torch.cuda.is_available():
332
+ torch.cuda.empty_cache()
333
+
334
+ try:
335
+ if check_fn == check_math:
336
+ correct = check_fn(response, test["answer"])
337
+ elif check_fn == check_code:
338
+ correct = check_fn(response, test)
339
+ elif check_fn == check_reasoning:
340
+ correct = check_fn(response, test["answer"])
341
+ elif check_fn == check_creativity:
342
+ correct = check_fn(response, test)
343
+ elif check_fn == check_knowledge:
344
+ correct = check_fn(response, test)
345
+ elif check_fn == check_instruction:
346
+ correct = check_fn(response, test)
347
+ else:
348
+ correct = False
349
+ except Exception as e:
350
+ print(f" [ERROR] Answer checker crashed: {e}")
351
+ correct = False
352
+
353
+ if correct:
354
+ passed += 1
355
+ status = "PASS" if correct else "FAIL"
356
+ short_q = q[:55] + "..." if len(q) > 55 else q
357
+ print(f" [{status}] {cat_name}/{short_q}")
358
+
359
+ score = passed / len(tests) if tests else 0.0
360
+ results[cat_name] = {"score": score, "passed": passed, "total": len(tests)}
361
+ total_passed += passed
362
+ total_questions += len(tests)
363
+ print(f" → {cat_name}: {passed}/{len(tests)} ({score:.0%})\n")
364
+
365
+ elapsed = time.time() - start
366
+ overall = total_passed / total_questions if total_questions > 0 else 0.0
367
+
368
+ print(f"{'='*60}")
369
+ print(f"HOLDOUT RESULT: {total_passed}/{total_questions} ({overall:.0%})")
370
+ print(f"Time: {elapsed/60:.1f} min")
371
+ print(f"{'='*60}")
372
+
373
+ # Cleanup
374
+ del model
375
+ gc.collect()
376
+ try:
377
+ import torch
378
+ if torch.cuda.is_available():
379
+ torch.cuda.empty_cache()
380
+ except ImportError:
381
+ pass
382
+
383
+ report = {
384
+ "model_path": model_path,
385
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
386
+ "overall_score": overall,
387
+ "total_passed": total_passed,
388
+ "total_questions": total_questions,
389
+ "duration_min": elapsed / 60,
390
+ "categories": results,
391
+ }
392
+
393
+ if output_path:
394
+ try:
395
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
396
+ with open(output_path, "w") as f:
397
+ json.dump(report, f, indent=2)
398
+ print(f"\nSaved to {output_path}")
399
+ except OSError as e:
400
+ print(f"\nWARNING: Could not save holdout report: {e}")
401
+
402
+ return report
403
+
404
+
405
+ if __name__ == "__main__":
406
+ import sys
407
+ model = sys.argv[1] if len(sys.argv) > 1 else ""
408
+ if not model:
409
+ # Auto-detect
410
+ base = Path("td_fuse_outputs/self_improve")
411
+ if base.exists():
412
+ for n in range(50, 0, -1):
413
+ d = base / f"improved_cycle{n}"
414
+ if d.exists() and list(d.glob("*.safetensors")):
415
+ model = str(d)
416
+ break
417
+ if not model:
418
+ healed = Path("td_fuse_outputs/reasoning_healed")
419
+ if healed.exists():
420
+ model = str(healed)
421
+ if not model:
422
+ print("No model found!")
423
+ sys.exit(1)
424
+
425
+ output = sys.argv[2] if len(sys.argv) > 2 else None
426
+ run_holdout(model, output)
hugging/td_fuse/td_loop.py ADDED
@@ -0,0 +1,1082 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TD Self-Improvement Loop v1 — The Full Autonomous Pipeline
3
+
4
+ Ties everything together into an endless self-improvement loop:
5
+
6
+ Step 1: Test (benchmark + weight health)
7
+ Step 2: Diagnose (weakness finder)
8
+ Step 3: Find data (dataset finder)
9
+ Step 4: Train + Repair (selfimprove.py)
10
+ Step 5: Verify (re-benchmark)
11
+ → Loop
12
+
13
+ This is the script you start and walk away from.
14
+
15
+ Usage:
16
+ python td_loop.py # Start from latest checkpoint
17
+ python td_loop.py --model path/to/model # Start from specific model
18
+ python td_loop.py --max-cycles 10 # Stop after 10 cycles
19
+ python td_loop.py --dry-run # Just test+diagnose, don't train
20
+ """
21
+
22
+ import json
23
+ import time
24
+ import shutil
25
+ import gc
26
+ import sys
27
+ import argparse
28
+ from pathlib import Path
29
+ from typing import Dict, List, Optional, Tuple
30
+ from dataclasses import dataclass
31
+
32
+
33
+ # ============================================================
34
+ # LOOP CONFIGURATION
35
+ # ============================================================
36
+
37
+ @dataclass
38
+ class LoopConfig:
39
+ """Configuration for the self-improvement loop."""
40
+
41
+ # Model
42
+ model_path: str = "" # Auto-detected if empty
43
+ reference_model: str = "Qwen/Qwen3-VL-8B-Instruct"
44
+
45
+ # Directories
46
+ output_dir: str = "td_fuse_outputs/self_improve"
47
+ benchmark_dir: str = "td_fuse_outputs/benchmarks"
48
+ weakness_dir: str = "td_fuse_outputs/weakness_reports"
49
+ data_dir: str = "td_fuse_outputs/training_data"
50
+
51
+ # Loop settings
52
+ max_cycles: int = 0 # 0 = infinite
53
+ dry_run: bool = False # Test + diagnose only
54
+ training_budget: int = 8000 # Samples per cycle
55
+
56
+ # Score targets (when to stop)
57
+ targets: Dict = None
58
+
59
+ # HuggingFace
60
+ hf_token: str = ""
61
+
62
+ # Regression protection
63
+ max_regression_pct: float = 0.05 # 5% drop = rollback
64
+ max_consecutive_regressions: int = 2 # 2 in a row = stop
65
+
66
+ # Safety limits
67
+ max_training_hours: float = 4.0 # Warn if training exceeds this (hours per cycle)
68
+
69
+ def __post_init__(self):
70
+ if self.targets is None:
71
+ self.targets = {
72
+ "math": 0.80,
73
+ "code": 0.70,
74
+ "reasoning": 0.75,
75
+ "creativity": 0.65,
76
+ "knowledge": 0.80,
77
+ "instruction_following": 0.85,
78
+ }
79
+
80
+
81
+ # ============================================================
82
+ # CYCLE STATE TRACKING
83
+ # ============================================================
84
+
85
+ @dataclass
86
+ class CycleResult:
87
+ """Results from one improvement cycle."""
88
+ cycle_num: int
89
+ benchmark_scores: Dict[str, float]
90
+ weight_health_summary: Dict
91
+ weaknesses_found: int
92
+ data_collected: int
93
+ training_loss: float
94
+ improved: bool # Overall, did we improve?
95
+ regressions: List[str] # Categories that got worse
96
+ duration_seconds: float
97
+ model_path: str
98
+
99
+
100
+ def load_cycle_history(output_dir: str) -> List[CycleResult]:
101
+ """Load previous cycle results. Returns empty list if file is corrupt."""
102
+ history_file = Path(output_dir) / "loop_history.json"
103
+ if not history_file.exists():
104
+ return []
105
+
106
+ try:
107
+ with open(history_file) as f:
108
+ data = json.load(f)
109
+ return [CycleResult(**c) for c in data]
110
+ except (json.JSONDecodeError, TypeError, KeyError) as e:
111
+ print(f" WARNING: History file corrupt ({e}) — starting fresh")
112
+ # Back up the corrupt file
113
+ backup = history_file.with_suffix(".json.bak")
114
+ try:
115
+ shutil.copy2(str(history_file), str(backup))
116
+ print(f" Backed up corrupt history to {backup}")
117
+ except Exception:
118
+ pass
119
+ return []
120
+
121
+
122
+ def save_cycle_history(history: List[CycleResult], output_dir: str):
123
+ """Save cycle history to disk. Atomic write to prevent corruption."""
124
+ history_file = Path(output_dir) / "loop_history.json"
125
+ tmp_file = Path(output_dir) / "loop_history.json.tmp"
126
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
127
+
128
+ data = []
129
+ for c in history:
130
+ d = {
131
+ "cycle_num": c.cycle_num,
132
+ "benchmark_scores": c.benchmark_scores,
133
+ "weight_health_summary": c.weight_health_summary,
134
+ "weaknesses_found": c.weaknesses_found,
135
+ "data_collected": c.data_collected,
136
+ "training_loss": c.training_loss,
137
+ "improved": c.improved,
138
+ "regressions": c.regressions,
139
+ "duration_seconds": c.duration_seconds,
140
+ "model_path": c.model_path,
141
+ }
142
+ data.append(d)
143
+
144
+ try:
145
+ # Write to temp file first, then atomic rename
146
+ with open(tmp_file, "w") as f:
147
+ json.dump(data, f, indent=2)
148
+ # Atomic rename — if this fails, the old file is still intact
149
+ tmp_file.replace(history_file)
150
+ except OSError as e:
151
+ print(f" WARNING: Failed to save history ({e}). Disk may be full!")
152
+ # Try to clean up temp file
153
+ try:
154
+ tmp_file.unlink(missing_ok=True)
155
+ except Exception:
156
+ pass
157
+
158
+
159
+ # ============================================================
160
+ # THE LOOP
161
+ # ============================================================
162
+
163
+ def _setup_logging(output_dir: str):
164
+ """
165
+ Tee all stdout/stderr to a log file so diagnostics survive terminal disconnects.
166
+ Returns the log file path.
167
+ """
168
+ import sys as _sys
169
+
170
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
171
+ log_path = Path(output_dir) / f"loop_{time.strftime('%Y%m%d_%H%M%S')}.log"
172
+
173
+ class Tee:
174
+ def __init__(self, original, log_file):
175
+ self.original = original
176
+ self.log_file = log_file
177
+ def write(self, data):
178
+ self.original.write(data)
179
+ try:
180
+ self.log_file.write(data)
181
+ self.log_file.flush()
182
+ except Exception:
183
+ pass # Don't crash if log write fails
184
+ def flush(self):
185
+ self.original.flush()
186
+ try:
187
+ self.log_file.flush()
188
+ except Exception:
189
+ pass
190
+
191
+ log_fh = open(log_path, "a")
192
+ _sys.stdout = Tee(_sys.stdout, log_fh)
193
+ _sys.stderr = Tee(_sys.stderr, log_fh)
194
+ return str(log_path)
195
+
196
+
197
+ def _acquire_lockfile(output_dir: str) -> Optional[str]:
198
+ """
199
+ Prevent two td_loop instances from running simultaneously.
200
+ Returns lockfile path if acquired, None if another instance is running.
201
+ """
202
+ import os
203
+ lock_path = Path(output_dir) / "td_loop.lock"
204
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
205
+
206
+ if lock_path.exists():
207
+ # Check if the PID in the lockfile is still alive
208
+ try:
209
+ with open(lock_path) as f:
210
+ old_pid = int(f.read().strip())
211
+ os.kill(old_pid, 0) # Signal 0 = check if alive
212
+ # Process is alive — another instance is running
213
+ return None
214
+ except (ValueError, ProcessLookupError, PermissionError, OSError):
215
+ # PID invalid or dead — stale lockfile, safe to remove
216
+ print(f" Removing stale lockfile (PID {lock_path.read_text().strip()} is dead)")
217
+
218
+ with open(lock_path, "w") as f:
219
+ f.write(str(os.getpid()))
220
+ return str(lock_path)
221
+
222
+
223
+ def _release_lockfile(output_dir: str):
224
+ """Release the lockfile."""
225
+ lock_path = Path(output_dir) / "td_loop.lock"
226
+ try:
227
+ lock_path.unlink(missing_ok=True)
228
+ except Exception:
229
+ pass
230
+
231
+
232
+ def run_loop(cfg: LoopConfig):
233
+ """
234
+ The main self-improvement loop.
235
+
236
+ Start it and walk away. It will:
237
+ 1. Test the model (benchmark + weight health)
238
+ 2. Find weaknesses
239
+ 3. Search for training data
240
+ 4. Train + repair
241
+ 5. Verify improvement
242
+ 6. Repeat
243
+ """
244
+ # ── LOCKFILE: Prevent duplicate instances ──
245
+ lock = _acquire_lockfile(cfg.output_dir)
246
+ if lock is None:
247
+ print("ERROR: Another td_loop instance is already running!")
248
+ print(f" Lock file: {Path(cfg.output_dir) / 'td_loop.lock'}")
249
+ print(" If this is wrong (crashed process), delete the lock file and retry.")
250
+ return
251
+
252
+ # ── LOG TEE: Save all output to file ──
253
+ log_path = _setup_logging(cfg.output_dir)
254
+
255
+ # ── SIGINT HANDLER: Graceful shutdown ──
256
+ import signal
257
+ shutdown_requested = [False] # Mutable container for closure
258
+
259
+ def _signal_handler(signum, frame):
260
+ if shutdown_requested[0]:
261
+ print("\n FORCED SHUTDOWN — exiting immediately!")
262
+ _release_lockfile(cfg.output_dir)
263
+ raise SystemExit(1)
264
+ shutdown_requested[0] = True
265
+ print(f"\n SHUTDOWN REQUESTED (Ctrl+C) — will stop after current step completes.")
266
+ print(f" Press Ctrl+C again to force quit.")
267
+
268
+ signal.signal(signal.SIGINT, _signal_handler)
269
+ signal.signal(signal.SIGTERM, _signal_handler)
270
+
271
+ print("\n" + "=" * 60)
272
+ print("TD SELF-IMPROVEMENT LOOP v1")
273
+ print("=" * 60)
274
+ print(f" Max cycles: {'infinite' if cfg.max_cycles == 0 else cfg.max_cycles}")
275
+ print(f" Training budget: {cfg.training_budget} samples/cycle")
276
+ print(f" Dry run: {cfg.dry_run}")
277
+ print(f" Started: {time.strftime('%Y-%m-%d %H:%M:%S')}")
278
+ print(f" Log file: {log_path}")
279
+ print("=" * 60)
280
+
281
+ try:
282
+ _run_loop_inner(cfg, shutdown_requested)
283
+ finally:
284
+ _release_lockfile(cfg.output_dir)
285
+ print(f"\n Lock released. Log saved to: {log_path}")
286
+
287
+
288
+ def _run_loop_inner(cfg: LoopConfig, shutdown_requested: list):
289
+ """Inner loop logic, separated so run_loop can handle cleanup."""
290
+
291
+ # ── GPU HEALTH CHECK ──
292
+ try:
293
+ import torch
294
+ if torch.cuda.is_available():
295
+ gpu_name = torch.cuda.get_device_name(0)
296
+ gpu_mem = torch.cuda.get_device_properties(0).total_mem / 1e9
297
+ print(f"\n GPU: {gpu_name} ({gpu_mem:.1f} GB)")
298
+ if gpu_mem < 20:
299
+ print(f" WARNING: GPU has only {gpu_mem:.1f} GB — Qwen3-VL-8B needs ~24 GB for training!")
300
+ # Quick allocation test — try to allocate 1GB tensor
301
+ test = torch.empty(256, 1024, 1024, dtype=torch.bfloat16, device="cuda")
302
+ del test
303
+ torch.cuda.empty_cache()
304
+ print(f" GPU health: OK (allocation test passed)")
305
+ else:
306
+ print("\n WARNING: No CUDA GPU detected! Training will be extremely slow or fail.")
307
+ except Exception as e:
308
+ print(f"\n WARNING: GPU check failed: {e}")
309
+
310
+ # ── DISK HEALTH CHECK ──
311
+ try:
312
+ disk = shutil.disk_usage(cfg.output_dir if Path(cfg.output_dir).exists() else ".")
313
+ free_gb = disk.free / 1e9
314
+ print(f" Disk: {free_gb:.1f} GB free")
315
+ if free_gb < 50:
316
+ print(f" WARNING: Low disk space! Need ~25 GB per cycle for checkpoints.")
317
+ except Exception:
318
+ pass
319
+
320
+ # Load history
321
+ history = load_cycle_history(cfg.output_dir)
322
+ cycle_num = len(history) + 1
323
+ consecutive_regressions = 0
324
+
325
+ # Auto-detect model if not specified
326
+ if not cfg.model_path:
327
+ cfg.model_path = _auto_detect_model()
328
+
329
+ print(f"\n Starting model: {cfg.model_path}")
330
+ print(f" Starting at cycle: {cycle_num}")
331
+
332
+ # The loop
333
+ while True:
334
+ # ── GRACEFUL SHUTDOWN CHECK ──
335
+ if shutdown_requested[0]:
336
+ print(f"\n Shutdown requested — stopping before cycle {cycle_num}")
337
+ _print_final_summary(history)
338
+ return
339
+ if cfg.max_cycles > 0 and cycle_num > cfg.max_cycles:
340
+ print(f"\n Reached max cycles ({cfg.max_cycles}). Stopping.")
341
+ break
342
+
343
+ cycle_start = time.time()
344
+ print(f"\n{'#' * 60}")
345
+ print(f"# CYCLE {cycle_num}")
346
+ print(f"# {time.strftime('%Y-%m-%d %H:%M:%S')}")
347
+ print(f"{'#' * 60}")
348
+
349
+ # ============================================
350
+ # STEP 1: TEST (benchmark + weight health)
351
+ # ============================================
352
+ print(f"\n{'='*50}")
353
+ print(f"STEP 1/{5 if not cfg.dry_run else 2}: TEST MODEL")
354
+ print(f"{'='*50}")
355
+
356
+ # 1A: Run benchmark (with crash recovery)
357
+ try:
358
+ benchmark_scores = _run_benchmark(cfg.model_path, cfg.benchmark_dir, cycle_num)
359
+ except Exception as e:
360
+ print(f"\n CRITICAL: Benchmark crashed: {e}")
361
+ import traceback
362
+ traceback.print_exc()
363
+ # Can't proceed without benchmark scores — try to clean GPU and retry once
364
+ gc.collect()
365
+ try:
366
+ import torch
367
+ if torch.cuda.is_available():
368
+ torch.cuda.empty_cache()
369
+ except ImportError:
370
+ pass
371
+ try:
372
+ print(" Retrying benchmark after GPU cleanup...")
373
+ benchmark_scores = _run_benchmark(cfg.model_path, cfg.benchmark_dir, cycle_num)
374
+ except Exception as e2:
375
+ print(f" Benchmark failed twice: {e2}")
376
+ print(f" Skipping cycle {cycle_num}")
377
+ consecutive_regressions += 1
378
+ if consecutive_regressions >= cfg.max_consecutive_regressions:
379
+ print(f"\n {consecutive_regressions} consecutive failures — STOPPING LOOP")
380
+ break
381
+ cycle_num += 1
382
+ continue
383
+
384
+ # 1B: Analyze weight health
385
+ health_data = _analyze_weights(cfg.model_path, cfg.reference_model, cfg.output_dir)
386
+
387
+ # ============================================
388
+ # STEP 2: DIAGNOSE (find weaknesses)
389
+ # ============================================
390
+ print(f"\n{'='*50}")
391
+ print(f"STEP 2/{5 if not cfg.dry_run else 2}: DIAGNOSE WEAKNESSES")
392
+ print(f"{'='*50}")
393
+
394
+ try:
395
+ weakness_report = _find_weaknesses(
396
+ benchmark_path=str(Path(cfg.benchmark_dir) / f"cycle{cycle_num}" / "results.json"),
397
+ health_data=health_data,
398
+ targets=cfg.targets,
399
+ budget=cfg.training_budget,
400
+ output_dir=cfg.weakness_dir,
401
+ cycle_num=cycle_num,
402
+ )
403
+ except Exception as e:
404
+ print(f"\n WARNING: Weakness analysis crashed: {e}")
405
+ print(f" Using fallback: train all categories equally")
406
+ weakness_report = {
407
+ "search_plan": [{"category": c, "budget": cfg.training_budget // 6}
408
+ for c in ["math", "code", "reasoning", "creativity",
409
+ "knowledge", "instruction_following"]],
410
+ "data_allocation": {c: cfg.training_budget // 6
411
+ for c in ["math", "code", "reasoning", "creativity",
412
+ "knowledge", "instruction_following"]},
413
+ "lora_target_layers": list(range(36)),
414
+ "norms_to_repair": [],
415
+ "total_weaknesses": 6,
416
+ }
417
+
418
+ if cfg.dry_run:
419
+ print("\n DRY RUN — stopping after diagnosis.")
420
+ result = CycleResult(
421
+ cycle_num=cycle_num,
422
+ benchmark_scores=benchmark_scores,
423
+ weight_health_summary=health_data.get("summary", {}),
424
+ weaknesses_found=weakness_report.get("total_weaknesses", 0),
425
+ data_collected=0,
426
+ training_loss=0.0,
427
+ improved=False,
428
+ regressions=[],
429
+ duration_seconds=time.time() - cycle_start,
430
+ model_path=cfg.model_path,
431
+ )
432
+ history.append(result)
433
+ save_cycle_history(history, cfg.output_dir)
434
+ break
435
+
436
+ # ============================================
437
+ # STEP 3: FIND TRAINING DATA
438
+ # ============================================
439
+ print(f"\n{'='*50}")
440
+ print(f"STEP 3/5: FIND TRAINING DATA")
441
+ print(f"{'='*50}")
442
+
443
+ # Guard: If no weaknesses found, skip data search + training
444
+ if not weakness_report.get("search_plan"):
445
+ print("\n No weaknesses found — all targets met! Skipping training.")
446
+ print(" (This shouldn't happen unless the model is already great)")
447
+ training_data = {"total_samples": 0, "categories_covered": [], "categories_missing": []}
448
+ else:
449
+ try:
450
+ training_data = _find_training_data(
451
+ weakness_report=weakness_report,
452
+ output_dir=cfg.data_dir,
453
+ hf_token=cfg.hf_token,
454
+ cycle_num=cycle_num,
455
+ )
456
+ except Exception as e:
457
+ print(f"\n WARNING: Data finder crashed: {e}")
458
+ print(f" Continuing with generic datasets (selfimprove.py fallback)")
459
+ training_data = {"total_samples": 0, "categories_covered": [], "categories_missing": []}
460
+
461
+ # ============================================
462
+ # STEP 4: TRAIN + REPAIR
463
+ # ============================================
464
+ print(f"\n{'='*50}")
465
+ print(f"STEP 4/5: TRAIN + REPAIR")
466
+ print(f"{'='*50}")
467
+
468
+ # ── DISK SPACE PRE-CHECK ──
469
+ try:
470
+ disk = shutil.disk_usage(cfg.output_dir if Path(cfg.output_dir).exists() else ".")
471
+ free_gb = disk.free / 1e9
472
+ if free_gb < 10:
473
+ print(f"\n CRITICAL: Only {free_gb:.1f} GB disk free — skipping training!")
474
+ print(f" Need at least 10 GB to safely save a checkpoint.")
475
+ consecutive_regressions += 1
476
+ cycle_num += 1
477
+ continue
478
+ except Exception:
479
+ pass # Can't check disk — proceed anyway
480
+
481
+ try:
482
+ new_model_path, training_loss = _train_model(
483
+ model_path=cfg.model_path,
484
+ training_data_path=str(Path(cfg.data_dir) / f"cycle{cycle_num}" / "training_data.jsonl"),
485
+ lora_targets=weakness_report.get("lora_target_layers", list(range(36))),
486
+ norms_to_repair=weakness_report.get("norms_to_repair", []),
487
+ reference_model=cfg.reference_model,
488
+ output_dir=cfg.output_dir,
489
+ cycle_num=cycle_num,
490
+ )
491
+ except Exception as e:
492
+ print(f"\n CRITICAL: Training crashed: {e}")
493
+ print(f" Skipping this cycle and retrying with current model...")
494
+ import traceback
495
+ traceback.print_exc()
496
+ # Save partial results and continue to next cycle
497
+ cycle_duration = time.time() - cycle_start
498
+ result = CycleResult(
499
+ cycle_num=cycle_num,
500
+ benchmark_scores=benchmark_scores,
501
+ weight_health_summary=health_data.get("summary", {}),
502
+ weaknesses_found=weakness_report.get("total_weaknesses", 0),
503
+ data_collected=training_data.get("total_samples", 0),
504
+ training_loss=0.0,
505
+ improved=False,
506
+ regressions=["TRAINING_CRASH"],
507
+ duration_seconds=cycle_duration,
508
+ model_path=cfg.model_path,
509
+ )
510
+ history.append(result)
511
+ save_cycle_history(history, cfg.output_dir)
512
+ consecutive_regressions += 1
513
+ if consecutive_regressions >= cfg.max_consecutive_regressions:
514
+ print(f"\n {consecutive_regressions} consecutive failures — STOPPING LOOP")
515
+ break
516
+ cycle_num += 1
517
+ # Free GPU memory before retry
518
+ gc.collect()
519
+ try:
520
+ import torch
521
+ if torch.cuda.is_available():
522
+ torch.cuda.empty_cache()
523
+ except ImportError:
524
+ pass
525
+ continue
526
+
527
+ # ── TRAINING DURATION WATCHDOG ──
528
+ train_elapsed_h = (time.time() - cycle_start) / 3600
529
+ if train_elapsed_h > cfg.max_training_hours:
530
+ print(f"\n WARNING: Training took {train_elapsed_h:.1f}h (limit: {cfg.max_training_hours}h)")
531
+ print(f" This may indicate a stuck process or oversized dataset.")
532
+
533
+ # ============================================
534
+ # STEP 5: VERIFY
535
+ # ============================================
536
+ print(f"\n{'='*50}")
537
+ print(f"STEP 5/5: VERIFY IMPROVEMENT")
538
+ print(f"{'='*50}")
539
+
540
+ try:
541
+ new_scores = _run_benchmark(new_model_path, cfg.benchmark_dir, cycle_num, suffix="_post")
542
+ except Exception as e:
543
+ print(f"\n CRITICAL: Post-training benchmark crashed: {e}")
544
+ print(f" Cannot verify improvement — treating as regression for safety.")
545
+ import traceback
546
+ traceback.print_exc()
547
+ new_scores = {} # Empty = all categories missing = treated as regression
548
+
549
+ # Compare scores
550
+ improved, regressions = _compare_scores(benchmark_scores, new_scores, cfg.max_regression_pct)
551
+
552
+ if improved:
553
+ print(f"\n ✓ IMPROVEMENT CONFIRMED — cycle {cycle_num} successful!")
554
+ consecutive_regressions = 0
555
+ cfg.model_path = new_model_path # Use improved model next cycle
556
+ else:
557
+ print(f"\n ✗ REGRESSION DETECTED in: {', '.join(regressions)}")
558
+ consecutive_regressions += 1
559
+
560
+ if consecutive_regressions >= cfg.max_consecutive_regressions:
561
+ print(f"\n {consecutive_regressions} consecutive regressions — STOPPING LOOP")
562
+ print(f" The model may have hit its improvement ceiling.")
563
+ break
564
+
565
+ # Still use the new model — small regressions can recover
566
+ cfg.model_path = new_model_path
567
+
568
+ # Save cycle results
569
+ cycle_duration = time.time() - cycle_start
570
+ result = CycleResult(
571
+ cycle_num=cycle_num,
572
+ benchmark_scores=new_scores,
573
+ weight_health_summary=health_data.get("summary", {}),
574
+ weaknesses_found=weakness_report.get("total_weaknesses", 0),
575
+ data_collected=training_data.get("total_samples", 0),
576
+ training_loss=training_loss,
577
+ improved=improved,
578
+ regressions=regressions,
579
+ duration_seconds=cycle_duration,
580
+ model_path=new_model_path,
581
+ )
582
+ history.append(result)
583
+ save_cycle_history(history, cfg.output_dir)
584
+
585
+ # Print cycle summary
586
+ _print_cycle_summary(result, history)
587
+
588
+ # Check if all targets met
589
+ if _all_targets_met(new_scores, cfg.targets):
590
+ print(f"\n{'='*60}")
591
+ print(f"TARGET PERFORMANCE REACHED AFTER {cycle_num} CYCLES!")
592
+ print(f"{'='*60}")
593
+ break
594
+
595
+ # ── PERIODIC FULL GC between cycles ──
596
+ # After a full cycle, Python has accumulated lots of garbage
597
+ # (deleted models, tokenizers, datasets). Force collection.
598
+ gc.collect()
599
+ try:
600
+ import torch
601
+ if torch.cuda.is_available():
602
+ torch.cuda.empty_cache()
603
+ # Print GPU memory status for debugging
604
+ allocated = torch.cuda.memory_allocated() / 1e9
605
+ reserved = torch.cuda.memory_reserved() / 1e9
606
+ print(f"\n GPU memory: {allocated:.1f} GB allocated, {reserved:.1f} GB reserved")
607
+ except ImportError:
608
+ pass
609
+
610
+ # ── SHUTDOWN CHECK before starting next cycle ──
611
+ if shutdown_requested[0]:
612
+ print(f"\n Shutdown requested — stopping after cycle {cycle_num}")
613
+ break
614
+
615
+ cycle_num += 1
616
+
617
+ # Final summary
618
+ _print_final_summary(history)
619
+
620
+
621
+ # ============================================================
622
+ # STEP IMPLEMENTATIONS
623
+ # ============================================================
624
+
625
+ def _auto_detect_model() -> str:
626
+ """Find the best starting model."""
627
+ base = Path("td_fuse_outputs/self_improve")
628
+
629
+ # Check for highest cycle output
630
+ if base.exists():
631
+ for n in range(50, 0, -1):
632
+ cycle_dir = base / f"improved_cycle{n}"
633
+ if cycle_dir.exists() and list(cycle_dir.glob("*.safetensors")):
634
+ print(f" Found cycle {n} output at {cycle_dir}")
635
+ return str(cycle_dir)
636
+
637
+ # Check for old "improved" folder
638
+ old_improved = base / "improved"
639
+ if old_improved.exists() and list(old_improved.glob("*.safetensors")):
640
+ return str(old_improved)
641
+
642
+ # Fall back to reasoning_healed
643
+ healed = Path("td_fuse_outputs/reasoning_healed")
644
+ if healed.exists() and list(healed.glob("*.safetensors")):
645
+ return str(healed)
646
+
647
+ raise FileNotFoundError("No model found! Need a starting model checkpoint.")
648
+
649
+
650
+ def _run_benchmark(
651
+ model_path: str,
652
+ benchmark_dir: str,
653
+ cycle_num: int,
654
+ suffix: str = "",
655
+ ) -> Dict[str, float]:
656
+ """Run td_benchmark.py and return scores per category."""
657
+ from td_benchmark import BenchmarkConfig, run_benchmark
658
+
659
+ output_dir = str(Path(benchmark_dir) / f"cycle{cycle_num}{suffix}")
660
+ results_path = str(Path(output_dir) / "results.json")
661
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
662
+
663
+ # Validate checkpoint before loading
664
+ mp = Path(model_path)
665
+ if mp.is_dir() and not (mp / "config.json").exists():
666
+ raise FileNotFoundError(f"No config.json in {model_path} — stale or corrupt checkpoint")
667
+
668
+ config = BenchmarkConfig(
669
+ model_path=model_path,
670
+ output_dir=output_dir,
671
+ output_path=results_path,
672
+ disable_thinking=True, # SPEED: Skip thinking for benchmark
673
+ )
674
+
675
+ print(f" Running benchmark on {model_path}...")
676
+ results = run_benchmark(config)
677
+
678
+ # Guard: results might be None or not a dict if benchmark crashed internally
679
+ if not isinstance(results, dict):
680
+ print(f" WARNING: Benchmark returned {type(results)} instead of dict — treating as empty")
681
+ results = {}
682
+
683
+ # Extract scores per category (already 0.0-1.0 after our fix)
684
+ scores = {}
685
+ for category, data in results.get("categories", {}).items():
686
+ if isinstance(data, dict):
687
+ scores[category] = data.get("score", 0.0)
688
+ else:
689
+ scores[category] = 0.0
690
+
691
+ print(f" Benchmark scores:")
692
+ for cat, score in sorted(scores.items()):
693
+ print(f" {cat}: {score:.0%}")
694
+
695
+ # Clean up model from GPU memory
696
+ gc.collect()
697
+ try:
698
+ import torch
699
+ if torch.cuda.is_available():
700
+ torch.cuda.empty_cache()
701
+ except ImportError:
702
+ pass
703
+
704
+ return scores
705
+
706
+
707
+ def _analyze_weights(
708
+ model_path: str,
709
+ reference_model: str,
710
+ output_dir: str,
711
+ ) -> Dict:
712
+ """Run weight health analysis from selfimprove.py."""
713
+ try:
714
+ from selfimprove import (
715
+ analyze_model_weights,
716
+ compute_health_scores,
717
+ compute_damage_scores,
718
+ compute_coherence_scores,
719
+ detect_clogged_norms,
720
+ )
721
+
722
+ print(f" Analyzing model weights...")
723
+ model_analysis = analyze_model_weights(model_path, "TD model")
724
+ health_scores = compute_health_scores(model_analysis)
725
+ coherence_scores = compute_coherence_scores(model_analysis)
726
+ clogged_norms = detect_clogged_norms(health_scores)
727
+
728
+ # Reference comparison — skip if no reference cached (saves ~5 min)
729
+ # selfimprove.py downloads it during training anyway
730
+ damage_scores = {}
731
+ ref_path = Path(output_dir) / "reference_weights"
732
+ if ref_path.exists() and list(ref_path.glob("*.safetensors")):
733
+ print(f" Found cached reference at {ref_path}, comparing...")
734
+ ref_analysis = analyze_model_weights(str(ref_path), "Reference")
735
+ damage_scores = compute_damage_scores(model_analysis, ref_analysis)
736
+ else:
737
+ print(f" No cached reference — skipping damage comparison (saves time)")
738
+
739
+ # Build summary
740
+ unhealthy = sum(1 for h in health_scores.values() if h["health_score"] > 0.2)
741
+ summary = {
742
+ "total_tensors": len(health_scores),
743
+ "unhealthy_tensors": unhealthy,
744
+ "clogged_norms": len(clogged_norms),
745
+ "coherence_issues": sum(1 for s in coherence_scores.values() if s > 0.3),
746
+ }
747
+
748
+ print(f" Weight health: {unhealthy}/{len(health_scores)} tensors with issues")
749
+ print(f" Clogged norms: {len(clogged_norms)}")
750
+
751
+ # Clean up
752
+ gc.collect()
753
+
754
+ return {
755
+ "health_scores": health_scores,
756
+ "damage_scores": damage_scores,
757
+ "coherence_scores": coherence_scores,
758
+ "clogged_norms": clogged_norms,
759
+ "summary": summary,
760
+ }
761
+
762
+ except Exception as e:
763
+ print(f" Warning: Weight analysis failed: {e}")
764
+ return {"summary": {}, "health_scores": {}, "clogged_norms": []}
765
+
766
+
767
+ def _find_weaknesses(
768
+ benchmark_path: str,
769
+ health_data: Dict,
770
+ targets: Dict,
771
+ budget: int,
772
+ output_dir: str,
773
+ cycle_num: int,
774
+ ) -> Dict:
775
+ """Run td_weakness.py analysis."""
776
+ from td_weakness import run_weakness_analysis
777
+
778
+ output_path = str(Path(output_dir) / f"cycle{cycle_num}" / "weakness_report.json")
779
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
780
+
781
+ report = run_weakness_analysis(
782
+ benchmark_path=benchmark_path,
783
+ health_scores=health_data.get("health_scores") or {},
784
+ damage_scores=health_data.get("damage_scores") or {},
785
+ coherence_scores=health_data.get("coherence_scores") or {},
786
+ clogged_norms=health_data.get("clogged_norms") or [],
787
+ targets=targets,
788
+ total_budget=budget,
789
+ output_path=output_path,
790
+ )
791
+
792
+ return {
793
+ "search_plan": report.search_plan,
794
+ "data_allocation": report.data_allocation,
795
+ "lora_target_layers": report.lora_target_layers,
796
+ "norms_to_repair": report.norms_to_repair,
797
+ "total_weaknesses": len(report.answer_weaknesses) + len(report.weight_weaknesses),
798
+ }
799
+
800
+
801
+ def _find_training_data(
802
+ weakness_report: Dict,
803
+ output_dir: str,
804
+ hf_token: str,
805
+ cycle_num: int,
806
+ ) -> Dict:
807
+ """Run td_data_finder.py to search for and download data."""
808
+ from td_data_finder import find_training_data
809
+
810
+ cycle_data_dir = str(Path(output_dir) / f"cycle{cycle_num}")
811
+
812
+ result = find_training_data(
813
+ search_plan=weakness_report["search_plan"],
814
+ data_allocation=weakness_report["data_allocation"],
815
+ output_dir=cycle_data_dir,
816
+ hf_token=hf_token if hf_token else None,
817
+ )
818
+
819
+ return {
820
+ "total_samples": result.total_samples,
821
+ "categories_covered": result.categories_covered,
822
+ "categories_missing": result.categories_missing,
823
+ }
824
+
825
+
826
+ def _train_model(
827
+ model_path: str,
828
+ training_data_path: str,
829
+ lora_targets: List[int],
830
+ norms_to_repair: List[str],
831
+ reference_model: str,
832
+ output_dir: str,
833
+ cycle_num: int,
834
+ ) -> Tuple[str, float]:
835
+ """
836
+ Run training using selfimprove.py's training pipeline.
837
+
838
+ Returns: (new_model_path, final_training_loss)
839
+ """
840
+ from selfimprove import SelfImproveConfig, run_cycle
841
+
842
+ cfg = SelfImproveConfig()
843
+ # CRITICAL: Set model_path BEFORE run_cycle so auto_detect_model_path
844
+ # doesn't pick a different checkpoint
845
+ cfg.model_path = model_path
846
+ cfg.reference_model = reference_model
847
+ cfg.output_dir = output_dir
848
+
849
+ # Apply mega mode settings (always for loop cycles)
850
+ cfg.mega_mode = True
851
+ cfg.top_n_layers = 36
852
+ cfg.min_improvement_score = 0.0
853
+ cfg.lora_r = 128
854
+ cfg.lora_alpha = 256
855
+ cfg.train_batch = 4
856
+ cfg.train_grad_accum = 4
857
+ cfg.num_train_samples = 8000
858
+ cfg.learning_rate = 1e-5
859
+ cfg.norm_learning_rate = 1e-3
860
+ cfg.save_steps = 200
861
+
862
+ # Pass targeted training data from td_data_finder
863
+ # selfimprove.py will use this INSTEAD of its hardcoded datasets
864
+ training_data_file = Path(training_data_path)
865
+ if training_data_file.exists() and training_data_file.stat().st_size > 0:
866
+ cfg.custom_data_path = str(training_data_file)
867
+ print(f" Using targeted training data: {training_data_file}")
868
+ else:
869
+ print(f" No targeted data found — selfimprove.py will use generic datasets")
870
+
871
+ # Figure out what cycle_num selfimprove.py will ACTUALLY use
872
+ # so we know where the output will be saved
873
+ import re as _re
874
+ si_match = _re.search(r'improved_cycle(\d+)', model_path)
875
+ if si_match:
876
+ si_cycle_num = int(si_match.group(1)) + 1
877
+ elif "improve" in model_path:
878
+ si_cycle_num = 2
879
+ else:
880
+ si_cycle_num = 1
881
+
882
+ print(f" Starting training (loop cycle {cycle_num}, selfimprove cycle {si_cycle_num})...")
883
+ print(f" Model: {model_path}")
884
+ print(f" Output will be: {output_dir}/improved_cycle{si_cycle_num}")
885
+ run_cycle(cfg)
886
+
887
+ # Find the output model — use selfimprove's cycle numbering
888
+ new_model_path = str(Path(output_dir) / f"improved_cycle{si_cycle_num}")
889
+ if not Path(new_model_path).exists():
890
+ # Fallback: try loop's cycle number
891
+ alt_path = str(Path(output_dir) / f"improved_cycle{cycle_num}")
892
+ if Path(alt_path).exists():
893
+ new_model_path = alt_path
894
+ else:
895
+ # Last resort: scan for any improved_cycle* dir
896
+ base = Path(output_dir)
897
+ found = False
898
+ for n in range(50, 0, -1):
899
+ check = base / f"improved_cycle{n}"
900
+ if check.exists() and list(check.glob("*.safetensors")):
901
+ new_model_path = str(check)
902
+ print(f" Output path mismatch — found model at {check}")
903
+ found = True
904
+ break
905
+ if not found:
906
+ old_improved = base / "improved"
907
+ if old_improved.exists():
908
+ new_model_path = str(old_improved)
909
+ else:
910
+ # Training produced nothing — return original model
911
+ print(f" ERROR: No output model found! Training may have failed silently.")
912
+ print(f" Returning original model: {model_path}")
913
+ return model_path, 0.0
914
+
915
+ # Validate the output model has actual weights
916
+ out_p = Path(new_model_path)
917
+ if out_p.is_dir():
918
+ st_files = list(out_p.glob("*.safetensors"))
919
+ if not st_files:
920
+ print(f" ERROR: Output model has no safetensors files! Returning original.")
921
+ return model_path, 0.0
922
+ total_sz = sum(f.stat().st_size for f in st_files) / 1e9
923
+ if total_sz < 1.0:
924
+ print(f" ERROR: Output model only {total_sz:.2f} GB — likely corrupt. Returning original.")
925
+ return model_path, 0.0
926
+
927
+ # Try to get final loss from training log
928
+ training_loss = 0.0
929
+ log_file = Path(new_model_path) / "training_log.json"
930
+ if log_file.exists():
931
+ try:
932
+ with open(log_file) as f:
933
+ log = json.load(f)
934
+ training_loss = log.get("final_loss", 0.0)
935
+ except Exception:
936
+ pass
937
+
938
+ return new_model_path, training_loss
939
+
940
+
941
+ def _compare_scores(
942
+ old_scores: Dict[str, float],
943
+ new_scores: Dict[str, float],
944
+ max_regression: float,
945
+ ) -> Tuple[bool, List[str]]:
946
+ """
947
+ Compare benchmark scores before and after training.
948
+ Returns: (overall_improved, list_of_regressed_categories)
949
+ """
950
+ regressions = []
951
+ improvements = 0
952
+
953
+ for category in old_scores:
954
+ if category not in new_scores:
955
+ print(f" ? {category}: MISSING from new scores — treating as regression!")
956
+ regressions.append(category)
957
+ continue
958
+
959
+ old = old_scores[category]
960
+ new = new_scores[category]
961
+ diff = new - old
962
+
963
+ if diff > 0.01: # Meaningful improvement (>1%)
964
+ improvements += 1
965
+ print(f" ↑ {category}: {old:.1%} → {new:.1%} (+{diff:.1%})")
966
+ elif diff < -max_regression: # Regression beyond threshold
967
+ regressions.append(category)
968
+ print(f" ↓ {category}: {old:.1%} → {new:.1%} ({diff:.1%}) REGRESSION!")
969
+ else:
970
+ print(f" = {category}: {old:.1%} → {new:.1%} (stable)")
971
+
972
+ # Improved if: at least one category improved AND improvements outnumber regressions.
973
+ # Old logic (improvements > 0 AND regressions == 0) was too strict —
974
+ # with 6 categories, some random fluctuation is expected. If math goes
975
+ # up 5% but creativity dips 2%, that's still a net win.
976
+ overall_improved = improvements > len(regressions)
977
+ return overall_improved, regressions
978
+
979
+
980
+ def _all_targets_met(scores: Dict[str, float], targets: Dict[str, float]) -> bool:
981
+ """Check if all scores meet or exceed targets."""
982
+ for category, target in targets.items():
983
+ if category not in scores:
984
+ return False
985
+ if scores[category] < target:
986
+ return False
987
+ return True
988
+
989
+
990
+ # ============================================================
991
+ # SUMMARIES
992
+ # ============================================================
993
+
994
+ def _print_cycle_summary(result: CycleResult, history: List[CycleResult]):
995
+ """Print a summary of the completed cycle."""
996
+ try:
997
+ hours = result.duration_seconds / 3600
998
+ print(f"\n{'='*50}")
999
+ print(f"CYCLE {result.cycle_num} SUMMARY")
1000
+ print(f"{'='*50}")
1001
+ print(f" Duration: {hours:.1f} hours")
1002
+ print(f" Weaknesses found: {result.weaknesses_found}")
1003
+ print(f" Data collected: {result.data_collected}")
1004
+ print(f" Training loss: {result.training_loss:.4f}")
1005
+ print(f" Improved: {'Yes' if result.improved else 'No'}")
1006
+ if result.regressions:
1007
+ print(f" Regressions: {', '.join(result.regressions)}")
1008
+ print(f" Model saved: {result.model_path}")
1009
+
1010
+ # Score history
1011
+ if len(history) > 1:
1012
+ print(f"\n Score progression:")
1013
+ for h in history[-5:]: # Last 5 cycles
1014
+ scores = h.benchmark_scores or {}
1015
+ avg = sum(scores.values()) / max(len(scores), 1) if scores else 0.0
1016
+ print(f" Cycle {h.cycle_num}: avg {avg:.1%}")
1017
+ except Exception as e:
1018
+ print(f" (Could not print cycle summary: {e})")
1019
+
1020
+
1021
+ def _print_final_summary(history: List[CycleResult]):
1022
+ """Print final summary after loop ends."""
1023
+ if not history:
1024
+ return
1025
+
1026
+ try:
1027
+ total_hours = sum(r.duration_seconds for r in history) / 3600
1028
+ improved_cycles = sum(1 for r in history if r.improved)
1029
+
1030
+ print(f"\n{'#' * 60}")
1031
+ print(f"# LOOP COMPLETE")
1032
+ print(f"{'#' * 60}")
1033
+ print(f" Total cycles: {len(history)}")
1034
+ print(f" Improved cycles: {improved_cycles}/{len(history)}")
1035
+ print(f" Total time: {total_hours:.1f} hours")
1036
+
1037
+ if len(history) >= 2:
1038
+ first = history[0]
1039
+ last = history[-1]
1040
+ first_scores = first.benchmark_scores or {}
1041
+ last_scores = last.benchmark_scores or {}
1042
+ if first_scores and last_scores:
1043
+ print(f"\n Score changes (first → last):")
1044
+ for cat in sorted(first_scores.keys()):
1045
+ if cat in last_scores:
1046
+ old = first_scores[cat]
1047
+ new = last_scores[cat]
1048
+ diff = new - old
1049
+ arrow = "↑" if diff > 0 else "↓" if diff < 0 else "="
1050
+ print(f" {arrow} {cat}: {old:.1%} → {new:.1%} ({diff:+.1%})")
1051
+
1052
+ print(f"\n Final model: {history[-1].model_path}")
1053
+ except Exception as e:
1054
+ print(f" (Could not print final summary: {e})")
1055
+
1056
+
1057
+ # ============================================================
1058
+ # CLI
1059
+ # ============================================================
1060
+
1061
+ def main():
1062
+ parser = argparse.ArgumentParser(description="TD Self-Improvement Loop")
1063
+ parser.add_argument("--model", default="", help="Starting model path")
1064
+ parser.add_argument("--max-cycles", type=int, default=0, help="Max cycles (0=infinite)")
1065
+ parser.add_argument("--budget", type=int, default=8000, help="Training samples per cycle")
1066
+ parser.add_argument("--dry-run", action="store_true", help="Test + diagnose only")
1067
+ parser.add_argument("--hf-token", default="", help="HuggingFace token")
1068
+ args = parser.parse_args()
1069
+
1070
+ cfg = LoopConfig(
1071
+ model_path=args.model,
1072
+ max_cycles=args.max_cycles,
1073
+ training_budget=args.budget,
1074
+ dry_run=args.dry_run,
1075
+ hf_token=args.hf_token,
1076
+ )
1077
+
1078
+ run_loop(cfg)
1079
+
1080
+
1081
+ if __name__ == "__main__":
1082
+ main()
hugging/td_fuse/td_weakness.py ADDED
@@ -0,0 +1,791 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TD Weakness Finder v1 — Diagnose What's Wrong
3
+
4
+ Takes BOTH benchmark results (what the model gets wrong) AND weight health
5
+ (what's broken inside) and outputs a structured weakness report.
6
+
7
+ This is Step 2 of the self-improvement loop.
8
+
9
+ Input:
10
+ - Benchmark JSON (from td_benchmark.py)
11
+ - Weight health data (from selfimprove.py health/damage/coherence functions)
12
+
13
+ Output:
14
+ - Structured weakness report with:
15
+ - Answer weaknesses (categories that scored low)
16
+ - Weight weaknesses (layers that are unhealthy)
17
+ - Search keywords for finding datasets
18
+ - Suggested actions (norm repair, LoRA targets, etc.)
19
+ - Data allocation (how many samples per weakness)
20
+ """
21
+
22
+ import json
23
+ import math
24
+ from pathlib import Path
25
+ from typing import Dict, List, Optional, Tuple
26
+ from dataclasses import dataclass, field
27
+
28
+
29
+ # ============================================================
30
+ # DATA STRUCTURES
31
+ # ============================================================
32
+
33
+ @dataclass
34
+ class AnswerWeakness:
35
+ """A weakness found from benchmark testing."""
36
+ category: str
37
+ score: float
38
+ target: float
39
+ gap: float # target - score
40
+ failed_questions: List[str]
41
+ search_keywords: List[str]
42
+ priority: float # higher = more urgent (based on gap size)
43
+
44
+
45
+ @dataclass
46
+ class WeightWeakness:
47
+ """A weakness found from weight health analysis."""
48
+ description: str
49
+ layers: List[int]
50
+ layer_names: List[str]
51
+ severity: str # "critical", "high", "medium", "low"
52
+ action: str # what to do about it
53
+ metrics: Dict # the actual numbers
54
+
55
+
56
+ @dataclass
57
+ class WeaknessReport:
58
+ """Complete weakness report combining both sources."""
59
+ answer_weaknesses: List[AnswerWeakness]
60
+ weight_weaknesses: List[WeightWeakness]
61
+ lora_target_layers: List[int] # which layers to target with LoRA
62
+ norms_to_repair: List[str] # which norms need surgical repair
63
+ data_allocation: Dict[str, int] # category -> num_samples
64
+ total_samples_needed: int
65
+ search_plan: List[Dict] # ordered list of searches to do
66
+
67
+
68
+ # ============================================================
69
+ # CATEGORY SEARCH KEYWORDS
70
+ # ============================================================
71
+
72
+ # For each benchmark category, what to search for when it scores low
73
+ CATEGORY_SEARCH_MAP = {
74
+ "math": {
75
+ "keywords": [
76
+ "math word problems dataset",
77
+ "grade school math GSM8K",
78
+ "arithmetic reasoning dataset",
79
+ "algebra training data",
80
+ "competition math problems",
81
+ "math chain of thought",
82
+ ],
83
+ "hf_tags": ["math", "arithmetic", "algebra", "gsm8k"],
84
+ "description": "Mathematical reasoning and problem solving",
85
+ },
86
+ "code": {
87
+ "keywords": [
88
+ "code generation dataset",
89
+ "python programming problems",
90
+ "code debugging pairs",
91
+ "competitive programming solutions",
92
+ "code review dataset",
93
+ "function implementation tasks",
94
+ ],
95
+ "hf_tags": ["code", "python", "programming", "code-generation"],
96
+ "description": "Code writing, debugging, and understanding",
97
+ },
98
+ "reasoning": {
99
+ "keywords": [
100
+ "chain of thought reasoning dataset",
101
+ "multi-step logic problems",
102
+ "logical reasoning training",
103
+ "common sense reasoning",
104
+ "deductive reasoning dataset",
105
+ "ARC challenge training",
106
+ ],
107
+ "hf_tags": ["reasoning", "logic", "commonsense", "chain-of-thought"],
108
+ "description": "Logic, multi-step deduction, common sense",
109
+ },
110
+ "creativity": {
111
+ "keywords": [
112
+ "creative writing dataset",
113
+ "story generation training",
114
+ "poetry writing pairs",
115
+ "text rewriting dataset",
116
+ "paraphrase generation",
117
+ "creative instruction following",
118
+ ],
119
+ "hf_tags": ["creative-writing", "story", "paraphrase", "text-generation"],
120
+ "description": "Writing, storytelling, creative text generation",
121
+ },
122
+ "knowledge": {
123
+ "keywords": [
124
+ "MMLU training data",
125
+ "science QA dataset",
126
+ "trivia question answer pairs",
127
+ "general knowledge dataset",
128
+ "encyclopedia QA training",
129
+ "fact verification dataset",
130
+ ],
131
+ "hf_tags": ["qa", "knowledge", "science", "trivia", "mmlu"],
132
+ "description": "Science, history, geography, general facts",
133
+ },
134
+ "instruction_following": {
135
+ "keywords": [
136
+ "instruction following dataset",
137
+ "format constraint training",
138
+ "structured output training",
139
+ "instruction tuning data",
140
+ "precise instruction pairs",
141
+ "constraint satisfaction dataset",
142
+ ],
143
+ "hf_tags": ["instruction-following", "instruction-tuning", "chat"],
144
+ "description": "Following instructions precisely and exactly",
145
+ },
146
+ }
147
+
148
+
149
+ # ============================================================
150
+ # SCORE TARGETS (what "good" looks like for each category)
151
+ # ============================================================
152
+
153
+ DEFAULT_TARGETS = {
154
+ "math": 0.80,
155
+ "code": 0.70,
156
+ "reasoning": 0.75,
157
+ "creativity": 0.65,
158
+ "knowledge": 0.80,
159
+ "instruction_following": 0.85,
160
+ }
161
+
162
+
163
+ # ============================================================
164
+ # ANSWER WEAKNESS DETECTION
165
+ # ============================================================
166
+
167
+ def find_answer_weaknesses(
168
+ benchmark_path: str,
169
+ targets: Optional[Dict[str, float]] = None,
170
+ ) -> List[AnswerWeakness]:
171
+ """
172
+ Read benchmark results and find which categories are weak.
173
+
174
+ Args:
175
+ benchmark_path: Path to benchmark JSON output
176
+ targets: Score targets per category (default: DEFAULT_TARGETS)
177
+
178
+ Returns:
179
+ List of AnswerWeakness sorted by priority (worst first)
180
+ """
181
+ targets = targets or DEFAULT_TARGETS
182
+
183
+ # Guard: benchmark file might be missing, empty, or corrupt
184
+ try:
185
+ with open(benchmark_path) as f:
186
+ results = json.load(f)
187
+ except FileNotFoundError:
188
+ print(f" WARNING: Benchmark file not found: {benchmark_path}")
189
+ return []
190
+ except json.JSONDecodeError as e:
191
+ print(f" WARNING: Benchmark file corrupt: {e}")
192
+ return []
193
+ except Exception as e:
194
+ print(f" WARNING: Could not read benchmark file: {e}")
195
+ return []
196
+
197
+ if not isinstance(results, dict) or "categories" not in results:
198
+ print(f" WARNING: Benchmark JSON missing 'categories' key — invalid format")
199
+ return []
200
+
201
+ weaknesses = []
202
+
203
+ for category, data in results.get("categories", {}).items():
204
+ score = data.get("score", 0.0)
205
+ target = targets.get(category, 0.75)
206
+ gap = target - score
207
+
208
+ # Only flag if below target
209
+ if gap <= 0:
210
+ continue
211
+
212
+ # BUG FIX: benchmark JSON uses "failed_questions" not "questions"
213
+ # Each failed question has "question", "response", "expected", "type"
214
+ failed = []
215
+ for q in data.get("failed_questions", []):
216
+ failed.append(q.get("question", "unknown")[:80])
217
+
218
+ # Look up search keywords for this category
219
+ search_info = CATEGORY_SEARCH_MAP.get(category, {})
220
+ keywords = search_info.get("keywords", [f"{category} training dataset"])
221
+
222
+ # Priority is based on how far below target we are
223
+ # Bigger gap = higher priority = more training data
224
+ priority = gap / max(target, 0.01)
225
+
226
+ weaknesses.append(AnswerWeakness(
227
+ category=category,
228
+ score=score,
229
+ target=target,
230
+ gap=gap,
231
+ failed_questions=failed,
232
+ search_keywords=keywords,
233
+ priority=priority,
234
+ ))
235
+
236
+ # Sort by priority (worst gaps first)
237
+ weaknesses.sort(key=lambda w: w.priority, reverse=True)
238
+ return weaknesses
239
+
240
+
241
+ # ============================================================
242
+ # WEIGHT WEAKNESS DETECTION
243
+ # ============================================================
244
+
245
+ def find_weight_weaknesses(
246
+ health_scores: Dict[str, Dict],
247
+ damage_scores: Optional[Dict[str, float]] = None,
248
+ coherence_scores: Optional[Dict[int, float]] = None,
249
+ clogged_norms: Optional[List[str]] = None,
250
+ ) -> List[WeightWeakness]:
251
+ """
252
+ Analyze weight health data and find structural problems.
253
+
254
+ Args:
255
+ health_scores: From selfimprove.compute_health_scores()
256
+ damage_scores: From selfimprove.compute_damage_scores()
257
+ coherence_scores: From selfimprove.compute_coherence_scores()
258
+ clogged_norms: From selfimprove.detect_clogged_norms()
259
+
260
+ Returns:
261
+ List of WeightWeakness sorted by severity
262
+ """
263
+ weaknesses = []
264
+
265
+ # Guard: health_scores might be empty or have unexpected structure
266
+ if not health_scores:
267
+ print(" WARNING: No health_scores provided — skipping weight weakness detection")
268
+ return []
269
+
270
+ # --- 1. Clogged norms (highest priority) ---
271
+ if clogged_norms:
272
+ # Group by layer number
273
+ norm_layers = _extract_layer_numbers(clogged_norms)
274
+
275
+ # Find the kurtosis values (with guards for missing keys)
276
+ kurtosis_vals = {}
277
+ for name in clogged_norms:
278
+ if name in health_scores:
279
+ stats = health_scores[name].get("stats", {})
280
+ if "kurtosis" in stats:
281
+ kurtosis_vals[name] = abs(stats["kurtosis"])
282
+
283
+ avg_kurt = sum(kurtosis_vals.values()) / max(len(kurtosis_vals), 1)
284
+ max_kurt = max(kurtosis_vals.values()) if kurtosis_vals else 0
285
+
286
+ severity = "critical" if max_kurt > 100 else "high" if max_kurt > 50 else "medium"
287
+
288
+ weaknesses.append(WeightWeakness(
289
+ description=f"{len(clogged_norms)} clogged norms (avg kurtosis {avg_kurt:.0f}, max {max_kurt:.0f})",
290
+ layers=sorted(set(norm_layers)),
291
+ layer_names=clogged_norms,
292
+ severity=severity,
293
+ action="surgical_norm_repair",
294
+ metrics={"avg_kurtosis": avg_kurt, "max_kurtosis": max_kurt, "count": len(clogged_norms)},
295
+ ))
296
+
297
+ # --- 2. High condition number layers (numerically unstable) ---
298
+ high_condition = []
299
+ for name, h in health_scores.items():
300
+ stats = h.get("stats", {}) if isinstance(h, dict) else {}
301
+ sv_ratio = stats.get("sv_ratio", 0)
302
+ if sv_ratio > 10000:
303
+ high_condition.append((name, sv_ratio))
304
+
305
+ if high_condition:
306
+ layers = _extract_layer_numbers([n for n, _ in high_condition])
307
+ max_cond = max(v for _, v in high_condition)
308
+
309
+ weaknesses.append(WeightWeakness(
310
+ description=f"{len(high_condition)} layers with extreme condition numbers (max {max_cond:.0f})",
311
+ layers=sorted(set(layers)),
312
+ layer_names=[n for n, _ in high_condition],
313
+ severity="critical" if max_cond > 50000 else "high",
314
+ action="lora_target_high_priority",
315
+ metrics={"max_condition": max_cond, "count": len(high_condition)},
316
+ ))
317
+
318
+ # --- 3. Low rank utilization (layers not using their capacity) ---
319
+ low_rank = []
320
+ for name, h in health_scores.items():
321
+ stats = h.get("stats", {}) if isinstance(h, dict) else {}
322
+ rank_util = stats.get("sv_rank_utilization", -1)
323
+ if 0 < rank_util < 0.1:
324
+ low_rank.append((name, rank_util))
325
+
326
+ if low_rank:
327
+ layers = _extract_layer_numbers([n for n, _ in low_rank])
328
+ avg_util = sum(v for _, v in low_rank) / len(low_rank)
329
+
330
+ weaknesses.append(WeightWeakness(
331
+ description=f"{len(low_rank)} layers with very low rank utilization (avg {avg_util:.1%})",
332
+ layers=sorted(set(layers)),
333
+ layer_names=[n for n, _ in low_rank],
334
+ severity="high",
335
+ action="lora_target_complex_tasks",
336
+ metrics={"avg_rank_util": avg_util, "count": len(low_rank)},
337
+ ))
338
+
339
+ # --- 4. Dead neurons ---
340
+ dead_neuron_layers = []
341
+ for name, h in health_scores.items():
342
+ stats = h.get("stats", {}) if isinstance(h, dict) else {}
343
+ dead = stats.get("near_zero_pct", 0)
344
+ if dead > 0.3:
345
+ dead_neuron_layers.append((name, dead))
346
+
347
+ if dead_neuron_layers:
348
+ layers = _extract_layer_numbers([n for n, _ in dead_neuron_layers])
349
+ avg_dead = sum(v for _, v in dead_neuron_layers) / len(dead_neuron_layers)
350
+
351
+ weaknesses.append(WeightWeakness(
352
+ description=f"{len(dead_neuron_layers)} layers with >30% dead neurons (avg {avg_dead:.1%})",
353
+ layers=sorted(set(layers)),
354
+ layer_names=[n for n, _ in dead_neuron_layers],
355
+ severity="high",
356
+ action="lora_target_activation",
357
+ metrics={"avg_dead_pct": avg_dead, "count": len(dead_neuron_layers)},
358
+ ))
359
+
360
+ # --- 5. Coherence issues (layer-to-layer jumps) ---
361
+ if coherence_scores:
362
+ bad_coherence = [(l, s) for l, s in coherence_scores.items() if s > 0.3]
363
+ if bad_coherence:
364
+ weaknesses.append(WeightWeakness(
365
+ description=f"{len(bad_coherence)} layers with coherence issues",
366
+ layers=sorted([l for l, _ in bad_coherence]),
367
+ layer_names=[],
368
+ severity="medium",
369
+ action="lora_target_smooth_transition",
370
+ metrics={"worst_score": max(s for _, s in bad_coherence)},
371
+ ))
372
+
373
+ # --- 6. Damage vs reference (merge artifacts) ---
374
+ if damage_scores:
375
+ high_damage = [(n, s) for n, s in damage_scores.items() if s > 0.5]
376
+ if high_damage:
377
+ layers = _extract_layer_numbers([n for n, _ in high_damage])
378
+ avg_damage = sum(s for _, s in high_damage) / len(high_damage)
379
+
380
+ weaknesses.append(WeightWeakness(
381
+ description=f"{len(high_damage)} layers with high merge damage (avg {avg_damage:.2f})",
382
+ layers=sorted(set(layers)),
383
+ layer_names=[n for n, _ in high_damage],
384
+ severity="high" if avg_damage > 0.8 else "medium",
385
+ action="lora_target_repair",
386
+ metrics={"avg_damage": avg_damage, "count": len(high_damage)},
387
+ ))
388
+
389
+ # Sort by severity
390
+ severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
391
+ weaknesses.sort(key=lambda w: severity_order.get(w.severity, 99))
392
+
393
+ return weaknesses
394
+
395
+
396
+ def _extract_layer_numbers(names: List[str]) -> List[int]:
397
+ """Pull layer numbers from parameter names like 'model.layers.5.self_attn.q_proj'."""
398
+ layers = []
399
+ for name in names:
400
+ parts = name.split(".")
401
+ for i, part in enumerate(parts):
402
+ if part == "layers" and i + 1 < len(parts) and parts[i + 1].isdigit():
403
+ layers.append(int(parts[i + 1]))
404
+ break
405
+ return layers
406
+
407
+
408
+ # ============================================================
409
+ # COMBINE INTO REPORT
410
+ # ============================================================
411
+
412
+ def build_weakness_report(
413
+ answer_weaknesses: List[AnswerWeakness],
414
+ weight_weaknesses: List[WeightWeakness],
415
+ total_budget: int = 8000,
416
+ ) -> WeaknessReport:
417
+ """
418
+ Combine answer + weight weaknesses into a single action plan.
419
+
420
+ Args:
421
+ answer_weaknesses: From find_answer_weaknesses()
422
+ weight_weaknesses: From find_weight_weaknesses()
423
+ total_budget: Total training samples to allocate
424
+
425
+ Returns:
426
+ WeaknessReport with everything the training loop needs
427
+ """
428
+ # --- Figure out LoRA target layers ---
429
+ lora_layers = set()
430
+ for ww in weight_weaknesses:
431
+ if "lora" in ww.action:
432
+ lora_layers.update(ww.layers)
433
+
434
+ # If no weight issues pointed to specific layers, target all layers
435
+ if not lora_layers:
436
+ lora_layers = set(range(36)) # Qwen3-VL has 36 layers
437
+
438
+ # --- Figure out norms to repair ---
439
+ norms_to_repair = []
440
+ for ww in weight_weaknesses:
441
+ if ww.action == "surgical_norm_repair":
442
+ norms_to_repair.extend(ww.layer_names)
443
+
444
+ # --- Allocate training data ---
445
+ data_allocation = _allocate_data(answer_weaknesses, total_budget)
446
+
447
+ # --- Build search plan ---
448
+ search_plan = _build_search_plan(answer_weaknesses)
449
+
450
+ return WeaknessReport(
451
+ answer_weaknesses=answer_weaknesses,
452
+ weight_weaknesses=weight_weaknesses,
453
+ lora_target_layers=sorted(lora_layers),
454
+ norms_to_repair=norms_to_repair,
455
+ data_allocation=data_allocation,
456
+ total_samples_needed=sum(data_allocation.values()),
457
+ search_plan=search_plan,
458
+ )
459
+
460
+
461
+ def _allocate_data(
462
+ weaknesses: List[AnswerWeakness],
463
+ total_budget: int,
464
+ ) -> Dict[str, int]:
465
+ """
466
+ Allocate training samples proportional to weakness severity.
467
+ Worse categories get more data.
468
+
469
+ IMPORTANT: Also allocates a FLOOR (10% of budget per category) to ALL
470
+ categories, even those above target. This prevents catastrophic forgetting
471
+ where training on weak categories makes strong categories worse.
472
+
473
+ Example:
474
+ math: 42% (gap 38%) → gets ~2x more data (from weakness pool)
475
+ code: 65% (gap 5%) → gets less data (from weakness pool)
476
+ knowledge: 85% (above target) → still gets floor allocation
477
+ """
478
+ ALL_CATEGORIES = ["math", "code", "reasoning", "creativity",
479
+ "knowledge", "instruction_following"]
480
+
481
+ # FLOOR: 10% of budget split equally across ALL categories
482
+ # This prevents catastrophic forgetting on categories above target
483
+ floor_pct = 0.10
484
+ floor_total = int(total_budget * floor_pct)
485
+ floor_per_cat = max(floor_total // len(ALL_CATEGORIES), 50)
486
+
487
+ # Start with floor for every category
488
+ allocation = {cat: floor_per_cat for cat in ALL_CATEGORIES}
489
+
490
+ if not weaknesses:
491
+ return allocation
492
+
493
+ # Remaining budget goes to weak categories proportionally
494
+ weakness_budget = total_budget - (floor_per_cat * len(ALL_CATEGORIES))
495
+ if weakness_budget <= 0:
496
+ return allocation
497
+
498
+ # Weight by gap size (bigger gap = more data)
499
+ total_gap = sum(w.gap for w in weaknesses)
500
+ if total_gap <= 0:
501
+ per_cat = weakness_budget // len(weaknesses)
502
+ for w in weaknesses:
503
+ allocation[w.category] = allocation.get(w.category, 0) + per_cat
504
+ return allocation
505
+
506
+ remaining = weakness_budget
507
+
508
+ for i, w in enumerate(weaknesses):
509
+ if i == len(weaknesses) - 1:
510
+ # Last category gets whatever is left — but never negative
511
+ allocation[w.category] = allocation.get(w.category, 0) + max(remaining, 0)
512
+ else:
513
+ share = int(weakness_budget * (w.gap / total_gap))
514
+ share = min(max(share, 500), remaining) # Don't exceed remaining budget
515
+ allocation[w.category] = allocation.get(w.category, 0) + share
516
+ remaining -= share
517
+
518
+ # Make sure we don't exceed budget
519
+ total_alloc = sum(allocation.values())
520
+ if total_alloc > total_budget:
521
+ scale = total_budget / total_alloc
522
+ allocation = {k: max(int(v * scale), 50) for k, v in allocation.items()}
523
+
524
+ return allocation
525
+
526
+
527
+ def _build_search_plan(weaknesses: List[AnswerWeakness]) -> List[Dict]:
528
+ """
529
+ Build an ordered list of dataset searches to perform.
530
+ Each entry has: category, keywords, hf_tags, priority, num_samples_needed.
531
+
532
+ Includes ALL categories (even above target) because the data allocation
533
+ now has a floor for every category to prevent catastrophic forgetting.
534
+ Weak categories come first (higher priority).
535
+ """
536
+ ALL_CATEGORIES = ["math", "code", "reasoning", "creativity",
537
+ "knowledge", "instruction_following"]
538
+
539
+ plan = []
540
+ weak_cats = set()
541
+
542
+ # First: weak categories (high priority, targeted search)
543
+ for w in weaknesses:
544
+ search_info = CATEGORY_SEARCH_MAP.get(w.category, {})
545
+ plan.append({
546
+ "category": w.category,
547
+ "keywords": w.search_keywords,
548
+ "hf_tags": search_info.get("hf_tags", []),
549
+ "priority": w.priority,
550
+ "score": w.score,
551
+ "target": w.target,
552
+ "gap": w.gap,
553
+ })
554
+ weak_cats.add(w.category)
555
+
556
+ # Then: strong categories (low priority, just use fallback datasets)
557
+ for cat in ALL_CATEGORIES:
558
+ if cat not in weak_cats:
559
+ search_info = CATEGORY_SEARCH_MAP.get(cat, {})
560
+ plan.append({
561
+ "category": cat,
562
+ "keywords": search_info.get("keywords", [f"{cat} training dataset"]),
563
+ "hf_tags": search_info.get("hf_tags", []),
564
+ "priority": 0.1, # Low priority — just floor allocation
565
+ "score": 1.0, # Above target
566
+ "target": DEFAULT_TARGETS.get(cat, 0.75),
567
+ "gap": 0.0,
568
+ })
569
+
570
+ return plan
571
+
572
+
573
+ # ============================================================
574
+ # PRETTY PRINT
575
+ # ============================================================
576
+
577
+ def print_report(report: WeaknessReport):
578
+ """Print a human-readable weakness report."""
579
+ print("\n" + "=" * 60)
580
+ print("TD WEAKNESS REPORT")
581
+ print("=" * 60)
582
+
583
+ # Answer weaknesses
584
+ if report.answer_weaknesses:
585
+ print("\n ANSWER WEAKNESSES (what it gets wrong):")
586
+ print(" " + "-" * 50)
587
+ for w in report.answer_weaknesses:
588
+ status = "CRITICAL" if w.gap > 0.3 else "HIGH" if w.gap > 0.15 else "MEDIUM"
589
+ print(f" [{status}] {w.category}: {w.score:.0%} (target: {w.target:.0%}, gap: {w.gap:.0%})")
590
+ print(f" Failed: {len(w.failed_questions)} questions")
591
+ print(f" Search: {', '.join(w.search_keywords[:3])}")
592
+ else:
593
+ print("\n ANSWER WEAKNESSES: None! All categories at or above target.")
594
+
595
+ # Weight weaknesses
596
+ if report.weight_weaknesses:
597
+ print(f"\n WEIGHT WEAKNESSES (what's broken inside):")
598
+ print(" " + "-" * 50)
599
+ for w in report.weight_weaknesses:
600
+ print(f" [{w.severity.upper()}] {w.description}")
601
+ if w.layers:
602
+ print(f" Layers: {w.layers}")
603
+ print(f" Action: {w.action}")
604
+ else:
605
+ print("\n WEIGHT WEAKNESSES: None! All layers healthy.")
606
+
607
+ # Data allocation
608
+ if report.data_allocation:
609
+ print(f"\n DATA ALLOCATION ({report.total_samples_needed} total samples):")
610
+ print(" " + "-" * 50)
611
+ for cat, num in sorted(report.data_allocation.items(), key=lambda x: -x[1]):
612
+ pct = num / max(report.total_samples_needed, 1)
613
+ bar = "█" * int(pct * 30)
614
+ print(f" {cat:25s} {num:5d} samples ({pct:5.1%}) {bar}")
615
+
616
+ # LoRA targets
617
+ if report.lora_target_layers:
618
+ print(f"\n LORA TARGETS: {len(report.lora_target_layers)} layers")
619
+ print(f" Layers: {report.lora_target_layers}")
620
+
621
+ # Norm repair
622
+ if report.norms_to_repair:
623
+ print(f"\n NORMS TO REPAIR: {len(report.norms_to_repair)} parameters")
624
+ for n in report.norms_to_repair[:10]: # Show first 10
625
+ print(f" - {n}")
626
+ if len(report.norms_to_repair) > 10:
627
+ print(f" ... and {len(report.norms_to_repair) - 10} more")
628
+
629
+ # Search plan
630
+ if report.search_plan:
631
+ print(f"\n SEARCH PLAN ({len(report.search_plan)} categories to search):")
632
+ print(" " + "-" * 50)
633
+ for i, s in enumerate(report.search_plan, 1):
634
+ print(f" {i}. {s['category']} (score: {s['score']:.0%}, need: {s['gap']:.0%} improvement)")
635
+ print(f" Keywords: {', '.join(s['keywords'][:2])}")
636
+
637
+ print("\n" + "=" * 60)
638
+
639
+
640
+ # ============================================================
641
+ # SAVE / LOAD
642
+ # ============================================================
643
+
644
+ def save_report(report: WeaknessReport, path: str):
645
+ """Save weakness report to JSON."""
646
+ data = {
647
+ "answer_weaknesses": [
648
+ {
649
+ "category": w.category,
650
+ "score": w.score,
651
+ "target": w.target,
652
+ "gap": w.gap,
653
+ "failed_questions": w.failed_questions,
654
+ "search_keywords": w.search_keywords,
655
+ "priority": w.priority,
656
+ }
657
+ for w in report.answer_weaknesses
658
+ ],
659
+ "weight_weaknesses": [
660
+ {
661
+ "description": w.description,
662
+ "layers": w.layers,
663
+ "layer_names": w.layer_names,
664
+ "severity": w.severity,
665
+ "action": w.action,
666
+ "metrics": w.metrics,
667
+ }
668
+ for w in report.weight_weaknesses
669
+ ],
670
+ "lora_target_layers": report.lora_target_layers,
671
+ "norms_to_repair": report.norms_to_repair,
672
+ "data_allocation": report.data_allocation,
673
+ "total_samples_needed": report.total_samples_needed,
674
+ "search_plan": report.search_plan,
675
+ }
676
+
677
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
678
+ with open(path, "w") as f:
679
+ json.dump(data, f, indent=2)
680
+ print(f"\nWeakness report saved to {path}")
681
+
682
+
683
+ def load_report(path: str) -> WeaknessReport:
684
+ """Load weakness report from JSON."""
685
+ with open(path) as f:
686
+ data = json.load(f)
687
+
688
+ answer_weaknesses = [
689
+ AnswerWeakness(**aw) for aw in data["answer_weaknesses"]
690
+ ]
691
+ weight_weaknesses = [
692
+ WeightWeakness(**ww) for ww in data["weight_weaknesses"]
693
+ ]
694
+
695
+ return WeaknessReport(
696
+ answer_weaknesses=answer_weaknesses,
697
+ weight_weaknesses=weight_weaknesses,
698
+ lora_target_layers=data["lora_target_layers"],
699
+ norms_to_repair=data["norms_to_repair"],
700
+ data_allocation=data["data_allocation"],
701
+ total_samples_needed=data["total_samples_needed"],
702
+ search_plan=data["search_plan"],
703
+ )
704
+
705
+
706
+ # ============================================================
707
+ # MAIN — run from command line
708
+ # ============================================================
709
+
710
+ def run_weakness_analysis(
711
+ benchmark_path: str,
712
+ health_scores: Optional[Dict] = None,
713
+ damage_scores: Optional[Dict] = None,
714
+ coherence_scores: Optional[Dict] = None,
715
+ clogged_norms: Optional[List] = None,
716
+ targets: Optional[Dict[str, float]] = None,
717
+ total_budget: int = 8000,
718
+ output_path: Optional[str] = None,
719
+ ) -> WeaknessReport:
720
+ """
721
+ Run the full weakness analysis pipeline.
722
+
723
+ This is the main entry point that td_loop.py calls.
724
+
725
+ Args:
726
+ benchmark_path: Path to benchmark results JSON
727
+ health_scores: From selfimprove.compute_health_scores()
728
+ damage_scores: From selfimprove.compute_damage_scores()
729
+ coherence_scores: From selfimprove.compute_coherence_scores()
730
+ clogged_norms: From selfimprove.detect_clogged_norms()
731
+ targets: Score targets per category
732
+ total_budget: Total training samples to allocate
733
+ output_path: Where to save the report JSON
734
+
735
+ Returns:
736
+ Complete WeaknessReport
737
+ """
738
+ print("\n" + "=" * 60)
739
+ print("TD WEAKNESS FINDER v1")
740
+ print("=" * 60)
741
+
742
+ # Step 1: Find answer weaknesses
743
+ print("\nAnalyzing benchmark results...")
744
+ answer_weaknesses = find_answer_weaknesses(benchmark_path, targets)
745
+ print(f" Found {len(answer_weaknesses)} answer weaknesses")
746
+
747
+ # Step 2: Find weight weaknesses
748
+ print("\nAnalyzing weight health...")
749
+ weight_weaknesses = []
750
+ if health_scores:
751
+ weight_weaknesses = find_weight_weaknesses(
752
+ health_scores=health_scores,
753
+ damage_scores=damage_scores,
754
+ coherence_scores=coherence_scores,
755
+ clogged_norms=clogged_norms,
756
+ )
757
+ print(f" Found {len(weight_weaknesses)} weight weaknesses")
758
+ else:
759
+ print(" No weight health data provided — skipping weight analysis")
760
+
761
+ # Step 3: Build combined report
762
+ print("\nBuilding combined report...")
763
+ report = build_weakness_report(answer_weaknesses, weight_weaknesses, total_budget)
764
+
765
+ # Step 4: Print it
766
+ print_report(report)
767
+
768
+ # Step 5: Save if requested
769
+ if output_path:
770
+ save_report(report, output_path)
771
+
772
+ return report
773
+
774
+
775
+ if __name__ == "__main__":
776
+ import sys
777
+
778
+ if len(sys.argv) < 2:
779
+ print("Usage: python td_weakness.py <benchmark_results.json> [output_path.json]")
780
+ print("\nThis reads benchmark results and outputs a weakness report.")
781
+ print("Weight health data is passed programmatically from td_loop.py.")
782
+ sys.exit(1)
783
+
784
+ benchmark_path = sys.argv[1]
785
+ output_path = sys.argv[2] if len(sys.argv) > 2 else None
786
+
787
+ # When run standalone, only uses benchmark data (no weight health)
788
+ report = run_weakness_analysis(
789
+ benchmark_path=benchmark_path,
790
+ output_path=output_path,
791
+ )