File size: 14,749 Bytes
eb0499d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Unit tests for GraphSAGE model and graph embedding service.

Tests the core FedGNN components:
1. GraphSAGE model forward pass, embedding dimensions, and gradient flow
2. Node feature extraction from entity dictionaries
3. Model weight serialization/deserialization for federation
4. Graph embedding service: local graph construction, training, similarity search
5. Integration with FL engine: GNN weight aggregation validation
"""

from __future__ import annotations

import numpy as np
import pytest
import torch

from app.application.services.graph_embedding_model import (
    NODE_FEATURE_DIM,
    GraphSAGELayer,
    GraphSAGEModel,
    extract_node_features,
)
from app.application.services.graph_embedding_service import GraphEmbeddingService
from app.application.services.graph_engine import GraphEngine
from app.domain.enums import AggregationMethod

# ── Node Feature Extraction ──────────────────────


class TestNodeFeatureExtraction:
    """Tests for extract_node_features()."""

    def test_feature_dimension(self) -> None:
        """Feature vector must be exactly NODE_FEATURE_DIM (12) dimensions."""
        entity = {
            "entity_type": "customer",
            "risk_level": "high",
            "alert_count": 5,
        }
        features = extract_node_features(entity, degree=3)
        assert features.shape == (NODE_FEATURE_DIM,)
        assert features.dtype == np.float32

    def test_entity_type_one_hot(self) -> None:
        """Entity type should be one-hot encoded in positions 0-6."""
        for etype, expected_idx in [
            ("customer", 0),
            ("merchant", 1),
            ("device", 2),
            ("card", 3),
        ]:
            features = extract_node_features({"entity_type": etype})
            # The expected index should be 1.0, all others 0.0
            assert features[expected_idx] == 1.0
            for i in range(7):
                if i != expected_idx:
                    assert features[i] == 0.0

    def test_risk_level_ordinal(self) -> None:
        """Risk level must be permanently blinded (0.0) and unmasked leakage must fail closed."""
        features_masked = extract_node_features({"risk_level": "critical"})
        assert features_masked[7] == 0.0, "Risk level ordinal must be masked to 0.0 by default"

        with pytest.raises(ValueError, match="Unmasked label leakage in graph features is strictly prohibited"):
            extract_node_features({"risk_level": "critical"}, mask_label_leakage=False)

    def test_alert_count_normalized(self) -> None:
        """Alert count should be log-normalized and bounded."""
        features_zero = extract_node_features({"alert_count": 0})
        features_high = extract_node_features({"alert_count": 100})
        assert features_zero[8] == 0.0
        assert 0.0 < features_high[8] <= 1.5  # log1p(100)/5.0 β‰ˆ 0.92

    def test_degree_centrality(self) -> None:
        """Degree should be log-normalized in position 9."""
        features_iso = extract_node_features({}, degree=0)
        features_hub = extract_node_features({}, degree=50)
        assert features_iso[9] == 0.0
        assert features_hub[9] > features_iso[9]

    def test_empty_entity_dict(self) -> None:
        """Should handle empty dict without crashing."""
        features = extract_node_features({})
        assert features.shape == (NODE_FEATURE_DIM,)
        # Default entity_type is customer (index 0)
        assert features[0] == 1.0


# ── GraphSAGE Layer ──────────────────────


class TestGraphSAGELayer:
    """Tests for individual GraphSAGE message-passing layers."""

    def test_output_shape(self) -> None:
        """Layer output shape should be (N, out_dim)."""
        layer = GraphSAGELayer(in_dim=12, out_dim=64)
        features = torch.randn(5, 12)
        adj = [[1, 2], [0, 2], [0, 1, 3], [2, 4], [3]]

        output = layer(features, adj)
        assert output.shape == (5, 64)

    def test_isolated_node(self) -> None:
        """Isolated nodes (no neighbors) should still produce embeddings."""
        layer = GraphSAGELayer(in_dim=12, out_dim=32)
        features = torch.randn(3, 12)
        adj = [[], [], []]  # All nodes isolated

        output = layer(features, adj)
        assert output.shape == (3, 32)
        # Output should not be all zeros (bias + self-loop)
        assert not torch.all(output == 0)

    def test_l2_normalization(self) -> None:
        """Output embeddings should be L2-normalized to unit sphere."""
        layer = GraphSAGELayer(in_dim=12, out_dim=32)
        features = torch.randn(4, 12)
        adj = [[1], [0, 2], [1, 3], [2]]

        output = layer(features, adj)
        norms = torch.norm(output, dim=1)
        # Should be approximately 1.0 for non-zero rows
        for i in range(4):
            if torch.any(output[i] != 0):
                assert abs(norms[i].item() - 1.0) < 1e-5

    def test_gradient_flow(self) -> None:
        """Gradients should flow through the layer."""
        layer = GraphSAGELayer(in_dim=12, out_dim=32)
        features = torch.randn(3, 12, requires_grad=True)
        adj = [[1, 2], [0], [0]]

        output = layer(features, adj)
        loss = output.sum()
        loss.backward()

        assert features.grad is not None
        assert not torch.all(features.grad == 0)


# ── GraphSAGE Model ──────────────────────


class TestGraphSAGEModel:
    """Tests for the full GraphSAGE model."""

    def test_embedding_dimension(self) -> None:
        """Model should produce embeddings of the configured dimension."""
        model = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)
        features = torch.randn(5, 12)
        adj = [[1, 2], [0, 3], [0, 4], [1], [2]]

        embeddings = model.get_embeddings(features, adj)
        assert embeddings.shape == (5, 32)

    def test_forward_returns_embeddings_and_predictions(self) -> None:
        """Forward pass should return both embeddings and fraud predictions."""
        model = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)
        features = torch.randn(4, 12)
        adj = [[1], [0, 2], [1, 3], [2]]

        embeddings, predictions = model(features, adj)
        assert embeddings.shape == (4, 32)
        assert predictions.shape == (4,)
        # Predictions should be in [0, 1] (sigmoid output)
        assert torch.all(predictions >= 0)
        assert torch.all(predictions <= 1)

    def test_model_weights_serialization_roundtrip(self) -> None:
        """Weights should survive serialize β†’ deserialize without loss."""
        model = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)

        # Set known weights
        for param in model.parameters():
            param.data = torch.randn_like(param)

        # Serialize
        weights = model.to_model_weights(include_classifier=True)
        assert len(weights.flat_weights) > 0
        assert len(weights.layer_shapes) > 0

        # Create new model and load weights
        model2 = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)
        model2.load_model_weights(weights, include_classifier=True)

        # Compare parameters
        for p1, p2 in zip(model.parameters(), model2.parameters()):
            assert torch.allclose(p1.data, p2.data, atol=1e-6)

    def test_model_weights_parameter_count(self) -> None:
        """Serialized weight count should match model parameter count."""
        model = GraphSAGEModel(input_dim=12, hidden_dim=128, embedding_dim=64, num_layers=2)
        weights = model.to_model_weights(include_classifier=True)

        expected_params = sum(p.numel() for p in model.parameters())
        assert weights.num_parameters == expected_params


# ── Model Weight Aggregation ──────────────────────


class TestGNNAggregation:
    """Tests for GNN weight aggregation via fl_engine."""

    def test_fedavg_gnn_weights(self) -> None:
        """FedAvg should correctly average GNN model weights."""
        from app.application.services.fl_engine import FederatedLearningEngine

        # Create two models with different weights
        model_a = GraphSAGEModel(input_dim=12, hidden_dim=32, embedding_dim=16, num_layers=1)
        model_b = GraphSAGEModel(input_dim=12, hidden_dim=32, embedding_dim=16, num_layers=1)

        for p in model_a.parameters():
            p.data = torch.ones_like(p) * 2.0
        for p in model_b.parameters():
            p.data = torch.ones_like(p) * 4.0

        weights_a = model_a.to_model_weights()
        weights_b = model_b.to_model_weights()

        # Create a mock engine (doesn't need real services for aggregation)
        engine = FederatedLearningEngine.__new__(FederatedLearningEngine)

        aggregated = engine.aggregate_parameters(
            [weights_a, weights_b],
            [100, 100],
            method=AggregationMethod.FED_AVG,
        )

        # Average of 2.0 and 4.0 = 3.0
        for w in aggregated.flat_weights:
            assert abs(w - 3.0) < 1e-6

    def test_gnn_layer_shape_validation(self) -> None:
        """aggregate_graph_parameters should reject mismatched layer shapes."""
        from app.application.services.fl_engine import FederatedLearningEngine

        # With num_layers=2, hidden_dim affects intermediate layer shapes
        model_a = GraphSAGEModel(input_dim=12, hidden_dim=32, embedding_dim=16, num_layers=2)
        model_b = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=16, num_layers=2)

        weights_a = model_a.to_model_weights()
        weights_b = model_b.to_model_weights()

        engine = FederatedLearningEngine.__new__(FederatedLearningEngine)

        with pytest.raises(ValueError, match="GNN layer shape mismatch"):
            engine.aggregate_graph_parameters(
                [weights_a, weights_b],
                [100, 100],
            )


# ── Graph Embedding Service ──────────────────────


class TestGraphEmbeddingService:
    """Tests for the graph embedding orchestration service."""

    def test_empty_graph_training(self) -> None:
        """Training on an empty graph should return zero loss without crashing."""
        service = GraphEmbeddingService(graph_engine=GraphEngine())
        weights, metrics = service.train_local_gnn(bank_id="bank_nonexistent")
        assert metrics["loss"] == 0.0
        assert metrics["num_nodes"] == 0

    def test_embedding_stats_empty(self) -> None:
        """Stats should return zeros when no embeddings exist."""
        service = GraphEmbeddingService()
        stats = service.get_embedding_stats()
        assert stats["num_embedded_nodes"] == 0

    def test_find_similar_no_embeddings(self) -> None:
        """Similarity search should return empty when no embeddings cached."""
        service = GraphEmbeddingService()
        results = service.find_similar_entities("nonexistent")
        assert results == []

    def test_get_all_embeddings_serializable(self) -> None:
        """get_all_embeddings should return JSON-serializable dict."""
        service = GraphEmbeddingService()
        # Manually inject a cached embedding
        service._embeddings["test_node"] = np.array([0.1, 0.2, 0.3], dtype=np.float32)

        result = service.get_all_embeddings()
        assert "test_node" in result
        assert isinstance(result["test_node"], list)
        assert len(result["test_node"]) == 3

    def test_cosine_similarity_search(self) -> None:
        """Cosine similarity search should find similar embeddings."""
        service = GraphEmbeddingService()

        # Create embeddings: A and B are similar, C is different
        service._embeddings["node_a"] = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32)
        service._embeddings["node_b"] = np.array([0.9, 0.1, 0.0, 0.0], dtype=np.float32)
        service._embeddings["node_c"] = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)

        results = service.find_similar_entities("node_a", top_k=5, threshold=0.5)

        # node_b should be found (high similarity to node_a)
        entity_ids = [r["entity_id"] for r in results]
        assert "node_b" in entity_ids

        # node_c should NOT be found (orthogonal to node_a)
        assert "node_c" not in entity_ids


class TestGraphSAGETensorEdgeIndex:
    """Tests evaluating PyG-style edge_index tensors and masked semi-supervised loss."""

    def test_graphsage_layer_forward_with_edge_index(self) -> None:
        """Verify GraphSAGELayer forward pass with tensor edge_index produces valid embeddings."""
        torch.manual_seed(42)
        layer = GraphSAGELayer(in_dim=16, out_dim=8)
        node_features = torch.randn(10, 16)
        edge_index = torch.tensor([[0, 1, 2, 3, 4], [1, 2, 3, 4, 0]], dtype=torch.long)

        out = layer(node_features, edge_index=edge_index)
        assert out.shape == (10, 8)
        # Verify L2 normalization on active embeddings
        for i in range(10):
            if torch.any(out[i] != 0):
                norm = torch.norm(out[i], p=2)
                assert abs(norm.item() - 1.0) < 1e-5

    def test_graphsage_model_edge_index_and_166_features(self) -> None:
        """Verify GraphSAGEModel handles 166-dim Elliptic features with edge_index."""
        model = GraphSAGEModel(input_dim=166, hidden_dim=32, embedding_dim=16, num_layers=2)
        node_features = torch.randn(20, 166)
        edge_index = torch.tensor([[0, 1, 2, 3], [1, 2, 3, 0]], dtype=torch.long)

        embeddings, predictions = model(node_features, edge_index=edge_index)
        assert embeddings.shape == (20, 16)
        assert predictions.shape == (20,)
        assert (predictions >= 0.0).all() and (predictions <= 1.0).all()

    def test_graphsage_model_compute_loss_with_mask(self) -> None:
        """Verify masked loss computation strictly isolates labeled nodes."""
        model = GraphSAGEModel(input_dim=16, hidden_dim=16, embedding_dim=8)
        predictions = torch.tensor([0.9, 0.1, 0.8, 0.2], requires_grad=True)
        targets = torch.tensor([1, 0, 0, 1])
        mask = torch.tensor([True, True, False, False])

        loss = model.compute_loss(predictions, targets, mask=mask)
        assert loss.item() > 0.0

        # Gradient should flow to masked items and not to unmasked items
        loss.backward()
        assert predictions.grad is not None
        assert predictions.grad[0] != 0.0
        assert predictions.grad[1] != 0.0
        assert predictions.grad[2] == 0.0
        assert predictions.grad[3] == 0.0