File size: 4,926 Bytes
aa68ee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Unit tests for the INDRA Poincaré Projection Engine (Sprint 29).

Tests cover:
1. Basic projection correctness (origin maps to origin)
2. Radial boundary enforcement (no vector exceeds R_MAX)
3. Floating-point variance underflow guard (identical vectors → distance 0)
4. Division-by-zero guard (boundary vectors → finite distance)
5. Sorting correctness (closer vectors rank higher)
6. Buffer reuse (no allocation leaks across calls)
"""

import numpy as np
import pytest
import sys
import os

# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from indra_engine import IndraProjectionEngine


@pytest.fixture
def engine():
    """Create engine with small batch for testing."""
    return IndraProjectionEngine(batch_size=10, dimensions=4)


class TestPoincareBallProjection:
    def test_zero_vector_maps_to_origin(self, engine):
        """A zero Euclidean vector should map to the Poincaré ball origin."""
        vec = np.zeros(4, dtype=np.float32)
        out = np.zeros(4, dtype=np.float32)
        engine.map_to_poincare_ball_inplace(vec, out)
        np.testing.assert_allclose(out, 0.0, atol=1e-7)

    def test_radial_boundary_clip(self, engine):
        """No projected vector should have norm > R_MAX."""
        vec = np.array([100.0, 200.0, 300.0, 400.0], dtype=np.float32)
        out = np.zeros(4, dtype=np.float32)
        engine.map_to_poincare_ball_inplace(vec, out)
        assert np.linalg.norm(out) <= engine.R_MAX + 1e-6

    def test_batch_radial_boundary_clip(self, engine):
        """No batch-projected vector should exceed R_MAX."""
        vecs = np.random.randn(5, 4).astype(np.float32) * 100
        out = np.zeros((10, 4), dtype=np.float32)
        engine.map_to_poincare_ball_inplace(vecs, out[:5])
        for i in range(5):
            assert np.linalg.norm(out[i]) <= engine.R_MAX + 1e-6


class TestPoincaréDistances:
    def test_identical_vectors_zero_distance(self, engine):
        """Identical vectors should produce distance ≈ 0."""
        query = np.array([0.5, 0.3, -0.2, 0.1], dtype=np.float32)
        candidates = np.tile(query, (3, 1))
        distances = engine.compute_poincare_distances(query, candidates, 3)
        np.testing.assert_allclose(distances, 0.0, atol=1e-5)

    def test_distance_ordering_preserved(self, engine):
        """Closer Euclidean vectors should also be closer in Poincaré space."""
        query = np.array([0.1, 0.1, 0.1, 0.1], dtype=np.float32)
        candidates = np.array([
            [0.11, 0.1, 0.1, 0.1],   # Very close
            [0.5, 0.5, 0.5, 0.5],     # Medium
            [2.0, 2.0, 2.0, 2.0],     # Far
        ], dtype=np.float32)
        distances = engine.compute_poincare_distances(query, candidates, 3)
        assert distances[0] < distances[1] < distances[2]

    def test_no_nan_or_inf(self, engine):
        """No NaN or Inf values in output, even with extreme inputs."""
        query = np.array([1e-10, 1e-10, 1e-10, 1e-10], dtype=np.float32)
        candidates = np.array([
            [0.0, 0.0, 0.0, 0.0],       # Zero vector
            [1e10, 1e10, 1e10, 1e10],     # Huge vector
            [1e-10, 1e-10, 1e-10, 1e-10], # Identical to query
        ], dtype=np.float32)
        distances = engine.compute_poincare_distances(query, candidates, 3)
        assert not np.any(np.isnan(distances))
        assert not np.any(np.isinf(distances))

    def test_boundary_vectors_finite(self, engine):
        """Vectors near the Poincaré ball boundary should produce finite distances."""
        query = np.array([0.998, 0.0, 0.0, 0.0], dtype=np.float32)
        candidates = np.array([
            [0.0, 0.998, 0.0, 0.0],
            [-0.998, 0.0, 0.0, 0.0],
        ], dtype=np.float32)
        distances = engine.compute_poincare_distances(query, candidates, 2)
        assert np.all(np.isfinite(distances))


class TestProjectAndRank:
    def test_returns_sorted_indices(self, engine):
        """project_and_rank should return indices sorted by ascending distance."""
        query = np.zeros(4, dtype=np.float32)
        candidates = np.array([
            [5.0, 5.0, 5.0, 5.0],     # Farthest
            [0.01, 0.01, 0.01, 0.01],  # Closest
            [1.0, 1.0, 1.0, 1.0],      # Middle
        ], dtype=np.float32)
        indices = engine.project_and_rank(query, candidates, 3)
        assert indices[0] == 1  # Closest first
        assert indices[-1] == 0  # Farthest last

    def test_buffer_reuse_stability(self, engine):
        """Running multiple times should produce consistent results (no buffer aliasing)."""
        query = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32)
        candidates = np.random.randn(5, 4).astype(np.float32)

        result1 = engine.project_and_rank(query, candidates, 5)
        result2 = engine.project_and_rank(query, candidates, 5)
        np.testing.assert_array_equal(result1, result2)