Spaces:
Runtime error
Runtime error
Download backend/tests/unit/test_compression_engine.py from yusufcalisir/Collaborative-Fraud-Intelligence-Simulator: direct link, hf CLI and curl.
- Browser
- Download file 17.6 kB
-
https://huggingface.co/spaces/yusufcalisir/Collaborative-Fraud-Intelligence-Simulator/resolve/main/backend/tests/unit/test_compression_engine.py
- Command line
-
hf download hf://spaces/yusufcalisir/Collaborative-Fraud-Intelligence-Simulator/backend/tests/unit/test_compression_engine.py
-
curl -L -o test_compression_engine.py https://huggingface.co/spaces/yusufcalisir/Collaborative-Fraud-Intelligence-Simulator/resolve/main/backend/tests/unit/test_compression_engine.py
17.6 kB
| """Unit tests for GradientCompressionEngine and FederatedCommunicationProfiler. | |
| Tests cover: | |
| - Top-K gradient sparsification (accuracy, sparsity invariants, thresholding, edge cases) | |
| - Lossless compression and decompression roundtrips (zlib / zstd format, bit-exact recovery) | |
| - Binary quantization (FP32, FP16 half-precision, INT8 symmetric affine) | |
| - Coordinate sparse (COO) encoding and decoding | |
| - Wire payload transfer size hierarchy and compression profiling | |
| - Protocol communication profiling (FedAvg vs FedProx vs SCAFFOLD vs Curve25519 SecAgg vs TenSEAL CKKS) | |
| - Integration with FederatedLearningEngine communication telemetry | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from unittest.mock import MagicMock | |
| import numpy as np | |
| import pytest | |
| from app.application.services.fl_engine import FederatedLearningEngine | |
| from app.domain.enums import AggregationMethod | |
| from app.infrastructure.security.compression_engine import ( | |
| BandwidthBenchmarkSuiteResult, | |
| FederatedCommunicationProfiler, | |
| FederatedProtocol, | |
| GradientCompressionEngine, | |
| ProtocolCommunicationProfile, | |
| SerializationFormat, | |
| ) | |
| class TestTopKSparsification: | |
| """Validates Top-K gradient sparsification algorithms and boundary behavior.""" | |
| def engine(self) -> GradientCompressionEngine: | |
| return GradientCompressionEngine(default_k_percent=0.20) | |
| def test_sparsify_top_k_retains_exact_percentage(self, engine: GradientCompressionEngine) -> None: | |
| """K=20% on 1,000 items retains exactly 200 non-zero elements.""" | |
| weights = [float(i) for i in range(1, 1001)] | |
| sparsified = engine.sparsify_top_k(weights, k_percent=0.20) | |
| assert len(sparsified) == 1000 | |
| non_zero = [w for w in sparsified if w != 0.0] | |
| assert len(non_zero) == 200 | |
| # The retained 200 should be 801 to 1000 | |
| assert min(non_zero) == 801.0 | |
| assert max(non_zero) == 1000.0 | |
| def test_sparsify_top_k_preserves_large_negative_magnitudes( | |
| self, engine: GradientCompressionEngine | |
| ) -> None: | |
| """Top-K operates on absolute magnitudes, preserving large negative gradients.""" | |
| weights = [-100.0, 0.5, -0.2, 50.0, 0.1, -80.0, 0.01, 20.0, -0.05, 0.02] | |
| # Keep top 30% (3 items): -100.0, -80.0, 50.0 | |
| sparsified = engine.sparsify_top_k(weights, k_percent=0.30) | |
| retained = {w for w in sparsified if w != 0.0} | |
| assert retained == {-100.0, -80.0, 50.0} | |
| def test_sparsify_top_k_clamping_and_defaults(self, engine: GradientCompressionEngine) -> None: | |
| """k_percent is clamped between 0.01 and 1.0.""" | |
| weights = [1.0, 2.0, 3.0, 4.0, 5.0] | |
| # None uses default_k_percent (0.20 -> 1 element) | |
| default_res = engine.sparsify_top_k(weights, k_percent=None) | |
| assert sum(1 for w in default_res if w != 0.0) == 1 | |
| # 0.0 clamped to 0.01 (at least 1 element) | |
| zero_res = engine.sparsify_top_k(weights, k_percent=0.0) | |
| assert sum(1 for w in zero_res if w != 0.0) == 1 | |
| # 1.5 clamped to 1.0 (all 5 elements retained) | |
| over_res = engine.sparsify_top_k(weights, k_percent=1.5) | |
| assert sum(1 for w in over_res if w != 0.0) == 5 | |
| def test_sparsify_top_k_empty_and_zero_inputs(self, engine: GradientCompressionEngine) -> None: | |
| """Handles empty and all-zero weight vectors safely.""" | |
| assert engine.sparsify_top_k([]) == [] | |
| assert engine.sparsify_top_k(None) == [] # type: ignore[arg-type] | |
| all_zeros = [0.0] * 50 | |
| res = engine.sparsify_top_k(all_zeros, k_percent=0.20) | |
| assert len(res) == 50 | |
| assert all(w == 0.0 for w in res) | |
| class TestLosslessCompression: | |
| """Validates lossless byte compression and decompression.""" | |
| def engine(self) -> GradientCompressionEngine: | |
| return GradientCompressionEngine(compression_level=6) | |
| def test_compress_decompress_roundtrip(self, engine: GradientCompressionEngine) -> None: | |
| """Compressed payload decompresses to identical bytes.""" | |
| original = b"FEDERATED_LEARNING_BANK_GRADIENT_UPDATE_PAYLOAD_TEST_VECTOR_12345" * 20 | |
| compressed = engine.compress_payload(original) | |
| assert len(compressed) < len(original) | |
| decompressed = engine.decompress_payload(compressed) | |
| assert decompressed == original | |
| def test_compress_empty_payload(self, engine: GradientCompressionEngine) -> None: | |
| """Empty input bytes returns empty bytes.""" | |
| assert engine.compress_payload(b"") == b"" | |
| assert engine.decompress_payload(b"") == b"" | |
| def test_compress_gradient_vector_backward_compatibility( | |
| self, engine: GradientCompressionEngine | |
| ) -> None: | |
| """Maintains backward compatibility with legacy compress_gradient_vector.""" | |
| weights = [0.1 * i for i in range(100)] | |
| meta = engine.compress_gradient_vector(weights, k_percent=0.10) | |
| assert meta["original_length"] == 100 | |
| assert meta["sparsified_length"] == 100 | |
| assert meta["compressed_size"] > 0 | |
| assert meta["raw_size"] > meta["compressed_size"] | |
| assert isinstance(meta["compressed_bytes"], bytes) | |
| class TestBinaryQuantization: | |
| """Validates FP32, FP16 half-precision, and INT8 uniform symmetric quantization.""" | |
| def engine(self) -> GradientCompressionEngine: | |
| return GradientCompressionEngine() | |
| def test_fp32_encode_decode_roundtrip(self, engine: GradientCompressionEngine) -> None: | |
| """FP32 binary serialization achieves zero error.""" | |
| weights = [1.25, -3.5, 0.0, 42.125, -0.0078125] | |
| buf = engine.encode_fp32(weights) | |
| assert len(buf) == len(weights) * 4 | |
| recovered = engine.decode_fp32(buf) | |
| assert len(recovered) == len(weights) | |
| for orig, rec in zip(weights, recovered, strict=False): | |
| assert math.isclose(orig, rec, rel_tol=1e-6) | |
| def test_fp16_encode_decode_precision(self, engine: GradientCompressionEngine) -> None: | |
| """FP16 half-precision reduces size by 50% with low reconstruction error.""" | |
| weights = [0.1234, -0.5678, 1.9876, -3.1415, 0.0] | |
| buf = engine.encode_fp16(weights) | |
| assert len(buf) == len(weights) * 2 # exactly 2 bytes per float | |
| recovered = engine.decode_fp16(buf) | |
| assert len(recovered) == len(weights) | |
| for orig, rec in zip(weights, recovered, strict=False): | |
| assert abs(orig - rec) < 1e-3 | |
| def test_int8_symmetric_quantization_bounded_error( | |
| self, engine: GradientCompressionEngine | |
| ) -> None: | |
| """INT8 symmetric affine quantization satisfies max error bound scale / 2.""" | |
| rng = np.random.default_rng(42) | |
| weights = rng.uniform(-1.0, 1.0, size=200).tolist() | |
| buf = engine.encode_int8(weights) | |
| # 4 bytes scale header + 200 bytes quantized values | |
| assert len(buf) == 4 + 200 | |
| recovered = engine.decode_int8(buf) | |
| assert len(recovered) == 200 | |
| max_val = max(abs(w) for w in weights) | |
| scale = max_val / 127.0 | |
| for orig, rec in zip(weights, recovered, strict=False): | |
| assert abs(orig - rec) <= (scale / 2.0) + 1e-5 | |
| def test_quantization_empty_and_zero_inputs(self, engine: GradientCompressionEngine) -> None: | |
| """Handles empty buffers and zero vectors gracefully.""" | |
| assert engine.encode_fp32([]) == b"" | |
| assert engine.decode_fp32(b"") == [] | |
| assert engine.encode_fp16([]) == b"" | |
| assert engine.decode_fp16(b"") == [] | |
| assert engine.encode_int8([]) == b"" | |
| assert engine.decode_int8(b"") == [] | |
| zeros = [0.0] * 10 | |
| rec_int8 = engine.decode_int8(engine.encode_int8(zeros)) | |
| assert len(rec_int8) == 10 | |
| assert all(w == 0.0 for w in rec_int8) | |
| class TestSparseCoordinateEncoding: | |
| """Validates Top-K Coordinate (COO) sparse binary format.""" | |
| def engine(self) -> GradientCompressionEngine: | |
| return GradientCompressionEngine() | |
| def test_sparse_coo_encode_decode_roundtrip(self, engine: GradientCompressionEngine) -> None: | |
| """Top-K COO buffer decodes back to dense vector with non-zeros preserved.""" | |
| weights = [0.0] * 500 | |
| weights[10] = 5.25 | |
| weights[100] = -3.50 | |
| weights[250] = 8.125 | |
| weights[499] = -1.75 | |
| buf = engine.encode_sparse_coo(weights, k_percent=0.05) | |
| # Header (10 bytes) + 4 indices (8 bytes) + 4 float16 values (8 bytes) = 26 bytes | |
| assert len(buf) < 100 | |
| recovered = engine.decode_sparse_coo(buf) | |
| assert len(recovered) == 500 | |
| assert math.isclose(recovered[10], 5.25, rel_tol=1e-3) | |
| assert math.isclose(recovered[100], -3.50, rel_tol=1e-3) | |
| assert math.isclose(recovered[250], 8.125, rel_tol=1e-3) | |
| assert math.isclose(recovered[499], -1.75, rel_tol=1e-3) | |
| # Other elements are zero | |
| assert sum(1 for w in recovered if w != 0.0) == 4 | |
| def test_sparse_coo_empty_and_invalid_buffers(self, engine: GradientCompressionEngine) -> None: | |
| """Corrupted or undersized COO buffers return safe empty lists.""" | |
| assert engine.encode_sparse_coo([]) == b"" | |
| assert engine.decode_sparse_coo(b"") == [] | |
| assert engine.decode_sparse_coo(b"CORRUPTED") == [] | |
| class TestSerializationDispatcherAndProfiling: | |
| """Validates full serialization dispatcher and wire size hierarchy.""" | |
| def engine(self) -> GradientCompressionEngine: | |
| return GradientCompressionEngine() | |
| def test_all_serialization_formats_produce_valid_roundtrip( | |
| self, engine: GradientCompressionEngine | |
| ) -> None: | |
| """All 7 SerializationFormat enums serialize and deserialize cleanly.""" | |
| rng = np.random.default_rng(42) | |
| weights = rng.normal(0.0, 0.1, size=250).tolist() | |
| for fmt in SerializationFormat: | |
| wire = engine.serialize_payload(weights, payload_format=fmt, k_percent=0.20) | |
| assert isinstance(wire, bytes) | |
| assert len(wire) > 0 | |
| rec = engine.deserialize_payload(wire, payload_format=fmt) | |
| assert len(rec) == 250 | |
| if fmt in (SerializationFormat.RAW_JSON, SerializationFormat.RAW_FP32, SerializationFormat.ZSTD_COMPRESSED): | |
| for o, r in zip(weights, rec, strict=False): | |
| assert math.isclose(o, r, abs_tol=1e-5) | |
| def test_wire_size_hierarchy(self, engine: GradientCompressionEngine) -> None: | |
| """Verifies wire size relationships: | |
| size(SPARSE_TOPK_ZSTD) < size(INT8) < size(FP16) < size(FP32) < size(RAW_JSON). | |
| """ | |
| rng = np.random.default_rng(42) | |
| weights = rng.normal(0.0, 0.05, size=1969).tolist() | |
| profiles = engine.compare_all_serialization_formats(weights, k_percent=0.20) | |
| p_json = profiles[SerializationFormat.RAW_JSON.value] | |
| p_fp32 = profiles[SerializationFormat.RAW_FP32.value] | |
| p_fp16 = profiles[SerializationFormat.QUANTIZED_FP16.value] | |
| p_int8 = profiles[SerializationFormat.QUANTIZED_INT8.value] | |
| p_sparse_zstd = profiles[SerializationFormat.SPARSE_TOPK_ZSTD.value] | |
| assert p_json.wire_bytes > p_fp32.wire_bytes | |
| assert p_fp32.wire_bytes > p_fp16.wire_bytes | |
| assert p_fp16.wire_bytes > p_int8.wire_bytes | |
| assert p_int8.wire_bytes > p_sparse_zstd.wire_bytes | |
| # FP32 on 1,969 parameters is exactly 7,876 bytes | |
| assert p_fp32.wire_bytes == 1969 * 4 | |
| # FP16 is exactly half of FP32 | |
| assert p_fp16.wire_bytes == 1969 * 2 | |
| # INT8 is 1 byte per param + 4 bytes header | |
| assert p_int8.wire_bytes == 1969 + 4 | |
| def test_profile_serialized_payload_metrics(self, engine: GradientCompressionEngine) -> None: | |
| """SerializedPayloadProfile fields are consistent and bounded.""" | |
| weights = [float(i) for i in range(100)] | |
| profile = engine.profile_serialized_payload( | |
| weights, payload_format=SerializationFormat.QUANTIZED_FP16 | |
| ) | |
| assert profile.num_parameters == 100 | |
| assert profile.raw_uncompressed_bytes == 400 | |
| assert profile.wire_bytes == 200 | |
| assert profile.compression_ratio_vs_fp32 == 0.50 | |
| assert profile.bandwidth_savings_pct == 50.0 | |
| assert profile.serialization_time_us >= 0.0 | |
| assert profile.deserialization_time_us >= 0.0 | |
| assert profile.reconstruction_mae >= 0.0 | |
| class TestFederatedCommunicationProfiler: | |
| """Validates multi-round communication accounting across federated aggregation protocols.""" | |
| def profiler(self) -> FederatedCommunicationProfiler: | |
| return FederatedCommunicationProfiler() | |
| def test_fedavg_and_fedprox_identical_bandwidth( | |
| self, profiler: FederatedCommunicationProfiler | |
| ) -> None: | |
| """FedProx has identical communication payload to FedAvg.""" | |
| p_avg = profiler.profile_protocol( | |
| num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.FED_AVG | |
| ) | |
| p_prox = profiler.profile_protocol( | |
| num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.FED_PROX | |
| ) | |
| assert p_avg.total_round_bytes == p_prox.total_round_bytes | |
| assert p_avg.total_multi_round_bytes == p_prox.total_multi_round_bytes | |
| assert p_avg.relative_overhead_vs_fedavg == 1.0 | |
| assert p_prox.relative_overhead_vs_fedavg == 1.0 | |
| # 1 server broadcast (7,876) + 3 client uploads (3 * 7,876) = 31,504 bytes per round | |
| assert p_avg.total_round_bytes == 31504 | |
| assert p_avg.total_multi_round_bytes == 31504 * 5 | |
| def test_scaffold_exact_2x_overhead(self, profiler: FederatedCommunicationProfiler) -> None: | |
| """SCAFFOLD requires exactly 2x bandwidth due to control variates.""" | |
| p_avg = profiler.profile_protocol( | |
| num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.FED_AVG | |
| ) | |
| p_scaffold = profiler.profile_protocol( | |
| num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.SCAFFOLD | |
| ) | |
| assert p_scaffold.total_round_bytes == 2 * p_avg.total_round_bytes | |
| assert p_scaffold.total_multi_round_bytes == 2 * p_avg.total_multi_round_bytes | |
| assert p_scaffold.relative_overhead_vs_fedavg == 2.0 | |
| def test_curve25519_secagg_overhead_bounds( | |
| self, profiler: FederatedCommunicationProfiler | |
| ) -> None: | |
| """Curve25519 SecAgg adds minor cryptographic coordination overhead (4% - 10%).""" | |
| p_secagg = profiler.profile_protocol( | |
| num_parameters=1969, | |
| num_clients=3, | |
| num_rounds=5, | |
| protocol=FederatedProtocol.CURVE25519_SECAGG, | |
| ) | |
| assert p_secagg.relative_overhead_vs_fedavg > 1.0 | |
| assert p_secagg.relative_overhead_vs_fedavg < 1.15 | |
| assert p_secagg.total_multi_round_bytes > 157520 | |
| def test_tenseal_ckks_expansion_bounds( | |
| self, profiler: FederatedCommunicationProfiler | |
| ) -> None: | |
| """TenSEAL CKKS exhibits polynomial ciphertext expansion (~10.5x).""" | |
| p_ckks = profiler.profile_protocol( | |
| num_parameters=1969, | |
| num_clients=3, | |
| num_rounds=5, | |
| protocol=FederatedProtocol.TENSEAL_CKKS, | |
| ) | |
| assert p_ckks.relative_overhead_vs_fedavg == 10.5 | |
| assert p_ckks.total_multi_round_mb > 1.0 | |
| def test_run_full_bandwidth_benchmark_suite( | |
| self, profiler: FederatedCommunicationProfiler | |
| ) -> None: | |
| """Executes complete suite across all 7 formats and 5 protocols.""" | |
| suite = profiler.run_full_bandwidth_benchmark( | |
| num_clients=3, num_rounds=5, model_name="CanonicalFraudMLP" | |
| ) | |
| assert isinstance(suite, BandwidthBenchmarkSuiteResult) | |
| assert suite.num_parameters == 1969 | |
| assert len(suite.serialization_profiles) == len(SerializationFormat) | |
| assert len(suite.protocol_profiles) == len(FederatedProtocol) | |
| assert "RAW_FP32" in suite.serialization_profiles | |
| assert "FED_AVG" in suite.protocol_profiles | |
| class TestFederatedEngineIntegration: | |
| """Validates live FederatedLearningEngine communication telemetry integration.""" | |
| def test_fl_engine_profile_communication_round(self) -> None: | |
| """FederatedLearningEngine delegates communication profiling to profiler.""" | |
| mock_settings = MagicMock() | |
| mock_model_svc = MagicMock() | |
| mock_privacy_svc = MagicMock() | |
| engine = FederatedLearningEngine( | |
| settings=mock_settings, | |
| model_service=mock_model_svc, | |
| privacy_service=mock_privacy_svc, | |
| ) | |
| # Profile FedAvg round | |
| profile_avg = engine.profile_communication_round( | |
| num_parameters=1969, | |
| num_clients=3, | |
| method=AggregationMethod.FED_AVG, | |
| serialization_format=SerializationFormat.RAW_FP32, | |
| ) | |
| assert isinstance(profile_avg, ProtocolCommunicationProfile) | |
| assert profile_avg.protocol == FederatedProtocol.FED_AVG | |
| assert profile_avg.total_round_bytes == 31504 | |
| # Profile SCAFFOLD round | |
| profile_scaffold = engine.profile_communication_round( | |
| num_parameters=1969, | |
| num_clients=3, | |
| method=AggregationMethod.SCAFFOLD, | |
| serialization_format=SerializationFormat.RAW_FP32, | |
| ) | |
| assert profile_scaffold.protocol == FederatedProtocol.SCAFFOLD | |
| assert profile_scaffold.total_round_bytes == 63008 | |