AbstractPhil commited on
Commit
c5e6ce6
Β·
verified Β·
1 Parent(s): 9f3395f

Create 13_projective_rehaul_probe_battery_testing.py

Browse files
13_projective_rehaul_probe_battery_testing.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ implicit_solver/A2_projective_reprobe_h2_64_singles.py
3
+ =======================================================
4
+
5
+ Apply the A0/A1 projective probe to all 16 single-noise h2-64 batteries
6
+ (indices 0-15, phase='final'). Tests whether the projective-axis
7
+ interpretation holds across:
8
+ - 16 different training distributions (one per noise type)
9
+ - Full 10-epoch convergence (not just 1000 batches)
10
+ - Production-grade sphere-solver batteries
11
+
12
+ For each battery:
13
+ 1. Collect M tensor from gaussian test inputs (512 samples)
14
+ 2. Identify antipodal pairs (mutual-strongest, cos < -0.9)
15
+ 3. Collapse to projective axes
16
+ 4. Run projective probe metrics:
17
+ - mean pairwise angle on ℝPΒ³
18
+ - deviation from uniform ℝPΒ³ baseline
19
+ - cluster silhouette (structure above uniform?)
20
+ - effective rank (dimension utilization)
21
+ - secondary antipodal count (further collapse?)
22
+
23
+ Expected if projective-reading hypothesis holds:
24
+ - All 16 batteries: |deviation| < 0.05
25
+ - All 16 batteries: effective rank 3.9+ of 4
26
+ - Axis count varies per battery (noise-type-dependent codebook size)
27
+ - Cluster silhouette low across all (no residual structure)
28
+
29
+ Output
30
+ ------
31
+ /content/implicit_solver_reports/A2_projective_h2_64_singles.json
32
+ /content/implicit_solver_reports/A2_projective_h2_64_singles.png
33
+ """
34
+
35
+ import json
36
+ import math
37
+ from pathlib import Path
38
+
39
+ import numpy as np
40
+ import torch
41
+ import matplotlib.pyplot as plt
42
+ from sklearn.cluster import KMeans
43
+ from sklearn.metrics import silhouette_score
44
+
45
+
46
+ OUTPUT_DIR = Path("/content/implicit_solver_reports")
47
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
48
+ OUTPUT_PLOT = OUTPUT_DIR / "A2_projective_h2_64_singles.png"
49
+ OUTPUT_JSON = OUTPUT_DIR / "A2_projective_h2_64_singles.json"
50
+
51
+ NOISE_TYPE_NAMES = [
52
+ 'gaussian', 'uniform', 'uniform_scaled', 'block',
53
+ 'gradient', 'checker', 'salt_pepper', 'cauchy',
54
+ 'laplace', 'periodic', 'exponential', 'mixed',
55
+ 'poisson', 'structural', 'rayleigh', 'lognormal',
56
+ ]
57
+
58
+
59
+ # ════════════════════════════════════════════════════════════════════
60
+ # Loading
61
+ # ════════════════════════════════════════════════════════════════════
62
+
63
+ def load_h2_64_array():
64
+ """Use `loaded` from globals if available, else fetch from HF."""
65
+ array_model = globals().get('loaded')
66
+ if array_model is None:
67
+ import geolip_svae.arrays # noqa β€” registers BatteryArrayConfig
68
+ from transformers import AutoModel
69
+ print(" `loaded` not in globals, fetching h2-64 from HF...")
70
+ array_model = AutoModel.from_pretrained(
71
+ "AbstractPhil/geolip-svae-h2-64")
72
+ else:
73
+ print(" Using `loaded` from global session")
74
+ return array_model
75
+
76
+
77
+ def collect_M_from_bank(bank, img_size=64, n_batches=8, batch_size=64):
78
+ """Collect per-sample M from one battery bank on gaussian test input."""
79
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
80
+ bank = bank.to(device)
81
+ ds = OmegaNoiseDataset(
82
+ size=n_batches * batch_size, img_size=img_size, allowed_types=[0])
83
+ loader = torch.utils.data.DataLoader(ds, batch_size=batch_size, shuffle=False)
84
+
85
+ all_M = []
86
+ with torch.no_grad():
87
+ for imgs, _ in loader:
88
+ imgs = imgs.to(device)
89
+ out = bank(imgs)
90
+ M_patch0 = out['svd']['M'][:, 0] # [B, V, D]
91
+ all_M.append(M_patch0.cpu())
92
+ return torch.cat(all_M, dim=0).numpy()
93
+
94
+
95
+ # ════════════════════════════════════════════════════════════════════
96
+ # Projective probe (carry from A0/A1)
97
+ # ════════════════════════════════════════════════════════════════════
98
+
99
+ def identify_antipodal_pairs(M_avg, threshold=-0.9):
100
+ norms = np.linalg.norm(M_avg, axis=1, keepdims=True)
101
+ unit = M_avg / np.clip(norms, 1e-12, None)
102
+ cosines = unit @ unit.T
103
+ np.fill_diagonal(cosines, 1.0)
104
+
105
+ V = M_avg.shape[0]
106
+ claimed = [False] * V
107
+ pairs = []
108
+ candidates = []
109
+ for i in range(V):
110
+ best_j = int(cosines[i].argmin())
111
+ best_cos = float(cosines[i, best_j])
112
+ if best_cos < threshold:
113
+ candidates.append((best_cos, i, best_j))
114
+ candidates.sort()
115
+ for cos_val, i, j in candidates:
116
+ if claimed[i] or claimed[j]:
117
+ continue
118
+ if cosines[j].argmin() == i or cosines[j, i] < threshold:
119
+ pairs.append((min(i, j), max(i, j)))
120
+ claimed[i] = True
121
+ claimed[j] = True
122
+ unpaired = [i for i in range(V) if not claimed[i]]
123
+ return pairs, unpaired
124
+
125
+
126
+ def collapse_to_axes(M_avg, pairs, unpaired):
127
+ norms = np.linalg.norm(M_avg, axis=1, keepdims=True)
128
+ unit = M_avg / np.clip(norms, 1e-12, None)
129
+ reps = []
130
+ for i, j in pairs:
131
+ merged = unit[i] - unit[j]
132
+ merged = merged / max(np.linalg.norm(merged), 1e-12)
133
+ for k in range(merged.shape[0]):
134
+ if abs(merged[k]) > 1e-6:
135
+ if merged[k] < 0:
136
+ merged = -merged
137
+ break
138
+ reps.append(merged)
139
+ for i in unpaired:
140
+ r = unit[i].copy()
141
+ for k in range(r.shape[0]):
142
+ if abs(r[k]) > 1e-6:
143
+ if r[k] < 0:
144
+ r = -r
145
+ break
146
+ reps.append(r)
147
+ return np.array(reps)
148
+
149
+
150
+ def projective_pairwise_angles(axes):
151
+ n = axes.shape[0]
152
+ cosines = np.clip(axes @ axes.T, -1, 1)
153
+ raw = np.arccos(cosines)
154
+ proj = np.minimum(raw, np.pi - raw)
155
+ return proj[np.triu_indices(n, k=1)]
156
+
157
+
158
+ def uniform_rp_baseline(D, n_axes, n_trials=10):
159
+ rng = np.random.RandomState(0)
160
+ means = []
161
+ for _ in range(n_trials):
162
+ x = rng.randn(n_axes, D)
163
+ x = x / np.linalg.norm(x, axis=1, keepdims=True)
164
+ for k in range(D):
165
+ mask = (x[:, k] != 0) & (
166
+ np.all(x[:, :k] == 0, axis=1) if k > 0
167
+ else np.ones(n_axes, dtype=bool))
168
+ x[mask] = x[mask] * np.sign(x[mask, k:k+1])
169
+ if not np.any(mask):
170
+ break
171
+ means.append(projective_pairwise_angles(x).mean())
172
+ return float(np.mean(means))
173
+
174
+
175
+ def probe_battery(M_avg):
176
+ pairs, unpaired = identify_antipodal_pairs(M_avg, threshold=-0.9)
177
+ axes = collapse_to_axes(M_avg, pairs, unpaired)
178
+ D = axes.shape[1]
179
+ n = axes.shape[0]
180
+
181
+ proj_angles = projective_pairwise_angles(axes)
182
+ baseline = uniform_rp_baseline(D, n)
183
+ deviation = float(proj_angles.mean() - baseline)
184
+
185
+ # Cluster silhouette
186
+ sils = []
187
+ for k in range(2, min(8, n)):
188
+ try:
189
+ km = KMeans(n_clusters=k, n_init=5, random_state=42)
190
+ labels = km.fit_predict(axes)
191
+ if len(set(labels)) >= 2:
192
+ sils.append((k, silhouette_score(axes, labels)))
193
+ except Exception:
194
+ pass
195
+ best_k, best_sil = (max(sils, key=lambda x: x[1])
196
+ if sils else (None, None))
197
+
198
+ # Effective rank
199
+ sv = np.linalg.svd(axes, compute_uv=False)
200
+ sv_norm = sv / sv.sum()
201
+ erank = math.exp(-(sv_norm * np.log(sv_norm + 1e-12)).sum())
202
+
203
+ # Secondary antipodal
204
+ cos_axes = axes @ axes.T
205
+ np.fill_diagonal(cos_axes, 1.0)
206
+ secondary = (cos_axes.min(axis=1) < -0.9).sum() // 2
207
+
208
+ return {
209
+ 'pairs': len(pairs),
210
+ 'unpaired': len(unpaired),
211
+ 'n_axes': n,
212
+ 'proj_angle_mean': float(proj_angles.mean()),
213
+ 'uniform_baseline': baseline,
214
+ 'deviation': deviation,
215
+ 'best_cluster_k': best_k,
216
+ 'best_silhouette': float(best_sil) if best_sil else None,
217
+ 'effective_rank': float(erank),
218
+ 'utilization': float(erank / D),
219
+ 'secondary_antipodal': int(secondary),
220
+ 'D': int(D),
221
+ 'proj_angles_subset': proj_angles[:100].tolist(),
222
+ }
223
+
224
+
225
+ def classify_projective_fit(probe):
226
+ """Is this battery well-described by projective-axis reading?"""
227
+ uniform = abs(probe['deviation']) < 0.05
228
+ full_rank = probe['utilization'] > 0.95
229
+ no_clusters = (probe['best_silhouette'] or 0) < 0.4
230
+ low_secondary = probe['secondary_antipodal'] <= 3
231
+
232
+ if uniform and full_rank and no_clusters and low_secondary:
233
+ return 'PROJECTIVE-CLEAN'
234
+ elif uniform and full_rank:
235
+ return 'PROJECTIVE-MOSTLY'
236
+ elif full_rank:
237
+ return 'STRUCTURED'
238
+ else:
239
+ return 'DEGENERATE'
240
+
241
+
242
+ # ════════════════════════════════════════════════════════════════════
243
+ # Main
244
+ # ════════════════════════════════════════════════════════════════════
245
+
246
+ def main():
247
+ print("=" * 70)
248
+ print("A2 β€” projective re-probe of h2-64 single-noise batteries 0-15")
249
+ print("Tests whether projective-axis reading holds across training")
250
+ print("distributions (16 noise types, all 10-epoch converged)")
251
+ print("=" * 70)
252
+
253
+ print("\nLoading h2-64 array...")
254
+ array_model = load_h2_64_array()
255
+
256
+ print("\nProbing each single-noise battery on gaussian inputs:\n")
257
+ print(f" {'Idx':>3} {'Noise type':<18} {'Pairs':>5} {'Axes':>5} "
258
+ f"{'Dev':>8} {'Sil':>6} {'Erank':>5} {'2Β°':>3} Verdict")
259
+ print(" " + "-" * 85)
260
+
261
+ results = []
262
+ for batt_idx in range(16):
263
+ cfg_dict = array_model.config.batteries[batt_idx]
264
+ noise_name = NOISE_TYPE_NAMES[batt_idx]
265
+ assert cfg_dict.get('noise_types') == [batt_idx], \
266
+ f"Expected battery {batt_idx} to be single noise {batt_idx}, " \
267
+ f"got {cfg_dict.get('noise_types')}"
268
+
269
+ bank = array_model.bank(batt_idx, 'final')
270
+ bank.eval()
271
+
272
+ try:
273
+ all_M = collect_M_from_bank(bank)
274
+ M_avg = all_M.mean(axis=0)
275
+ probe = probe_battery(M_avg)
276
+ probe['battery_idx'] = batt_idx
277
+ probe['noise_name'] = noise_name
278
+ probe['verdict'] = classify_projective_fit(probe)
279
+
280
+ print(f" {batt_idx:>3} {noise_name:<18} "
281
+ f"{probe['pairs']:>5} {probe['n_axes']:>5} "
282
+ f"{probe['deviation']:>+.3f} "
283
+ f"{probe['best_silhouette'] or 0:>6.3f} "
284
+ f"{probe['effective_rank']:>5.2f} "
285
+ f"{probe['secondary_antipodal']:>3} {probe['verdict']}")
286
+
287
+ except Exception as e:
288
+ print(f" {batt_idx:>3} {noise_name:<18} ERROR: "
289
+ f"{type(e).__name__}: {str(e)[:40]}")
290
+ probe = {'battery_idx': batt_idx, 'noise_name': noise_name,
291
+ 'error': str(e)}
292
+
293
+ results.append(probe)
294
+
295
+ # ════════════════════════════════════════════════════════════════
296
+ # Aggregate summary
297
+ # ════════════════════════════════════════════════════════════════
298
+
299
+ ok_results = [r for r in results if 'error' not in r]
300
+
301
+ print("\n" + "=" * 70)
302
+ print("AGGREGATE RESULTS")
303
+ print("=" * 70)
304
+
305
+ verdicts = {}
306
+ for r in ok_results:
307
+ verdicts[r['verdict']] = verdicts.get(r['verdict'], 0) + 1
308
+
309
+ print("\nVerdict distribution:")
310
+ for v, n in sorted(verdicts.items(), key=lambda x: -x[1]):
311
+ print(f" {v}: {n}/{len(ok_results)}")
312
+
313
+ # Axis count statistics
314
+ axis_counts = [r['n_axes'] for r in ok_results]
315
+ pairs_counts = [r['pairs'] for r in ok_results]
316
+ deviations = [r['deviation'] for r in ok_results]
317
+ silhouettes = [r['best_silhouette'] or 0 for r in ok_results]
318
+ eranks = [r['effective_rank'] for r in ok_results]
319
+
320
+ print(f"\nAxis count across 16 batteries:")
321
+ print(f" min: {min(axis_counts)}, max: {max(axis_counts)}, "
322
+ f"mean: {np.mean(axis_counts):.1f}, std: {np.std(axis_counts):.1f}")
323
+ print(f"\nAntipodal pairs across 16 batteries:")
324
+ print(f" min: {min(pairs_counts)}, max: {max(pairs_counts)}, "
325
+ f"mean: {np.mean(pairs_counts):.1f}, std: {np.std(pairs_counts):.1f}")
326
+ print(f"\nDeviation from uniform ℝPΒ³:")
327
+ print(f" min: {min(deviations):+.4f}, max: {max(deviations):+.4f}, "
328
+ f"mean: {np.mean(deviations):+.4f}, std: {np.std(deviations):.4f}")
329
+ print(f"\nCluster silhouette:")
330
+ print(f" min: {min(silhouettes):.3f}, max: {max(silhouettes):.3f}, "
331
+ f"mean: {np.mean(silhouettes):.3f}")
332
+ print(f"\nEffective rank (max 4.0):")
333
+ print(f" min: {min(eranks):.3f}, max: {max(eranks):.3f}, "
334
+ f"mean: {np.mean(eranks):.3f}")
335
+
336
+ # ════════════════════════════════════════════════════════════════
337
+ # Save JSON
338
+ # ════════════════════════════════════════════════════════════════
339
+
340
+ with open(OUTPUT_JSON, 'w') as f:
341
+ json.dump({
342
+ 'results_per_battery': results,
343
+ 'aggregate': {
344
+ 'n_batteries': len(ok_results),
345
+ 'verdict_counts': verdicts,
346
+ 'axis_count_stats': {
347
+ 'min': int(min(axis_counts)),
348
+ 'max': int(max(axis_counts)),
349
+ 'mean': float(np.mean(axis_counts)),
350
+ 'std': float(np.std(axis_counts)),
351
+ },
352
+ 'deviation_stats': {
353
+ 'min': float(min(deviations)),
354
+ 'max': float(max(deviations)),
355
+ 'mean': float(np.mean(deviations)),
356
+ 'std': float(np.std(deviations)),
357
+ },
358
+ 'silhouette_stats': {
359
+ 'min': float(min(silhouettes)),
360
+ 'max': float(max(silhouettes)),
361
+ 'mean': float(np.mean(silhouettes)),
362
+ },
363
+ 'erank_stats': {
364
+ 'min': float(min(eranks)),
365
+ 'max': float(max(eranks)),
366
+ 'mean': float(np.mean(eranks)),
367
+ },
368
+ },
369
+ }, f, indent=2, default=str)
370
+ print(f"\nSaved: {OUTPUT_JSON}")
371
+
372
+ # ══════════════════════════════════════════════════���═════════════
373
+ # Plot: 6 panels summarizing the cross-battery picture
374
+ # ════════════════════════════════════════════════════════════════
375
+
376
+ fig = plt.figure(figsize=(18, 12))
377
+
378
+ # Panel 1: per-battery deviation from uniform
379
+ ax1 = fig.add_subplot(2, 3, 1)
380
+ x = np.arange(len(ok_results))
381
+ devs = [r['deviation'] for r in ok_results]
382
+ colors = ['green' if abs(d) < 0.05 else 'orange' if abs(d) < 0.1 else 'red'
383
+ for d in devs]
384
+ ax1.bar(x, devs, color=colors)
385
+ ax1.axhline(0.05, color='red', linestyle='--', alpha=0.5,
386
+ label='Β±0.05 threshold')
387
+ ax1.axhline(-0.05, color='red', linestyle='--', alpha=0.5)
388
+ ax1.axhline(0, color='black', linestyle='-', alpha=0.3)
389
+ ax1.set_xticks(x)
390
+ ax1.set_xticklabels([r['noise_name'][:6] for r in ok_results],
391
+ rotation=60, ha='right', fontsize=8)
392
+ ax1.set_ylabel('Deviation from uniform ℝPΒ³')
393
+ ax1.set_title('Projective uniformity per battery')
394
+ ax1.legend(fontsize=8)
395
+ ax1.grid(alpha=0.3, axis='y')
396
+
397
+ # Panel 2: axis count per battery
398
+ ax2 = fig.add_subplot(2, 3, 2)
399
+ axes_n = [r['n_axes'] for r in ok_results]
400
+ pairs_n = [r['pairs'] for r in ok_results]
401
+ ax2.bar(x, axes_n, color='steelblue', label='Total axes')
402
+ ax2.bar(x, pairs_n, color='darkorange', label='Antipodal pairs collapsed')
403
+ ax2.set_xticks(x)
404
+ ax2.set_xticklabels([r['noise_name'][:6] for r in ok_results],
405
+ rotation=60, ha='right', fontsize=8)
406
+ ax2.set_ylabel('Count')
407
+ ax2.set_title('Axis codebook size per battery\n'
408
+ '(V=32 rows β†’ N axes after collapse)')
409
+ ax2.legend(fontsize=8)
410
+ ax2.grid(alpha=0.3, axis='y')
411
+
412
+ # Panel 3: cluster silhouette
413
+ ax3 = fig.add_subplot(2, 3, 3)
414
+ sils = [r['best_silhouette'] or 0 for r in ok_results]
415
+ colors = ['green' if s < 0.4 else 'orange' if s < 0.5 else 'red' for s in sils]
416
+ ax3.bar(x, sils, color=colors)
417
+ ax3.axhline(0.4, color='orange', linestyle='--', alpha=0.5,
418
+ label='weak structure')
419
+ ax3.axhline(0.5, color='red', linestyle='--', alpha=0.5,
420
+ label='strong structure')
421
+ ax3.set_xticks(x)
422
+ ax3.set_xticklabels([r['noise_name'][:6] for r in ok_results],
423
+ rotation=60, ha='right', fontsize=8)
424
+ ax3.set_ylabel('Best cluster silhouette')
425
+ ax3.set_title('Residual structure on ℝPΒ³\n(low = clean projective)')
426
+ ax3.legend(fontsize=8)
427
+ ax3.grid(alpha=0.3, axis='y')
428
+
429
+ # Panel 4: effective rank
430
+ ax4 = fig.add_subplot(2, 3, 4)
431
+ eranks_arr = [r['effective_rank'] for r in ok_results]
432
+ ax4.bar(x, eranks_arr, color='purple')
433
+ ax4.axhline(4.0, color='green', linestyle='--', alpha=0.5,
434
+ label='max (full rank 4)')
435
+ ax4.axhline(3.8, color='orange', linestyle='--', alpha=0.5,
436
+ label='0.95 Γ— max')
437
+ ax4.set_xticks(x)
438
+ ax4.set_xticklabels([r['noise_name'][:6] for r in ok_results],
439
+ rotation=60, ha='right', fontsize=8)
440
+ ax4.set_ylabel('Effective rank')
441
+ ax4.set_title('Dimension utilization on ℝPΒ³')
442
+ ax4.set_ylim([3.0, 4.05])
443
+ ax4.legend(fontsize=8)
444
+ ax4.grid(alpha=0.3, axis='y')
445
+
446
+ # Panel 5: aggregate angle distribution
447
+ ax5 = fig.add_subplot(2, 3, 5)
448
+ all_angles = []
449
+ for r in ok_results:
450
+ all_angles.extend(r['proj_angles_subset'])
451
+ ax5.hist(all_angles, bins=40, density=True, alpha=0.7, color='steelblue')
452
+
453
+ # Empirical uniform baseline for ℝPΒ³
454
+ avg_baseline = np.mean([r['uniform_baseline'] for r in ok_results])
455
+ ax5.axvline(avg_baseline, color='red', linestyle='--',
456
+ label=f'uniform ℝPΒ³ baseline ({avg_baseline:.3f})')
457
+ ax5.set_xlabel('Projective pairwise angle (radians)')
458
+ ax5.set_ylabel('Density')
459
+ ax5.set_title(f'Aggregate angle distribution\n'
460
+ f'(all 16 batteries pooled)')
461
+ ax5.legend(fontsize=8)
462
+
463
+ # Panel 6: verdict summary text
464
+ ax6 = fig.add_subplot(2, 3, 6)
465
+ ax6.axis('off')
466
+
467
+ n_clean = verdicts.get('PROJECTIVE-CLEAN', 0)
468
+ n_mostly = verdicts.get('PROJECTIVE-MOSTLY', 0)
469
+ n_struct = verdicts.get('STRUCTURED', 0)
470
+ n_degen = verdicts.get('DEGENERATE', 0)
471
+ total = len(ok_results)
472
+
473
+ clean_frac = (n_clean + n_mostly) / max(total, 1)
474
+
475
+ if clean_frac >= 0.9:
476
+ headline = "βœ“ HYPOTHESIS SUPPORTED"
477
+ color = 'lightgreen'
478
+ elif clean_frac >= 0.7:
479
+ headline = "~ MOSTLY SUPPORTED"
480
+ color = 'palegreen'
481
+ elif clean_frac >= 0.5:
482
+ headline = "~ MIXED"
483
+ color = 'lightyellow'
484
+ else:
485
+ headline = "βœ— HYPOTHESIS NOT SUPPORTED"
486
+ color = 'mistyrose'
487
+
488
+ summary_text = (
489
+ f"16 single-noise batteries probed.\n"
490
+ f"All h2-64 architecture (V=32, D=4, H2_linear_matched).\n"
491
+ f"All 10-epoch fully converged.\n\n"
492
+ f"PROJECTIVE-CLEAN: {n_clean}/{total}\n"
493
+ f"PROJECTIVE-MOSTLY: {n_mostly}/{total}\n"
494
+ f"STRUCTURED: {n_struct}/{total}\n"
495
+ f"DEGENERATE: {n_degen}/{total}\n\n"
496
+ f"Axis count range: {min(axis_counts)}-{max(axis_counts)}\n"
497
+ f"Mean deviation: {np.mean(deviations):+.4f}\n"
498
+ f"Mean silhouette: {np.mean(silhouettes):.3f}\n"
499
+ f"Mean effective rank: {np.mean(eranks):.2f} / 4\n\n"
500
+ f"Interpretation:\n"
501
+ )
502
+
503
+ if clean_frac >= 0.9:
504
+ summary_text += (
505
+ "Projective-axis reading is GENERAL β€” holds across\n"
506
+ "16 different training distributions at full convergence.\n"
507
+ "h2-64 is a library of per-noise-type axis codebooks."
508
+ )
509
+ elif clean_frac >= 0.7:
510
+ summary_text += (
511
+ "Most batteries fit the projective reading.\n"
512
+ "Outliers suggest noise-type-specific geometry variations.\n"
513
+ "Worth investigating which noise types deviate and why."
514
+ )
515
+ else:
516
+ summary_text += (
517
+ "Projective reading doesn't generalize cleanly.\n"
518
+ "Either the threshold (|dev|<0.05) is too strict,\n"
519
+ "or h2-64 batteries have noise-specific non-projective\n"
520
+ "geometry that only the D=3 and Q-rank02 cases shared."
521
+ )
522
+
523
+ ax6.text(0.5, 0.95, headline, ha='center', va='top',
524
+ fontsize=16, fontweight='bold',
525
+ bbox=dict(boxstyle='round', facecolor=color, alpha=0.8))
526
+ ax6.text(0.05, 0.78, summary_text, ha='left', va='top',
527
+ fontsize=9, family='monospace')
528
+
529
+ plt.tight_layout()
530
+ plt.savefig(OUTPUT_PLOT, dpi=120, bbox_inches='tight')
531
+ plt.show()
532
+ print(f"Saved: {OUTPUT_PLOT}")
533
+
534
+ # Conclusion
535
+ print("\n" + "=" * 70)
536
+ print("CONCLUSION")
537
+ print("=" * 70)
538
+ print(f"\n {n_clean + n_mostly}/{total} batteries fit the projective "
539
+ f"reading (clean or mostly).")
540
+ print(f" Mean deviation from uniform ℝPΒ³: {np.mean(deviations):+.4f}")
541
+ print(f" Mean cluster silhouette: {np.mean(silhouettes):.3f}")
542
+ print()
543
+ if clean_frac >= 0.9:
544
+ print(" The projective-axis hypothesis is SUPPORTED across 16 different")
545
+ print(" trained sphere-solvers. This is strong evidence that the")
546
+ print(" ℝP^(D-1) reading is general, not D=3-specific or Q-sweep-specific.")
547
+ print()
548
+ print(" h2-64 is effectively a library of 16 trained axis codebooks,")
549
+ print(" each with its own cardinality and orientation on ℝPΒ³,")
550
+ print(" each trained for a specific noise discrimination task.")
551
+
552
+ return results
553
+
554
+
555
+ if __name__ == '__main__':
556
+ results = main()