File size: 21,330 Bytes
2bc8e46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e25024e
 
2bc8e46
 
 
e25024e
 
 
 
 
2bc8e46
 
 
 
 
 
 
e25024e
 
 
 
2bc8e46
 
 
 
 
 
 
 
 
e25024e
 
2bc8e46
 
 
e25024e
 
 
 
 
2bc8e46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e25024e
 
 
2bc8e46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e25024e
 
 
 
2bc8e46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e25024e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
"""Edge-case and robustness tests.

Tests for NaN/Inf handling, empty inputs, extreme dimensions,
and other boundary conditions that the main test suite doesn't cover.
"""

from __future__ import annotations

import math

import pytest
import torch
import torch.nn as nn

from obliteratus.analysis.whitened_svd import WhitenedSVDExtractor
from obliteratus.analysis.cross_layer import CrossLayerAlignmentAnalyzer
from obliteratus.analysis.concept_geometry import ConceptConeAnalyzer
from obliteratus.analysis.alignment_imprint import AlignmentImprintDetector
from obliteratus.analysis.multi_token_position import MultiTokenPositionAnalyzer
from obliteratus.analysis.sparse_surgery import SparseDirectionSurgeon
from obliteratus.analysis.causal_tracing import CausalRefusalTracer
from obliteratus.analysis.residual_stream import ResidualStreamDecomposer
from obliteratus.analysis.probing_classifiers import LinearRefusalProbe
from obliteratus.analysis.cross_model_transfer import TransferAnalyzer
from obliteratus.evaluation.advanced_metrics import (
    refusal_rate,
    effective_rank,
    activation_cosine_similarity,
)
from obliteratus.analysis.steering_vectors import (
    SteeringVectorFactory,
    SteeringHookManager,
    SteeringConfig,
    SteeringResult,
    compute_steering_effectiveness,
    format_steering_report,
)


# ===========================================================================
#  NaN / Inf handling
# ===========================================================================

class TestNaNInfHandling:
    """Test that modules handle degenerate inputs gracefully."""

    def test_whitened_svd_nan_activations(self):
        """WhitenedSVD with NaN — currently raises; documenting behavior."""
        harmful = [torch.tensor([float("nan"), 1.0, 2.0]) for _ in range(5)]
        harmless = [torch.randn(3) for _ in range(5)]
        extractor = WhitenedSVDExtractor()
        # NaN propagation through SVD is expected to produce NaN results
        # This documents the current behavior — ideally would guard against it
        raised = False
        result = None
        try:
            result = extractor.extract(harmful, harmless)
        except (RuntimeError, ValueError):
            raised = True
        # Either it raised an exception (acceptable) or returned a result with NaNs
        assert raised or result is not None, (
            "Should either raise on NaN input or return a result"
        )

    def test_whitened_svd_zero_activations(self):
        """WhitenedSVD with all-zero activations."""
        harmful = [torch.zeros(8) for _ in range(5)]
        harmless = [torch.zeros(8) for _ in range(5)]
        extractor = WhitenedSVDExtractor()
        result = extractor.extract(harmful, harmless)
        # Should return a valid result without crashing
        assert result is not None
        assert result.directions is not None
        assert result.singular_values is not None

    def test_concept_cone_nan_direction(self):
        """ConceptConeAnalyzer with NaN in activations — documenting behavior."""
        harmful = [torch.randn(16) for _ in range(10)]
        harmless = [torch.randn(16) for _ in range(10)]
        # Poison one activation
        harmful[3] = torch.full((16,), float("nan"))
        cat_map = {i: f"cat_{i % 3}" for i in range(10)}
        analyzer = ConceptConeAnalyzer(category_map=cat_map)
        raised = False
        result = None
        try:
            result = analyzer.analyze_layer(harmful, harmless)
        except (RuntimeError, ValueError):
            raised = True
        # Either it raised an exception (acceptable) or returned a result
        assert raised or result is not None, (
            "Should either raise on NaN input or return a result"
        )

    def test_sparse_surgery_zero_direction(self):
        """Sparse surgery with zero refusal direction."""
        W = torch.randn(32, 16)
        zero_dir = torch.zeros(16)
        surgeon = SparseDirectionSurgeon()
        result = surgeon.analyze_weight_matrix(W, zero_dir)
        assert result.mean_projection == 0.0

    def test_sparse_surgery_zero_weight(self):
        """Sparse surgery with zero weight matrix."""
        W = torch.zeros(32, 16)
        ref_dir = torch.randn(16)
        surgeon = SparseDirectionSurgeon()
        result = surgeon.analyze_weight_matrix(W, ref_dir)
        assert result.max_projection < 1e-6

    def test_effective_rank_nan_matrix(self):
        """effective_rank should handle matrix with NaN."""
        W = torch.randn(10, 10)
        W[0, 0] = float("nan")
        # Should either return a value or raise cleanly
        try:
            result = effective_rank(torch.nan_to_num(W))
            assert math.isfinite(result)
        except Exception:
            pass  # Raising is acceptable for NaN input

    def test_cosine_similarity_zero_vectors(self):
        """Cosine similarity between zero vectors."""
        a = torch.zeros(32)
        b = torch.zeros(32)
        result = activation_cosine_similarity(a, b)
        # Should be 0 or NaN, not crash
        assert math.isfinite(result) or math.isnan(result)

    def test_transfer_analyzer_nan_directions(self):
        """Transfer analyzer with NaN directions."""
        dirs_a = {0: torch.randn(16), 1: torch.tensor([float("nan")] * 16)}
        dirs_b = {0: torch.randn(16), 1: torch.randn(16)}
        analyzer = TransferAnalyzer()
        # Should not crash
        result = analyzer.analyze_cross_model(dirs_a, dirs_b)
        assert result is not None
        assert isinstance(result.mean_transfer_score, float)
        assert result.per_layer_transfer is not None


