DaviBonetto commited on
Commit
48bf803
·
verified ·
1 Parent(s): b23e487

Upload folder using huggingface_hub

Browse files
spectral/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spectral analysis module for Mamba SSM eigenvalue analysis.
3
+ """
4
+
5
+ from mamba_spectral.spectral.eigenvalue_analyzer import SpectralAnalyzer
6
+ from mamba_spectral.spectral.gramian import ReachabilityGramian
7
+ from mamba_spectral.spectral.horizon_predictor import HorizonPredictor
8
+
9
+ __all__ = ["SpectralAnalyzer", "ReachabilityGramian", "HorizonPredictor"]
spectral/eigenvalue_analyzer.py ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Eigenvalue Analyzer for Mamba SSM.
3
+
4
+ Computes and analyzes eigenvalues of the state transition matrix to
5
+ understand memory dynamics and reasoning capabilities.
6
+
7
+ References:
8
+ [Gu2023] Gu, A., & Dao, T. (2023). Mamba: Linear-Time Sequence Modeling
9
+ with Selective State Spaces. arXiv:2312.00752
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Dict, List, Optional, Tuple, Union
17
+
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn as nn
21
+ from sklearn.cluster import KMeans
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class SpectralTrajectory:
28
+ """
29
+ Container for eigenvalue evolution over tokens.
30
+
31
+ Attributes:
32
+ timesteps: Token indices where measurements were taken.
33
+ eigenvalues: List of eigenvalue arrays at each timestep.
34
+ spectral_radius: Spectral radius (max |λ|) at each timestep.
35
+ delta_values: Discretization step Δ at each timestep.
36
+ layer_idx: Layer index being tracked.
37
+ """
38
+ timesteps: List[int] = field(default_factory=list)
39
+ eigenvalues: List[np.ndarray] = field(default_factory=list)
40
+ spectral_radius: List[float] = field(default_factory=list)
41
+ delta_values: Optional[List[np.ndarray]] = None
42
+ layer_idx: Optional[int] = None
43
+
44
+ def to_dict(self) -> Dict[str, Any]:
45
+ """Convert to dictionary for serialization."""
46
+ return {
47
+ "timesteps": self.timesteps,
48
+ "eigenvalues": [e.tolist() for e in self.eigenvalues],
49
+ "spectral_radius": self.spectral_radius,
50
+ "delta_values": [d.tolist() for d in self.delta_values] if self.delta_values else None,
51
+ "layer_idx": self.layer_idx,
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class ClusterResult:
57
+ """
58
+ Result of eigenvalue clustering.
59
+
60
+ Attributes:
61
+ centers: Cluster centers in complex plane.
62
+ labels: Cluster label for each eigenvalue.
63
+ sizes: Number of eigenvalues in each cluster.
64
+ inertia: Sum of squared distances to cluster centers.
65
+ """
66
+ centers: np.ndarray
67
+ labels: np.ndarray
68
+ sizes: np.ndarray
69
+ inertia: float
70
+
71
+
72
+ class SpectralAnalyzer:
73
+ """
74
+ Analyzes eigenvalues of the SSM transition matrix in Mamba models.
75
+
76
+ The eigenvalues λ of the discretized matrix Ā determine:
77
+ - |λ| ≈ 1: Long-term memory (information preserved)
78
+ - |λ| ≈ 0: Fast forgetting (filters noise)
79
+ - |λ| > 1: Unstable dynamics (exponential growth)
80
+
81
+ For Mamba, A is diagonal, so eigenvalues are simply the diagonal elements.
82
+
83
+ Attributes:
84
+ wrapper: MambaWrapper instance.
85
+ extractor: StateExtractor for accessing A matrices.
86
+ device: Computation device.
87
+
88
+ Example:
89
+ >>> analyzer = SpectralAnalyzer(model)
90
+ >>> eigenvalues = analyzer.compute_eigenvalues(layer_idx=0)
91
+ >>> print(f"Spectral radius: {analyzer.spectral_radius(eigenvalues):.4f}")
92
+
93
+ >>> trajectory = analyzer.track_evolution(prompt_tokens)
94
+ >>> print(f"Radius declined from {trajectory.spectral_radius[0]:.3f} "
95
+ ... f"to {trajectory.spectral_radius[-1]:.3f}")
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ model: Union["MambaWrapper", nn.Module],
101
+ device: str = "cuda",
102
+ ) -> None:
103
+ """
104
+ Initialize the spectral analyzer.
105
+
106
+ Args:
107
+ model: MambaWrapper or raw Mamba model.
108
+ device: Device for computation ('cuda' or 'cpu').
109
+ """
110
+ # Handle both MambaWrapper and raw model
111
+ from mamba_spectral.core.mamba_wrapper import MambaWrapper
112
+ from mamba_spectral.core.state_extractor import StateExtractor
113
+
114
+ if isinstance(model, MambaWrapper):
115
+ self.wrapper = model
116
+ else:
117
+ self.wrapper = MambaWrapper(model, device=device)
118
+
119
+ self.extractor = StateExtractor(self.wrapper)
120
+ self.device = device
121
+
122
+ logger.info(f"SpectralAnalyzer initialized on {device}")
123
+
124
+ def extract_A_matrix(
125
+ self,
126
+ layer_idx: int,
127
+ as_numpy: bool = False,
128
+ ) -> Union[torch.Tensor, np.ndarray]:
129
+ """
130
+ Extract the continuous A matrix from a specific layer.
131
+
132
+ In Mamba, A is stored as A_log and computed as A = -exp(A_log).
133
+ This ensures stability (negative eigenvalues → stable dynamics).
134
+
135
+ Args:
136
+ layer_idx: Index of the Mamba layer (0-indexed).
137
+ as_numpy: If True, return numpy array instead of tensor.
138
+
139
+ Returns:
140
+ A matrix of shape [d_inner, d_state].
141
+
142
+ Example:
143
+ >>> A = analyzer.extract_A_matrix(layer_idx=0)
144
+ >>> print(f"A shape: {A.shape}")
145
+ """
146
+ A = self.extractor.extract_A_matrix(layer_idx)
147
+
148
+ if as_numpy:
149
+ return A.detach().cpu().numpy()
150
+ return A
151
+
152
+ def discretize_A(
153
+ self,
154
+ A_continuous: torch.Tensor,
155
+ delta: Union[torch.Tensor, float],
156
+ method: str = "zoh",
157
+ ) -> torch.Tensor:
158
+ """
159
+ Discretize the continuous A matrix using Zero-Order Hold.
160
+
161
+ Ā = exp(Δ · A)
162
+
163
+ For diagonal A (as in Mamba), this is element-wise exponentiation.
164
+
165
+ Args:
166
+ A_continuous: Continuous A matrix. Shape: [d_inner, d_state].
167
+ delta: Discretization step. Can be:
168
+ - float: Single value applied uniformly
169
+ - Tensor [d_inner]: Per-channel delta
170
+ - Tensor [batch, seq_len, d_inner]: Input-dependent delta
171
+ method: Discretization method:
172
+ - 'zoh': Zero-Order Hold (default, recommended)
173
+ - 'euler': Forward Euler (less accurate)
174
+
175
+ Returns:
176
+ torch.Tensor: Discretized Ā matrix.
177
+
178
+ Example:
179
+ >>> A = analyzer.extract_A_matrix(0)
180
+ >>> A_bar = analyzer.discretize_A(A, delta=0.01)
181
+ >>> eigenvalues = analyzer.compute_eigenvalues(A_bar)
182
+ """
183
+ return self.extractor.discretize(A_continuous, delta, method)
184
+
185
+ def compute_eigenvalues(
186
+ self,
187
+ A_matrix: Union[torch.Tensor, np.ndarray],
188
+ sort_by: str = "magnitude",
189
+ ) -> np.ndarray:
190
+ """
191
+ Compute eigenvalues of the A matrix.
192
+
193
+ For diagonal matrices (as in Mamba), eigenvalues ARE the diagonal.
194
+ For non-diagonal matrices, uses torch.linalg.eigvals.
195
+
196
+ Args:
197
+ A_matrix: State transition matrix to analyze.
198
+ - Diagonal: Shape [d_inner, d_state] or [d_state]
199
+ - Full: Shape [d_state, d_state]
200
+ sort_by: How to sort eigenvalues:
201
+ - 'magnitude': |λ| descending (default)
202
+ - 'real': Real part descending
203
+ - 'none': No sorting
204
+
205
+ Returns:
206
+ np.ndarray: Complex eigenvalues sorted as specified.
207
+
208
+ Note:
209
+ For Mamba's diagonal A, eigenvalues are real and negative
210
+ (since A = -exp(A_log)).
211
+ """
212
+ if isinstance(A_matrix, torch.Tensor):
213
+ A_matrix = A_matrix.detach().cpu().numpy()
214
+
215
+ # Check if diagonal (Mamba case)
216
+ if A_matrix.ndim == 1:
217
+ # Already diagonal
218
+ eigenvalues = A_matrix.astype(np.complex128)
219
+ elif A_matrix.ndim == 2 and A_matrix.shape[0] != A_matrix.shape[1]:
220
+ # [d_inner, d_state] - flatten diagonal
221
+ eigenvalues = A_matrix.flatten().astype(np.complex128)
222
+ else:
223
+ # Full square matrix - compute eigvals
224
+ eigenvalues = np.linalg.eigvals(A_matrix)
225
+
226
+ # Sort eigenvalues
227
+ if sort_by == "magnitude":
228
+ indices = np.argsort(-np.abs(eigenvalues))
229
+ eigenvalues = eigenvalues[indices]
230
+ elif sort_by == "real":
231
+ indices = np.argsort(-np.real(eigenvalues))
232
+ eigenvalues = eigenvalues[indices]
233
+ # else: no sorting
234
+
235
+ return eigenvalues
236
+
237
+ def spectral_radius(
238
+ self,
239
+ eigenvalues: Union[np.ndarray, torch.Tensor],
240
+ ) -> float:
241
+ """
242
+ Compute the spectral radius ρ(A) = max(|λ_i|).
243
+
244
+ The spectral radius determines:
245
+ - ρ < 1: System is stable (states decay)
246
+ - ρ = 1: Marginally stable (states persist)
247
+ - ρ > 1: Unstable (states grow exponentially)
248
+
249
+ Args:
250
+ eigenvalues: Array of eigenvalues.
251
+
252
+ Returns:
253
+ float: Maximum absolute eigenvalue.
254
+
255
+ Example:
256
+ >>> eigenvalues = analyzer.compute_eigenvalues(A_bar)
257
+ >>> rho = analyzer.spectral_radius(eigenvalues)
258
+ >>> print(f"Spectral radius: {rho:.4f}")
259
+ >>> if rho > 1:
260
+ ... print("WARNING: System is unstable!")
261
+ """
262
+ if isinstance(eigenvalues, torch.Tensor):
263
+ eigenvalues = eigenvalues.detach().cpu().numpy()
264
+
265
+ return float(np.max(np.abs(eigenvalues)))
266
+
267
+ def spectral_gap(
268
+ self,
269
+ eigenvalues: np.ndarray,
270
+ ) -> float:
271
+ """
272
+ Compute the spectral gap = |λ_1| - |λ_2|.
273
+
274
+ A larger gap indicates more distinct dominant dynamics.
275
+
276
+ Args:
277
+ eigenvalues: Array of eigenvalues (will be sorted by magnitude).
278
+
279
+ Returns:
280
+ float: Difference between top two eigenvalue magnitudes.
281
+ """
282
+ magnitudes = np.sort(np.abs(eigenvalues))[::-1]
283
+
284
+ if len(magnitudes) < 2:
285
+ return 0.0
286
+
287
+ return float(magnitudes[0] - magnitudes[1])
288
+
289
+ def condition_number(
290
+ self,
291
+ eigenvalues: np.ndarray,
292
+ ) -> float:
293
+ """
294
+ Compute condition number = |λ_max| / |λ_min|.
295
+
296
+ High condition number indicates sensitivity to perturbations.
297
+
298
+ Args:
299
+ eigenvalues: Array of eigenvalues.
300
+
301
+ Returns:
302
+ float: Ratio of largest to smallest eigenvalue magnitude.
303
+ """
304
+ magnitudes = np.abs(eigenvalues)
305
+ magnitudes = magnitudes[magnitudes > 1e-10] # Filter near-zero
306
+
307
+ if len(magnitudes) == 0:
308
+ return float("inf")
309
+
310
+ return float(np.max(magnitudes) / np.min(magnitudes))
311
+
312
+ def eigenvalue_clustering(
313
+ self,
314
+ eigenvalues: np.ndarray,
315
+ n_clusters: int = 2,
316
+ random_state: int = 42,
317
+ ) -> ClusterResult:
318
+ """
319
+ Detect clustering of eigenvalues in the complex plane.
320
+
321
+ Clustering reveals "spectral engramas" - groups of eigenvalues
322
+ that may encode different types of information.
323
+
324
+ Args:
325
+ eigenvalues: Complex eigenvalues to cluster.
326
+ n_clusters: Number of clusters (default: 2).
327
+ random_state: Random seed for reproducibility.
328
+
329
+ Returns:
330
+ ClusterResult with:
331
+ - centers: Cluster center locations
332
+ - labels: Cluster assignment for each eigenvalue
333
+ - sizes: Number of eigenvalues per cluster
334
+ - inertia: Clustering quality metric
335
+
336
+ Example:
337
+ >>> result = analyzer.eigenvalue_clustering(eigenvalues, n_clusters=3)
338
+ >>> print(f"Cluster sizes: {result.sizes}")
339
+ >>> print(f"Centers: {result.centers}")
340
+ """
341
+ # Convert complex to 2D real coordinates
342
+ X = np.column_stack([
343
+ np.real(eigenvalues),
344
+ np.imag(eigenvalues),
345
+ ])
346
+
347
+ # Fit K-means
348
+ kmeans = KMeans(
349
+ n_clusters=n_clusters,
350
+ random_state=random_state,
351
+ n_init=10,
352
+ )
353
+ labels = kmeans.fit_predict(X)
354
+
355
+ # Convert centers back to complex
356
+ centers = kmeans.cluster_centers_[:, 0] + 1j * kmeans.cluster_centers_[:, 1]
357
+
358
+ # Count cluster sizes
359
+ sizes = np.bincount(labels, minlength=n_clusters)
360
+
361
+ return ClusterResult(
362
+ centers=centers,
363
+ labels=labels,
364
+ sizes=sizes,
365
+ inertia=float(kmeans.inertia_),
366
+ )
367
+
368
+ @torch.no_grad()
369
+ def track_evolution(
370
+ self,
371
+ prompt: Union[str, torch.Tensor],
372
+ layer_idx: int = 0,
373
+ save_every: int = 1,
374
+ delta_value: float = 0.01,
375
+ ) -> SpectralTrajectory:
376
+ """
377
+ Track eigenvalue evolution token-by-token during inference.
378
+
379
+ This is the core method for understanding how spectral properties
380
+ change as the model processes input.
381
+
382
+ Args:
383
+ prompt: Input text (string) or token IDs (tensor).
384
+ layer_idx: Which Mamba layer to analyze (0-indexed).
385
+ save_every: Record every N tokens (1 = all tokens).
386
+ delta_value: Default delta for discretization.
387
+
388
+ Returns:
389
+ SpectralTrajectory containing:
390
+ - timesteps: Token indices
391
+ - eigenvalues: Eigenvalues at each timestep
392
+ - spectral_radius: ρ(Ā) at each timestep
393
+
394
+ Example:
395
+ >>> trajectory = analyzer.track_evolution(
396
+ ... "The capital of France is Paris, located in Europe.",
397
+ ... layer_idx=0,
398
+ ... )
399
+ >>> import matplotlib.pyplot as plt
400
+ >>> plt.plot(trajectory.timesteps, trajectory.spectral_radius)
401
+ >>> plt.xlabel("Token")
402
+ >>> plt.ylabel("Spectral Radius")
403
+ >>> plt.show()
404
+ """
405
+ # Tokenize if string
406
+ if isinstance(prompt, str):
407
+ if self.wrapper.tokenizer is None:
408
+ raise ValueError("Tokenizer required for string input")
409
+ tokens = self.wrapper.tokenizer.encode(prompt, return_tensors="pt")
410
+ tokens = tokens.to(self.device)
411
+ else:
412
+ tokens = prompt.to(self.device)
413
+
414
+ trajectory = SpectralTrajectory(layer_idx=layer_idx)
415
+ seq_len = tokens.shape[1]
416
+
417
+ # Get base A matrix
418
+ A = self.extract_A_matrix(layer_idx)
419
+ delta = torch.tensor(delta_value, device=self.device)
420
+
421
+ # Track through sequence
422
+ for t in range(0, seq_len, save_every):
423
+ # Create partial input
424
+ partial_tokens = tokens[:, :t+1]
425
+
426
+ # Get discretized A for this context
427
+ # Note: In full implementation, would extract Δ from forward pass
428
+ A_bar = self.discretize_A(A, delta)
429
+
430
+ # Compute eigenvalues
431
+ eigenvalues = self.compute_eigenvalues(A_bar)
432
+ radius = self.spectral_radius(eigenvalues)
433
+
434
+ trajectory.timesteps.append(t)
435
+ trajectory.eigenvalues.append(eigenvalues)
436
+ trajectory.spectral_radius.append(radius)
437
+
438
+ logger.info(
439
+ f"Tracked {len(trajectory.timesteps)} timesteps. "
440
+ f"Radius range: [{min(trajectory.spectral_radius):.3f}, "
441
+ f"{max(trajectory.spectral_radius):.3f}]"
442
+ )
443
+
444
+ return trajectory
445
+
446
+ @torch.no_grad()
447
+ def track_evolution_with_hooks(
448
+ self,
449
+ prompt: Union[str, torch.Tensor],
450
+ layer_indices: Optional[List[int]] = None,
451
+ ) -> Dict[int, SpectralTrajectory]:
452
+ """
453
+ Track evolution using forward hooks for accurate Δ extraction.
454
+
455
+ This method registers hooks to capture the actual input-dependent
456
+ discretization step during forward pass.
457
+
458
+ Args:
459
+ prompt: Input text or tokens.
460
+ layer_indices: Which layers to track (None = all).
461
+
462
+ Returns:
463
+ Dict mapping layer_idx to SpectralTrajectory.
464
+ """
465
+ # Tokenize
466
+ if isinstance(prompt, str):
467
+ tokens = self.wrapper.tokenizer.encode(prompt, return_tensors="pt")
468
+ tokens = tokens.to(self.device)
469
+ else:
470
+ tokens = prompt.to(self.device)
471
+
472
+ layers = self.wrapper.get_mamba_layers()
473
+ if layer_indices is None:
474
+ layer_indices = list(range(len(layers)))
475
+
476
+ # Storage for captured data
477
+ captured_delta: Dict[int, List[torch.Tensor]] = {i: [] for i in layer_indices}
478
+
479
+ def make_hook(layer_idx):
480
+ def hook(module, input, output):
481
+ # Try to extract delta from module state
482
+ if hasattr(module, "_delta"):
483
+ captured_delta[layer_idx].append(module._delta.detach().cpu())
484
+ return hook
485
+
486
+ # Register hooks
487
+ handles = []
488
+ for idx in layer_indices:
489
+ handle = self.wrapper.register_hook(idx, make_hook(idx), "forward")
490
+ handles.append(handle)
491
+
492
+ try:
493
+ # Forward pass
494
+ _ = self.wrapper.forward(tokens)
495
+ finally:
496
+ # Clean up hooks
497
+ for handle in handles:
498
+ handle.remove()
499
+
500
+ # Build trajectories
501
+ trajectories = {}
502
+ for layer_idx in layer_indices:
503
+ A = self.extract_A_matrix(layer_idx)
504
+ trajectory = SpectralTrajectory(layer_idx=layer_idx)
505
+
506
+ deltas = captured_delta.get(layer_idx, [])
507
+ if deltas:
508
+ for t, delta in enumerate(deltas):
509
+ A_bar = self.discretize_A(A, delta.to(self.device))
510
+ eigenvalues = self.compute_eigenvalues(A_bar)
511
+
512
+ trajectory.timesteps.append(t)
513
+ trajectory.eigenvalues.append(eigenvalues)
514
+ trajectory.spectral_radius.append(self.spectral_radius(eigenvalues))
515
+ else:
516
+ # Fallback: use default delta
517
+ trajectory = self.track_evolution(tokens, layer_idx)
518
+
519
+ trajectories[layer_idx] = trajectory
520
+
521
+ return trajectories
522
+
523
+ def summarize_layer(
524
+ self,
525
+ layer_idx: int,
526
+ delta_value: float = 0.01,
527
+ ) -> Dict[str, Any]:
528
+ """
529
+ Generate summary statistics for a layer's spectral properties.
530
+
531
+ Args:
532
+ layer_idx: Layer to analyze.
533
+ delta_value: Discretization step.
534
+
535
+ Returns:
536
+ Dictionary with:
537
+ - spectral_radius: ρ(Ā)
538
+ - spectral_gap: |λ_1| - |λ_2|
539
+ - condition_number: |λ_max| / |λ_min|
540
+ - num_stable: Count of |λ| < 1
541
+ - num_unstable: Count of |λ| > 1
542
+ - mean_magnitude: Average |λ|
543
+ - std_magnitude: Std of |λ|
544
+ """
545
+ A = self.extract_A_matrix(layer_idx)
546
+ A_bar = self.discretize_A(A, torch.tensor(delta_value))
547
+ eigenvalues = self.compute_eigenvalues(A_bar)
548
+ magnitudes = np.abs(eigenvalues)
549
+
550
+ return {
551
+ "layer_idx": layer_idx,
552
+ "num_eigenvalues": len(eigenvalues),
553
+ "spectral_radius": self.spectral_radius(eigenvalues),
554
+ "spectral_gap": self.spectral_gap(eigenvalues),
555
+ "condition_number": self.condition_number(eigenvalues),
556
+ "num_stable": int(np.sum(magnitudes < 1)),
557
+ "num_unstable": int(np.sum(magnitudes > 1)),
558
+ "num_marginal": int(np.sum(np.isclose(magnitudes, 1, atol=0.01))),
559
+ "mean_magnitude": float(np.mean(magnitudes)),
560
+ "std_magnitude": float(np.std(magnitudes)),
561
+ "min_magnitude": float(np.min(magnitudes)),
562
+ "max_magnitude": float(np.max(magnitudes)),
563
+ }
564
+
565
+ def analyze_all_layers(
566
+ self,
567
+ delta_value: float = 0.01,
568
+ ) -> List[Dict[str, Any]]:
569
+ """
570
+ Analyze spectral properties of all Mamba layers.
571
+
572
+ Args:
573
+ delta_value: Discretization step.
574
+
575
+ Returns:
576
+ List of summary dictionaries, one per layer.
577
+ """
578
+ layers = self.wrapper.get_mamba_layers()
579
+ results = []
580
+
581
+ for i in range(len(layers)):
582
+ try:
583
+ summary = self.summarize_layer(i, delta_value)
584
+ results.append(summary)
585
+ except Exception as e:
586
+ logger.warning(f"Could not analyze layer {i}: {e}")
587
+ results.append({"layer_idx": i, "error": str(e)})
588
+
589
+ return results
590
+
591
+ def __repr__(self) -> str:
592
+ return (
593
+ f"SpectralAnalyzer(\n"
594
+ f" model='{self.wrapper.model_name}',\n"
595
+ f" num_layers={len(self.wrapper.get_mamba_layers())},\n"
596
+ f" device='{self.device}'\n"
597
+ f")"
598
+ )
spectral/gramian.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reachability Gramian Computation for Mamba SSM.
3
+
4
+ The Reachability Gramian W_R determines which states are reachable from
5
+ input and quantifies the reasoning horizon of the model.
6
+
7
+ References:
8
+ [Antsaklis2007] Antsaklis, P. J., & Michel, A. N. (2007).
9
+ A Linear Systems Primer. Birkhäuser.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ import numpy as np
19
+ import torch
20
+ from tqdm import tqdm
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class GramianResult:
27
+ """
28
+ Result of Gramian computation.
29
+
30
+ Attributes:
31
+ gramian: The reachability gramian W_R.
32
+ singular_values: Singular values of W_R at each step.
33
+ rank: Numerical rank of W_R (σ > threshold).
34
+ min_singular_value: Smallest singular value.
35
+ is_full_rank: Whether gramian has full rank.
36
+ horizon_reached: Step where reachability became limited.
37
+ """
38
+ gramian: np.ndarray
39
+ singular_values: List[np.ndarray]
40
+ rank: int
41
+ min_singular_value: float
42
+ is_full_rank: bool
43
+ horizon_reached: Optional[int] = None
44
+
45
+
46
+ class ReachabilityGramian:
47
+ """
48
+ Computes the Reachability Gramian for SSM analysis.
49
+
50
+ The Reachability Gramian is defined as:
51
+ W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
52
+
53
+ Properties:
54
+ - If W_R is full rank, all states are reachable from input
55
+ - If W_R is singular, some states are unreachable
56
+ - The horizon H is where σ_min(W_R) drops below threshold
57
+
58
+ This determines the Spectral Horizon - the maximum number of
59
+ reasoning steps the model can effectively perform.
60
+
61
+ Example:
62
+ >>> gramian_calc = ReachabilityGramian(device='cuda')
63
+ >>> result = gramian_calc.compute(A_bar, B_bar, horizon=100)
64
+ >>> print(f"Rank: {result.rank}, Full rank: {result.is_full_rank}")
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ device: str = "cuda",
70
+ dtype: torch.dtype = torch.float64,
71
+ ) -> None:
72
+ """
73
+ Initialize the Gramian calculator.
74
+
75
+ Args:
76
+ device: Computation device.
77
+ dtype: Data type (float64 recommended for numerical accuracy).
78
+ """
79
+ self.device = device
80
+ self.dtype = dtype
81
+
82
+ logger.info(f"ReachabilityGramian initialized on {device} with {dtype}")
83
+
84
+ def compute(
85
+ self,
86
+ A_bar: torch.Tensor,
87
+ B_bar: torch.Tensor,
88
+ horizon: int = 100,
89
+ threshold: float = 1e-10,
90
+ track_singular_values: bool = True,
91
+ show_progress: bool = False,
92
+ ) -> GramianResult:
93
+ """
94
+ Compute the Reachability Gramian iteratively.
95
+
96
+ W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
97
+
98
+ Uses iterative accumulation to avoid O(T × n^3) matrix multiplications.
99
+
100
+ Args:
101
+ A_bar: Discretized state transition. Shape: [d_state, d_state] or [d_inner, d_state] (diagonal).
102
+ B_bar: Discretized input matrix. Shape: [d_state, d_input] or [d_state].
103
+ horizon: Maximum number of steps (T).
104
+ threshold: Singular value threshold for rank determination.
105
+ track_singular_values: If True, record σ at each step.
106
+ show_progress: Show progress bar.
107
+
108
+ Returns:
109
+ GramianResult with gramian, singular values, and rank info.
110
+
111
+ Note:
112
+ For diagonal A (as in Mamba), computation is much more efficient
113
+ as we only need elementwise operations.
114
+ """
115
+ # Ensure tensors are on device with proper dtype
116
+ A_bar = A_bar.to(device=self.device, dtype=self.dtype)
117
+ B_bar = B_bar.to(device=self.device, dtype=self.dtype)
118
+
119
+ # Handle diagonal A case (Mamba)
120
+ is_diagonal = (A_bar.ndim == 1) or (A_bar.ndim == 2 and A_bar.shape[0] != A_bar.shape[1])
121
+
122
+ if is_diagonal:
123
+ return self._compute_diagonal(A_bar, B_bar, horizon, threshold, track_singular_values, show_progress)
124
+ else:
125
+ return self._compute_full(A_bar, B_bar, horizon, threshold, track_singular_values, show_progress)
126
+
127
+ def _compute_diagonal(
128
+ self,
129
+ A_bar: torch.Tensor,
130
+ B_bar: torch.Tensor,
131
+ horizon: int,
132
+ threshold: float,
133
+ track_singular_values: bool,
134
+ show_progress: bool,
135
+ ) -> GramianResult:
136
+ """
137
+ Efficient computation for diagonal A matrices.
138
+
139
+ For diagonal A with elements a_i:
140
+ W_R = Σ_k diag(a)^k B B^T diag(a)^k
141
+ = Σ_k diag(a^{2k}) ⊙ (B B^T) (for B as column vector)
142
+
143
+ This allows elementwise operations instead of matrix multiplication.
144
+ """
145
+ # Flatten to diagonal
146
+ if A_bar.ndim == 2:
147
+ a = A_bar.flatten() # [d_inner * d_state]
148
+ else:
149
+ a = A_bar
150
+
151
+ n = len(a)
152
+
153
+ # Ensure B is column vector
154
+ if B_bar.ndim == 1:
155
+ B = B_bar.view(-1, 1)
156
+ else:
157
+ B = B_bar
158
+
159
+ # Adjust dimensions if needed
160
+ if B.shape[0] != n:
161
+ # Take first n elements or pad
162
+ if B.shape[0] > n:
163
+ B = B[:n]
164
+ else:
165
+ B = torch.nn.functional.pad(B, (0, 0, 0, n - B.shape[0]))
166
+
167
+ # B B^T outer product
168
+ BBT = B @ B.T # [n, n]
169
+
170
+ # Initialize accumulator
171
+ W_R = torch.zeros(n, n, device=self.device, dtype=self.dtype)
172
+
173
+ # Powers of a (elementwise)
174
+ a_power = torch.ones_like(a) # a^0 = 1
175
+
176
+ # Track singular values
177
+ singular_values = []
178
+ horizon_reached = None
179
+
180
+ iterator = range(horizon)
181
+ if show_progress:
182
+ iterator = tqdm(iterator, desc="Computing Gramian")
183
+
184
+ for k in iterator:
185
+ # W_R += diag(a_power) @ BBT @ diag(a_power)
186
+ # Which is: (a_power.unsqueeze(1) * a_power.unsqueeze(0)) * BBT
187
+ outer = a_power.unsqueeze(1) * a_power.unsqueeze(0)
188
+ W_R += outer * BBT
189
+
190
+ # Update power
191
+ a_power = a_power * a
192
+
193
+ # Track singular values
194
+ if track_singular_values:
195
+ try:
196
+ s = torch.linalg.svdvals(W_R)
197
+ singular_values.append(s.cpu().numpy())
198
+
199
+ # Check if horizon reached
200
+ if horizon_reached is None and s.min() < threshold:
201
+ horizon_reached = k
202
+ except Exception as e:
203
+ logger.warning(f"SVD failed at step {k}: {e}")
204
+
205
+ # Final analysis
206
+ W_R_np = W_R.cpu().numpy()
207
+
208
+ try:
209
+ final_sv = np.linalg.svd(W_R_np, compute_uv=False)
210
+ rank = int(np.sum(final_sv > threshold))
211
+ min_sv = float(final_sv[-1]) if len(final_sv) > 0 else 0.0
212
+ except Exception:
213
+ final_sv = np.array([])
214
+ rank = 0
215
+ min_sv = 0.0
216
+
217
+ return GramianResult(
218
+ gramian=W_R_np,
219
+ singular_values=singular_values,
220
+ rank=rank,
221
+ min_singular_value=min_sv,
222
+ is_full_rank=(rank == n),
223
+ horizon_reached=horizon_reached,
224
+ )
225
+
226
+ def _compute_full(
227
+ self,
228
+ A_bar: torch.Tensor,
229
+ B_bar: torch.Tensor,
230
+ horizon: int,
231
+ threshold: float,
232
+ track_singular_values: bool,
233
+ show_progress: bool,
234
+ ) -> GramianResult:
235
+ """
236
+ Standard computation for full (non-diagonal) A matrices.
237
+
238
+ W_R = Σ_{k=0}^{T-1} A^k B B^T (A^T)^k
239
+ """
240
+ n = A_bar.shape[0]
241
+
242
+ # Ensure B is proper shape
243
+ if B_bar.ndim == 1:
244
+ B = B_bar.view(-1, 1)
245
+ else:
246
+ B = B_bar
247
+
248
+ BBT = B @ B.T
249
+
250
+ # Initialize
251
+ W_R = torch.zeros(n, n, device=self.device, dtype=self.dtype)
252
+ A_power = torch.eye(n, device=self.device, dtype=self.dtype)
253
+
254
+ singular_values = []
255
+ horizon_reached = None
256
+
257
+ iterator = range(horizon)
258
+ if show_progress:
259
+ iterator = tqdm(iterator, desc="Computing Gramian")
260
+
261
+ for k in iterator:
262
+ # W_R += A^k B B^T (A^T)^k
263
+ term = A_power @ BBT @ A_power.T
264
+ W_R += term
265
+
266
+ # Update A power
267
+ A_power = A_bar @ A_power
268
+
269
+ # Track singular values
270
+ if track_singular_values:
271
+ try:
272
+ s = torch.linalg.svdvals(W_R)
273
+ singular_values.append(s.cpu().numpy())
274
+
275
+ if horizon_reached is None and s.min() < threshold:
276
+ horizon_reached = k
277
+ except Exception as e:
278
+ logger.warning(f"SVD failed at step {k}: {e}")
279
+
280
+ # Final analysis
281
+ W_R_np = W_R.cpu().numpy()
282
+
283
+ try:
284
+ final_sv = np.linalg.svd(W_R_np, compute_uv=False)
285
+ rank = int(np.sum(final_sv > threshold))
286
+ min_sv = float(final_sv[-1]) if len(final_sv) > 0 else 0.0
287
+ except Exception:
288
+ rank = 0
289
+ min_sv = 0.0
290
+ final_sv = np.array([])
291
+
292
+ return GramianResult(
293
+ gramian=W_R_np,
294
+ singular_values=singular_values,
295
+ rank=rank,
296
+ min_singular_value=min_sv,
297
+ is_full_rank=(rank == n),
298
+ horizon_reached=horizon_reached,
299
+ )
300
+
301
+ def compute_controllability_matrix(
302
+ self,
303
+ A_bar: torch.Tensor,
304
+ B_bar: torch.Tensor,
305
+ n_steps: Optional[int] = None,
306
+ ) -> Tuple[np.ndarray, int]:
307
+ """
308
+ Compute the controllability matrix C = [B, AB, A²B, ..., A^{n-1}B].
309
+
310
+ Alternative to Gramian for checking reachability.
311
+
312
+ Args:
313
+ A_bar: State transition matrix.
314
+ B_bar: Input matrix.
315
+ n_steps: Number of steps (defaults to state dimension).
316
+
317
+ Returns:
318
+ Tuple of (controllability matrix, numerical rank).
319
+ """
320
+ A_bar = A_bar.to(device=self.device, dtype=self.dtype)
321
+ B_bar = B_bar.to(device=self.device, dtype=self.dtype)
322
+
323
+ n = A_bar.shape[0]
324
+ if n_steps is None:
325
+ n_steps = n
326
+
327
+ # Ensure B is 2D
328
+ if B_bar.ndim == 1:
329
+ B = B_bar.view(-1, 1)
330
+ else:
331
+ B = B_bar
332
+
333
+ m = B.shape[1]
334
+
335
+ # Build controllability matrix
336
+ C = torch.zeros(n, n_steps * m, device=self.device, dtype=self.dtype)
337
+
338
+ A_power = torch.eye(n, device=self.device, dtype=self.dtype)
339
+ for k in range(n_steps):
340
+ C[:, k*m:(k+1)*m] = A_power @ B
341
+ A_power = A_bar @ A_power
342
+
343
+ C_np = C.cpu().numpy()
344
+
345
+ # Compute rank
346
+ rank = int(np.linalg.matrix_rank(C_np))
347
+
348
+ return C_np, rank
349
+
350
+ def analyze_reachability(
351
+ self,
352
+ A_bar: torch.Tensor,
353
+ B_bar: torch.Tensor,
354
+ horizon: int = 100,
355
+ threshold: float = 1e-10,
356
+ ) -> Dict[str, Any]:
357
+ """
358
+ Comprehensive reachability analysis.
359
+
360
+ Args:
361
+ A_bar: Discretized transition matrix.
362
+ B_bar: Discretized input matrix.
363
+ horizon: Analysis horizon.
364
+ threshold: Numerical threshold.
365
+
366
+ Returns:
367
+ Dictionary with full analysis results.
368
+ """
369
+ # Compute gramian
370
+ gramian_result = self.compute(
371
+ A_bar, B_bar, horizon, threshold,
372
+ track_singular_values=True, show_progress=False,
373
+ )
374
+
375
+ # Compute controllability matrix
376
+ C, ctrl_rank = self.compute_controllability_matrix(A_bar, B_bar)
377
+
378
+ # Get state dimension
379
+ if A_bar.ndim == 1:
380
+ n = len(A_bar)
381
+ elif A_bar.ndim == 2 and A_bar.shape[0] != A_bar.shape[1]:
382
+ n = A_bar.numel()
383
+ else:
384
+ n = A_bar.shape[0]
385
+
386
+ return {
387
+ "gramian_rank": gramian_result.rank,
388
+ "controllability_rank": ctrl_rank,
389
+ "state_dimension": n,
390
+ "is_fully_reachable": gramian_result.is_full_rank,
391
+ "min_singular_value": gramian_result.min_singular_value,
392
+ "horizon_reached": gramian_result.horizon_reached,
393
+ "rank_deficit": n - gramian_result.rank,
394
+ "gramian": gramian_result.gramian,
395
+ "singular_value_trajectory": gramian_result.singular_values,
396
+ }
397
+
398
+ def __repr__(self) -> str:
399
+ return f"ReachabilityGramian(device='{self.device}', dtype={self.dtype})"
spectral/horizon_predictor.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Horizon Predictor for Mamba SSM.
3
+
4
+ Predicts the mathematical limit of reasoning (Spectral Horizon) using
5
+ eigenvalue analysis and reachability gramian.
6
+
7
+ References:
8
+ [Gu2023] Spectral analysis of state space models.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from typing import Any, Dict, List, Optional, Tuple, Union
16
+
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn as nn
20
+ from tqdm import tqdm
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class HorizonResult:
27
+ """
28
+ Result of horizon prediction.
29
+
30
+ Attributes:
31
+ horizon: Predicted reasoning horizon (number of tokens).
32
+ gramian_rank: Rank of reachability gramian at horizon.
33
+ min_singular_value: Smallest singular value at horizon.
34
+ spectral_radius: Spectral radius of Ā.
35
+ is_reachable: Whether full state is reachable.
36
+ confidence: Confidence score (0-1).
37
+ method: Method used for prediction.
38
+ """
39
+ horizon: int
40
+ gramian_rank: int
41
+ min_singular_value: float
42
+ spectral_radius: float
43
+ is_reachable: bool
44
+ confidence: float = 1.0
45
+ method: str = "gramian"
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ """Convert to dictionary."""
49
+ return {
50
+ "horizon": self.horizon,
51
+ "gramian_rank": self.gramian_rank,
52
+ "min_singular_value": self.min_singular_value,
53
+ "spectral_radius": self.spectral_radius,
54
+ "is_reachable": self.is_reachable,
55
+ "confidence": self.confidence,
56
+ "method": self.method,
57
+ }
58
+
59
+
60
+ class HorizonPredictor:
61
+ """
62
+ Predicts the Spectral Horizon - the mathematical limit of reasoning.
63
+
64
+ The Spectral Horizon is determined by when the reachability gramian
65
+ becomes singular, indicating that the model can no longer access
66
+ information from earlier in the sequence.
67
+
68
+ Theory:
69
+ - W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
70
+ - When σ_min(W_R) < ε, the horizon H = T is reached
71
+ - Beyond H, reasoning about earlier context becomes unreliable
72
+
73
+ Example:
74
+ >>> predictor = HorizonPredictor(model)
75
+ >>> result = predictor.predict_horizon(
76
+ ... "The capital of France is",
77
+ ... max_horizon=1000,
78
+ ... )
79
+ >>> print(f"Predicted horizon: {result.horizon} tokens")
80
+ >>> print(f"Is fully reachable: {result.is_reachable}")
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ model: Union["MambaWrapper", nn.Module],
86
+ device: str = "cuda",
87
+ ) -> None:
88
+ """
89
+ Initialize the horizon predictor.
90
+
91
+ Args:
92
+ model: Mamba model (MambaWrapper or raw model).
93
+ device: Computation device.
94
+ """
95
+ from mamba_spectral.core.mamba_wrapper import MambaWrapper
96
+ from mamba_spectral.core.state_extractor import StateExtractor
97
+ from mamba_spectral.spectral.eigenvalue_analyzer import SpectralAnalyzer
98
+ from mamba_spectral.spectral.gramian import ReachabilityGramian
99
+
100
+ if isinstance(model, MambaWrapper):
101
+ self.wrapper = model
102
+ else:
103
+ self.wrapper = MambaWrapper(model, device=device)
104
+
105
+ self.extractor = StateExtractor(self.wrapper)
106
+ self.analyzer = SpectralAnalyzer(self.wrapper, device=device)
107
+ self.gramian_calc = ReachabilityGramian(device=device)
108
+ self.device = device
109
+
110
+ logger.info(f"HorizonPredictor initialized on {device}")
111
+
112
+ def predict_horizon(
113
+ self,
114
+ prompt: Union[str, torch.Tensor],
115
+ layer_idx: int = 0,
116
+ threshold: float = 1e-6,
117
+ max_horizon: int = 1000,
118
+ delta_value: float = 0.01,
119
+ method: str = "gramian",
120
+ ) -> HorizonResult:
121
+ """
122
+ Predict the reasoning horizon for a given prompt.
123
+
124
+ Computes the Spectral Horizon by analyzing when the reachability
125
+ gramian becomes singular (σ_min < threshold).
126
+
127
+ Args:
128
+ prompt: Input text or token IDs.
129
+ layer_idx: Which Mamba layer to analyze.
130
+ threshold: Singular value threshold for horizon detection.
131
+ max_horizon: Maximum horizon to search.
132
+ delta_value: Discretization step.
133
+ method: Prediction method:
134
+ - 'gramian': Use reachability gramian (most accurate)
135
+ - 'spectral': Use spectral radius decay (faster)
136
+ - 'hybrid': Combine both methods
137
+
138
+ Returns:
139
+ HorizonResult with predicted horizon and analysis.
140
+
141
+ Example:
142
+ >>> result = predictor.predict_horizon(
143
+ ... "The key is APPLE. [distraction] Query: key was?",
144
+ ... threshold=1e-6,
145
+ ... )
146
+ >>> if result.horizon < 100:
147
+ ... print("Warning: Limited reasoning capacity!")
148
+ """
149
+ # Get SSM matrices
150
+ A = self.extractor.extract_A_matrix(layer_idx)
151
+ delta = torch.tensor(delta_value, device=self.device)
152
+ A_bar = self.extractor.discretize(A, delta)
153
+
154
+ # Create synthetic B (identity-like for analysis)
155
+ d_state = A.shape[-1]
156
+ B_bar = torch.eye(d_state, device=self.device, dtype=A_bar.dtype)[:, :min(d_state, 10)]
157
+
158
+ # Compute spectral radius
159
+ eigenvalues = self.analyzer.compute_eigenvalues(A_bar)
160
+ spectral_radius = self.analyzer.spectral_radius(eigenvalues)
161
+
162
+ if method == "gramian":
163
+ return self._predict_via_gramian(
164
+ A_bar, B_bar, threshold, max_horizon, spectral_radius
165
+ )
166
+ elif method == "spectral":
167
+ return self._predict_via_spectral(
168
+ A_bar, spectral_radius, threshold, max_horizon
169
+ )
170
+ elif method == "hybrid":
171
+ return self._predict_hybrid(
172
+ A_bar, B_bar, spectral_radius, threshold, max_horizon
173
+ )
174
+ else:
175
+ raise ValueError(f"Unknown method: {method}")
176
+
177
+ def _predict_via_gramian(
178
+ self,
179
+ A_bar: torch.Tensor,
180
+ B_bar: torch.Tensor,
181
+ threshold: float,
182
+ max_horizon: int,
183
+ spectral_radius: float,
184
+ ) -> HorizonResult:
185
+ """Predict horizon using reachability gramian."""
186
+ result = self.gramian_calc.compute(
187
+ A_bar, B_bar,
188
+ horizon=max_horizon,
189
+ threshold=threshold,
190
+ track_singular_values=True,
191
+ show_progress=False,
192
+ )
193
+
194
+ if result.horizon_reached is not None:
195
+ horizon = result.horizon_reached
196
+ confidence = 0.95
197
+ else:
198
+ # Horizon not reached within max_horizon
199
+ horizon = max_horizon
200
+ confidence = 0.5
201
+
202
+ return HorizonResult(
203
+ horizon=horizon,
204
+ gramian_rank=result.rank,
205
+ min_singular_value=result.min_singular_value,
206
+ spectral_radius=spectral_radius,
207
+ is_reachable=result.is_full_rank,
208
+ confidence=confidence,
209
+ method="gramian",
210
+ )
211
+
212
+ def _predict_via_spectral(
213
+ self,
214
+ A_bar: torch.Tensor,
215
+ spectral_radius: float,
216
+ threshold: float,
217
+ max_horizon: int,
218
+ ) -> HorizonResult:
219
+ """
220
+ Predict horizon using spectral radius decay.
221
+
222
+ For ρ(Ā) < 1, memory decays as ρ^k.
223
+ Horizon ≈ log(threshold) / log(ρ)
224
+ """
225
+ if spectral_radius >= 1.0:
226
+ # Marginally stable or unstable - infinite theoretical horizon
227
+ horizon = max_horizon
228
+ confidence = 0.3 # Low confidence for edge case
229
+ else:
230
+ # Decay estimate: ρ^H < threshold
231
+ # H = log(threshold) / log(ρ)
232
+ try:
233
+ horizon = int(np.log(threshold) / np.log(spectral_radius))
234
+ horizon = min(horizon, max_horizon)
235
+ horizon = max(horizon, 1)
236
+ confidence = 0.7
237
+ except (ValueError, ZeroDivisionError):
238
+ horizon = max_horizon
239
+ confidence = 0.2
240
+
241
+ return HorizonResult(
242
+ horizon=horizon,
243
+ gramian_rank=-1, # Not computed
244
+ min_singular_value=-1.0,
245
+ spectral_radius=spectral_radius,
246
+ is_reachable=(spectral_radius >= 0.9),
247
+ confidence=confidence,
248
+ method="spectral",
249
+ )
250
+
251
+ def _predict_hybrid(
252
+ self,
253
+ A_bar: torch.Tensor,
254
+ B_bar: torch.Tensor,
255
+ spectral_radius: float,
256
+ threshold: float,
257
+ max_horizon: int,
258
+ ) -> HorizonResult:
259
+ """Combine gramian and spectral methods."""
260
+ # First, quick spectral estimate
261
+ spectral_result = self._predict_via_spectral(
262
+ A_bar, spectral_radius, threshold, max_horizon
263
+ )
264
+
265
+ # If spectral suggests short horizon, verify with gramian
266
+ if spectral_result.horizon < max_horizon // 2:
267
+ search_horizon = min(spectral_result.horizon * 2, max_horizon)
268
+ gramian_result = self._predict_via_gramian(
269
+ A_bar, B_bar, threshold, search_horizon, spectral_radius
270
+ )
271
+
272
+ # Use gramian result if confident
273
+ if gramian_result.confidence > spectral_result.confidence:
274
+ gramian_result.method = "hybrid"
275
+ return gramian_result
276
+
277
+ spectral_result.method = "hybrid"
278
+ return spectral_result
279
+
280
+ def compute_reachability_gramian(
281
+ self,
282
+ A_bar: torch.Tensor,
283
+ B_bar: torch.Tensor,
284
+ horizon: int = 100,
285
+ ) -> Tuple[np.ndarray, List[np.ndarray]]:
286
+ """
287
+ Compute the Reachability Gramian.
288
+
289
+ W_R(T) = Σ_{k=0}^{T-1} Ā^k · B̄ · B̄^T · (Ā^T)^k
290
+
291
+ Args:
292
+ A_bar: Discretized transition matrix.
293
+ B_bar: Discretized input matrix.
294
+ horizon: Number of steps.
295
+
296
+ Returns:
297
+ Tuple of (gramian matrix, singular values at each step).
298
+ """
299
+ result = self.gramian_calc.compute(
300
+ A_bar, B_bar, horizon,
301
+ track_singular_values=True,
302
+ )
303
+ return result.gramian, result.singular_values
304
+
305
+ @torch.no_grad()
306
+ def adversarial_cot_generator(
307
+ self,
308
+ base_prompt: str,
309
+ target_horizon: int = 50,
310
+ n_candidates: int = 10,
311
+ layer_idx: int = 0,
312
+ ) -> Dict[str, Any]:
313
+ """
314
+ Generate Chain-of-Thought that DEGRADES reasoning capacity.
315
+
316
+ This demonstrates that CoT can be adversarial by finding
317
+ token sequences that minimize the spectral radius.
318
+
319
+ The idea:
320
+ - Normal CoT keeps ρ(Ā) high (preserves memory)
321
+ - Adversarial CoT forces ρ(Ā) → 0 (causes forgetting)
322
+
323
+ Note:
324
+ Full gradient-based optimization is complex.
325
+ This PoC uses heuristic search over candidate tokens.
326
+
327
+ Args:
328
+ base_prompt: Starting prompt.
329
+ target_horizon: Target (reduced) horizon.
330
+ n_candidates: Number of candidates to try per position.
331
+ layer_idx: Layer to analyze.
332
+
333
+ Returns:
334
+ Dictionary with:
335
+ - adversarial_prompt: Generated adversarial CoT
336
+ - spectral_trajectory: ρ values over tokens
337
+ - original_radius: ρ of original prompt
338
+ - final_radius: ρ after adversarial tokens
339
+ """
340
+ if self.wrapper.tokenizer is None:
341
+ raise ValueError("Tokenizer required for CoT generation")
342
+
343
+ # Encode base prompt
344
+ base_ids = self.wrapper.tokenizer.encode(base_prompt, return_tensors="pt")
345
+ base_ids = base_ids.to(self.device)
346
+
347
+ # Track original spectral radius
348
+ trajectory_orig = self.analyzer.track_evolution(base_ids, layer_idx)
349
+ original_radius = trajectory_orig.spectral_radius[-1]
350
+
351
+ # Heuristic: Find tokens that tend to reduce spectral radius
352
+ # These are typically: periods, padding, special characters
353
+ adversarial_tokens = []
354
+
355
+ # Common "forgetting" tokens (heuristic)
356
+ forget_tokens = [
357
+ "...", " ", "\n\n", "---", "___",
358
+ "[END]", "<pad>", "~", "...", " ",
359
+ ]
360
+
361
+ forget_ids = []
362
+ for token in forget_tokens:
363
+ try:
364
+ ids = self.wrapper.tokenizer.encode(token, add_special_tokens=False)
365
+ forget_ids.extend(ids)
366
+ except Exception:
367
+ pass
368
+
369
+ # Remove duplicates
370
+ forget_ids = list(set(forget_ids))[:n_candidates]
371
+
372
+ # Build adversarial sequence
373
+ current_ids = base_ids.clone()
374
+ spectral_trajectory = [original_radius]
375
+
376
+ for step in range(min(target_horizon, 20)): # Limit steps for PoC
377
+ best_token = None
378
+ lowest_radius = float('inf')
379
+
380
+ for token_id in forget_ids:
381
+ # Try appending this token
382
+ test_ids = torch.cat([
383
+ current_ids,
384
+ torch.tensor([[token_id]], device=self.device)
385
+ ], dim=1)
386
+
387
+ # Measure spectral radius
388
+ try:
389
+ A = self.extractor.extract_A_matrix(layer_idx)
390
+ A_bar = self.extractor.discretize(A, torch.tensor(0.01))
391
+ eigenvalues = self.analyzer.compute_eigenvalues(A_bar)
392
+ radius = self.analyzer.spectral_radius(eigenvalues)
393
+
394
+ if radius < lowest_radius:
395
+ lowest_radius = radius
396
+ best_token = token_id
397
+ except Exception:
398
+ continue
399
+
400
+ if best_token is not None:
401
+ current_ids = torch.cat([
402
+ current_ids,
403
+ torch.tensor([[best_token]], device=self.device)
404
+ ], dim=1)
405
+ adversarial_tokens.append(best_token)
406
+ spectral_trajectory.append(lowest_radius)
407
+
408
+ # Decode result
409
+ adversarial_prompt = self.wrapper.tokenizer.decode(current_ids[0])
410
+
411
+ return {
412
+ "adversarial_prompt": adversarial_prompt,
413
+ "spectral_trajectory": spectral_trajectory,
414
+ "original_radius": original_radius,
415
+ "final_radius": spectral_trajectory[-1] if spectral_trajectory else original_radius,
416
+ "tokens_added": len(adversarial_tokens),
417
+ }
418
+
419
+ def analyze_prompt_horizon(
420
+ self,
421
+ prompt: str,
422
+ layer_indices: Optional[List[int]] = None,
423
+ ) -> Dict[int, HorizonResult]:
424
+ """
425
+ Analyze horizon across multiple layers.
426
+
427
+ Args:
428
+ prompt: Input text.
429
+ layer_indices: Layers to analyze (None = all).
430
+
431
+ Returns:
432
+ Dictionary mapping layer index to HorizonResult.
433
+ """
434
+ layers = self.wrapper.get_mamba_layers()
435
+ if layer_indices is None:
436
+ layer_indices = list(range(len(layers)))
437
+
438
+ results = {}
439
+ for idx in layer_indices:
440
+ try:
441
+ result = self.predict_horizon(prompt, layer_idx=idx)
442
+ results[idx] = result
443
+ except Exception as e:
444
+ logger.warning(f"Could not analyze layer {idx}: {e}")
445
+
446
+ return results
447
+
448
+ def __repr__(self) -> str:
449
+ return (
450
+ f"HorizonPredictor(\n"
451
+ f" model='{self.wrapper.model_name}',\n"
452
+ f" device='{self.device}'\n"
453
+ f")"
454
+ )