| """Tests for logit lens refusal direction analysis.""" |
|
|
| from __future__ import annotations |
|
|
| from unittest.mock import MagicMock |
|
|
| import torch |
|
|
| from obliteratus.analysis.logit_lens import ( |
| RefusalLogitLens, |
| LogitLensResult, |
| MultiLayerLogitLensResult, |
| REFUSAL_TOKENS, |
| COMPLIANCE_TOKENS, |
| ) |
|
|
|
|
| def _make_mock_model(hidden_dim=32, vocab_size=100): |
| """Create a mock model with LM head and layer norm.""" |
| model = MagicMock() |
|
|
| |
| lm_head = MagicMock() |
| lm_head.weight = MagicMock() |
| lm_head.weight.data = torch.randn(vocab_size, hidden_dim) |
| model.lm_head = lm_head |
|
|
| |
| ln_f = MagicMock() |
| ln_f.weight = MagicMock() |
| ln_f.weight.data = torch.ones(hidden_dim) |
| ln_f.bias = MagicMock() |
| ln_f.bias.data = torch.zeros(hidden_dim) |
| model.transformer = MagicMock() |
| model.transformer.ln_f = ln_f |
|
|
| return model |
|
|
|
|
| def _make_mock_tokenizer(vocab_size=100): |
| """Create a mock tokenizer.""" |
| tokenizer = MagicMock() |
|
|
| def mock_decode(ids): |
| if isinstance(ids, list) and len(ids) == 1: |
| return f"tok_{ids[0]}" |
| return f"tok_{ids}" |
|
|
| def mock_encode(text, add_special_tokens=False): |
| |
| return [hash(text) % vocab_size] |
|
|
| tokenizer.decode = mock_decode |
| tokenizer.encode = mock_encode |
| return tokenizer |
|
|
|
|
| class TestRefusalLogitLens: |
| def test_basic_analysis(self): |
| """Should produce a LogitLensResult with expected fields.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| direction = torch.randn(32) |
|
|
| lens = RefusalLogitLens(top_k=10) |
| result = lens.analyze_direction(direction, model, tokenizer, layer_idx=5) |
|
|
| assert isinstance(result, LogitLensResult) |
| assert result.layer_idx == 5 |
| assert len(result.top_promoted) == 10 |
| assert len(result.top_suppressed) == 10 |
| assert isinstance(result.refusal_specificity, float) |
| assert isinstance(result.logit_effect_entropy, float) |
| assert isinstance(result.refusal_compliance_gap, float) |
|
|
| def test_promoted_suppressed_ordering(self): |
| """Top promoted should have higher logit boost than top suppressed.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| direction = torch.randn(32) |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_direction(direction, model, tokenizer) |
|
|
| |
| |
| max_promoted = max(v for _, v in result.top_promoted) |
| min_suppressed = min(v for _, v in result.top_suppressed) |
| assert max_promoted > min_suppressed |
|
|
| def test_multi_layer_analysis(self): |
| """Should analyze multiple layers.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| directions = {0: torch.randn(32), 1: torch.randn(32), 2: torch.randn(32)} |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_all_layers(directions, model, tokenizer) |
|
|
| assert isinstance(result, MultiLayerLogitLensResult) |
| assert len(result.per_layer) == 3 |
| assert result.strongest_refusal_layer in [0, 1, 2] |
| assert result.peak_specificity_layer in [0, 1, 2] |
|
|
| def test_strong_layers_filter(self): |
| """Should only analyze specified strong layers.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| directions = {i: torch.randn(32) for i in range(10)} |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_all_layers( |
| directions, model, tokenizer, strong_layers=[2, 5] |
| ) |
| assert set(result.per_layer.keys()) == {2, 5} |
|
|
| def test_handles_unnormalized_direction(self): |
| """Should handle non-unit directions.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| direction = torch.randn(32) * 100.0 |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_direction(direction, model, tokenizer) |
| |
| assert len(result.top_promoted) == 5 |
|
|
| def test_format_report(self): |
| """Format report should produce readable output.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| directions = {0: torch.randn(32), 1: torch.randn(32)} |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_all_layers(directions, model, tokenizer) |
| report = RefusalLogitLens.format_report(result) |
| assert "Logit Lens" in report |
| assert "Layer 0:" in report |
|
|
| def test_empty_directions(self): |
| """Should handle empty input gracefully.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_all_layers({}, model, tokenizer) |
| assert len(result.per_layer) == 0 |
|
|
| def test_token_lists_nonempty(self): |
| """Refusal and compliance token lists should have entries.""" |
| assert len(REFUSAL_TOKENS) > 10 |
| assert len(COMPLIANCE_TOKENS) > 10 |
|
|
| def test_entropy_nonnegative(self): |
| """Logit effect entropy should be non-negative.""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| direction = torch.randn(32) |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_direction(direction, model, tokenizer) |
| assert result.logit_effect_entropy >= 0 |
|
|
| def test_2d_direction_input(self): |
| """Should handle 2D direction input (unsqueezed).""" |
| model = _make_mock_model() |
| tokenizer = _make_mock_tokenizer() |
| direction = torch.randn(1, 32) |
|
|
| lens = RefusalLogitLens(top_k=5) |
| result = lens.analyze_direction(direction, model, tokenizer) |
| assert len(result.top_promoted) == 5 |
|
|