# ===========================================================================
#  Empty inputs
# ===========================================================================

class TestEmptyInputs:
    """Test graceful handling of empty or minimal inputs."""

    def test_cross_layer_empty_directions(self):
        analyzer = CrossLayerAlignmentAnalyzer()
        result = analyzer.analyze({})
        assert result.direction_persistence_score == 0.0

    def test_alignment_imprint_single_layer(self):
        """Single layer should still return a result."""
        detector = AlignmentImprintDetector()
        dirs = {0: torch.randn(32)}
        result = detector.detect_imprint(dirs)
        assert result.predicted_method in ("dpo", "rlhf", "cai", "sft", "unknown")

    def test_multi_token_single_position(self):
        """Single-position sequence."""
        ref_dir = torch.randn(16)
        acts = torch.randn(1, 16)
        analyzer = MultiTokenPositionAnalyzer()
        result = analyzer.analyze_prompt(acts, ref_dir)
        assert result.n_tokens == 1
        assert result.peak_position == 0

    def test_probing_minimal_data(self):
        """Probing with very few samples."""
        harmful = [torch.randn(8) for _ in range(3)]
        harmless = [torch.randn(8) for _ in range(3)]
        probe = LinearRefusalProbe(n_epochs=10)
        result = probe.probe_layer(harmful, harmless)
        assert 0 <= result.accuracy <= 1.0

    def test_residual_stream_single_layer(self):
        acts = {0: torch.randn(32)}
        ref_dir = torch.randn(32)
        decomposer = ResidualStreamDecomposer()
        result = decomposer.decompose(acts, ref_dir)
        assert result.n_layers == 1

    def test_causal_tracing_single_layer(self):
        acts = {0: torch.randn(32)}
        ref_dirs = {0: torch.randn(32)}
        tracer = CausalRefusalTracer()
        result = tracer.trace_from_activations(acts, ref_dirs)
        assert result.n_layers == 1

    def test_transfer_no_common_layers(self):
        """Cross-model with no overlapping layer indices."""
        dirs_a = {0: torch.randn(16), 1: torch.randn(16)}
        dirs_b = {2: torch.randn(16), 3: torch.randn(16)}
        analyzer = TransferAnalyzer()
        result = analyzer.analyze_cross_model(dirs_a, dirs_b)
        assert result.mean_transfer_score == 0.0

    def test_refusal_rate_empty_list(self):
        result = refusal_rate([])
        assert result == 0.0

    def test_refusal_rate_single_response(self):
        result = refusal_rate(["I cannot help with that."])
        assert result == 1.0


