"""Deterministic property contracts for high-consequence pure behavior.""" from __future__ import annotations import math import pytest import torch from hypothesis import given, seed, settings, strategies as st from obliteratus.analysis.whitened_svd import WhitenedSVDExtractor from obliteratus.evaluation.advanced_metrics import ( _is_refusal, linear_cka, token_kl_divergence, ) from obliteratus.evaluation.metrics import accuracy, f1_score_metric, perplexity PROPERTY_SETTINGS = settings(max_examples=60, deadline=None, database=None) @seed(7001) @PROPERTY_SETTINGS @given(st.lists(st.integers(-5, 5), max_size=50)) def test_accuracy_is_invariant_to_joint_reversal_and_duplication(values): references = [value % 3 for value in values] predictions = [value if index % 4 else value + 1 for index, value in enumerate(references)] expected = accuracy(predictions, references) assert accuracy(list(reversed(predictions)), list(reversed(references))) == expected if values: assert accuracy(predictions * 2, references * 2) == expected @seed(7002) @PROPERTY_SETTINGS @given( st.lists(st.integers(0, 4), min_size=1, max_size=50), st.lists(st.booleans(), min_size=1, max_size=50), ) def test_f1_is_invariant_to_bijective_label_renaming(references, flips): predictions = [ value if flips[index % len(flips)] else (value + 1) % 5 for index, value in enumerate(references) ] expected = f1_score_metric(predictions, references) assert f1_score_metric( [value + 10 for value in predictions], [value + 10 for value in references], ) == pytest.approx(expected) @seed(7003) @PROPERTY_SETTINGS @given( vocab_size=st.integers(2, 40), batch_size=st.integers(1, 4), sequence_length=st.integers(2, 12), ) def test_uniform_causal_lm_perplexity_equals_vocabulary_size( vocab_size, batch_size, sequence_length, ): logits = torch.zeros(batch_size, sequence_length, vocab_size) labels = torch.arange(batch_size * sequence_length).reshape(batch_size, sequence_length) labels %= vocab_size assert perplexity(logits, labels) == pytest.approx(float(vocab_size), rel=1e-6) @seed(7004) @PROPERTY_SETTINGS @given( original=st.lists( st.floats(-20, 20, allow_nan=False, allow_infinity=False), min_size=24, max_size=24, ), modified=st.lists( st.floats(-20, 20, allow_nan=False, allow_infinity=False), min_size=24, max_size=24, ), offset=st.floats(-100, 100, allow_nan=False, allow_infinity=False), ) def test_token_kl_is_nonnegative_and_common_logit_shift_invariant( original, modified, offset, ): original_logits = torch.tensor(original).reshape(2, 3, 4) modified_logits = torch.tensor(modified).reshape(2, 3, 4) expected = token_kl_divergence(original_logits, modified_logits) shifted = token_kl_divergence(original_logits + offset, modified_logits - offset) assert expected >= -1e-5 assert shifted == pytest.approx(expected, abs=2e-5) @seed(7005) @PROPERTY_SETTINGS @given( scale=st.floats( min_value=0.1, max_value=20, allow_nan=False, allow_infinity=False, ), offset=st.floats(-20, 20, allow_nan=False, allow_infinity=False), ) def test_linear_cka_is_invariant_to_isotropic_scale_and_translation(scale, offset): generator = torch.Generator().manual_seed(7005) representation = torch.randn(12, 6, generator=generator) assert linear_cka(representation, representation * scale + offset) == pytest.approx( 1.0, abs=1e-5, ) @seed(7006) @PROPERTY_SETTINGS @given( refusal=st.sampled_from([ "I'm sorry, I cannot help with that.", "I must decline this request.", "Je ne peux pas vous aider.", "我无法帮助这个请求。", ]), leading_space=st.integers(0, 8), upper=st.booleans(), ) def test_refusal_classification_is_invariant_to_case_and_leading_space( refusal, leading_space, upper, ): transformed = " " * leading_space + (refusal.upper() if upper else refusal.lower()) assert _is_refusal(transformed, mode="combined") @seed(7007) @PROPERTY_SETTINGS @given( n_samples=st.integers(3, 12), hidden_dim=st.integers(3, 16), offset=st.floats(-50, 50, allow_nan=False, allow_infinity=False), ) def test_whitened_direction_is_invariant_to_common_activation_translation( n_samples, hidden_dim, offset, ): generator = torch.Generator().manual_seed(n_samples * 100 + hidden_dim) harmless = torch.randn(n_samples, hidden_dim, generator=generator) signal = torch.linspace(-1.0, 1.0, hidden_dim) harmful = harmless + signal extractor = WhitenedSVDExtractor() original = extractor.extract(list(harmful), list(harmless), n_directions=1) translated = extractor.extract( list(harmful + offset), list(harmless + offset), n_directions=1, ) alignment = torch.dot(original.directions[0], translated.directions[0]).abs() assert alignment == pytest.approx(1.0, abs=2e-4) assert translated.variance_explained == pytest.approx( original.variance_explained, abs=2e-5, ) @seed(7008) @PROPERTY_SETTINGS @given(permutation=st.permutations(tuple(range(8)))) def test_whitened_direction_is_invariant_to_joint_sample_permutation(permutation): generator = torch.Generator().manual_seed(7008) harmless = torch.randn(8, 10, generator=generator) harmful = harmless + torch.linspace(-2.0, 2.0, 10) extractor = WhitenedSVDExtractor() original = extractor.extract(list(harmful), list(harmless), n_directions=1) permuted = extractor.extract( [harmful[index] for index in permutation], [harmless[index] for index in permutation], n_directions=1, ) alignment = torch.dot(original.directions[0], permuted.directions[0]).abs() assert alignment == pytest.approx(1.0, abs=2e-5) assert permuted.singular_values == pytest.approx(original.singular_values, rel=2e-5) @seed(7009) @PROPERTY_SETTINGS @given( n_samples=st.integers(2, 12), hidden_dim=st.integers(2, 16), requested=st.integers(1, 8), ) def test_whitened_outputs_obey_normalization_ordering_and_bounds( n_samples, hidden_dim, requested, ): generator = torch.Generator().manual_seed(n_samples * 1000 + hidden_dim) harmless = torch.randn(n_samples, hidden_dim, generator=generator) harmful = harmless + torch.randn(n_samples, hidden_dim, generator=generator) result = WhitenedSVDExtractor(min_variance_ratio=0).extract( list(harmful), list(harmless), n_directions=requested, ) expected_count = min(requested, n_samples, hidden_dim) assert result.directions.shape == (expected_count, hidden_dim) assert result.directions.norm(dim=1) == pytest.approx(torch.ones(expected_count)) assert torch.all(result.singular_values >= 0) assert torch.all(result.singular_values[:-1] >= result.singular_values[1:]) assert 0.0 <= result.variance_explained <= 1.0 assert math.isfinite(result.condition_number) assert math.isfinite(result.effective_rank) @pytest.mark.parametrize("dtype", [torch.float16, torch.float32, torch.float64]) def test_whitened_direction_normalizes_supported_cpu_input_dtypes(dtype): harmless = torch.tensor( [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=dtype, ) harmful = harmless + torch.tensor([0, 0, 2], dtype=dtype) result = WhitenedSVDExtractor().extract( list(harmful), list(harmless), n_directions=1, ) assert result.directions.dtype == torch.float32 assert result.directions.norm() == pytest.approx(1.0)