File size: 17,557 Bytes
8af5729
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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."""

    @pytest.fixture
    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."""

    @pytest.fixture
    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."""

    @pytest.fixture
    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."""

    @pytest.fixture
    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."""

    @pytest.fixture
    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."""

    @pytest.fixture
    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