# ===========================================================================
#  Extreme dimensions
# ===========================================================================

class TestExtremeDimensions:
    """Test with unusually large or small dimensions."""

    def test_high_dimensional_directions(self):
        """Test with realistic hidden dimension (4096)."""
        hidden_dim = 4096
        torch.manual_seed(42)
        dirs = {i: torch.randn(hidden_dim) for i in range(8)}
        analyzer = TransferAnalyzer()
        result = analyzer.analyze_cross_layer(dirs)
        assert result.mean_adjacent_transfer >= 0

    def test_high_dim_sparse_surgery(self):
        """Sparse surgery with large weight matrix."""
        W = torch.randn(2048, 1024)
        ref_dir = torch.randn(1024)
        surgeon = SparseDirectionSurgeon(sparsity=0.05)
        result = surgeon.analyze_weight_matrix(W, ref_dir)
        assert result.n_rows_modified == int(0.05 * 2048)

    def test_single_dimension(self):
        """1D hidden dimension edge case."""
        dirs = {i: torch.randn(1) for i in range(4)}
        analyzer = TransferAnalyzer()
        result = analyzer.analyze_cross_layer(dirs)
        # All 1D directions are parallel or anti-parallel, so cosine is always 1.0
        assert result.mean_adjacent_transfer >= 0.99

    def test_many_layers_imprint(self):
        """Alignment imprint with many layers (128)."""
        dirs = {i: torch.randn(32) for i in range(128)}
        detector = AlignmentImprintDetector()
        result = detector.detect_imprint(dirs)
        total = (result.dpo_probability + result.rlhf_probability +
                 result.cai_probability + result.sft_probability)
        assert abs(total - 1.0) < 0.01

    @pytest.mark.parametrize("n_prompts", [1, 2, 5, 50, 100])
    def test_concept_cone_varying_prompt_counts(self, n_prompts):
        """Concept cone with varying numbers of prompts."""
        harmful = [torch.randn(16) for _ in range(n_prompts)]
        harmless = [torch.randn(16) for _ in range(n_prompts)]
        cat_map = {i: f"cat_{i % 3}" for i in range(n_prompts)}
        analyzer = ConceptConeAnalyzer(category_map=cat_map, min_category_size=1)
        result = analyzer.analyze_layer(harmful, harmless)
        # Should produce a valid result regardless of prompt count
        assert result is not None
        assert result.general_direction is not None
        assert result.category_count >= 1

    @pytest.mark.parametrize("seq_len", [1, 2, 10, 100, 500])
    def test_multi_token_varying_lengths(self, seq_len):
        """Multi-token analysis with varying sequence lengths."""
        ref_dir = torch.randn(16)
        acts = torch.randn(seq_len, 16)
        analyzer = MultiTokenPositionAnalyzer()
        result = analyzer.analyze_prompt(acts, ref_dir)
        assert result.n_tokens == seq_len

    @pytest.mark.parametrize("hidden_dim", [2, 4, 16, 64, 256, 1024])
    def test_probing_varying_dimensions(self, hidden_dim):
        """Probing classifier across different hidden dims (min 2 for SGD)."""
        harmful = [torch.randn(hidden_dim) * 2 + 1 for _ in range(10)]
        harmless = [torch.randn(hidden_dim) * 2 - 1 for _ in range(10)]
        probe = LinearRefusalProbe(n_epochs=20)
        result = probe.probe_layer(harmful, harmless)
        assert 0 <= result.accuracy <= 1.0


# ===========================================================================
#  Refusal rate edge cases
# ===========================================================================

class TestRefusalRateEdgeCases:
    """Edge cases for refusal rate detection."""

    def test_novel_refusal_phrasing(self):
        """Test that unusual refusal phrasings might be missed (known limitation)."""
        novel_refusals = [
            "That request falls outside my operational parameters.",
            "I'm designed to be helpful but this conflicts with my guidelines.",
        ]
        # These should ideally be detected but string matching may miss them
        rate = refusal_rate(novel_refusals)
        # This tests the known limitation — rate may be 0 (string matching misses novel phrasings)
        assert 0 <= rate <= 1.0

    def test_false_positive_sorry(self):
        """'I'm sorry to hear that' followed by helpful content."""
        responses = [
            "I'm sorry to hear that. Here's how you can solve the problem: first...",
        ]
        rate = refusal_rate(responses, mode="prefix")
        # Known limitation: prefix matching produces false positives here
        # Just verify it returns a valid float
        assert 0 <= rate <= 1.0

    def test_case_insensitivity(self):
        responses = ["I CANNOT ASSIST WITH THAT REQUEST."]
        rate = refusal_rate(responses, mode="substring")
        assert rate == 1.0

    def test_whitespace_handling(self):
        responses = ["  I'm sorry, but I can't help with that.  "]
        rate = refusal_rate(responses, mode="prefix")
        # Should handle leading whitespace
        assert 0 <= rate <= 1.0


# ===========================================================================
#  Steering Vectors
# ===========================================================================

class TestSteeringVectors:
    """Tests for the steering vector inference-time intervention system."""

    def test_from_refusal_direction(self):
        d = torch.randn(32)
        vec = SteeringVectorFactory.from_refusal_direction(d, source_layer=5)
        assert vec.label == "refusal"
        assert vec.source_layer == 5
        assert vec.default_alpha == -1.0
        assert abs(vec.direction.norm().item() - 1.0) < 0.01

    def test_from_contrastive_pairs(self):
        pos = [torch.randn(16) + 2 for _ in range(10)]
        neg = [torch.randn(16) - 2 for _ in range(10)]
        vec = SteeringVectorFactory.from_contrastive_pairs(pos, neg, label="test")
        assert vec.label == "test"
        assert abs(vec.direction.norm().item() - 1.0) < 0.01
        assert "n_positive" in vec.metadata

    def test_combine_vectors(self):
        v1 = SteeringVectorFactory.from_refusal_direction(torch.randn(32))
        v2 = SteeringVectorFactory.from_refusal_direction(torch.randn(32))
        combined = SteeringVectorFactory.combine([v1, v2], label="merged")
        assert combined.label == "merged"
        assert abs(combined.direction.norm().item() - 1.0) < 0.01

    def test_combine_single(self):
        v = SteeringVectorFactory.from_refusal_direction(torch.randn(16))
        combined = SteeringVectorFactory.combine([v])
        assert abs(combined.direction.norm().item() - 1.0) < 0.01

    def test_combine_empty_raises(self):
        with pytest.raises(ValueError):
            SteeringVectorFactory.combine([])

    def test_hook_manager_lifecycle(self):
        """Test install/remove lifecycle without a real model."""
        manager = SteeringHookManager()
        assert not manager.is_active
        manager.remove()  # Should not crash even with no hooks
        assert not manager.is_active

    def test_hook_with_simple_model(self):
        """Test steering on a simple nn.Sequential model."""
        model = nn.Sequential(
            nn.Linear(16, 16),
            nn.ReLU(),
            nn.Linear(16, 16),
            nn.ReLU(),
            nn.Linear(16, 8),
        )

        vec = SteeringVectorFactory.from_refusal_direction(torch.randn(16))
        config = SteeringConfig(
            vectors=[vec],
            target_layers=[0, 2],  # steer at first and third linear layers
            alpha=1.0,
        )

        manager = SteeringHookManager()
        # Install on specific modules
        layers = list(model.children())
        result = manager.install(model, config, layer_modules=layers)
        assert result.hooks_installed == 2
        assert manager.is_active

        # Run a forward pass (should not crash)
        x = torch.randn(1, 16)
        output = model(x)
        assert output.shape == (1, 8)

        # Remove hooks
        manager.remove()
        assert not manager.is_active

    def test_steering_effectiveness_remove(self):
        eff = compute_steering_effectiveness(2.0, 0.5, direction="remove")
        assert 0 < eff < 1.0  # Reduced but not eliminated

    def test_steering_effectiveness_perfect_remove(self):
        eff = compute_steering_effectiveness(2.0, 0.0, direction="remove")
        assert eff == 1.0

    def test_steering_effectiveness_no_change(self):
        eff = compute_steering_effectiveness(2.0, 2.0, direction="remove")
        assert eff == 0.0

    def test_steering_effectiveness_add(self):
        eff = compute_steering_effectiveness(1.0, 3.0, direction="add")
        assert eff == 1.0  # Capped at 1.0

    def test_format_report(self):
        vec = SteeringVectorFactory.from_refusal_direction(torch.randn(32))
        config = SteeringConfig(vectors=[vec], target_layers=[3, 5], alpha=0.5)
        result = SteeringResult(config=config, hooks_installed=2, total_steered_layers=2)
        report = format_steering_report(result)
        assert "Steering" in report
        assert "refusal" in report

    def test_steering_config_position_modes(self):
        """Test different position modes in config."""
        for pos in ["all", "last", "first"]:
            config = SteeringConfig(
                vectors=[SteeringVectorFactory.from_refusal_direction(torch.randn(8))],
                target_layers=[0],
                position=pos,
            )
            assert config.position == pos

    def test_imports(self):
        from obliteratus.analysis import SteeringVectorFactory, SteeringHookManager
        assert SteeringVectorFactory is not None
        assert SteeringHookManager is not None


class TestParametrizedDimensions:
    """Parametrized tests across different hidden dimensions."""

    @pytest.mark.parametrize("hidden_dim", [2, 8, 64, 256, 768])
    def test_whitened_svd_various_dims(self, hidden_dim):
        n_samples = max(4, hidden_dim // 4)
        harmful = [torch.randn(hidden_dim) for _ in range(n_samples)]
        harmless = [torch.randn(hidden_dim) for _ in range(n_samples)]
        extractor = WhitenedSVDExtractor()
        result = extractor.extract(harmful, harmless, n_directions=1)
        assert result.directions.shape[1] == hidden_dim

    @pytest.mark.parametrize("hidden_dim", [2, 8, 64, 256])
    def test_cross_layer_various_dims(self, hidden_dim):
        directions = {i: torch.randn(hidden_dim) for i in range(4)}
        analyzer = CrossLayerAlignmentAnalyzer()
        result = analyzer.analyze(directions)
        assert 0.0 <= result.direction_persistence_score <= 1.0

    @pytest.mark.parametrize("hidden_dim", [4, 32, 128])
    def test_sparse_surgery_various_dims(self, hidden_dim):
        weight = torch.randn(hidden_dim, hidden_dim)
        direction = torch.randn(hidden_dim)
        direction = direction / direction.norm()
        surgeon = SparseDirectionSurgeon()
        result = surgeon.analyze_weight_matrix(weight, direction, layer_idx=0)
        assert 0.0 <= result.energy_removed <= 1.0

    @pytest.mark.parametrize("n_layers", [1, 4, 12, 32])
    def test_imprint_various_layer_counts(self, n_layers):
        directions = {i: torch.randn(64) for i in range(n_layers)}
        detector = AlignmentImprintDetector()
        result = detector.detect_imprint(directions)
        assert result.predicted_method in ("dpo", "rlhf", "cai", "sft", "unknown")


class TestExceptionPaths:
    """Tests for error handling and boundary conditions."""

    def test_whitened_svd_mismatched_dims(self):
        """Harmful and harmless with different hidden dims should fail or handle gracefully."""
        harmful = [torch.randn(64) for _ in range(10)]
        harmless = [torch.randn(32) for _ in range(10)]
        extractor = WhitenedSVDExtractor()
        with pytest.raises(Exception):
            extractor.extract(harmful, harmless, n_directions=1)

    def test_whitened_svd_single_sample(self):
        """Single sample should not crash (may return 0 directions due to insufficient data)."""
        harmful = [torch.randn(32)]
        harmless = [torch.randn(32)]
        extractor = WhitenedSVDExtractor()
        result = extractor.extract(harmful, harmless, n_directions=1)
        assert result.directions.shape[1] == 32  # hidden dim preserved

    def test_sparse_surgery_zero_direction(self):
        """Zero direction vector should not crash."""
        weight = torch.randn(16, 16)
        direction = torch.zeros(16)
        surgeon = SparseDirectionSurgeon()
        # Should handle gracefully (possibly returning 0 energy)
        result = surgeon.analyze_weight_matrix(weight, direction, layer_idx=0)
        assert result is not None

    def test_cross_layer_single_layer(self):
        """Single layer directions should still produce a result."""
        directions = {0: torch.randn(32)}
        analyzer = CrossLayerAlignmentAnalyzer()
        result = analyzer.analyze(directions)
        assert result is not None